diff --git a/changelog/snippets/features.6479.md b/changelog/snippets/features.6479.md new file mode 100644 index 0000000000..8e5556e10c --- /dev/null +++ b/changelog/snippets/features.6479.md @@ -0,0 +1,7 @@ +- (#6479) Rework the in-game matchmaker lobby from the ground up + +From a user perspective the matchmaker lobby now has a map preview and a connection matrix. The map preview can help the players to understand what they'll be gating into. The connection matrix can help players to understand what source (a player) is connected to what source (another player). The diagonal represents what sources (other players) the local client is connected to. When you receive a message from a peer then it blinks the corresponding box in the matrix. + +From a developers perspective the matchmaker lobby is now maintainable. You can now start the matchmaker lobby locally through your development environment. This allows you to run the matchmaker lobby as if you would find a match in the client. The matchmaker lobby is build from the ground up with maintainability in mind. It now supports a hot reload-like functionality for the interface. This allows you to change the interface on the go, without having to relaunch the game. + +All taken together this is still very much a work in progress and we would love to hear the feedback of the community. We welcome you on Discord in the dev-talk channel. diff --git a/engine/User.lua b/engine/User.lua index b68f8f6564..e80673bc60 100644 --- a/engine/User.lua +++ b/engine/User.lua @@ -624,9 +624,9 @@ end function GpgNetActive() end ----@param cmd string ----@param ... any -function GpgNetSend(cmd, ...) +---@param command string +---@param ... number | string +function GpgNetSend(command, ...) end --- @@ -709,14 +709,15 @@ end ---@alias UILobbyProtocols "UDP" | "TCP" | "None --- For internal use by `CreateLobbyComm()` ----@param lobbyComClass fa-class +---@generic T +---@param lobbyComClass T ---@param protocol UILobbyProtocols ---@param localPort number ---@param maxConnections number ---@param playerName string ---@param playerUID? string ---@param natTraversalProvider? userdata ----@return UILobbyCommunication +---@return T function InternalCreateLobby(lobbyComClass, protocol, localPort, maxConnections, playerName, playerUID, natTraversalProvider) end @@ -918,7 +919,7 @@ end --- Start a background load with the given map and mods. --- If `hipri` is true, this will interrupt any previous loads in progress. ----@param mapname string +---@param mapname string # path to the `scmap` file ---@param mods ModInfo[] ---@param hipri? boolean function PrefetchSession(mapname, mods, hipri) diff --git a/engine/User/CLobby.lua b/engine/User/CLobby.lua index a45c48197a..eb132bd52e 100644 --- a/engine/User/CLobby.lua +++ b/engine/User/CLobby.lua @@ -1,28 +1,86 @@ ---@meta ----@class moho.lobby_methods : Destroyable + + +---@class moho.lobby_methods : Destroyable, InternalObject local CLobby = {} +--- "0", "1", "2", but also "32254" and the like. +---@alias UILobbyPeerId string + ---@alias GPGNetAddress string | number +---@alias UILobbyProtocol 'UDP' | 'TCP' + +---@alias UIPeerConnectionStatus 'None' | 'Pending' | 'Connecting' | 'Answering' | 'Established' | 'TimedOut' | 'Errored' + ---@class Peer ----@field establishedPeers string[] ----@field id string +---@field establishedPeers UILobbyPeerId[] +---@field id UILobbyPeerId # Is -1 when the status is pending ---@field ping number ---@field name string ---@field quiet number ----@field status string +---@field status UIPeerConnectionStatus + +--- A piece of data that is one can send with `BroadcastData` or `SendData` to other player(s) in the lobby. +---@class UILobbyReceivedMessage : table +---@field SenderID UILobbyPeerId # Set by the engine, allows us to identify the source. +---@field SenderName string # Set by the engine, nickname of the source. +---@field Type string # Type of message + +--- A piece of data that is one can send with `BroadcastData` or `SendData` to other player(s) in the lobby. +---@class UILobbyData : table +---@field Type string # Type of message + +--- All the following fields are read by the engine upon launching the lobby to setup the scenario. +---@class UILobbyLaunchGameOptionsConfiguration +---@field UnitCap any # Read by the engine to determine the initial unit cap. See also the globals `GetArmyUnitCap`, `GetArmyUnitCostTotal` and `SetArmyUnitCap` to manipulate it throughout the scenario. +---@field CheatsEnabled any # Read by the engine to determine whether cheats are enabled. +---@field FogOfWar any # Read by the engine to determine how to manage the fog of war. +---@field NoRushOption any # Read by the engine to create the anti-rush mechanic. +---@field PrebuiltUnits any # Read by the engine to create initial, prebuilt units. +---@field ScenarioFile any # Read by the engine to load the scenario of the game. +---@field Timeouts any # Read by the engine to determine the behavior of time outs. +---@field CivilianAlliance any # Read by the engine to determine the alliance towards civilians. +---@field GameSpeed any # Read by the engine to determine the behavior of game speed (adjustments). + +---@class UILobbyLaunchGameModsConfiguration +---@field name string # Read by the engine, TODO +---@field uid string # Read by the engine, TODO + +---@class UILobbyLaunchObserverConfiguration +---@field OwnerID UILobbyPeerId # Read by the engine, TODO +---@field PlayerName string # Read by the engine, TODO + +---@class UILobbyLaunchPlayerConfiguration +---@field StartSpot number # Read by Lua code to determine start locations +---@field ArmyName string # Read by the engine, TODO +---@field PlayerName string # Read by the engine, TODO +---@field Civilian boolean # Read by the engine, TODO +---@field Human boolean # Read by the engine, TODO +---@field AIPersonality string # Read by the engine iff Human is false +---@field ArmyColor number # Read by the engine, is mapped to a color by reading the values of `lua\GameColors.lua`. +---@field PlayerColor number # Read by the engine, is mapped to a color by reading the values of `lua\GameColors.lua` +---@field Faction number # Read by the engine to determine the faction of the player. +---@field OwnerID UILobbyPeerId # Read by the engine, TODO + +--- All the following fields are read by the engine upon launching the lobby. +---@class UILobbyLaunchConfiguration +---@field GameMods UILobbyLaunchGameModsConfiguration[] # ModInfo[] +---@field GameOptions UILobbyLaunchGameOptionsConfiguration # GameOptions +---@field Observers UILobbyLaunchObserverConfiguration # PlayerData[] +---@field PlayerOptions UILobbyLaunchPlayerConfiguration[] # PlayerData[] --- Broadcasts information to all peers. See `SendData` for sending to a specific peer. ----@param data CommunicationData +---@param data UILobbyData function CLobby:BroadcastData(data) end --- Connect to a new peer. The peer will now show up in `GetPeer` and `GetPeers` ---@param address GPGNetAddress # includes the port ---@param name string ----@param uid string -function CLobby:ConnectToPeer(address, name, uid) +---@param peerId UILobbyPeerId +function CLobby:ConnectToPeer(address, name, peerId) end --- @@ -34,18 +92,18 @@ function CLobby:Destroy() end --- Disconnect from a peer. The peer will no longer show in `GetPeer` and `GetPeers`. ----@param uid string -function CLobby:DisconnectFromPeer(uid) +---@param peerId UILobbyPeerId +function CLobby:DisconnectFromPeer(peerId) end --- Eject a peer from the lobby. The peer will no longer show in `GetPeer` and `GetPeers`. ----@param targetID string +---@param peerId UILobbyPeerId ---@param reason string -function CLobby:EjectPeer(targetID, reason) +function CLobby:EjectPeer(peerId, reason) end --- Retrieves the local client identifier. ----@return number +---@return UILobbyPeerId function CLobby:GetLocalPlayerID() end @@ -60,9 +118,9 @@ function CLobby:GetLocalPort() end --- Retrieves a specific peer ----@param uid string +---@param peerId UILobbyPeerId ---@return Peer -function CLobby:GetPeer(uid) +function CLobby:GetPeer(peerId) end --- Retrieves all peers @@ -80,14 +138,16 @@ function CLobby:IsHost() end --- Joins a lobby hosted by another peer. See `HostGame` to host a game. +--- +--- Is not idempotent - joining twice will generate an error. ---@param address GPGNetAddress ---@param remotePlayerName? string | nil ----@param remotePlayerUID? string -function CLobby:JoinGame(address, remotePlayerName, remotePlayerUID) +---@param remotePlayerPeerId? UILobbyPeerId +function CLobby:JoinGame(address, remotePlayerName, remotePlayerPeerId) end --- ----@param gameConfig GameData +---@param gameConfig UILobbyLaunchConfiguration function CLobby:LaunchGame(gameConfig) end @@ -98,16 +158,16 @@ function CLobby:MakeValidGameName(origName) end --- Creates a unique, alternative player name if that is required ----@param uid string +---@param peerId UILobbyPeerId ---@param origName string ---@return string -function CLobby:MakeValidPlayerName(uid, origName) +function CLobby:MakeValidPlayerName(peerId, origName) end --- Sends data to a specific peer. See `BroadcastData` for sending to all peers. ----@param targetID string ----@param data CommunicationData -function CLobby:SendData(targetID, data) +---@param peerId UILobbyPeerId +---@param data UILobbyData +function CLobby:SendData(peerId, data) end return CLobby diff --git a/engine/User/CMauiBitmap.lua b/engine/User/CMauiBitmap.lua index 8a5f7f851b..ebef638f72 100644 --- a/engine/User/CMauiBitmap.lua +++ b/engine/User/CMauiBitmap.lua @@ -94,4 +94,10 @@ end function CMauiBitmap:UseAlphaHitTest(doHit) end +--- Defines the color mask to be applied to bitmap during rendering (white images will get this color for example). +--- Introduced by [binary patch #42](https://github.com/FAForever/FA-Binary-Patches/pull/42) +---@param color string +function CMauiBitmap:SetColorMask(color) +end + return CMauiBitmap diff --git a/engine/User/CUIMapPreview.lua b/engine/User/CUIMapPreview.lua index caaf400b4a..9d2c634a91 100644 --- a/engine/User/CUIMapPreview.lua +++ b/engine/User/CUIMapPreview.lua @@ -9,11 +9,13 @@ end --- ---@param textureName string +---@return boolean function CUIMapPreview:SetTexture(textureName) end --- ---@param mapName string +---@return boolean function CUIMapPreview:SetTextureFromMap(mapName) end diff --git a/lua/GameColors.lua b/lua/GameColors.lua index c8b31dffcc..6a1d956efe 100644 --- a/lua/GameColors.lua +++ b/lua/GameColors.lua @@ -1,3 +1,66 @@ +--****************************************************************************************************** +--** Copyright (c) 2024 Willem 'Jip' Wijnia +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- When launching a lobby each player has a configuration. This configuration has the +-- fields `ArmyColor` and `PlayerColor`. The values of these fields are numbers. This +-- module is responsible for converting the integer-based player and army colors +-- into a hex-based color string that the engine understands. + +local WarmColdMapping = { + -- 1v1 + 11, -- "ff436eee" (11) new blue1 + 01, -- "FFe80a0a" (01) Cybran red + + -- 2v2 + 12, -- "FF2929e1" (12) UEF blue + 02, -- "ff901427" (02) dark red + + -- 3v3 + 14, -- "ff9161ff" (14) purple + 03, -- "FFFF873E" (03) Nomads orange + + -- 4v4 + 15, -- "ff66ffcc" (15) aqua + 05, -- "ffa79602" (05) Sera golden + + -- beyond 4v4, which we'll not likely support any time soon. + 08, + 19, + 07, + 05, + 13, + 16, + 17, + 04 +} + +--- Maps the start location of a player into a a warm vs cold color scheme. Read the +--- introduction of this module for more context. +---@param startSpot number +---@return number +MapToWarmCold = function(startSpot) + return WarmColdMapping[startSpot] +end + --- Determines the available colors for players and the default color order for -- matchmaking. See autolobby.lua and lobby.lua for more information. GameColors = { @@ -7,7 +70,6 @@ GameColors = { -- Default color order used for lobbies/TMM if not otherwise specified. Tightly coupled -- with the ArmyColors and the PlayerColors tables. LobbyColorOrder = { 01, 02, 03, 04, 05, 06, 07, 08, 09, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19 }, -- rainbow-like color for Fearghal - TMMColorOrder = { 11, 01, 12, 02, 14, 03, 15, 05, 08, 19, 07, 05, 13, 16, 17, 04 }, -- warm vs cold -- If you end up working with this file, suggestion to install the Color Highlight extension: -- - https://marketplace.visualstudio.com/items?itemName=naumovs.color-highlight diff --git a/lua/maui/bitmap.lua b/lua/maui/bitmap.lua index 0e92ad4eb0..b0e23a25bf 100644 --- a/lua/maui/bitmap.lua +++ b/lua/maui/bitmap.lua @@ -40,7 +40,7 @@ local ScaleNumber = import("/lua/maui/layouthelpers.lua").ScaleNumber Bitmap = ClassUI(moho.bitmap_methods, Control) { ---@param self Bitmap ---@param parent Control - ---@param filename Lazy + ---@param filename? Lazy ---@param debugname? string __init = function(self, parent, filename, debugname) InternalCreateBitmap(self, parent) diff --git a/lua/shared/color.lua b/lua/shared/color.lua index f892e85231..9af8c30462 100644 --- a/lua/shared/color.lua +++ b/lua/shared/color.lua @@ -581,7 +581,6 @@ end --- Map of named colors the Moho engine can recognize and their representation ---@see EnumColorNames() ----@type table EnumColors = { AliceBlue = "F7FBFF", AntiqueWhite = "FFEBD6", diff --git a/lua/system/utils.lua b/lua/system/utils.lua index ff066e6c2f..4b108ed8ed 100644 --- a/lua/system/utils.lua +++ b/lua/system/utils.lua @@ -626,6 +626,9 @@ function StringJoin(items, delimiter) end --- "explode" a string into a series of tokens, using a separator character `sep` +---@param str string +---@param sep string +---@return string[] function StringSplit(str, sep) local sep, fields = sep or ":", {} local pattern = string.format("([^%s]+)", sep) diff --git a/lua/ui/globals/GpgNetSend.lua b/lua/ui/globals/GpgNetSend.lua new file mode 100644 index 0000000000..5b5f4b3555 --- /dev/null +++ b/lua/ui/globals/GpgNetSend.lua @@ -0,0 +1,75 @@ +---@declare-global + +--****************************************************************************************************** +--** Copyright (c) 2024 Willem 'Jip' Wijnia +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +------------------------------------------------------------------------------- +--#region Game <-> Server communications + +-- All the following logic is tightly coupled with functionality on either the +-- lobby server, the ice adapter, the java server and/or the client. For more +-- context you can search for the various keywords in the following repositories: +-- - Lobby server: https://github.com/FAForever/server +-- - Java server: https://github.com/FAForever/faf-java-server +-- - Java Ice adapter: https://github.com/FAForever/java-ice-adapter +-- - Kotlin Ice adapter: https://github.com/FAForever/kotlin-ice-adapter +-- +-- If we do not send this information then the client is unaware of changes made +-- to the lobby after hosting. These messages are usually only accepted from the +-- host of the lobby. + +--- Original function that we should not use directly +local oldGpgNetSend = GpgNetSend + + +---@param command string +---@param ... number | string +_G.GpgNetSend = function(command, ...) + + --- Add a hook that generates sim callbacks for communication to the + --- server. Useful for moderation purposes. + + SPEW("GpgNetSend", command, unpack(arg)) + + if SessionIsActive() and not SessionIsReplay() then + local stringifiedArgs = "" + for k = 1, table.getn(arg) do + stringifiedArgs = stringifiedArgs .. tostring(arg[k]) .. "," + end + + local currentFocusArmy = GetFocusArmy() + SimCallback( + { + Func = "ModeratorEvent", + Args = { + From = currentFocusArmy, + Message = string.format("GpgNetSend with command '%s' and data '%s'", tostring(command), + stringifiedArgs), + }, + } + ) + end + + oldGpgNetSend(command, unpack(arg)) +end + + diff --git a/lua/ui/lobby/autolobby-classes.lua b/lua/ui/lobby/autolobby-classes.lua deleted file mode 100644 index 5e58d07191..0000000000 --- a/lua/ui/lobby/autolobby-classes.lua +++ /dev/null @@ -1,125 +0,0 @@ - -local UIUtil = import("/lua/ui/uiutil.lua") -local LayoutHelpers = import("/lua/maui/layouthelpers.lua") -local Group = import("/lua/maui/group.lua").Group -local Bitmap = import("/lua/maui/bitmap.lua").Bitmap - --- upvalue for performance -local MathMax = math.max -local MathMin = math.min - ---- A small UI component created according to the Model / View / Controller (MVC) principle ----@class ConnectionStatus : Group -ConnectionStatus = ClassUI(Group) { - - -- Initialisation - - __init = function(self, parent) - Group.__init(self, parent) - - -- set our dimensions - LayoutHelpers.SetDimensions(self, 200, 110) - - -- put a border around ourselves - UIUtil.SurroundWithBorder(self, '/scx_menu/lan-game-lobby/frame/') - - -- give ourself a background to become more readable - self.Background = Bitmap(self) - self.Background:SetSolidColor("000000") - self.Background:SetAlpha(0.2) - LayoutHelpers.FillParent(self.Background, self, 0.01) - - -- generic header - self.HeaderText = UIUtil.CreateText( - self, - "", - 16, - UIUtil.titleFont - ) - LayoutHelpers.AtCenterIn(self.HeaderText, self, 0.18) - LayoutHelpers.AtTopIn(self.HeaderText, self, 6) - - -- connection status to other players - self.ConnectionsText = UIUtil.CreateText( - self, - "", - 16, - UIUtil.bodyFont - ) - LayoutHelpers.CenteredBelow(self.ConnectionsText, self.HeaderText, 20) - self.ConnectionsCheckbox = UIUtil.CreateCheckboxStd(self, '/dialogs/check-box_btn/radio') - -- self.ConnectionsCheckbox:Disable() - LayoutHelpers.LeftOf(self.ConnectionsCheckbox, self.ConnectionsText) - LayoutHelpers.AtVerticalCenterIn(self.ConnectionsCheckbox, self.ConnectionsText) - - -- hide for now - self.ConnectionsCheckbox:Hide() - - -- initial view update - self:UpdateView() - end, - - -- Model elements - - -- these start at 1 as we're always connected to ourself - TotalPlayersCount = 1, - IsTotalPlayersCountSet = false, - ConnectedPlayersCount = 1, - - - -- View elements - - --- Updates the view of the model / view / controller of this UI element - UpdateView = function(self) - local headerText = LOC('Connection status') - self.HeaderText:SetText(headerText) - - local connectionsText - if not self.IsTotalPlayersCountSet then - - if self.ConnectedPlayersCount == 1 then - connectionsText = LOCF('%s player is connected', tostring(self.ConnectedPlayersCount)) - else - connectionsText = LOCF('%s players are connected', tostring(self.ConnectedPlayersCount)) - end - else - if self.ConnectedPlayersCount == 1 then - connectionsText = LOCF('%s / %s is connected', tostring(self.ConnectedPlayersCount), tostring(self.TotalPlayersCount)) - else - connectionsText = LOCF('%s / %s are connected', tostring(self.ConnectedPlayersCount), tostring(self.TotalPlayersCount)) - end - end - - self.ConnectionsText:SetText(connectionsText) - self.ConnectionsCheckbox:SetCheck(self.ConnectedPlayersCount == self.TotalPlayersCount) - end, - - -- Controller elements - - --- Updates the internal state and the text - SetTotalPlayersCount = function(self, count) - self.TotalPlayersCount = count - self.IsTotalPlayersCountSet = true - self:UpdateView() - end, - - --- Updates the internal state and the text - SetPlayersConnectedCount = function(self, count) - self.ConnectedPlayersCount = MathMax(MathMin(count, self.TotalPlayersCount), 1) - self:UpdateView() - end, - - AddConnectedPlayer = function(self) - self.ConnectedPlayersCount = self.ConnectedPlayersCount + 1 - if self.IsTotalPlayersCountSet then - self.ConnectedPlayersCount = MathMin(self.ConnectedPlayersCount, self.TotalPlayersCount) - end - - self:UpdateView() - end, - - RemoveConnectedPlayer = function(self) - self.ConnectedPlayersCount = MathMax(self.ConnectedPlayersCount - 1, 1) - self:UpdateView() - end, -} \ No newline at end of file diff --git a/lua/ui/lobby/autolobby.lua b/lua/ui/lobby/autolobby.lua index d20479e4e7..2e31c407d0 100644 --- a/lua/ui/lobby/autolobby.lua +++ b/lua/ui/lobby/autolobby.lua @@ -1,508 +1,138 @@ --***************************************************************************** ---* File: lua/modules/ui/lobby/autolobby.lua ---* Author: Sam Demulling ---* Summary: Autolaunching games from GPGNet. This is intentionally designed ---* to have no user options as GPGNet is setting them for the player. ---* ---* Copyright © 2006 Gas Powered Games, Inc. All rights reserved. ---***************************************************************************** --* FAF notes: --* Automatch games are configured by the lobby server by sending parameters --* to the FAF client which then relays that configuration to autolobby.lua --* through command line arguments. --***************************************************************************** -local UIUtil = import("/lua/ui/uiutil.lua") -local LayoutHelpers = import("/lua/maui/layouthelpers.lua") -local Group = import("/lua/maui/group.lua").Group -local MenuCommon = import("/lua/ui/menus/menucommon.lua") -local LobbyComm = import("/lua/ui/lobby/lobbycomm.lua") -local gameColors = import("/lua/gamecolors.lua").GameColors -local utils = import("/lua/system/utils.lua") - -local ConnectionStatus = import("/lua/ui/lobby/autolobby-classes.lua").ConnectionStatus - - - -local parent = false -local localPlayerName = false -local requiredPlayers = false - -local currentDialog = false -local connectionStatusGUI = false - -local localPlayerID = false - - ---- The default game information for an automatch. This should typically never be changed directly --- as the server can change game options as it wishes since PR 3385. -local gameInfo = { - GameOptions = { - Score = 'no', - TeamSpawn = 'fixed', - TeamLock = 'locked', - Victory = 'demoralization', - Timeouts = '3', - CheatsEnabled = 'false', - CivilianAlliance = 'enemy', - RevealCivilians = 'Yes', - GameSpeed = 'normal', - FogOfWar = 'explored', - UnitCap = '1500', - PrebuiltUnits = 'Off', - Share = 'FullShare', - ShareUnitCap = 'allies', - DisconnectionDelay02 = '90', - - -- yep, great - Ranked = true, - Unranked = 'No', - }, - PlayerOptions = {}, - Observers = {}, - GameMods = {}, -} - -local Strings = LobbyComm.Strings - -local lobbyComm = false - -local connectedTo = {} -local peerLaunchStatuses = {} - --- Cancels automatching and closes the game -local function CleanupAndExit() - if lobbyComm then - lobbyComm:Destroy() - end - ExitApplication() -end - --- Replace the currently displayed dialog (there is only 1). -local function SetDialog(...) - if currentDialog then - currentDialog:Destroy() - end - - currentDialog = UIUtil.ShowInfoDialog(unpack(arg)) -end - --- Create PlayerInfo for our local player from command line options -local function MakeLocalPlayerInfo(name) - local result = LobbyComm.GetDefaultPlayerOptions(name) - result.Human = true - - -- Game must have factions for players or else it won't start, so default to UEF. - result.Faction = 1 - local factionData = import("/lua/factions.lua") - for index, tbl in factionData.Factions do - if HasCommandLineArg("/" .. tbl.Key) then - result.Faction = index - break - end - end - - result.Team = tonumber(GetCommandLineArg("/team", 1)[1]) - result.StartSpot = tonumber(GetCommandLineArg("/startspot", 1)[1]) or false - - result.DEV = tonumber(GetCommandLineArg("/deviation", 1)[1] or 500) - result.MEAN = tonumber(GetCommandLineArg("/mean", 1)[1] or 1500) - result.NG = tonumber(GetCommandLineArg("/numgames", 1)[1] or 0) - result.DIV = (GetCommandLineArg("/division", 1)[1]) or "" - result.SUBDIV = (GetCommandLineArg("/subdivision", 1)[1]) or "" - result.PL = math.floor(result.MEAN - 3 * result.DEV) - LOG('Local player info: ' .. repr(result)) - return result -end - -function wasConnected(peer) - return table.find(connectedTo, peer) ~= nil -end - -function FindSlotForID(id) - for k,player in gameInfo.PlayerOptions do - if player.OwnerID == id and player.Human then - return k - end - end - return nil -end - -function IsPlayer(id) - return FindSlotForID(id) ~= nil -end - -local function HostAddPlayer(senderId, playerInfo) - playerInfo.OwnerID = senderId - - local slot = playerInfo.StartSpot or 1 - if not playerInfo.StartSpot then - while gameInfo.PlayerOptions[slot] do - slot = slot + 1 - end - playerInfo.StartSpot = slot - end - - playerInfo.PlayerName = lobbyComm:MakeValidPlayerName(playerInfo.OwnerID,playerInfo.PlayerName) - -- TODO: Should colors be based on teams? - playerInfo.PlayerColor = gameColors.TMMColorOrder[slot] - - gameInfo.PlayerOptions[slot] = playerInfo -end - ---- Waits to receive confirmation from all players as to whether they share the same --- game options. Is used to reject a game when this is not the case. Typically --- this happens when the players do not share the same (FAF) client. -local function WaitLaunchAccepted() - while true do - local allAccepted = true - for _, status in peerLaunchStatuses do - if status == 'Rejected' then - return false - elseif not status or status ~= 'Accepted' then - allAccepted = false - break - end - end - if allAccepted then - return true - end - WaitSeconds(1) - end -end - --- Check if we can launch the game and then do so. To launch the game we need --- to be connected to the correct number of players as configured by the --- command line args. -local function CheckForLaunch() - local important = {} - for slot,player in gameInfo.PlayerOptions do - GpgNetSend('PlayerOption', player.OwnerID, 'StartSpot', slot) - GpgNetSend('PlayerOption', player.OwnerID, 'Army', slot) - GpgNetSend('PlayerOption', player.OwnerID, 'Faction', player.Faction) - GpgNetSend('PlayerOption', player.OwnerID, 'Color', player.PlayerColor) - - if not table.find(important, player.OwnerID) then - table.insert(important, player.OwnerID) - end - end - - -- counts the number of players in the game. Include yourself by default. - local playercount = 1 - for k,id in important do - if id ~= localPlayerID then - local peer = lobbyComm:GetPeer(id) - if peer.status ~= 'Established' then - return - end - if not table.find(peer.establishedPeers, localPlayerID) then - return - end - playercount = playercount + 1 - for k2,other in important do - if id ~= other and not table.find(peer.establishedPeers, other) then - return - end - end - end - end - - if playercount < requiredPlayers then - return - end - - local allRatings = {} - local allDivisions = {} - for k,v in gameInfo.PlayerOptions do - if v.Human and v.PL then - allRatings[v.PlayerName] = v.PL - if v.DIV ~= "unlisted" then - local divisiontext = v.DIV - if v.SUBDIV and v.SUBDIV ~="" then - divisiontext = divisiontext .. ' ' .. v.SUBDIV - end - allDivisions[v.PlayerName]= divisiontext - end - -- Initialize peer launch statuses - peerLaunchStatuses[v.OwnerID] = false - end - end - -- We don't need to wait for a launch status from ourselves - peerLaunchStatuses[localPlayerID] = nil - gameInfo.GameOptions['Ratings'] = allRatings - gameInfo.GameOptions['Divisions'] = allDivisions - - LOG("Host launching game.") - lobbyComm:BroadcastData({ Type = 'Launch', GameInfo = gameInfo }) - LOG(repr(gameInfo)) - - ForkThread(function() - if WaitLaunchAccepted() then - lobbyComm:LaunchGame(gameInfo) - return - end - - LOG("Some players rejected the launch! " .. repr(peerLaunchStatuses)) - SetDialog(parent, Strings.LaunchRejected, "", CleanupAndExit) - end) -end - - -local function CreateUI() - - LOG("Don't mind me x2") - - if currentDialog ~= false then - MenuCommon.MenuCleanup() - currentDialog:Destroy() - currentDialog = false - end - - -- control layout - if not parent then parent = UIUtil.CreateScreenGroup(GetFrame(0), "Lobby CreateUI ScreenGroup") end - - local background = MenuCommon.SetupBackground(GetFrame(0)) - - SetDialog(parent, "Setting up automatch...") - - -- construct the connection status GUI and position it right below the dialog - connectionStatusGUI = ConnectionStatus(GetFrame(0)) - LayoutHelpers.CenteredBelow(connectionStatusGUI, currentDialog, 20) - LayoutHelpers.DepthOverParent(connectionStatusGUI, background, 1) -end - - --- LobbyComm Callbacks -local function InitLobbyComm(protocol, localPort, desiredPlayerName, localPlayerUID, natTraversalProvider) - local LobCreateFunc = import("/lua/ui/lobby/lobbycomm.lua").CreateLobbyComm - local lob = LobCreateFunc(protocol, localPort, desiredPlayerName, localPlayerUID, natTraversalProvider) - if not lob then - error('Creating lobby using protocol ' .. repr(protocol) .. ' and port ' .. tostring(localPort) .. ' failed.') - end - lobbyComm = lob - - lobbyComm.Connecting = function(self) - SetDialog(parent, Strings.Connecting, "", CleanupAndExit) - end - - lobbyComm.ConnectionFailed = function(self, reason) - LOG("CONNECTION FAILED " .. reason) - SetDialog(parent, LOCF(Strings.ConnectionFailed, reason), "", CleanupAndExit) - end - - lobbyComm.LaunchFailed = function(self, reasonKey) - LOG("LAUNCH FAILED") - SetDialog(parent, LOCF(Strings.LaunchFailed,LOC(reasonKey)), "", CleanupAndExit) - end - - lobbyComm.Ejected = function(self, reason) - LOG("EJECTED " .. reason) - SetDialog(parent, Strings.Ejected, "", CleanupAndExit) - end - - lobbyComm.ConnectionToHostEstablished = function(self, myID, newLocalName, theHostID) - LOG("CONNECTED TO HOST") - hostID = theHostID - localPlayerName = newLocalName - localPlayerID = myID - - -- Ok, I'm connected to the host. Now request to become a player - self:SendData(hostID, { Type = 'AddPlayer', PlayerInfo = MakeLocalPlayerInfo(newLocalName), }) - end - - lobbyComm.DataReceived = function(self, data) - LOG('DATA RECEIVED: ', reprsl(data)) - - if data.Type == 'LaunchStatus' then - peerLaunchStatuses[data.SenderID] = data.Status - return - end - - if self:IsHost() then - -- Host Messages - if data.Type == 'AddPlayer' then - HostAddPlayer(data.SenderID, data.PlayerInfo) - end - else - -- Non-Host Messages - if data.Type == 'Launch' then - -- The client compares the game options with those of the host. They both look like the local 'gameInfo' as defined - -- above, but the host adds these fields upon launch (see: CheckForLaunch) so that we can display them on the scoreboard. - -- A client won't have this information attached, and therefore we remove it manually here - local hostOptions = table.copy(data.GameInfo.GameOptions) - hostOptions['Ratings'] = nil - hostOptions['ScenarioFile'] = nil - hostOptions['Divisions'] = nil - - -- This is a sanity check so we don't accidentally launch games - -- with the wrong game settings because the host is using a - -- client that doesn't support game options for matchmaker. - if not table.equal(gameInfo.GameOptions, hostOptions) then - WARN("Game options missmatch!") - - LOG("Client settings: ") - reprsl(gameInfo.GameOptions) - - LOG("Host settings: ") - reprsl(hostOptions) - - SetDialog(parent, Strings.LaunchRejected, "", CleanupAndExit) - - self:BroadcastData({ Type = 'LaunchStatus', Status = 'Rejected' }) - -- To distinguish this from regular failed connections - GpgNetSend('LaunchStatus', 'Rejected') - else - self:BroadcastData({ Type = 'LaunchStatus', Status = 'Accepted' }) - self:LaunchGame(data.GameInfo) - end - end - end - end - - lobbyComm.SystemMessage = function(self, text) - LOG("System: ",text) - end - - lobbyComm.GameLaunched = function(self) - GpgNetSend('GameState', 'Launching') - parent:Destroy() - parent = false - MenuCommon.MenuCleanup() - lobbyComm:Destroy() - lobbyComm = false - end - - lobbyComm.Hosting = function(self) - localPlayerID = self:GetLocalPlayerID() - hostID = localPlayerID - - -- Give myself the first slot - HostAddPlayer(hostID, MakeLocalPlayerInfo(localPlayerName)) - - -- Fill in the desired scenario. - gameInfo.GameOptions.ScenarioFile = self.desiredScenario - end - - lobbyComm.EstablishedPeers = function(self, uid, peers) - if not wasConnected(uid) then - table.insert(connectedTo, uid) - end - - -- update ui to inform players - connectionStatusGUI:SetPlayersConnectedCount(table.getn(connectedTo)) - - if self:IsHost() then - CheckForLaunch() - end - end - - lobbyComm.PeerDisconnected = function(self, peerName, peerID) - LOG('>DEBUG> PeerDisconnected : peerName='..peerName..' peerID='..peerID) - if IsPlayer(peerID) then - local slot = FindSlotForID(peerID) - if slot and self:IsHost() then - gameInfo.PlayerOptions[slot] = nil - end - end - end - -end - - --- Create a new unconnected lobby. +--****************************************************************************************************** +--** Copyright (c) 2024 Willem 'Jip' Wijnia +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- This module exists because the engine expects this particular file to exist with +-- the provided functionality. It now acts as a wrapper for the autolobby controller +-- that can be found at: lua\ui\lobby\autolobby\AutolobbyController.lua + +---@type UIAutolobbyCommunications | false +local AutolobbyCommunicationsInstance = false + +--- Creates the lobby communications, called (indirectly) by the engine to setup the module state. +---@param protocol UILobbyProtocol +---@param localPort number +---@param desiredPlayerName string +---@param localPlayerUID UILobbyPeerId +---@param natTraversalProvider any +---@return UIAutolobbyCommunications function CreateLobby(protocol, localPort, desiredPlayerName, localPlayerUID, natTraversalProvider) - if not parent then parent = UIUtil.CreateScreenGroup(GetFrame(0), "CreateLobby ScreenGroup") end - -- don't parent background to screen group so it doesn't get destroyed until we leave the menus - local background = MenuCommon.SetupBackground(GetFrame(0)) - - -- construct the initial dialog - SetDialog(parent, Strings.TryingToConnect) - - InitLobbyComm(protocol, localPort, desiredPlayerName, localPlayerUID, natTraversalProvider) - - localPlayerName = lobbyComm:GetLocalPlayerName() -end - - --- create the lobby as a host + LOG("CreateLobby", protocol, localPort, desiredPlayerName, localPlayerUID, natTraversalProvider) + + -- create the interface, needs to be done before the lobby is + local playerCount = tonumber(GetCommandLineArg("/players", 1)[1]) or 8 + local interface = import("/lua/ui/lobby/autolobby/AutolobbyInterface.lua").SetupSingleton(playerCount) + + -- create the lobby + local maxConnections = 16 + AutolobbyCommunicationsInstance = InternalCreateLobby( + import("/lua/ui/lobby/autolobby/AutolobbyController.lua").AutolobbyCommunications, + protocol, localPort, maxConnections, desiredPlayerName, + localPlayerUID, natTraversalProvider + ) + + AutolobbyCommunicationsInstance.LobbyParameters = AutolobbyCommunicationsInstance.LobbyParameters or {} + AutolobbyCommunicationsInstance.LobbyParameters.Protocol = protocol + AutolobbyCommunicationsInstance.LobbyParameters.LocalPort = localPort + AutolobbyCommunicationsInstance.LobbyParameters.MaxConnections = maxConnections + AutolobbyCommunicationsInstance.LobbyParameters.DesiredPlayerName = desiredPlayerName + AutolobbyCommunicationsInstance.LobbyParameters.LocalPlayerPeerId = localPlayerUID + AutolobbyCommunicationsInstance.LobbyParameters.NatTraversalProvider = natTraversalProvider + + AutolobbyCommunicationsInstance:SendGameStateToServer('Idle') + + return AutolobbyCommunicationsInstance +end + +--- Instantiates a lobby instance by hosting one. +--- +--- Assumes that the lobby communications is initialized by calling `CreateLobby`. +---@param gameName any +---@param scenarioFileName any +---@param singlePlayer any function HostGame(gameName, scenarioFileName, singlePlayer) - CreateUI() - - requiredPlayers = 2 - local args = GetCommandLineArg("/players", 1) - if args then - requiredPlayers = tonumber(args[1]) - LOG("requiredPlayers was set to: "..requiredPlayers) - end - - SetGameOptionsFromCommandLine() - - -- update the connection status GUI - connectionStatusGUI:SetTotalPlayersCount(requiredPlayers) - - -- The guys at GPG were unable to make a standard for map. We dirty-solve it. - lobbyComm.desiredScenario = string.gsub(scenarioFileName, ".v%d%d%d%d_scenario.lua", "_scenario.lua") + LOG("HostGame", gameName, scenarioFileName, singlePlayer) - lobbyComm:HostGame() -end + if AutolobbyCommunicationsInstance then --- join an already existing lobby -function JoinGame(address, asObserver, playerName, uid) - LOG("Joingame (name=" .. tostring(playerName) .. ", uid=" .. tostring(uid) .. ", address=" .. tostring(address) ..")") - CreateUI() + AutolobbyCommunicationsInstance.HostParameters = AutolobbyCommunicationsInstance.HostParameters or {} + AutolobbyCommunicationsInstance.HostParameters.GameName = gameName + AutolobbyCommunicationsInstance.HostParameters.ScenarioFile = scenarioFileName + AutolobbyCommunicationsInstance.HostParameters.SinglePlayer = singlePlayer - -- TODO: I'm not sure if this argument is passed along when you are joining a lobby - requiredPlayers = 2 - local args = GetCommandLineArg("/players", 1) - if args then - requiredPlayers = tonumber(args[1]) - LOG("requiredPlayers was set to: "..requiredPlayers) + AutolobbyCommunicationsInstance.GameOptions.ScenarioFile = string.gsub(scenarioFileName, + ".v%d%d%d%d_scenario.lua", + "_scenario.lua") + AutolobbyCommunicationsInstance:HostGame() end - - SetGameOptionsFromCommandLine() - - -- update the connection status GUI - connectionStatusGUI:SetTotalPlayersCount(requiredPlayers) - - lobbyComm:JoinGame(address, playerName, uid) end -function ConnectToPeer(addressAndPort,name,uid) - if not string.find(addressAndPort, '127.0.0.1') then - LOG("ConnectToPeer (name=" .. name .. ", uid=" .. uid .. ", address=" .. addressAndPort ..")") - else - DisconnectFromPeer(uid, true) - LOG("ConnectToPeer (name=" .. name .. ", uid=" .. uid .. ", address=" .. addressAndPort ..", USE PROXY)") - end +local rejoinTest = false - -- update ui to inform players - connectionStatusGUI:AddConnectedPlayer() +--- Joins an instantiated lobby instance. +--- +--- Assumes that the lobby communications is initialized by calling `CreateLobby`. +---@param address GPGNetAddress +---@param asObserver boolean +---@param playerName string +---@param uid UILobbyPeerId +function JoinGame(address, asObserver, playerName, uid) + LOG("JoinGame", address, asObserver, playerName, uid) - lobbyComm:ConnectToPeer(addressAndPort,name,uid) + if AutolobbyCommunicationsInstance then + AutolobbyCommunicationsInstance.JoinParameters = AutolobbyCommunicationsInstance.JoinParameters or {} + AutolobbyCommunicationsInstance.JoinParameters.Address = address + AutolobbyCommunicationsInstance.JoinParameters.AsObserver = asObserver + AutolobbyCommunicationsInstance.JoinParameters.DesiredPlayerName = playerName + AutolobbyCommunicationsInstance.JoinParameters.DesiredPeerId = uid + AutolobbyCommunicationsInstance:JoinGame(address, playerName, uid) + end end -function DisconnectFromPeer(uid, doNotUpdateView) - LOG("DisconnectFromPeer (uid=" .. uid ..")") - if wasConnected(uid) then - table.remove(connectedTo, uid) - end - GpgNetSend('Disconnected', string.format("%d", uid)) +--- Called by the engine. +---@param addressAndPort GPGNetAddress +---@param name any +---@param uid UILobbyPeerId +function ConnectToPeer(addressAndPort, name, uid) + LOG("ConnectToPeer", addressAndPort, name, uid) - -- sometimes we disconnect immediately, but secretly connect through a proxy - if not doNotUpdateView then - connectionStatusGUI:RemoveConnectedPlayer() + if AutolobbyCommunicationsInstance then + AutolobbyCommunicationsInstance:ConnectToPeer(addressAndPort, name, uid) end - - lobbyComm:DisconnectFromPeer(uid) end +--- Called by the engine. +---@param uid UILobbyPeerId +---@param doNotUpdateView any +function DisconnectFromPeer(uid, doNotUpdateView) + LOG("DisconnectFromPeer", uid, doNotUpdateView) -function SetGameOptionsFromCommandLine() - for name, value in utils.GetCommandLineArgTable("/gameoptions") do - if name and value then - gameInfo.GameOptions[name] = value - else - LOG("Malformed gameoption. ignoring name: " .. repr(name) .. " and value: " .. repr(value)) - end + if AutolobbyCommunicationsInstance then + AutolobbyCommunicationsInstance:DisconnectFromPeer(uid) end end diff --git a/lua/ui/lobby/autolobby/AutolobbyConnectionMatrix.lua b/lua/ui/lobby/autolobby/AutolobbyConnectionMatrix.lua new file mode 100644 index 0000000000..336c69b7c2 --- /dev/null +++ b/lua/ui/lobby/autolobby/AutolobbyConnectionMatrix.lua @@ -0,0 +1,134 @@ +--****************************************************************************************************** +--** Copyright (c) 2024 Willem 'Jip' Wijnia +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") + +local Group = import("/lua/maui/group.lua").Group + +local AutolobbyConnectionMatrixDot = import("/lua/ui/lobby/autolobby/AutolobbyConnectionMatrixDot.lua") + +---@class UIAutolobbyConnectionMatrix : Group +---@field PlayerCount number +---@field Elements UIAutolobbyConnectionMatrixDot[][] +local AutolobbyConnectionMatrix = Class(Group) { + + ---@param self UIAutolobbyConnectionMatrix + ---@param parent Control + __init = function(self, parent, playerCount) + Group.__init(self, parent, "AutolobbyConnectionMatrix") + + self.PlayerCount = playerCount + + self.Border = UIUtil.SurroundWithBorder(self, '/scx_menu/lan-game-lobby/frame/') + self.Background = UIUtil.CreateBitmapColor(self, '99000000') + + -- create the matrix + self.Elements = {} + for y = 1, self.PlayerCount do + self.Elements[y] = {} + for x = 1, self.PlayerCount do + self.Elements[y][x] = AutolobbyConnectionMatrixDot.Create(self) + end + end + end, + + ---@param self UIAutolobbyConnectionMatrix + ---@param parent Control + __post_init = function(self, parent) + LayoutHelpers.ReusedLayoutFor(self) + :Width(self.PlayerCount * 24) + :Height(self.PlayerCount * 24) + :End() + + LayoutHelpers.ReusedLayoutFor(self.Background) + :Fill(self) + :End() + + -- layout the matrix + for y = 1, self.PlayerCount do + for x = 1, self.PlayerCount do + LayoutHelpers.ReusedLayoutFor(self.Elements[y][x]) + :Width(22) + :Height(22) + :AtLeftTopIn(self, 2 + 24 * (x - 1), 2 + 24 * (y - 1)) + end + end + end, + + ---@param self UIAutolobbyConnectionMatrix + ---@param ownershipMatrix boolean[][] + UpdateOwnership = function(self, ownershipMatrix) + for y, connectionRow in ownershipMatrix do + for x, isOwned in connectionRow do + ---@type UIAutolobbyConnectionMatrixDot + local dot = self.Elements[y][x] + if dot and y ~= x then + dot:SetOwnership(isOwned) + end + end + end + end, + + ---@param self UIAutolobbyConnectionMatrix + ---@param connectionMatrix UIAutolobbyConnections + UpdateConnections = function(self, connectionMatrix) + for y, connectionRow in connectionMatrix do + for x, isConnected in connectionRow do + ---@type UIAutolobbyConnectionMatrixDot + local dot = self.Elements[y][x] + if dot and y ~= x then + dot:SetConnected(isConnected) + end + end + end + end, + + ---@param self UIAutolobbyConnectionMatrix + ---@param statuses UIAutolobbyStatus + UpdateStatuses = function(self, statuses) + for k, status in statuses do + ---@type UIAutolobbyConnectionMatrixDot + local dot = self.Elements[k][k] + if dot then + dot:SetStatus(status) + end + end + end, + + ---@param self UIAutolobbyConnectionMatrix + ---@param id number + UpdateIsAliveTimestamp = function(self, id) + ---@type UIAutolobbyConnectionMatrixDot + local dot = self.Elements[id][id] + if dot then + dot:SetIsAliveTimestamp(GetSystemTimeSeconds()) + end + end, +} + +---@param parent Control +---@param count number +---@return UIAutolobbyConnectionMatrix +Create = function(parent, count) + return AutolobbyConnectionMatrix(parent, count) +end diff --git a/lua/ui/lobby/autolobby/AutolobbyConnectionMatrixDot.lua b/lua/ui/lobby/autolobby/AutolobbyConnectionMatrixDot.lua new file mode 100644 index 0000000000..8d7ef1a853 --- /dev/null +++ b/lua/ui/lobby/autolobby/AutolobbyConnectionMatrixDot.lua @@ -0,0 +1,104 @@ +--****************************************************************************************************** +--** Copyright (c) 2024 Willem 'Jip' Wijnia +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +local EnumColors = import("/lua/shared/color.lua").EnumColors + +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap + +--- A small dot that represents the connection status between players. +---@class UIAutolobbyConnectionMatrixDot : Bitmap +---@field IsAliveTimestamp number +local AutolobbyConnectionMatrixDot = Class(Bitmap) { + + ---@param self UIAutolobbyConnectionMatrixDot + ---@param parent Control + __init = function(self, parent) + Bitmap.__init(self, parent) + + self.IsAliveTimestamp = 0 + + -- initial state + self:SetConnected(false) + self:SetSolidColor('999999') + end, + + ---@param self UIAutolobbyConnectionMatrixDot + ---@param parent Control + __post_init = function(self, parent) + + end, + + ---@param self UIAutolobbyConnectionMatrixDot + ---@param delta number + OnFrame = function(self, delta) + local time = GetSystemTimeSeconds() + local diff = time - self.IsAliveTimestamp + self:SetAlpha(math.max(0, 1 - (0.25 * diff))) + end, + + ---@param self UIAutolobbyConnectionMatrixDot + ---@param relatedToLocalPeer boolean + SetOwnership = function(self, relatedToLocalPeer) + if relatedToLocalPeer then + self:SetSolidColor('ffffff') + else + self:SetSolidColor('999999') + end + end, + + ---@param self UIAutolobbyConnectionMatrixDot + ---@param isConnected boolean + SetConnected = function(self, isConnected) + if isConnected then + self:SetAlpha(0.9) + else + self:SetAlpha(0.5) + end + end, + + ---@param self UIAutolobbyConnectionMatrixDot + ---@param status UIPeerLaunchStatus + SetStatus = function(self, status) + if status == 'Unknown' then + self:SetSolidColor(EnumColors.Blue) + elseif status == 'Rejoining' then + self:SetSolidColor(EnumColors.HotPink) + elseif status == 'Missing local peers' then + self:SetSolidColor(EnumColors.Orange) + elseif status == 'Ready' then + self:SetSolidColor(EnumColors.Green) + end + end, + + ---@param self UIAutolobbyConnectionMatrixDot + ---@param timestamp number + SetIsAliveTimestamp = function(self, timestamp) + self:SetNeedsFrameUpdate(true) + self.IsAliveTimestamp = timestamp + end +} + +---@param parent Control +---@return UIAutolobbyConnectionMatrixDot +Create = function(parent) + return AutolobbyConnectionMatrixDot(parent) +end diff --git a/lua/ui/lobby/autolobby/AutolobbyController.lua b/lua/ui/lobby/autolobby/AutolobbyController.lua new file mode 100644 index 0000000000..94d3bf59e0 --- /dev/null +++ b/lua/ui/lobby/autolobby/AutolobbyController.lua @@ -0,0 +1,1035 @@ +--****************************************************************************************************** +--** Copyright (c) 2024 Willem 'Jip' Wijnia +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +local Utils = import("/lua/system/utils.lua") +local MapUtil = import("/lua/ui/maputil.lua") +local GameColors = import("/lua/GameColors.lua") + +local MohoLobbyMethods = moho.lobby_methods +local DebugComponent = import("/lua/shared/components/DebugComponent.lua").DebugComponent +local AutolobbyServerCommunicationsComponent = import("/lua/ui/lobby/autolobby/components/AutolobbyServerCommunicationsComponent.lua") + .AutolobbyServerCommunicationsComponent + +local AutolobbyMessages = import("/lua/ui/lobby/autolobby/AutolobbyMessages.lua").AutolobbyMessages + +local AutolobbyEngineStrings = { + -- General info strings + ['Connecting'] = "Connecting to Game", + ['AbortConnect'] = "Abort Connect", + ['TryingToConnect'] = "Connecting...", + ['TimedOut'] = "%s timed out.", + ['TimedOutToHost'] = "Timed out to host.", + ['Ejected'] = "You have been ejected: %s", + ['ConnectionFailed'] = "Connection failed: %s", + ['LaunchFailed'] = "Launch failed: %s", + ['LobbyFull'] = "The game lobby is full.", + + -- Error reasons + ['StartSpots'] = "The map does not support this number of players.", + ['NoConfig'] = "No valid game configurations found.", + ['NoObservers'] = "Observers not allowed.", + ['KickedByHost'] = "Kicked by host.", + ['GameLaunched'] = "Game was launched.", + ['NoLaunchLimbo'] = "No clients allowed in limbo at launch", + ['HostLeft'] = "Host abandoned lobby", + ['LaunchRejected'] = "Some players are using an incompatible client version.", +} + +---@class UIAutolobbyPlayer: UILobbyLaunchPlayerConfiguration +---@field StartSpot number +---@field DEV number # Related to rating/divisions +---@field MEAN number # Related to rating/divisions +---@field NG number # Related to rating/divisions +---@field DIV string # Related to rating/divisions +---@field SUBDIV string # Related to rating/divisions +---@field PL number # Related to rating/divisions + +---@alias UIAutolobbyConnections boolean[][] +---@alias UIAutolobbyStatus UIPeerLaunchStatus[] + +---@class UIAutolobbyParameters +---@field Protocol UILobbyProtocol +---@field LocalPort number +---@field MaxConnections number +---@field DesiredPlayerName string +---@field LocalPlayerPeerId UILobbyPeerId +---@field NatTraversalProvider any + +---@class UIAutolobbyHostParameters +---@field GameName string +---@field ScenarioFile string # path to the _scenario.lua file +---@field SinglePlayer boolean + +---@class UIAutolobbyJoinParameters +---@field Address GPGNetAddress +---@field AsObserver boolean +---@field DesiredPlayerName string +---@field DesiredPeerId UILobbyPeerId + +--- Responsible for the behavior of the automated lobby. +---@class UIAutolobbyCommunications : moho.lobby_methods, DebugComponent, UIAutolobbyServerCommunicationsComponent +---@field Trash TrashBag +---@field LocalPeerId UILobbyPeerId # a number that is stringified +---@field LocalPlayerName string # nickname +---@field HostID UILobbyPeerId +---@field PlayerCount number # Originates from the command line +---@field GameMods UILobbyLaunchGameModsConfiguration[] +---@field GameOptions UILobbyLaunchGameOptionsConfiguration # Is synced from the host via `SendData` or `BroadcastData`. +---@field PlayerOptions UIAutolobbyPlayer[] # Is synced from the host via `SendData` or `BroadcastData`. +---@field ConnectionMatrix table # Is synced between players via `EstablishedPeers` +---@field LaunchStatutes table # Is synced between players via `BroadcastData` +---@field LobbyParameters? UIAutolobbyParameters # Used for rejoining functionality +---@field HostParameters? UIAutolobbyHostParameters # Used for rejoining functionality +---@field JoinParameters? UIAutolobbyJoinParameters # Used for rejoining functionality +AutolobbyCommunications = Class(MohoLobbyMethods, AutolobbyServerCommunicationsComponent, DebugComponent) { + + ---@param self UIAutolobbyCommunications + __init = function(self) + self.Trash = TrashBag() + + self.LocalPeerId = "-2" + self.LocalPlayerName = "Charlie" + self.PlayerCount = tonumber(GetCommandLineArg("/players", 1)[1]) or 2 + self.HostID = "-2" + + self.GameMods = {} + self.GameOptions = self:CreateLocalGameOptions() + self.PlayerOptions = {} + self.LaunchStatutes = {} + self.ConnectionMatrix = {} + end, + + ---@param self UIAutolobbyCommunications + __post_init = function(self) + + end, + + --- Creates a table that represents the local player settings. This represents the initial player. It can be edited by the host accordingly. + ---@param self UIAutolobbyCommunications + ---@return UIAutolobbyPlayer + CreateLocalPlayer = function(self) + ---@type UIAutolobbyPlayer + local info = {} + + info.Human = true + info.Civilian = false + + -- determine player name + info.PlayerName = self.LocalPlayerName or self:GetLocalPlayerName() or "player" + + -- retrieve faction + info.Faction = 1 + local factionData = import("/lua/factions.lua") + for index, tbl in factionData.Factions do + if HasCommandLineArg("/" .. tbl.Key) then + info.Faction = index + break + end + end + + -- retrieve team and start spot + info.Team = tonumber(GetCommandLineArg("/team", 1)[1]) + info.StartSpot = tonumber(GetCommandLineArg("/startspot", 1)[1]) or -1 -- TODO + + -- determine army color based on start location + info.PlayerColor = GameColors.MapToWarmCold(info.StartSpot) + info.ArmyColor = GameColors.MapToWarmCold(info.StartSpot) + + -- retrieve rating + info.DEV = tonumber(GetCommandLineArg("/deviation", 1)[1]) or 500 + info.MEAN = tonumber(GetCommandLineArg("/mean", 1)[1]) or 1500 + info.NG = tonumber(GetCommandLineArg("/numgames", 1)[1]) or 0 + info.DIV = (GetCommandLineArg("/division", 1)[1]) or "" + info.SUBDIV = (GetCommandLineArg("/subdivision", 1)[1]) or "" + info.PL = math.floor(info.MEAN - 3 * info.DEV) + + return info + end, + + --- Creates a table that represents the local game options. + ---@param self UIAutolobbyCommunications + ---@return UILobbyLaunchGameOptionsConfiguration + CreateLocalGameOptions = function(self) + ---@type UILobbyLaunchGameOptionsConfiguration + local options = { + Score = 'no', + TeamSpawn = 'fixed', + TeamLock = 'locked', + Victory = 'demoralization', + Timeouts = '3', + CheatsEnabled = 'false', + CivilianAlliance = 'enemy', + RevealCivilians = 'Yes', + GameSpeed = 'normal', + FogOfWar = 'explored', + UnitCap = '1500', + PrebuiltUnits = 'Off', + Share = 'FullShare', + ShareUnitCap = 'allies', + DisconnectionDelay02 = '90', + + -- yep, great + Ranked = true, + Unranked = 'No', + } + + -- process game options from the command line + for name, value in Utils.GetCommandLineArgTable("/gameoptions") do + if name and value then + options[name] = value + else + LOG("Malformed gameoption. ignoring name: " .. repr(name) .. " and value: " .. repr(value)) + end + end + + return options + end, + + --------------------------------------------------------------------------- + --#region Utilities + + ---@param self UIAutolobbyCommunications + ---@param playerOptions UIAutolobbyPlayer[] + ---@param connectionMatrix table + ---@return UIAutolobbyConnections + CreateConnectionsMatrix = function(self, playerOptions, connectionMatrix) + ---@type UIAutolobbyConnections + local connections = {} + + -- initial setup + for y = 1, self.PlayerCount do + connections[y] = {} + for x = 1, self.PlayerCount do + connections[y][x] = false + end + end + + -- populate the matrix + for peerId, establishedPeers in connectionMatrix do + for _, peerConnectedToId in establishedPeers do + local peerIdNumber = self:PeerIdToIndex(playerOptions, peerId) + local peerConnectedToIdNumber = self:PeerIdToIndex(playerOptions, peerConnectedToId) + + -- connection works both ways + if peerIdNumber and peerConnectedToIdNumber then + if peerIdNumber > self.PlayerCount or peerConnectedToIdNumber > self.PlayerCount then + self:DebugWarn("Invalid peer id", peerIdNumber, peerConnectedToIdNumber) + else + connections[peerIdNumber][peerConnectedToIdNumber] = true + connections[peerConnectedToIdNumber][peerIdNumber] = true + end + end + end + end + + return connections + end, + + ---@param self UIAutolobbyCommunications + ---@param playerOptions UIAutolobbyPlayer[] + ---@param statuses table + ---@return UIAutolobbyStatus + CreateConnectionStatuses = function(self, playerOptions, statuses) + local output = {} + for peerId, launchStatus in statuses do + local peerIdNumber = self:PeerIdToIndex(playerOptions, peerId) + if peerIdNumber then + output[peerIdNumber] = launchStatus + end + end + + return output + end, + + ---@param self UIAutolobbyCommunications + ---@param playerCount number + ---@param localIndex number + ---@return boolean[][] + CreateOwnershipMatrix = function(self, playerCount, localIndex) + local output = {} + for y = 1, playerCount do + output[y] = {} + for x = 1, playerCount do + output[y][x] = false + end + end + + for k = 1, playerCount do + output[localIndex][k] = true + output[k][localIndex] = true + end + return output + end, + + --- Determines the launch status of the local peer. + ---@param self UIAutolobbyCommunications + ---@param connectionMatrix table + ---@return UIPeerLaunchStatus + CreateLaunchStatus = function(self, connectionMatrix) + -- check number of peers + local validPeerCount = self.PlayerCount - 1 + if table.getsize(connectionMatrix) < validPeerCount then + return 'Missing local peers' + end + + return 'Ready' + end, + + --- Verifies whether we can launch the game. + ---@param self UIAutolobbyCommunications + ---@param peerStatus UIAutolobbyStatus + ---@return boolean + CanLaunch = function(self, peerStatus) + -- check if we know of all peers + if table.getsize(peerStatus) ~= self.PlayerCount then + return false + end + + -- check if all peers are ready for launch + for k, launchStatus in peerStatus do + if launchStatus ~= 'Ready' then + return false + end + end + + return true + end, + + --- Maps a peer id to an index that can be used in the interface. In + --- practice the peer id can be all over the place, ranging from -1 + --- to numbers such as 35240. With this function we map it to a sane + --- index that we can use in the interface. + ---@param self UIAutolobbyCommunications + ---@param playerOptions UIAutolobbyPlayer[] + ---@param peerId UILobbyPeerId + ---@return number | false + PeerIdToIndex = function(self, playerOptions, peerId) + if type(peerId) ~= 'string' then + self:DebugWarn("Invalid peer id", peerId) + return false + end + + -- try to find matching player options + if playerOptions then + for k, options in playerOptions do + if options.OwnerID == peerId then + if options.StartSpot then + return options.StartSpot + end + end + end + end + + return false + end, + + --- Prefetches a scenario to try and reduce the loading screen time. + ---@param self UIAutolobbyCommunications + ---@param gameOptions UILobbyLaunchGameOptionsConfiguration + ---@param gameMods UILobbyLaunchGameModsConfiguration[] + Prefetch = function(self, gameOptions, gameMods) + local scenarioPath = gameOptions.ScenarioFile + if not scenarioPath then + return + end + + local scenarioFile = MapUtil.LoadScenario(gameOptions.ScenarioFile) + if not scenarioFile then + -- ??? + return + end + + PrefetchSession(scenarioFile.map, gameMods, true) + end, + + ---@param self UIAutolobbyCommunications + ---@param lobbyParameters UIAutolobbyParameters + ---@param joinParameters UIAutolobbyJoinParameters + Rejoin = function(self, lobbyParameters, joinParameters) + local autolobbyModule = import("/lua/ui/lobby/autolobby.lua") + + -- start disposing threads to prevent race conditions + self.Trash:Destroy() + + ForkThread( + function() + self:SendLaunchStatusToServer('Rejoining') + + -- prevent race condition on network + WaitSeconds(1.0) + + -- inform peers and server that we're rejoining + self:BroadcastData({ Type = "UpdateLaunchStatus", LaunchStatus = 'Rejoining' }) + + -- prevent race condition on network + WaitSeconds(1.0) + + -- create a new lobby + self:Destroy() + + -- prevent race conditions + WaitSeconds(1.0) + local newLobby = autolobbyModule.CreateLobby( + lobbyParameters.Protocol, + lobbyParameters.LocalPort, + lobbyParameters.DesiredPlayerName, + lobbyParameters.LocalPlayerPeerId, + lobbyParameters.NatTraversalProvider + ) + + -- wait a bit before we join + WaitSeconds(1.0) + + autolobbyModule.JoinGame(joinParameters.Address, joinParameters.AsObserver, + joinParameters.DesiredPlayerName, + joinParameters.DesiredPeerId) + end + ) + end, + + + --------------------------------------------------------------------------- + --#region Threads + + ---@param self UIAutolobbyCommunications + CheckForRejoinThread = function(self) + + local rejoinThreshold = 3 + local rejoinCount = 0 + + while not IsDestroyed(self) do + + -- check if we're ready to launch + if self.LaunchStatutes[self.LocalPeerId] ~= 'Ready' then + + -- if we're not, check the status of peers + local onePeerIsRejoining = false + local onePeerIsReady = false + for k, launchStatus in self.LaunchStatutes do + onePeerIsReady = onePeerIsReady or (launchStatus == 'Ready') + onePeerIsRejoining = onePeerIsRejoining or (launchStatus == 'Rejoining') + end + + if onePeerIsReady then + rejoinCount = rejoinCount + 1 + end + + -- try to not rejoin at the same time + if onePeerIsRejoining then + rejoinCount = 0 + end + else + rejoinCount = 0 + end + + -- if we reached the threshold, time to rejoin! + if rejoinCount > rejoinThreshold then + self:Rejoin(self.LobbyParameters, self.JoinParameters) + end + + WaitSeconds(1.0 + 1 * Random()) + end + end, + + --- Passes the local launch status to all peers. + ---@param self UIAutolobbyCommunications + ShareLaunchStatusThread = function(self) + while not IsDestroyed(self) do + local launchStatus = self:CreateLaunchStatus(self.ConnectionMatrix) + self.LaunchStatutes[self.LocalPeerId] = launchStatus + + -- update peers + self:BroadcastData({ Type = "UpdateLaunchStatus", LaunchStatus = launchStatus }) + + -- update server + self:SendLaunchStatusToServer(launchStatus) + + -- update UI for launch statuses + import("/lua/ui/lobby/autolobby/AutolobbyInterface.lua").GetSingleton() + :UpdateLaunchStatuses(self:CreateConnectionStatuses(self.PlayerOptions, self.LaunchStatutes)) + + WaitSeconds(2.0) + end + end, + + ---@param self UIAutolobbyCommunications + LaunchThread = function(self) + while not IsDestroyed(self) do + if self:CanLaunch(self.LaunchStatutes) then + + WaitSeconds(5.0) + if (not IsDestroyed(self)) and self:CanLaunch(self.LaunchStatutes) then + + -- send player options to the server + for slot, playerOptions in self.PlayerOptions do + local ownerId = playerOptions.OwnerID + self:SendPlayerOptionToServer(ownerId, 'Team', playerOptions.Team) + self:SendPlayerOptionToServer(ownerId, 'Army', playerOptions.StartSpot) + self:SendPlayerOptionToServer(ownerId, 'StartSpot', playerOptions.StartSpot) + self:SendPlayerOptionToServer(ownerId, 'Faction', playerOptions.Faction) + end + + -- create game configuration + local gameConfiguration = { + GameMods = self.GameMods, + GameOptions = self.GameOptions, + PlayerOptions = self.PlayerOptions, + Observers = {}, + } + + -- send it to all players and tell them to launch + self:BroadcastData({ Type = "Launch", GameConfig = gameConfiguration }) + self:LaunchGame(gameConfiguration) + end + end + + WaitSeconds(1.0) + end + end, + + --#endregion + + --------------------------------------------------------------------------- + --#region Message Handlers + -- + -- All the message functions in this section run asynchroniously on each + -- client. They are responsible for processing the data received from + -- other peers. Validation is done in `AutolobbyMessages` before the message + -- processed. + + ---@param self UIAutolobbyCommunications + ---@param data UIAutolobbyAddPlayerMessage + ProcessAddPlayerMessage = function(self, data) + ---@type UIAutolobbyPlayer + local playerOptions = data.PlayerOptions + + -- override some data + playerOptions.OwnerID = data.SenderID + playerOptions.PlayerName = self:MakeValidPlayerName(playerOptions.OwnerID, playerOptions.PlayerName) + + -- TODO: verify that the StartSpot is not occupied + -- put the player where it belongs + self.PlayerOptions[playerOptions.StartSpot] = playerOptions + + -- sync game options with the connected peer + self:SendData(data.SenderID, { Type = "UpdateGameOptions", GameOptions = self.GameOptions }) + + -- sync player options to all connected peers + self:BroadcastData({ Type = "UpdatePlayerOptions", PlayerOptions = self.PlayerOptions }) + + -- update UI for player options + import("/lua/ui/lobby/autolobby/AutolobbyInterface.lua").GetSingleton() + :UpdateScenario(self.GameOptions.ScenarioFile, self.PlayerOptions) + + local localIndex = self:PeerIdToIndex(self.PlayerOptions, self.LocalPeerId) + if localIndex then + local ownershipMatrix = self:CreateOwnershipMatrix(self.PlayerCount, localIndex) + import("/lua/ui/lobby/autolobby/AutolobbyInterface.lua").GetSingleton() + :UpdateOwnership(ownershipMatrix) + end + end, + + ---@param self UIAutolobbyCommunications + ---@param data UIAutolobbyUpdatePlayerOptionsMessage + ProcessUpdatePlayerOptionsMessage = function(self, data) + self.PlayerOptions = data.PlayerOptions + + -- update UI for player options + import("/lua/ui/lobby/autolobby/AutolobbyInterface.lua").GetSingleton() + :UpdateScenario(self.GameOptions.ScenarioFile, self.PlayerOptions) + + local localIndex = self:PeerIdToIndex(self.PlayerOptions, self.LocalPeerId) + if localIndex then + local ownershipMatrix = self:CreateOwnershipMatrix(self.PlayerCount, localIndex) + -- update UI for player options + import("/lua/ui/lobby/autolobby/AutolobbyInterface.lua").GetSingleton() + :UpdateOwnership(ownershipMatrix) + end + end, + + ---@param self UIAutolobbyCommunications + ---@param data UIAutolobbyUpdateGameOptionsMessage + ProcessUpdateGameOptionsMessage = function(self, data) + self.GameOptions = data.GameOptions + + self:Prefetch(self.GameOptions, self.GameMods) + + -- update UI for game options + import("/lua/ui/lobby/autolobby/AutolobbyInterface.lua").GetSingleton() + :UpdateScenario(self.GameOptions.ScenarioFile, self.PlayerOptions) + end, + + ---@param self UIAutolobbyCommunications + ---@param data UIAutolobbyLaunchMessage + ProcessLaunchMessage = function(self, data) + self:LaunchGame(data.GameConfig) + end, + + ---@param self UIAutolobbyCommunications + ---@param data UIAutolobbyUpdateLaunchStatusMessage + ProcessUpdateLaunchStatusMessage = function(self, data) + self.LaunchStatutes[data.SenderID] = data.LaunchStatus + + -- update UI for launch statuses + import("/lua/ui/lobby/autolobby/AutolobbyInterface.lua").GetSingleton() + :UpdateLaunchStatuses(self:CreateConnectionStatuses(self.PlayerOptions, self.LaunchStatutes)) + end, + + --#endregion + + --------------------------------------------------------------------------- + --#region Engine interface + + --- Broadcasts data to all (connected) peers. + ---@param self UIAutolobbyCommunications + ---@param data UILobbyData + BroadcastData = function(self, data) + self:DebugSpew("BroadcastData", data.Type) + + -- validate message type + local message = AutolobbyMessages[data.Type] + if not message then + self:DebugWarn("Blocked broadcasting unknown message type", data.Type) + return + end + + -- validate message format + if not message.Validate(self, data) then + self:DebugWarn("Blocked broadcasting malformed message of type", data.Type) + return + end + + return MohoLobbyMethods.BroadcastData(self, data) + end, + + --- (Re)Connects to a peer. + ---@param self any + ---@param address any + ---@param name any + ---@param peerId UILobbyPeerId + ---@return nil + ConnectToPeer = function(self, address, name, peerId) + self:DebugSpew("ConnectToPeer", address, name, peerId) + return MohoLobbyMethods.ConnectToPeer(self, address, name, peerId) + end, + + --- ??? + ---@param self UIAutolobbyCommunications + ---@return nil + DebugDump = function(self) + self:DebugSpew("DebugDump") + return MohoLobbyMethods.DebugDump(self) + end, + + --- Destroys the C-object and all the (UI) entities in the trash bag. + ---@param self UIAutolobbyCommunications + ---@return nil + Destroy = function(self) + self:DebugSpew("Destroy") + + self.Trash:Destroy() + return MohoLobbyMethods.Destroy(self) + end, + + --- Disconnects from a peer. + --- See also `ConnectToPeer` to connect + ---@param self UIAutolobbyCommunications + ---@param peerId UILobbyPeerId + ---@return nil + DisconnectFromPeer = function(self, peerId) + self:DebugSpew("DisconnectFromPeer", peerId) + + return MohoLobbyMethods.DisconnectFromPeer(self, peerId) + end, + + --- Ejects a peer from the lobby. + ---@param self UIAutolobbyCommunications + ---@param peerId UILobbyPeerId + ---@param reason string + ---@return nil + EjectPeer = function(self, peerId, reason) + self:DebugSpew("EjectPeer", peerId, reason) + return MohoLobbyMethods.EjectPeer(self, peerId, reason) + end, + + --- Retrieves the local identifier. + ---@param self UIAutolobbyCommunications + ---@return UILobbyPeerId + GetLocalPlayerID = function(self) + self:DebugSpew("GetLocalPlayerID") + return MohoLobbyMethods.GetLocalPlayerID(self) + end, + + --- Retrieves the local name. Note that this name can be overwritten by the host via `MakeValidPlayerName` + ---@param self UIAutolobbyCommunications + ---@return string + GetLocalPlayerName = function(self) + self:DebugSpew("GetLocalPlayerName") + return MohoLobbyMethods.GetLocalPlayerName(self) + end, + + --- Retrieves the local port. + ---@param self any + ---@return number|nil + GetLocalPort = function(self) + self:DebugSpew("GetLocalPort") + return MohoLobbyMethods.GetLocalPort(self) + end, + + --- Retrieves information about a peer. See `GetPeers` to get the same information for all connected peers. + ---@param self UIAutolobbyCommunications + ---@param peerId UILobbyPeerId + ---@return Peer + GetPeer = function(self, peerId) + self:DebugSpew("GetPeer", peerId) + return MohoLobbyMethods.GetPeer(self, peerId) + end, + + --- Retrieves information about all connected peers. See `GetPeer` to get information for a specific peer. + ---@param self UIAutolobbyCommunications + GetPeers = function(self) + -- self:DebugSpew("GetPeers") + return MohoLobbyMethods.GetPeers(self) + end, + + --- Transforms the lobby to be discoveryable and joinable for other players. + ---@param self UIAutolobbyCommunications + ---@return nil + HostGame = function(self) + self:DebugSpew("HostGame") + return MohoLobbyMethods.HostGame(self) + end, + + --- Retrieves whether the local client is the host. + ---@param self any + ---@return boolean + IsHost = function(self) + self:DebugSpew("IsHost") + return MohoLobbyMethods.IsHost(self) + end, + + --- Join a lobby that is set to be a host. + ---@param self UIAutolobbyCommunications + ---@param address GPGNetAddress + ---@param remotePlayerName string + ---@param remotePlayerPeerId UILobbyPeerId + ---@return nil + JoinGame = function(self, address, remotePlayerName, remotePlayerPeerId) + self:DebugSpew("JoinGame", address, remotePlayerName, remotePlayerPeerId) + return MohoLobbyMethods.JoinGame(self, address, remotePlayerName, remotePlayerPeerId) + end, + + --- Launches the game for the local client. The game configuration that is passed in should originate from the host. + ---@param self UIAutolobbyCommunications + ---@param gameConfig UILobbyLaunchConfiguration + ---@return nil + LaunchGame = function(self, gameConfig) + self:DebugSpew("LaunchGame") + self:DebugSpew(reprs(gameConfig, { depth = 10 })) + + self:SendGameStateToServer('Launching') + return MohoLobbyMethods.LaunchGame(self, gameConfig) + end, + + --- Returns a valid game name. + ---@param self UIAutolobbyCommunications + ---@param name string + ---@return string + MakeValidGameName = function(self, name) + + self:DebugSpew("MakeValidGameName", name) + return MohoLobbyMethods.MakeValidGameName(self, name) + end, + + --- Returns a valid player name. + ---@param self UIAutolobbyCommunications + ---@param peerId UILobbyPeerId + ---@param name string + ---@return string + MakeValidPlayerName = function(self, peerId, name) + self:DebugSpew("MakeValidPlayerName", peerId, name) + return MohoLobbyMethods.MakeValidPlayerName(self, peerId, name) + end, + + ---@param self UIAutolobbyCommunications + ---@param peerId UILobbyPeerId + ---@param data UILobbyData + ---@return nil + SendData = function(self, peerId, data) + self:DebugSpew("SendData", peerId, data.Type) + + -- validate message type + local message = AutolobbyMessages[data.Type] + if not message then + self:DebugWarn("Blocked sending unknown message type", data.Type, "to", peerId) + return + end + + -- validate message type + if not message.Validate(self, data) then + self:DebugWarn("Blocked sending malformed message of type", data.Type, "to", peerId) + return + end + + return MohoLobbyMethods.SendData(self, peerId, data) + end, + + --#endregion + + --------------------------------------------------------------------------- + --#region Connection events + + --- Called by the engine as we're trying to host a lobby. + ---@param self UIAutolobbyCommunications + Hosting = function(self) + self:DebugSpew("Hosting") + + self.LocalPeerId = self:GetLocalPlayerID() + self.LocalPlayerName = self:GetLocalPlayerName() + self.HostID = self:GetLocalPlayerID() + + -- give ourself a seat at the table + local hostPlayerOptions = self:CreateLocalPlayer() + hostPlayerOptions.OwnerID = self.LocalPeerId + hostPlayerOptions.PlayerName = self:MakeValidPlayerName(self.LocalPeerId, self.LocalPlayerName) + self.PlayerOptions[hostPlayerOptions.StartSpot] = hostPlayerOptions + + -- occasionally send data over the network to create pings on screen + self.Trash:Add(ForkThread(self.ShareLaunchStatusThread, self)) + self.Trash:Add(ForkThread(self.LaunchThread, self)) + + -- start prefetching the scenario + self:Prefetch(self.GameOptions, self.GameMods) + + self:SendGameStateToServer('Lobby') + self:SendLaunchStatusToServer('Hosting') + + -- update UI for game options + import("/lua/ui/lobby/autolobby/AutolobbyInterface.lua").GetSingleton() + :UpdateScenario(self.GameOptions.ScenarioFile, self.PlayerOptions) + end, + + --- Called by the engine as we're trying to join a lobby. + ---@param self UIAutolobbyCommunications + Connecting = function(self) + self:DebugSpew("Connecting") + self:SendLaunchStatusToServer('Connecting') + end, + + --- Called by the engine when the connection fails. + ---@param self UIAutolobbyCommunications + ---@param reason string # reason for connection failure, populated by the engine + ConnectionFailed = function(self, reason) + self:DebugSpew("ConnectionFailed", reason) + + -- try to rejoin + self:Rejoin(self.LobbyParameters, self.JoinParameters) + end, + + --- Called by the engine when the connection succeeds with the host. + ---@param self UIAutolobbyCommunications + ---@param localPeerId UILobbyPeerId + ---@param hostPeerId string + ConnectionToHostEstablished = function(self, localPeerId, newLocalName, hostPeerId) + self:DebugSpew("ConnectionToHostEstablished", localPeerId, newLocalName, hostPeerId) + self.LocalPlayerName = newLocalName + self.LocalPeerId = localPeerId + self.HostID = hostPeerId + + self:SendGameStateToServer('Lobby') + + -- occasionally send data over the network to create pings on screen + self.Trash:Add(ForkThread(self.ShareLaunchStatusThread, self)) + -- self.Trash:Add(ForkThread(self.CheckForRejoinThread, self)) -- disabled, for now + + self:SendData(self.HostID, { Type = "AddPlayer", PlayerOptions = self:CreateLocalPlayer() }) + end, + + --- Called by the engine when a peer establishes a connection. + ---@param self UIAutolobbyCommunications + ---@param peerId UILobbyPeerId + ---@param peerConnectedTo UILobbyPeerId[] # all established conenctions for the given player + EstablishedPeers = function(self, peerId, peerConnectedTo) + self:DebugSpew("EstablishedPeers", peerId, reprs(peerConnectedTo)) + + -- update server + self:SendEstablishedPeer(peerId) + + self.LaunchStatutes[peerId] = self.LaunchStatutes[peerId] or 'Unknown' + -- update UI for launch statuses + import("/lua/ui/lobby/autolobby/AutolobbyInterface.lua").GetSingleton() + :UpdateLaunchStatuses(self:CreateConnectionStatuses(self.PlayerOptions, self.LaunchStatutes)) + + -- update the matrix and the UI + self.ConnectionMatrix[peerId] = peerConnectedTo + local connections = self:CreateConnectionsMatrix(self.PlayerOptions, self.ConnectionMatrix) + import("/lua/ui/lobby/autolobby/AutolobbyInterface.lua").GetSingleton() + :UpdateConnections(connections) + end, + + --#endregion + + --------------------------------------------------------------------------- + --#region Lobby events + + --- Called by the engine when you are ejected from a lobby. + ---@param self UIAutolobbyCommunications + ---@param reason string # reason for disconnection, populated by the host + Ejected = function(self, reason) + self:DebugSpew("Ejected", reason) + self:SendLaunchStatusToServer('Ejected') + end, + + --- ??? + ---@param self UIAutolobbyCommunications + ---@param text string + SystemMessage = function(self, text) + self:DebugSpew("SystemMessage", text) + end, + + --- Called by the engine when we receive data from other players. There is no checking to see if the data is legitimate, these need to be done in Lua. + --- + --- Data can be send via `BroadcastData` and/or `SendData`. + ---@param self UIAutolobbyCommunications + ---@param data UILobbyReceivedMessage + DataReceived = function(self, data) + self:DebugSpew("DataReceived", data.Type, data.SenderID, data.SenderName) + + -- signal UI that we received something + local peerIndex = self:PeerIdToIndex(self.PlayerOptions, data.SenderID) + if peerIndex then + import("/lua/ui/lobby/autolobby/AutolobbyInterface.lua").GetSingleton() + :UpdateIsAliveStamp(peerIndex) + end + + -- validate message type + local message = AutolobbyMessages[data.Type] + if not message then + self:DebugWarn("Ignoring unknown message type", data.Type, "from", data.SenderID) + return + end + + -- validate message data + if not message.Validate(self, data) then + self:DebugWarn("Ignoring malformed message of type", data.Type, "from", data.SenderID) + return + end + + -- validate message source + if not message.Accept(self, data) then + self:DebugWarn("Message rejected: ", data.Type) + return + end + + -- handle the message + message.Handler(self, data) + end, + + --- Called by the engine when the game configuration is requested by the discovery service. + ---@param self UIAutolobbyCommunications + GameConfigRequested = function(self) + self:DebugSpew("GameConfigRequested") + end, + + --- Called by the engine when a peer disconnects. + ---@param self UIAutolobbyCommunications + ---@param peerName string + ---@param peerId UILobbyPeerId + PeerDisconnected = function(self, peerName, peerId) + self:DebugSpew("PeerDisconnected", peerName, peerId) + self:SendDisconnectedPeer(peerId) + end, + + --- Called by the engine when the game is launched. + ---@param self UIAutolobbyCommunications + GameLaunched = function(self) + self:DebugSpew("GameLaunched") + + -- clear out the interface + import("/lua/ui/lobby/autolobby/AutolobbyInterface.lua").GetSingleton():Destroy() + + -- destroy ourselves, the game takes over the management of peers + self:Destroy() + + self:SendGameStateToServer('Launching') + end, + + --- Called by the engine when the launch failed. + ---@param self UIAutolobbyCommunications + ---@param reasonKey string + LaunchFailed = function(self, reasonKey) + self:DebugSpew("LaunchFailed", LOC(reasonKey)) + self:SendLaunchStatusToServer('Failed') + end, + + --#endregion + + --#region Debugging + + ---@param self UIAutolobbyCommunications + ---@param ... any + DebugSpew = function(self, ...) + if not self.EnabledSpewing then + return + end + + SPEW("Autolobby communications", unpack(arg)) + end, + + + ---@param self UIAutolobbyCommunications + ---@param ... any + DebugLog = function(self, ...) + if not self.EnabledLogging then + return + end + + LOG("Autolobby communications", unpack(arg)) + end, + + ---@param self UIAutolobbyCommunications + ---@param ... any + DebugWarn = function(self, ...) + if not self.EnabledWarnings then + return + end + + WARN("Autolobby communications", unpack(arg)) + end, + + ---@param self UIAutolobbyCommunications + ---@param ... any + DebugError = function(self, ...) + if not self.EnabledErrors then + return + end + + local message = "Autolobby communications" + for _, arg in ipairs(arg) do + message = message .. "\t" .. tostring(arg) + end + + error(message) + end, + + --#endregion +} diff --git a/lua/ui/lobby/autolobby/AutolobbyInterface.lua b/lua/ui/lobby/autolobby/AutolobbyInterface.lua new file mode 100644 index 0000000000..e7b1dc9497 --- /dev/null +++ b/lua/ui/lobby/autolobby/AutolobbyInterface.lua @@ -0,0 +1,234 @@ +--****************************************************************************************************** +--** Copyright (c) 2024 Willem 'Jip' Wijnia +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- This module is designed to support a form of 'hot reload' that is seen in modern programming +-- languages. To make this possible there can be only one instance of the class that this module +-- represents. And no direct references of the module and/or of the instance should be kept. In +-- short: +-- +-- - (1) Always import the module whenever you need to interact with it. +-- - (2) Always use the `GetSingleton` helper function to obtain a reference to the instance. + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") + +local Group = import("/lua/maui/group.lua").Group +local AutolobbyMapPreview = import("/lua/ui/lobby/autolobby/AutolobbyMapPreview.lua") +local AutolobbyConnectionMatrix = import("/lua/ui/lobby/autolobby/AutolobbyConnectionMatrix.lua") + +---@class UIAutolobbyInterfaceState +---@field PlayerCount number +---@field PlayerOptions? table +---@field PathToScenarioFile? FileName +---@field GameOptions? UILobbyLaunchGameOptionsConfiguration +---@field Connections? UIAutolobbyConnections +---@field Statuses? UIAutolobbyStatus + +---@class UIAutolobbyInterface : Group +---@field State UIAutolobbyInterfaceState +---@field BackgroundTextures string[] +---@field Background Bitmap +---@field Preview UIAutolobbyMapPreview +---@field ConnectionMatrix UIAutolobbyConnectionMatrix +local AutolobbyInterface = Class(Group) { + + BackgroundTextures = { + "/menus02/background-paint01_bmp.dds", + "/menus02/background-paint02_bmp.dds", + "/menus02/background-paint03_bmp.dds", + "/menus02/background-paint04_bmp.dds", + "/menus02/background-paint05_bmp.dds", + }, + + ---@param self UIAutolobbyInterface + ---@param parent Control + __init = function(self, parent, playerCount) + Group.__init(self, parent, "AutolobbyInterface") + + -- initial, empty state + self.State = { + PlayerCount = playerCount + } + + local backgroundTexture = self.BackgroundTextures[math.random(1, 5)] --[[@as FileName]] + self.Background = UIUtil.CreateBitmap(self, backgroundTexture) + self.Preview = AutolobbyMapPreview.GetInstance(self) + self.ConnectionMatrix = AutolobbyConnectionMatrix.Create(self, playerCount) + end, + + ---@param self UIAutolobbyInterface + ---@param parent Control + __post_init = function(self, parent) + LayoutHelpers.ReusedLayoutFor(self) + :Fill(parent) + :End() + + LayoutHelpers.ReusedLayoutFor(self.Background) + :Fill(self) + :End() + + LayoutHelpers.ReusedLayoutFor(self.Preview) + :AtCenterIn(self, -100, 0) + :Width(400) + :Height(400) + :Hide() + :End() + + LayoutHelpers.ReusedLayoutFor(self.ConnectionMatrix) + :CenteredBelow(self.Preview, 20) + :Hide() + :End() + end, + + ---@param self UIAutolobbyInterface + ---@param ownership boolean[][] + UpdateOwnership = function(self, ownership) + self.State.OwnerShip = ownership + + self.ConnectionMatrix:Show() + self.ConnectionMatrix:UpdateOwnership(ownership) + end, + + ---@param self UIAutolobbyInterface + ---@param connections UIAutolobbyConnections + UpdateConnections = function(self, connections) + self.State.Connections = connections + + self.ConnectionMatrix:Show() + self.ConnectionMatrix:UpdateConnections(connections) + end, + + ---@param self UIAutolobbyInterface + ---@param statuses UIAutolobbyStatus + UpdateLaunchStatuses = function(self, statuses) + self.State.Statuses = statuses + + self.ConnectionMatrix:Show() + self.ConnectionMatrix:UpdateStatuses(statuses) + end, + + ---@param self UIAutolobbyInterface + ---@param pathToScenarioInfo FileName + ---@param playerOptions UIAutolobbyPlayer[] + UpdateScenario = function(self, pathToScenarioInfo, playerOptions) + self.State.PathToScenarioFile = pathToScenarioInfo + self.State.PlayerOptions = playerOptions + + if pathToScenarioInfo and playerOptions then + self.Preview:Show() + self.Preview:UpdateScenario(pathToScenarioInfo, playerOptions) + end + end, + + ---@param self UIAutolobbyInterface + ---@param id number + UpdateIsAliveStamp = function(self, id) + self.ConnectionMatrix:UpdateIsAliveTimestamp(id) + end, + + --#region Debugging + + ---@param self UIAutolobbyInterface + ---@param state UIAutolobbyInterfaceState + RestoreState = function(self, state) + self.State = state + + if state.PathToScenarioFile and state.PlayerOptions then + local ok, msg = pcall(self.UpdateScenario, self, state.PathToScenarioFile, state.PlayerOptions) + if not ok then + WARN(msg) + end + end + + if state.Connections then + local ok, msg = pcall(self.UpdateConnections, self, state.Connections) + if not ok then + WARN(msg) + end + end + + if state.Statuses then + local ok, msg = pcall(self.UpdateLaunchStatuses, self, state.Statuses) + if not ok then + WARN(msg) + end + end + end, + + --#endregion +} + +--- A trashbag that should be destroyed upon reload. +local ModuleTrash = TrashBag() + +---@type UIAutolobbyInterface | false +local AutolobbyInterfaceInstance = false + +---@param playerCount? number +---@return UIAutolobbyInterface +GetSingleton = function(playerCount) + if AutolobbyInterfaceInstance then + return AutolobbyInterfaceInstance + end + + -- default + playerCount = playerCount or 8 + + AutolobbyInterfaceInstance = AutolobbyInterface(GetFrame(0), playerCount) + ModuleTrash:Add(AutolobbyInterfaceInstance) + return AutolobbyInterfaceInstance +end + +---@param playerCount? number +---@return UIAutolobbyInterface +SetupSingleton = function(playerCount) + if AutolobbyInterfaceInstance then + AutolobbyInterfaceInstance:Destroy() + end + + -- default + playerCount = playerCount or tonumber(GetCommandLineArg("/players", 1)[1]) or 8 + + AutolobbyInterfaceInstance = AutolobbyInterface(GetFrame(0), playerCount) + ModuleTrash:Add(AutolobbyInterfaceInstance) + return AutolobbyInterfaceInstance +end + +------------------------------------------------------------------------------- +--#region Debugging + +--- Called by the module manager when this module is reloaded +---@param newModule any +function __moduleinfo.OnReload(newModule) + if AutolobbyInterfaceInstance then + local handle = newModule.SetupSingleton(AutolobbyInterfaceInstance.State.PlayerCount) + handle:RestoreState(AutolobbyInterfaceInstance.State) + end +end + +--- Called by the module manager when this module becomes dirty +function __moduleinfo.OnDirty() + ModuleTrash:Destroy() + import(__moduleinfo.name) +end + +--#endregionGetSingleton diff --git a/lua/ui/lobby/autolobby/AutolobbyMapPreview.lua b/lua/ui/lobby/autolobby/AutolobbyMapPreview.lua new file mode 100644 index 0000000000..381150d500 --- /dev/null +++ b/lua/ui/lobby/autolobby/AutolobbyMapPreview.lua @@ -0,0 +1,298 @@ +--****************************************************************************************************** +--** Copyright (c) 2024 Willem 'Jip' Wijnia +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +local UIUtil = import("/lua/ui/uiutil.lua") +local MapUtil = import("/lua/ui/maputil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") + +local Group = import("/lua/maui/group.lua").Group +local MapPreview = import("/lua/ui/controls/mappreview.lua").MapPreview +local AutolobbyMapPreviewSpawn = import("/lua/ui/lobby/autolobby/AutolobbyMapPreviewSpawn.lua") + +---@class UIAutolobbyMapPreview : Group +---@field Preview MapPreview +---@field Overlay Bitmap +---@field PathToScenarioFile? FileName +---@field ScenarioInfo? UILobbyScenarioInfo +---@field ScenarioSave? UIScenarioSaveFile +---@field EnergyIcon Bitmap # Acts as a pool +---@field MassIcon Bitmap # Acts as a pool +---@field WreckageIcon Bitmap # Acts as a pool +---@field IconTrash TrashBag # Trashbag that contains all icons +---@field SpawnIcons UIAutolobbyMapPreviewSpawn[] +local AutolobbyMapPreview = ClassUI(Group) { + + ---@param self UIAutolobbyMapPreview + ---@param parent Control + __init = function(self, parent) + Group.__init(self, parent) + + self.Preview = MapPreview(self) + + -- D:\SteamLibrary\steamapps\common\Supreme Commander Forged Alliance\gamedata\textures\textures\ui\common\game\mini-map-glow-brd ? + self.Overlay = UIUtil.CreateBitmap(self, '/scx_menu/gameselect/map-panel-glow_bmp.dds') + + self.EnergyIcon = UIUtil.CreateBitmap(self, "/game/build-ui/icon-energy_bmp.dds") + self.MassIcon = UIUtil.CreateBitmap(self, "/game/build-ui/icon-mass_bmp.dds") + self.WreckageIcon = UIUtil.CreateBitmap(self, "/scx_menu/lan-game-lobby/mappreview/wreckage.dds") + self.SpawnIcons = {} + + UIUtil.CreateDialogBrackets(self, 30, 24, 30, 24) + + self.IconTrash = TrashBag() + end, + + ---@param self UIAutolobbyMapPreview + ---@param parent Control + __post_init = function(self, parent) + LayoutHelpers.ReusedLayoutFor(self.Overlay) + :Fill(self) + :DisableHitTest(true) + :End() + + LayoutHelpers.ReusedLayoutFor(self.Preview) + :FillFixedBorder(self.Overlay, 24) + :End() + + LayoutHelpers.ReusedLayoutFor(self.EnergyIcon) + :Hide() + :End() + + LayoutHelpers.ReusedLayoutFor(self.MassIcon) + :Hide() + :End() + + LayoutHelpers.ReusedLayoutFor(self.WreckageIcon) + :Hide() + :End() + end, + + --- Creates an icon that shares the texture with a source. + --- + --- This function is private and should not be called from outside the class. + ---@param self UIAutolobbyMapPreview + ---@param icon Control + ---@param scenarioWidth number + ---@param scenarioHeight number + ---@param px number + ---@param pz number + PositionIcon = function(self, icon, scenarioWidth, scenarioHeight, px, pz) + local size = self.Preview.Width() + local xOffset = 0 + local xFactor = 1 + local yOffset = 0 + local yFactor = 1 + if scenarioWidth > scenarioHeight then + local ratio = scenarioHeight / scenarioWidth -- 1/2 + yOffset = ((size / ratio) - size) / 4 + yFactor = ratio + else + local ratio = scenarioWidth / scenarioHeight + xOffset = ((size / ratio) - size) / 4 + xFactor = ratio + end + + local x = xOffset + (px / scenarioWidth) * (size - 2) * xFactor + local z = yOffset + (pz / scenarioHeight) * (size - 2) * yFactor + + -- position it + LayoutHelpers.ReusedLayoutFor(icon) + :AtLeftTopIn(self.Preview, x - 0.5 * icon.Width(), z - 0.5 * icon.Height()) + :End() + + return icon + end, + + --- Creates the map preview. + --- + --- This function is private and should not be called from outside the class. + ---@param self UIAutolobbyMapPreview + ---@param scenarioInfo UILobbyScenarioInfo + _UpdatePreview = function(self, scenarioInfo) + if not self.Preview:SetTexture(scenarioInfo.preview) then + self.Preview:SetTextureFromMap(scenarioInfo.map) + end + end, + + --- Creates icons for resource markers. + --- + --- This function is private and should not be called from outside the class. + ---@param self UIAutolobbyMapPreview + ---@param scenarioInfo UILobbyScenarioInfo + ---@param scenarioSave UIScenarioSaveFile + _UpdateMarkers = function(self, scenarioInfo, scenarioSave) + local scenarioWidth = scenarioInfo.size[1] + local scenarioHeight = scenarioInfo.size[2] + + local allmarkers = scenarioSave.MasterChain['_MASTERCHAIN_'].Markers + if not allmarkers then + return + end + + for _, marker in allmarkers do + if marker['type'] == "Mass" then + ---@type Bitmap + local icon = LayoutHelpers.ReusedLayoutFor(self.IconTrash:Add(UIUtil.CreateBitmapColor(self, 'ffffff'))) + :Width(12) + :Height(12) + :End() + + icon:ShareTextures(self.MassIcon) + self:PositionIcon( + icon, scenarioWidth, scenarioHeight, + marker.position[1], marker.position[3] + ) + + elseif marker['type'] == "Hydrocarbon" then + ---@type Bitmap + local icon = LayoutHelpers.ReusedLayoutFor(self.IconTrash:Add(UIUtil.CreateBitmapColor(self, 'ffffff'))) + :Width(12) + :Height(12) + :End() + icon:ShareTextures(self.EnergyIcon) + self:PositionIcon( + icon, scenarioWidth, scenarioHeight, + marker.position[1], marker.position[3] + ) + end + end + end, + + --- Creates icons for wreckages. + --- + --- This function is private and should not be called from outside the class. + ---@param self UIAutolobbyMapPreview + ---@param scenarioInfo UILobbyScenarioInfo + ---@param scenarioSave UIScenarioSaveFile + _UpdateWreckages = function(self, scenarioInfo, scenarioSave) + -- TODO + end, + + --- Creates icons for spawn locations. + --- + --- This function is private and should not be called from outside the class. + ---@param self UIAutolobbyMapPreview + ---@param scenarioInfo UILobbyScenarioInfo + ---@param scenarioSave UIScenarioSaveFile + ---@param playerOptions UIAutolobbyPlayer[] + _UpdateSpawnLocations = function(self, scenarioInfo, scenarioSave, playerOptions) + local spawnIcons = self.SpawnIcons + local positions = MapUtil.GetStartPositionsFromScenario(scenarioInfo, scenarioSave) + if not positions then + -- clean up + for id, icon in spawnIcons do + icon:Destroy() + end + + return + end + + -- clean up + for id, icon in spawnIcons do + if not positions[id] then + icon:Destroy() + end + end + + -- create/update icons + for id, position in positions do + local icon = spawnIcons[id] + if not icon then + icon = AutolobbyMapPreviewSpawn.Create(self) + end + + spawnIcons[id] = icon + + self:PositionIcon( + icon, scenarioInfo.size[1], scenarioInfo.size[2], + position[1], position[2] + ) + + local playerOptions = playerOptions[id] + if playerOptions then + icon:Update(playerOptions.Faction) + else + icon:Reset() + end + end + end, + + --- Updates the map preview, including the mass, energy and wreckage icons. + ---@param self UIAutolobbyMapPreview + ---@param pathToScenarioInfo FileName # a reference to a _scenario.lua file + ---@param playerOptions UIAutolobbyPlayer[] + UpdateScenario = function(self, pathToScenarioInfo, playerOptions) + -- -- make it idempotent + -- if self.PathToScenarioFile ~= pathToScenarioInfo then + -- return + -- end + + -- clear up previous iteration + self.IconTrash:Destroy() + self.Preview:ClearTexture() + self.PathToScenarioFile = pathToScenarioInfo + + -- try and load the scenario info + local scenarioInfo = MapUtil.LoadScenario(pathToScenarioInfo) + if not scenarioInfo then + -- TODO: show default image that indicates something is off + return + end + + self.ScenarioInfo = scenarioInfo + self:_UpdatePreview(scenarioInfo) + + -- try and load the scenario save + local scenarioSave = MapUtil.LoadScenarioSaveFile(scenarioInfo.save) + if not scenarioSave then + return + end + + self.ScenarioSave = scenarioSave + self:_UpdateMarkers(scenarioInfo, scenarioSave) + self:_UpdateWreckages(scenarioInfo, scenarioSave) + + self.PlayerOptions = playerOptions + self:_UpdateSpawnLocations(scenarioInfo, scenarioSave, playerOptions) + end, + + --------------------------------------------------------------------------- + --#region Engine hooks + + ---@param self UIAutolobbyMapPreview + Show = function(self) + Group.Show(self) + + -- do not show the pooled icons + self.EnergyIcon:Hide() + self.MassIcon:Hide() + self.WreckageIcon:Hide() + end, + + --#endregion +} + +---@param parent Control +---@return UIAutolobbyMapPreview +GetInstance = function(parent) + return AutolobbyMapPreview(parent) +end diff --git a/lua/ui/lobby/autolobby/AutolobbyMapPreviewSpawn.lua b/lua/ui/lobby/autolobby/AutolobbyMapPreviewSpawn.lua new file mode 100644 index 0000000000..94afa51766 --- /dev/null +++ b/lua/ui/lobby/autolobby/AutolobbyMapPreviewSpawn.lua @@ -0,0 +1,108 @@ +--****************************************************************************************************** +--** Copyright (c) 2024 Willem 'Jip' Wijnia +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") + +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap + +---@class UIAutolobbyMapPreviewSpawn : Bitmap +---@field Icon Bitmap +---@field Faction? number +local AutolobbyMapPreviewSpawn = ClassUI(Bitmap) { + + BorderPath = "/textures/ui/common/scx_menu/gameselect/map-slot_bmp.dds", + EmptyPath = "/textures/ui/common/dialogs/mapselect02/commander_alpha.dds", + UnknownIconPath = "/textures/ui/common/faction_icon-sm/random_ico.dds", + FactionIconPaths = { + -- faction_icon-lg + -- D:\SteamLibrary\steamapps\common\Supreme Commander Forged Alliance\gamedata\textures\textures\ui\common\dialogs\logo-btn + "/textures/ui/common/faction_icon-lg/uef_med.dds", + "/textures/ui/common/faction_icon-lg/aeon_med.dds", + "/textures/ui/common/faction_icon-lg/cybran_med.dds", + "/textures/ui/common/faction_icon-lg/seraphim_med.dds", + }, + + ---@param self UIAutolobbyMapPreviewSpawn + ---@param parent Control + __init = function(self, parent) + Bitmap.__init(self, parent, self.EmptyPath) + + self.Faction = nil + self:Hide() + end, + + ---@param self UIAutolobbyMapPreviewSpawn + ---@param parent Control + __post_init = function(self, parent) + LayoutHelpers.ReusedLayoutFor(self) + :Width(32) + :Height(32) + :Over(parent, 32) + :End() + end, + + ---@param self UIAutolobbyMapPreviewSpawn + Reset = function(self) + self.Faction = nil + self:Hide() + end, + + ---@param self Control + ---@param event KeyEvent + ---@return boolean + HandleEvent = function(self, event) + if event.Type == 'MouseEnter' then + self:SetAlpha(0.25) + elseif event.Type == 'MouseExit' then + self:SetAlpha(1.0) + end + + return true + end, + + ---@param self UIAutolobbyMapPreviewSpawn + Show = function(self) + if self.Faction then + Bitmap.Show(self) + else + self:Hide() + end + end, + + ---@param self UIAutolobbyMapPreviewSpawn + ---@param faction number + Update = function(self, faction) + local factionIcon = self.FactionIconPaths[faction] + if factionIcon then + self.Faction = faction + self:SetTexture(UIUtil.UIFile(factionIcon)) + self:Show() + end + end, +} + +---@param parent Control +---@return UIAutolobbyMapPreviewSpawn +Create = function(parent) + return AutolobbyMapPreviewSpawn(parent) +end diff --git a/lua/ui/lobby/autolobby/AutolobbyMessages.lua b/lua/ui/lobby/autolobby/AutolobbyMessages.lua new file mode 100644 index 0000000000..597daa97d8 --- /dev/null +++ b/lua/ui/lobby/autolobby/AutolobbyMessages.lua @@ -0,0 +1,245 @@ +--****************************************************************************************************** +--** Copyright (c) 2024 Willem 'Jip' Wijnia +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- This module represents all valid messages that the autolobby accepts from other +-- peers. Messages can be send with `lobby:SendData` or with `lobby:BroadcastData`. +-- Messages are automatically checked to exist and then verified with the `Accept` +-- function. If the message is accepted the handler is called, which is just a +-- wrapper to another function in the autolobby. + +---@class UIAutolobbyMessageHandler +---@field Validate fun(lobby: UIAutolobbyCommunications, data: UILobbyReceivedMessage): boolean # Responsible for filtering out non-sense +---@field Accept fun(lobby: UIAutolobbyCommunications, data: UILobbyReceivedMessage): boolean # Responsible for filtering out malicous messages +---@field Handler fun(lobby: UIAutolobbyCommunications, data: UILobbyReceivedMessage) # Responsible for handling the message + +---@param lobby UIAutolobbyCommunications +---@param data UILobbyReceivedMessage +local function IsFromHost(lobby, data) + return data.SenderID == lobby.HostID +end + +---@param lobby UIAutolobbyCommunications +---@param data UILobbyReceivedMessage +local function IsHost(lobby, data) + return lobby:IsHost() +end + +--- Represents all valid message types that can be sent between peers. +---@type table +AutolobbyMessages = { + UpdateLaunchStatus = { + ---@class UIAutolobbyUpdateLaunchStatusMessage : UILobbyReceivedMessage + ---@field LaunchStatus UIPeerLaunchStatus + + ---@param lobby UIAutolobbyCommunications + ---@param data UIAutolobbyUpdateLaunchStatusMessage + ---@return boolean + Validate = function(lobby, data) + if not data.LaunchStatus then + return false + end + + return true + end, + + ---@param lobby UIAutolobbyCommunications + ---@param data UIAutolobbyUpdateLaunchStatusMessage + ---@return boolean + Accept = function(lobby, data) + return true + end, + + ---@param lobby UIAutolobbyCommunications + ---@param data UIAutolobbyUpdateLaunchStatusMessage + Handler = function(lobby, data) + lobby:ProcessUpdateLaunchStatusMessage(data) + end + }, + + + AddPlayer = { + + ---@class UIAutolobbyAddPlayerMessage : UILobbyReceivedMessage + ---@field PlayerOptions UIAutolobbyPlayer + + ---@param lobby UIAutolobbyCommunications + ---@param data UIAutolobbyAddPlayerMessage + ---@return boolean + Validate = function(lobby, data) + if not data.PlayerOptions then + return false + end + + return true + end, + + ---@param lobby UIAutolobbyCommunications + ---@param data UIAutolobbyAddPlayerMessage + ---@return boolean + Accept = function(lobby, data) + if not IsHost(lobby, data) then + lobby:DebugWarn("Received message for the host peer of type ", data.Type) + return false + end + + -- verify integrity of the message + ---@type UIAutolobbyPlayer + local playerOptions = data.PlayerOptions + if not playerOptions then + lobby:DebugWarn("Received malformed message of type ", data.Type) + return false + end + + -- verify that the player is not already in the lobby + for _, otherPlayerOptions in lobby.PlayerOptions do + if otherPlayerOptions.OwnerID == data.SenderID then + lobby:DebugWarn("Received duplicate message of type ", data.Type) + return false + end + end + + return true + end, + + ---@param lobby UIAutolobbyCommunications + ---@param data UIAutolobbyAddPlayerMessage + Handler = function(lobby, data) + lobby:ProcessAddPlayerMessage(data) + end + }, + + UpdatePlayerOptions = { + + ---@class UIAutolobbyUpdatePlayerOptionsMessage : UILobbyReceivedMessage + ---@field PlayerOptions UIAutolobbyPlayer[] + + ---@param lobby UIAutolobbyCommunications + ---@param data UIAutolobbyUpdatePlayerOptionsMessage + ---@return boolean + Validate = function(lobby, data) + if not data.PlayerOptions then + return false + end + + return true + end, + + ---@param lobby UIAutolobbyCommunications + ---@param data UIAutolobbyUpdatePlayerOptionsMessage + ---@return boolean + Accept = function(lobby, data) + if not IsFromHost(lobby, data) then + lobby:DebugWarn("Received message from non-host peer of type ", data.Type) + return false + end + + return true + end, + + ---@param lobby UIAutolobbyCommunications + ---@param data UIAutolobbyUpdatePlayerOptionsMessage + Handler = function(lobby, data) + lobby:ProcessUpdatePlayerOptionsMessage(data) + end + }, + + UpdateGameOptions = { + + ---@class UIAutolobbyUpdateGameOptionsMessage : UILobbyReceivedMessage + ---@field GameOptions UILobbyLaunchGameOptionsConfiguration + + ---@param lobby UIAutolobbyCommunications + ---@param data UIAutolobbyUpdateGameOptionsMessage + ---@return boolean + Validate = function(lobby, data) + if not data.GameOptions then + return false + end + + return true + end, + + ---@param lobby UIAutolobbyCommunications + ---@param data UIAutolobbyUpdateGameOptionsMessage + ---@return boolean + Accept = function(lobby, data) + if not IsFromHost(lobby, data) then + lobby:DebugWarn("Received message from non-host peer of type ", data.Type) + return false + end + + -- TODO: verify integrity of the message + + return true + end, + + ---@param lobby UIAutolobbyCommunications + ---@param data UIAutolobbyUpdateGameOptionsMessage + Handler = function(lobby, data) + lobby:ProcessUpdateGameOptionsMessage(data) + end + }, + + Launch = { + + ---@class UIAutolobbyLaunchMessage : UILobbyReceivedMessage + ---@field GameConfig UILobbyLaunchConfiguration + + ---@param lobby UIAutolobbyCommunications + ---@param data UIAutolobbyLaunchMessage + ---@return boolean + Validate = function(lobby, data) + if not data.GameConfig then + return false + end + + if not + ( + data.GameConfig.GameMods and data.GameConfig.GameOptions and data.GameConfig.Observers and + data.GameConfig.PlayerOptions) then + return false + end + + return true + end, + + ---@param lobby UIAutolobbyCommunications + ---@param data UIAutolobbyLaunchMessage + ---@return boolean + Accept = function(lobby, data) + if not IsFromHost(lobby, data) then + lobby:DebugWarn("Received message from non-host peer of type ", data.Type) + return false + end + + -- TODO: verify integrity of the message + + return true + end, + + ---@param lobby UIAutolobbyCommunications + ---@param data UIAutolobbyLaunchMessage + Handler = function(lobby, data) + lobby:ProcessLaunchMessage(data) + end + } +} diff --git a/lua/ui/lobby/autolobby/components/AutolobbyServerCommunicationsComponent.lua b/lua/ui/lobby/autolobby/components/AutolobbyServerCommunicationsComponent.lua new file mode 100644 index 0000000000..c3fb273ac1 --- /dev/null +++ b/lua/ui/lobby/autolobby/components/AutolobbyServerCommunicationsComponent.lua @@ -0,0 +1,133 @@ +--****************************************************************************************************** +--** Copyright (c) 2024 Willem 'Jip' Wijnia +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +------------------------------------------------------------------------------- +--#region Game <-> Server communications + +-- All the following logic is tightly coupled with functionality on either the +-- lobby server, the ice adapter, the java server and/or the client. For more +-- context you can search for the various keywords in the following repositories: +-- - Lobby server: https://github.com/FAForever/server +-- - Java Ice adapter: https://github.com/FAForever/java-ice-adapter +-- - Kotlin Ice adapter: https://github.com/FAForever/kotlin-ice-adapter +-- +-- Specifically, the following file processes these messages on the server: +-- - https://github.com/FAForever/server/blob/98271c421412467fa387f3a6530fe8d24e360fa4/server/gameconnection.py + +-- upvalue scope for performance +local GpgNetSend = GpgNetSend + +--- Interpretation of the lobby status of a single peer. +---@alias UILobbyState +---| 'None' +---| 'Idle' +---| 'Lobby' +---| 'Launching' +---| 'Ended' + +--- Interpretation of the lobby launch status of a single peer. +---@alias UIPeerLaunchStatus +--- | 'Unknown' # Initial value, is never send. +--- | 'Connecting' # Send when the local peer is connecting to the lobby +--- | 'Missing local peers' # Send when the local peer is missing other peers +--- | 'Rejoining' # Send when the local peer is rejoining +--- | 'Ready' # Send when the local peer is ready to launch +--- | 'Ejected' # Send when the local peer is ejected +--- | 'Rejected' # Send when there is a game version missmatch +--- | 'Failed' # Send when the game fails to launch + +--- A component that represent all the supported lobby <-> server communications. +---@class UIAutolobbyServerCommunicationsComponent +AutolobbyServerCommunicationsComponent = ClassSimple { + + --- Sends a message to the server to update relevant army options of a player. + ---@param self UIAutolobbyServerCommunicationsComponent | UIAutolobbyCommunications + ---@param peerId UILobbyPeerId + ---@param key 'Team' | 'Army' | 'StartSpot' | 'Faction' + ---@param value any + SendPlayerOptionToServer = function(self, peerId, key, value) + -- message is only accepted by the server if it originates from the host + if not self:IsHost() then + self:DebugWarn("Ignoring server message of type `PlayerOption` since that is only accepted when it originates from the host.") + return + end + + GpgNetSend('PlayerOption', peerId, key, value) + end, + + --- Sends a message to the server to update relevant army options of an AI. + ---@param self UIAutolobbyServerCommunicationsComponent | UIAutolobbyCommunications + ---@param aiName string + ---@param key 'Team' | 'Army' | 'StartSpot' | 'Faction' + ---@param value any + SendAIOptionToServer = function(self, aiName, key, value) + -- message is only accepted by the server if it originates from the host + if not self:IsHost() then + self:DebugWarn("Ignoring server message of type `AIOption` since that is only accepted when it originates from the host.") + return + end + + GpgNetSend('AIOption', aiName, key, value) + end, + + --- Sends a message to the server to update relevant game options. + ---@param self UIAutolobbyServerCommunicationsComponent | UIAutolobbyCommunications + ---@param key 'Slots' | any + ---@param value any + SendGameOptionToServer = function(self, key, value) + -- message is only accepted by the server if it originates from the host + if not self:IsHost() then + self:DebugWarn("Ignoring server message of type `GameOption` since that is only accepted when it originates from the host.") + return + end + + GpgNetSend('GameOption', key, value) + end, + + --- Sends a message to the server indicating what the status of the lobby as a whole. + ---@param self UIAutolobbyServerCommunicationsComponent | UIAutolobbyCommunications + ---@param value UILobbyState + SendGameStateToServer = function(self, value) + GpgNetSend('GameState', value) + end, + + --- sends a message to the server about the status of the local peer. + ---@param self UIAutolobbyServerCommunicationsComponent | UIAutolobbyCommunications + ---@param value UIPeerLaunchStatus + SendLaunchStatusToServer = function(self, value) + GpgNetSend('LaunchStatus', value) + end, + + --- Sends a message to the server that we established a connection to a peer. This message can be send multiple times for the same peer and the server should be idempotent to it. + ---@param self UIAutolobbyServerCommunicationsComponent | UIAutolobbyCommunications + ---@param peerId UILobbyPeerId + SendEstablishedPeer = function(self, peerId) + GpgNetSend('EstablishedPeer', peerId) + end, + + --- Sends a message to the server that we disconnected from a peer. Note that a peer may be trying to rejoin. See also the launch status of the given peer. + ---@param self UIAutolobbyServerCommunicationsComponent | UIAutolobbyCommunications + ---@param peerId UILobbyPeerId + SendDisconnectedPeer = function(self, peerId) + GpgNetSend('DisconnectedPeer', peerId) + end, +} diff --git a/lua/ui/maputil.lua b/lua/ui/maputil.lua index e74974a92a..b7da6605fc 100644 --- a/lua/ui/maputil.lua +++ b/lua/ui/maputil.lua @@ -132,23 +132,57 @@ ---@field PlayableAreaHeight number Syncs when the playable area changes ---@field PlayableRect { [1]: number, [2]: number, [3]: number, [4]: number } Coordinates `{x0, y0, x1, y1}` of the playable area Rectangle. Syncs when the playable area changes. +--- Given the path to a scenario info file, returns a path with the `_scenario.lua` bit removed. +---@param pathToScenarioInfo any +---@return string +local function GetPathToFolder(pathToScenarioInfo) + return string.sub(pathToScenarioInfo, 1, string.len(pathToScenarioInfo) - string.len("scenario.lua")) +end + +--- Given the path to a scenario info file, returns the path to the folder it resides in. +---@param pathToScenarioInfo any +---@return string +local function GetPathToScenario(pathToScenarioInfo) + local splits = StringSplit(pathToScenarioInfo, "/") + return string.sub(pathToScenarioInfo, 1, string.len(pathToScenarioInfo) - string.len(splits[table.getn(splits)])) +end + --- Given the path to a scenario info file, returns the path to the scenario options file. The reference to this file is not stored in the _scenario.lua file. ---@param pathToScenarioInfo FileName ---@return FileName function GetPathToScenarioOptions(pathToScenarioInfo) - return string.sub(pathToScenarioInfo, 1, string.len(pathToScenarioInfo) - string.len("scenario.lua")) .. - "options.lua" --[[@as FileName]] + return GetPathToScenario(pathToScenarioInfo) .. "options.lua" --[[@as FileName]] end --- Given the path to a scenario info file, returns the path to the scenario strings file. The reference to this file is not stored in the _scenario.lua file. ---@param pathToScenarioInfo FileName ---@return FileName function GetPathToScenarioStrings(pathToScenarioInfo) - return string.sub(pathToScenarioInfo, 1, string.len(pathToScenarioInfo) - string.len("scenario.lua")) .. - "strings.lua" --[[@as FileName]] + return GetPathToScenario(pathToScenarioInfo) .. "strings.lua" --[[@as FileName]] end ---- Loads in the scenario save. +--- Given the path to a scenario info file, returns the path to the scenario water mask. The water mask can help players understand where water is. +---@param pathToScenarioInfo string +---@return FileName +function GetPathToWaterMask(pathToScenarioInfo) + return GetPathToFolder(pathToScenarioInfo) .. "/lobby/preview-water.dds" --[[@as FileName]] +end + +--- Given the path to a scenario info file, returns the path to the scenario cliff mask. The cliffs mask can help players understand where units can go. +---@param pathToScenarioInfo string +---@return FileName +function GetPathToCliffMask(pathToScenarioInfo) + return GetPathToFolder(pathToScenarioInfo) .. "/lobby/preview-cliffs.dds" --[[@as FileName]] +end + +--- Given the path to a scenario info file, returns the path to the scenario buildable mask. The buildable mask can help players understand where they have large, buildable areas. +---@param pathToScenarioInfo string +---@return FileName +function GetPathToBuildableMask(pathToScenarioInfo) + return GetPathToFolder(pathToScenarioInfo) .. "/lobby/preview-buildable.dds" --[[@as FileName]] +end + +--- Loads in the scenario save. This function is expensive and should be used sparingly. ---@param pathToScenarioSave FileName ---@return UIScenarioSaveFile | nil function LoadScenarioSaveFile(pathToScenarioSave) @@ -468,3 +502,73 @@ function CheckMapHasMarkers(scenario) end return false end + +------------------------------------------------------------------------------- +--#region Efficient utility functions + +--- Retrieves all of the playable armies for a scenario. Does not allocate new memory. +---@param scenarioInfo UIScenarioInfoFile +---@return string[]? # If defined, looks like: { 'ARMY_01', 'ARMY_02', ... }. Returns nil when the scenario is malformed. +function GetArmiesFromScenario(scenarioInfo) + + -- Usually the configuration looks like the following: + -- Configurations = { + -- ['standard'] = { + -- teams = { + -- { name = 'FFA', armies = { 'ARMY_1', 'ARMY_2', 'ARMY_3', 'ARMY_4', } }, + -- }, + -- customprops = { + -- }, + -- }, + -- } + -- + -- It is clearly an unfinished design. There's not much we can do about that. We first check + -- if it looks like that and we just return that accordingly. + + if scenarioInfo.Configurations.standard and scenarioInfo.Configurations.standard.teams then + for _, teamConfig in scenarioInfo.Configurations.standard.teams do + if teamConfig.name and (teamConfig.name == 'FFA') then + return teamConfig.armies + end + end + end + + -- Scenario format is malformed, not much we can do about this. + + return nil +end + +--- Retrieves all the starting positions for a scenario. Allocates and returns new tables on each call. +---@param scenarioInfo UIScenarioInfoFile +---@param scenarioSave UIScenarioSaveFile +---@return Vector2[]? +function GetStartPositionsFromScenario(scenarioInfo, scenarioSave) + local armies = GetArmiesFromScenario(scenarioInfo) + if not armies then + return nil + end + + local markers = scenarioSave.MasterChain._MASTERCHAIN_.Markers + if not markers then + return nil + end + + local output = {} + for _, army in armies do + local marker = markers[army] + if marker then + table.insert(output, { marker.position[1], marker.position[3] }) + else + table.insert(output, { 0, 0 }) + + WARN( + "MapUtil - no initial position marker for army", army, "found in", + scenarioInfo.name, "version", tostring(scenarioInfo.map_version) + ) + end + end + + return output +end + +--#endregion diff --git a/lua/userInit.lua b/lua/userInit.lua index 601548a706..96df2e9c3d 100644 --- a/lua/userInit.lua +++ b/lua/userInit.lua @@ -17,6 +17,7 @@ end -- # Global (and shared) init doscript '/lua/globalInit.lua' +doscript '/lua/ui/globals/GpgNetSend.lua' -- Do we have an custom language set inside user-options ? local selectedlanguage = import("/lua/user/prefs.lua").GetFromCurrentProfile('options').selectedlanguage @@ -290,24 +291,6 @@ do oldSimCallback(callback, addUnitSelection or false) end - - local oldGpgNetSend = GpgNetSend - _G.GpgNetSend = function(command, ...) - - if SessionIsActive() and not SessionIsReplay() then - local stringifiedArgs = "" - for k = 1, table.getn(arg) do - stringifiedArgs = stringifiedArgs .. tostring(arg[k]) .. "," - end - - -- try to inform moderators - ForkThread(SendModeratorEventThread, - string.format("GpgNetSend with command '%s' and data '%s'", tostring(command), - stringifiedArgs)) - end - - oldGpgNetSend(command, unpack(arg)) - end end do diff --git a/textures/ui/common/faction_icon-lg/aeon_med.dds b/textures/ui/common/faction_icon-lg/aeon_med.dds new file mode 100644 index 0000000000..5332e822aa Binary files /dev/null and b/textures/ui/common/faction_icon-lg/aeon_med.dds differ diff --git a/textures/ui/common/faction_icon-lg/aeon_med.png b/textures/ui/common/faction_icon-lg/aeon_med.png new file mode 100644 index 0000000000..6a6024c347 Binary files /dev/null and b/textures/ui/common/faction_icon-lg/aeon_med.png differ diff --git a/textures/ui/common/faction_icon-lg/aeon_mini.dds b/textures/ui/common/faction_icon-lg/aeon_mini.dds new file mode 100644 index 0000000000..97c57462c2 Binary files /dev/null and b/textures/ui/common/faction_icon-lg/aeon_mini.dds differ diff --git a/textures/ui/common/faction_icon-lg/aeon_mini.png b/textures/ui/common/faction_icon-lg/aeon_mini.png new file mode 100644 index 0000000000..12316e2f16 Binary files /dev/null and b/textures/ui/common/faction_icon-lg/aeon_mini.png differ diff --git a/textures/ui/common/faction_icon-lg/aeon_thick.dds b/textures/ui/common/faction_icon-lg/aeon_thick.dds new file mode 100644 index 0000000000..40be020330 Binary files /dev/null and b/textures/ui/common/faction_icon-lg/aeon_thick.dds differ diff --git a/textures/ui/common/faction_icon-lg/aeon_thick.png b/textures/ui/common/faction_icon-lg/aeon_thick.png new file mode 100644 index 0000000000..bdfae64094 Binary files /dev/null and b/textures/ui/common/faction_icon-lg/aeon_thick.png differ diff --git a/textures/ui/common/faction_icon-lg/cybran_med.dds b/textures/ui/common/faction_icon-lg/cybran_med.dds new file mode 100644 index 0000000000..bbc7974f2b Binary files /dev/null and b/textures/ui/common/faction_icon-lg/cybran_med.dds differ diff --git a/textures/ui/common/faction_icon-lg/cybran_med.png b/textures/ui/common/faction_icon-lg/cybran_med.png new file mode 100644 index 0000000000..fa4367cc4e Binary files /dev/null and b/textures/ui/common/faction_icon-lg/cybran_med.png differ diff --git a/textures/ui/common/faction_icon-lg/cybran_mini.dds b/textures/ui/common/faction_icon-lg/cybran_mini.dds new file mode 100644 index 0000000000..22ed89da61 Binary files /dev/null and b/textures/ui/common/faction_icon-lg/cybran_mini.dds differ diff --git a/textures/ui/common/faction_icon-lg/cybran_mini.png b/textures/ui/common/faction_icon-lg/cybran_mini.png new file mode 100644 index 0000000000..eb6e252a04 Binary files /dev/null and b/textures/ui/common/faction_icon-lg/cybran_mini.png differ diff --git a/textures/ui/common/faction_icon-lg/cybran_thick.dds b/textures/ui/common/faction_icon-lg/cybran_thick.dds new file mode 100644 index 0000000000..c4c276a328 Binary files /dev/null and b/textures/ui/common/faction_icon-lg/cybran_thick.dds differ diff --git a/textures/ui/common/faction_icon-lg/cybran_thick.png b/textures/ui/common/faction_icon-lg/cybran_thick.png new file mode 100644 index 0000000000..f01fdc1b15 Binary files /dev/null and b/textures/ui/common/faction_icon-lg/cybran_thick.png differ diff --git a/textures/ui/common/faction_icon-lg/factionicons-mini_bordered.svg b/textures/ui/common/faction_icon-lg/factionicons-mini_bordered.svg new file mode 100644 index 0000000000..ea2e4bdca2 --- /dev/null +++ b/textures/ui/common/faction_icon-lg/factionicons-mini_bordered.svg @@ -0,0 +1,54 @@ + + diff --git a/textures/ui/common/faction_icon-lg/seraphim_med.dds b/textures/ui/common/faction_icon-lg/seraphim_med.dds new file mode 100644 index 0000000000..42bf7c6244 Binary files /dev/null and b/textures/ui/common/faction_icon-lg/seraphim_med.dds differ diff --git a/textures/ui/common/faction_icon-lg/seraphim_med.png b/textures/ui/common/faction_icon-lg/seraphim_med.png new file mode 100644 index 0000000000..519faad7b0 Binary files /dev/null and b/textures/ui/common/faction_icon-lg/seraphim_med.png differ diff --git a/textures/ui/common/faction_icon-lg/seraphim_mini.dds b/textures/ui/common/faction_icon-lg/seraphim_mini.dds new file mode 100644 index 0000000000..5266c4d8f7 Binary files /dev/null and b/textures/ui/common/faction_icon-lg/seraphim_mini.dds differ diff --git a/textures/ui/common/faction_icon-lg/seraphim_mini.png b/textures/ui/common/faction_icon-lg/seraphim_mini.png new file mode 100644 index 0000000000..23e407c734 Binary files /dev/null and b/textures/ui/common/faction_icon-lg/seraphim_mini.png differ diff --git a/textures/ui/common/faction_icon-lg/seraphim_thick.dds b/textures/ui/common/faction_icon-lg/seraphim_thick.dds new file mode 100644 index 0000000000..4eb014ed16 Binary files /dev/null and b/textures/ui/common/faction_icon-lg/seraphim_thick.dds differ diff --git a/textures/ui/common/faction_icon-lg/seraphim_thick.png b/textures/ui/common/faction_icon-lg/seraphim_thick.png new file mode 100644 index 0000000000..dcd902697a Binary files /dev/null and b/textures/ui/common/faction_icon-lg/seraphim_thick.png differ diff --git a/textures/ui/common/faction_icon-lg/uef_med.dds b/textures/ui/common/faction_icon-lg/uef_med.dds new file mode 100644 index 0000000000..b44b67def6 Binary files /dev/null and b/textures/ui/common/faction_icon-lg/uef_med.dds differ diff --git a/textures/ui/common/faction_icon-lg/uef_med.png b/textures/ui/common/faction_icon-lg/uef_med.png new file mode 100644 index 0000000000..2c14e08b27 Binary files /dev/null and b/textures/ui/common/faction_icon-lg/uef_med.png differ diff --git a/textures/ui/common/faction_icon-lg/uef_mini.dds b/textures/ui/common/faction_icon-lg/uef_mini.dds new file mode 100644 index 0000000000..f54215cc24 Binary files /dev/null and b/textures/ui/common/faction_icon-lg/uef_mini.dds differ diff --git a/textures/ui/common/faction_icon-lg/uef_mini.png b/textures/ui/common/faction_icon-lg/uef_mini.png new file mode 100644 index 0000000000..42ca4afdaa Binary files /dev/null and b/textures/ui/common/faction_icon-lg/uef_mini.png differ diff --git a/textures/ui/common/faction_icon-lg/uef_thick.dds b/textures/ui/common/faction_icon-lg/uef_thick.dds new file mode 100644 index 0000000000..3dceb3e88a Binary files /dev/null and b/textures/ui/common/faction_icon-lg/uef_thick.dds differ diff --git a/textures/ui/common/faction_icon-lg/uef_thick.png b/textures/ui/common/faction_icon-lg/uef_thick.png new file mode 100644 index 0000000000..77debb11a0 Binary files /dev/null and b/textures/ui/common/faction_icon-lg/uef_thick.png differ