From e2adef9713fad27f26646ac1594d8f11cb81e023 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sat, 20 Jun 2026 20:49:28 +0200 Subject: [PATCH 01/98] First refactor to MVC-pattern --- lua/ui/lobby/autolobby.lua | 14 +- .../lobby/autolobby/AutolobbyController.lua | 386 +++++------------- lua/ui/lobby/autolobby/AutolobbyInterface.lua | 145 ++++--- lua/ui/lobby/autolobby/AutolobbyMessages.lua | 4 +- lua/ui/lobby/autolobby/AutolobbyModel.lua | 340 +++++++++++++++ lua/ui/lobby/autolobby/CLAUDE.md | 120 ++++++ 6 files changed, 663 insertions(+), 346 deletions(-) create mode 100644 lua/ui/lobby/autolobby/AutolobbyModel.lua create mode 100644 lua/ui/lobby/autolobby/CLAUDE.md diff --git a/lua/ui/lobby/autolobby.lua b/lua/ui/lobby/autolobby.lua index 407ae7b237a..8d93659a410 100644 --- a/lua/ui/lobby/autolobby.lua +++ b/lua/ui/lobby/autolobby.lua @@ -45,8 +45,11 @@ function CreateLobby(protocol, localPort, desiredPlayerName, localPlayerUID, nat -- we intentionally do not log the 'natTraversalProvider' parameter as it can cause issues due to being an uninitialized C object LOG("CreateLobby", protocol, localPort, desiredPlayerName, localPlayerUID) - -- create the interface, needs to be done before the lobby is + -- create the model and interface, needs to be done before the lobby is. + -- the model is set up first so the interface subscribes against it, and so + -- a rejoin (which re-runs CreateLobby) starts from fresh, non-stale state. local playerCount = tonumber(GetCommandLineArg("/players", 1)[1]) or 8 + import("/lua/ui/lobby/autolobby/autolobbymodel.lua").SetupSingleton(playerCount) local interface = import("/lua/ui/lobby/autolobby/autolobbyinterface.lua").SetupSingleton(playerCount) -- create the lobby @@ -84,9 +87,12 @@ function HostGame(gameName, scenarioFileName, singlePlayer) AutolobbyCommunicationsInstance.HostParameters.ScenarioFile = scenarioFileName AutolobbyCommunicationsInstance.HostParameters.SinglePlayer = singlePlayer - AutolobbyCommunicationsInstance.GameOptions.ScenarioFile = string.gsub(scenarioFileName, - ".v%d%d%d%d_scenario.lua", - "_scenario.lua") + -- the synced game options live on the model; copy-then-Set so the + -- view's scenario observer reacts and the host sees the map preview + local model = import("/lua/ui/lobby/autolobby/autolobbymodel.lua").GetSingleton() + local gameOptions = table.copy(model.GameOptions()) + gameOptions.ScenarioFile = string.gsub(scenarioFileName, ".v%d%d%d%d_scenario.lua", "_scenario.lua") + model.GameOptions:Set(gameOptions) AutolobbyCommunicationsInstance:HostGame() end end diff --git a/lua/ui/lobby/autolobby/AutolobbyController.lua b/lua/ui/lobby/autolobby/AutolobbyController.lua index d4d03c19e63..3e9e0b291af 100644 --- a/lua/ui/lobby/autolobby/AutolobbyController.lua +++ b/lua/ui/lobby/autolobby/AutolobbyController.lua @@ -33,6 +33,8 @@ local AutolobbyArgumentsComponent = import("/lua/ui/lobby/autolobby/components/a local AutolobbyMessages = import("/lua/ui/lobby/autolobby/autolobbymessages.lua").AutolobbyMessages +local AutolobbyModel = import("/lua/ui/lobby/autolobby/autolobbymodel.lua") + local AutolobbyEngineStrings = { -- General info strings ['Connecting'] = "Connecting to Game", @@ -109,17 +111,16 @@ local AutolobbyEngineStrings = { ---@field DesiredPeerId UILobbyPeerId --- Responsible for the behavior of the automated lobby. +--- +--- The synced lobby state (player options, game options, connections, launch +--- statuses, ...) lives in `AutolobbyModel` — a reactive singleton the view +--- observes. This controller is the only writer to that model. The fields it +--- keeps here are purely controller-internal (identity + rejoin parameters) +--- and are not observed by the view. ---@class UIAutolobbyCommunications : moho.lobby_methods, DebugComponent, UIAutolobbyServerCommunicationsComponent, UIAutolobbyArgumentsComponent ---@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 @@ -129,16 +130,19 @@ AutolobbyCommunications = Class(MohoLobbyMethods, AutolobbyServerCommunicationsC __init = function(self) self.Trash = TrashBag() - self.LocalPeerId = "-2" self.LocalPlayerName = "Charlie" - self.PlayerCount = self:GetCommandLineArgumentNumber("/players", 2) self.HostID = "-2" - self.GameMods = {} - self.GameOptions = self:CreateLocalGameOptions() - self.PlayerOptions = {} - self.LaunchStatutes = {} - self.ConnectionMatrix = {} + -- The model singleton is created in `autolobby.lua > CreateLobby` + -- before the lobby (and thus this controller) is instantiated. Seed + -- the initial state here. + local model = AutolobbyModel.GetSingleton() + model.LocalPeerId:Set("-2") + model.GameMods:Set({}) + model.GameOptions:Set(self:CreateLocalGameOptions()) + model.PlayerOptions:Set({}) + model.LaunchStatutes:Set({}) + model.ConnectionMatrix:Set({}) end, ---@param self UIAutolobbyCommunications @@ -233,194 +237,13 @@ AutolobbyCommunications = Class(MohoLobbyMethods, AutolobbyServerCommunicationsC --------------------------------------------------------------------------- --#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, - - ---@param self UIAutolobbyCommunications - ---@param playerOptions UIAutolobbyPlayer[] - ---@return table - CreateRatingsTable = function(self, playerOptions) - ---@type table - local allRatings = {} - - for slot, options in pairs(playerOptions) do - if options.Human and options.PL then - allRatings[options.PlayerName] = options.PL - end - end - - return allRatings - end, - - ---@param self UIAutolobbyCommunications - ---@param playerOptions UIAutolobbyPlayer[] - ---@return table - CreateDivisionsTable = function(self, playerOptions) - ---@type table - local allDivisions = {} - - for slot, options in pairs(playerOptions) do - if options.Human and options.PL then - if options.DIV ~= "unlisted" then - local division = options.DIV - if options.SUBDIV and options.SUBDIV ~= "" then - division = division .. ' ' .. options.SUBDIV - end - allDivisions[options.PlayerName] = division - end - end - end - - return allDivisions - end, - - ---@param self UIAutolobbyCommunications - ---@param playerOptions UIAutolobbyPlayer[] - ---@return table - CreateClanTagsTable = function(self, playerOptions) - local allClanTags = {} - - for slot, options in pairs(playerOptions) do - if options.PlayerClan then - allClanTags[options.PlayerName] = options.PlayerClan - end - end - - return allClanTags - 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, + -- + -- The pure derivation helpers that used to live here (CreateConnectionsMatrix, + -- CreateConnectionStatuses, CreateOwnershipMatrix, CreateLaunchStatus, + -- CreateRatingsTable, CreateDivisionsTable, CreateClanTagsTable, CanLaunch, + -- PeerIdToIndex) moved to `AutolobbyModel` as free functions. The model + -- uses them to compute its derived LazyVars; this controller calls the few + -- it still needs (launch flow, alive stamp) via `AutolobbyModel.`. --- Prefetches a scenario to try and reduce the loading screen time. ---@param self UIAutolobbyCommunications @@ -498,13 +321,16 @@ AutolobbyCommunications = Class(MohoLobbyMethods, AutolobbyServerCommunicationsC while not IsDestroyed(self) do + local model = AutolobbyModel.GetSingleton() + local launchStatutes = model.LaunchStatutes() + -- check if we're ready to launch - if self.LaunchStatutes[self.LocalPeerId] ~= 'Ready' then + if launchStatutes[model.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 + for k, launchStatus in launchStatutes do onePeerIsReady = onePeerIsReady or (launchStatus == 'Ready') onePeerIsRejoining = onePeerIsRejoining or (launchStatus == 'Rejoining') end @@ -534,8 +360,12 @@ AutolobbyCommunications = Class(MohoLobbyMethods, AutolobbyServerCommunicationsC ---@param self UIAutolobbyCommunications ShareLaunchStatusThread = function(self) while not IsDestroyed(self) do - local launchStatus = self:CreateLaunchStatus(self.ConnectionMatrix) - self.LaunchStatutes[self.LocalPeerId] = launchStatus + local model = AutolobbyModel.GetSingleton() + local launchStatus = AutolobbyModel.CreateLaunchStatus(model.ConnectionMatrix(), model.PlayerCount()) + + local statuses = table.copy(model.LaunchStatutes()) + statuses[model.LocalPeerId()] = launchStatus + model.LaunchStatutes:Set(statuses) -- update peers self:BroadcastData({ Type = "UpdateLaunchStatus", LaunchStatus = launchStatus }) @@ -543,10 +373,6 @@ AutolobbyCommunications = Class(MohoLobbyMethods, AutolobbyServerCommunicationsC -- 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, @@ -554,39 +380,44 @@ AutolobbyCommunications = Class(MohoLobbyMethods, AutolobbyServerCommunicationsC ---@param self UIAutolobbyCommunications LaunchThread = function(self) while not IsDestroyed(self) do - if self:CanLaunch(self.LaunchStatutes) then + local model = AutolobbyModel.GetSingleton() + if AutolobbyModel.CanLaunch(model.LaunchStatutes(), model.PlayerCount()) then WaitSeconds(5.0) - if (not IsDestroyed(self)) and self:CanLaunch(self.LaunchStatutes) then + if (not IsDestroyed(self)) and AutolobbyModel.CanLaunch(model.LaunchStatutes(), model.PlayerCount()) then + + local playerOptions = model.PlayerOptions() -- Army numbers need to be calculated: they are numbered incrementally in slot order. local slots = {} - for slotIndex, _ in pairs(self.PlayerOptions) do + for slotIndex, _ in pairs(playerOptions) do table.insert(slots, slotIndex) end table.sort(slots) -- send player options to the server for armyIndex, slotIndex in ipairs(slots) do - local playerOptions = self.PlayerOptions[slotIndex] - local ownerId = playerOptions.OwnerID - self:SendPlayerOptionToServer(ownerId, 'Team', playerOptions.Team) + local options = playerOptions[slotIndex] + local ownerId = options.OwnerID + self:SendPlayerOptionToServer(ownerId, 'Team', options.Team) self:SendPlayerOptionToServer(ownerId, 'Army', armyIndex) - self:SendPlayerOptionToServer(ownerId, 'StartSpot', playerOptions.StartSpot) - self:SendPlayerOptionToServer(ownerId, 'Faction', playerOptions.Faction) + self:SendPlayerOptionToServer(ownerId, 'StartSpot', options.StartSpot) + self:SendPlayerOptionToServer(ownerId, 'Faction', options.Faction) end -- tuck them into the game options. By all means a hack, but -- this way they are available in both the sim and the UI - self.GameOptions.Ratings = self:CreateRatingsTable(self.PlayerOptions) - self.GameOptions.Divisions = self:CreateDivisionsTable(self.PlayerOptions) - self.GameOptions.ClanTags = self:CreateClanTagsTable(self.PlayerOptions) + local gameOptions = table.copy(model.GameOptions()) + gameOptions.Ratings = AutolobbyModel.CreateRatingsTable(playerOptions) + gameOptions.Divisions = AutolobbyModel.CreateDivisionsTable(playerOptions) + gameOptions.ClanTags = AutolobbyModel.CreateClanTagsTable(playerOptions) + model.GameOptions:Set(gameOptions) -- create game configuration local gameConfiguration = { - GameMods = self.GameMods, - GameOptions = self.GameOptions, - PlayerOptions = self.PlayerOptions, + GameMods = model.GameMods(), + GameOptions = gameOptions, + PlayerOptions = playerOptions, Observers = {}, } @@ -620,56 +451,40 @@ AutolobbyCommunications = Class(MohoLobbyMethods, AutolobbyServerCommunicationsC playerOptions.OwnerID = data.SenderID playerOptions.PlayerName = self:MakeValidPlayerName(playerOptions.OwnerID, playerOptions.PlayerName) + local model = AutolobbyModel.GetSingleton() + -- TODO: verify that the StartSpot is not occupied -- put the player where it belongs - self.PlayerOptions[playerOptions.StartSpot] = playerOptions + local players = table.copy(model.PlayerOptions()) + players[playerOptions.StartSpot] = playerOptions + model.PlayerOptions:Set(players) -- sync game options with the connected peer - self:SendData(data.SenderID, { Type = "UpdateGameOptions", GameOptions = self.GameOptions }) + self:SendData(data.SenderID, { Type = "UpdateGameOptions", GameOptions = model.GameOptions() }) -- sync player options to all connected peers - self:BroadcastData({ Type = "UpdatePlayerOptions", PlayerOptions = self.PlayerOptions }) + self:BroadcastData({ Type = "UpdatePlayerOptions", PlayerOptions = players }) - -- 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 + -- the view's scenario + ownership observers react to the PlayerOptions change 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 + -- a fresh table straight off the network; replace wholesale. The view's + -- scenario + ownership observers react to the change. + AutolobbyModel.GetSingleton().PlayerOptions:Set(data.PlayerOptions) end, ---@param self UIAutolobbyCommunications ---@param data UIAutolobbyUpdateGameOptionsMessage ProcessUpdateGameOptionsMessage = function(self, data) - self.GameOptions = data.GameOptions + local model = AutolobbyModel.GetSingleton() + model.GameOptions:Set(data.GameOptions) - self:Prefetch(self.GameOptions, self.GameMods) + self:Prefetch(model.GameOptions(), model.GameMods()) - -- update UI for game options - import("/lua/ui/lobby/autolobby/autolobbyinterface.lua").GetSingleton() - :UpdateScenario(self.GameOptions.ScenarioFile, self.PlayerOptions) + -- the view's scenario observer reacts to the GameOptions change end, ---@param self UIAutolobbyCommunications @@ -681,11 +496,12 @@ AutolobbyCommunications = Class(MohoLobbyMethods, AutolobbyServerCommunicationsC ---@param self UIAutolobbyCommunications ---@param data UIAutolobbyUpdateLaunchStatusMessage ProcessUpdateLaunchStatusMessage = function(self, data) - self.LaunchStatutes[data.SenderID] = data.LaunchStatus + local model = AutolobbyModel.GetSingleton() + local statuses = table.copy(model.LaunchStatutes()) + statuses[data.SenderID] = data.LaunchStatus + model.LaunchStatutes:Set(statuses) - -- update UI for launch statuses - import("/lua/ui/lobby/autolobby/autolobbyinterface.lua").GetSingleton() - :UpdateLaunchStatuses(self:CreateConnectionStatuses(self.PlayerOptions, self.LaunchStatutes)) + -- the view's Statuses observer reacts to the LaunchStatutes change end, --#endregion @@ -896,28 +712,31 @@ AutolobbyCommunications = Class(MohoLobbyMethods, AutolobbyServerCommunicationsC Hosting = function(self) self:DebugSpew("Hosting") - self.LocalPeerId = self:GetLocalPlayerID() + local model = AutolobbyModel.GetSingleton() + + local localPeerId = self:GetLocalPlayerID() + model.LocalPeerId:Set(localPeerId) self.LocalPlayerName = self:GetLocalPlayerName() - self.HostID = self:GetLocalPlayerID() + self.HostID = localPeerId -- 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 + hostPlayerOptions.OwnerID = localPeerId + hostPlayerOptions.PlayerName = self:MakeValidPlayerName(localPeerId, self.LocalPlayerName) + local players = table.copy(model.PlayerOptions()) + players[hostPlayerOptions.StartSpot] = hostPlayerOptions + model.PlayerOptions:Set(players) -- 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:Prefetch(model.GameOptions(), model.GameMods()) self:SendLaunchStatusToServer('Hosting') - -- update UI for game options - import("/lua/ui/lobby/autolobby/autolobbyinterface.lua").GetSingleton() - :UpdateScenario(self.GameOptions.ScenarioFile, self.PlayerOptions) + -- the view's scenario observer reacts to the PlayerOptions change end, --- Called by the engine as we're trying to join a lobby. @@ -944,7 +763,7 @@ AutolobbyCommunications = Class(MohoLobbyMethods, AutolobbyServerCommunicationsC ConnectionToHostEstablished = function(self, localPeerId, newLocalName, hostPeerId) self:DebugSpew("ConnectionToHostEstablished", localPeerId, newLocalName, hostPeerId) self.LocalPlayerName = newLocalName - self.LocalPeerId = localPeerId + AutolobbyModel.GetSingleton().LocalPeerId:Set(localPeerId) self.HostID = hostPeerId -- occasionally send data over the network to create pings on screen @@ -964,16 +783,18 @@ AutolobbyCommunications = Class(MohoLobbyMethods, AutolobbyServerCommunicationsC -- 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)) + local model = AutolobbyModel.GetSingleton() + + -- seed an initial status for the peer if we don't have one yet; the + -- view's Statuses observer reacts to the change + local statuses = table.copy(model.LaunchStatutes()) + statuses[peerId] = statuses[peerId] or 'Unknown' + model.LaunchStatutes:Set(statuses) - -- 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) + -- update the connection matrix; the view's Connections observer reacts + local connectionMatrix = table.copy(model.ConnectionMatrix()) + connectionMatrix[peerId] = peerConnectedTo + model.ConnectionMatrix:Set(connectionMatrix) end, --#endregion @@ -1005,11 +826,12 @@ AutolobbyCommunications = Class(MohoLobbyMethods, AutolobbyServerCommunicationsC -- make it more convenient to debug malicious traffic SPEW(string.format("Received data of type %s from %s (%s)", tostring(data.Type), tostring(data.SenderID), tostring(data.SenderName))) - -- signal UI that we received something - local peerIndex = self:PeerIdToIndex(self.PlayerOptions, data.SenderID) + -- signal UI that we received something; a fresh stamp table fires the + -- view's IsAlive observer even for repeated pulses from the same peer + local model = AutolobbyModel.GetSingleton() + local peerIndex = AutolobbyModel.PeerIdToIndex(model.PlayerOptions(), data.SenderID) if peerIndex then - import("/lua/ui/lobby/autolobby/autolobbyinterface.lua").GetSingleton() - :UpdateIsAliveStamp(peerIndex) + model.IsAliveStamp:Set({ Index = peerIndex, Time = GetSystemTimeSeconds() }) end -- validate message type diff --git a/lua/ui/lobby/autolobby/AutolobbyInterface.lua b/lua/ui/lobby/autolobby/AutolobbyInterface.lua index de46cf0d5ce..3178e6fe3d7 100644 --- a/lua/ui/lobby/autolobby/AutolobbyInterface.lua +++ b/lua/ui/lobby/autolobby/AutolobbyInterface.lua @@ -34,21 +34,22 @@ 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") +local AutolobbyModel = import("/lua/ui/lobby/autolobby/autolobbymodel.lua") ----@class UIAutolobbyInterfaceState ----@field PlayerCount number ----@field PlayerOptions? table ----@field PathToScenarioFile? FileName ----@field GameOptions? UILobbyLaunchGameOptionsConfiguration ----@field Connections? UIAutolobbyConnections ----@field Statuses? UIAutolobbyStatus +local LazyVarDerive = import("/lua/lazyvar.lua").Derive ---@class UIAutolobbyInterface : Group ----@field State UIAutolobbyInterfaceState +---@field Trash TrashBag ---@field BackgroundTextures string[] ---@field Background Bitmap ---@field Preview UIAutolobbyMapPreview ---@field ConnectionMatrix UIAutolobbyConnectionMatrix +---@field GameOptionsObserver LazyVar +---@field PlayerOptionsObserver LazyVar +---@field ConnectionsObserver LazyVar +---@field StatusesObserver LazyVar +---@field OwnershipObserver LazyVar +---@field IsAliveObserver LazyVar local AutolobbyInterface = Class(Group) { BackgroundTextures = { @@ -64,15 +65,55 @@ local AutolobbyInterface = Class(Group) { __init = function(self, parent, playerCount) Group.__init(self, parent, "AutolobbyInterface") - -- initial, empty state - self.State = { - PlayerCount = playerCount - } + self.Trash = TrashBag() 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) + + -- Subscribe to the model. The handlers read the current value from the + -- model and feed the existing child controls, replacing the imperative + -- `Update*` pushes the controller used to make. The scenario preview + -- depends on both the scenario file (carried in GameOptions) and the + -- player options, so both feed `OnScenarioChanged`. + local model = AutolobbyModel.GetSingleton() + + -- Each handler must read its own LazyVar (`gameOptionsLazy()` / + -- `playerOptionsLazy()`) so the dependency edge is (re)established and + -- later `:Set` calls re-fire it; the other half is read straight from + -- the model. Reading neither would leave the edge unformed and the + -- observer would only ever fire once, during construction. + self.GameOptionsObserver = self.Trash:Add( + LazyVarDerive(model.GameOptions, function(gameOptionsLazy) + self:OnScenarioChanged(gameOptionsLazy(), model.PlayerOptions()) + end)) + self.PlayerOptionsObserver = self.Trash:Add( + LazyVarDerive(model.PlayerOptions, function(playerOptionsLazy) + self:OnScenarioChanged(model.GameOptions(), playerOptionsLazy()) + end)) + + self.ConnectionsObserver = self.Trash:Add( + LazyVarDerive(model.Connections, function(connectionsLazy) + self:OnConnectionsChanged(connectionsLazy()) + end)) + self.StatusesObserver = self.Trash:Add( + LazyVarDerive(model.Statuses, function(statusesLazy) + self:OnStatusesChanged(statusesLazy()) + end)) + self.OwnershipObserver = self.Trash:Add( + LazyVarDerive(model.Ownership, function(ownershipLazy) + self:OnOwnershipChanged(ownershipLazy()) + end)) + self.IsAliveObserver = self.Trash:Add( + LazyVarDerive(model.IsAliveStamp, function(stampLazy) + self:OnIsAliveChanged(stampLazy()) + end)) + end, + + ---@param self UIAutolobbyInterface + OnDestroy = function(self) + self.Trash:Destroy() end, ---@param self UIAutolobbyInterface @@ -100,9 +141,11 @@ local AutolobbyInterface = Class(Group) { end, ---@param self UIAutolobbyInterface - ---@param ownership boolean[][] - UpdateOwnership = function(self, ownership) - self.State.OwnerShip = ownership + ---@param ownership boolean[][] | false + OnOwnershipChanged = function(self, ownership) + if not ownership then + return + end self.ConnectionMatrix:Show() self.ConnectionMatrix:UpdateOwnership(ownership) @@ -110,28 +153,37 @@ local AutolobbyInterface = Class(Group) { ---@param self UIAutolobbyInterface ---@param connections UIAutolobbyConnections - UpdateConnections = function(self, connections) - self.State.Connections = connections + OnConnectionsChanged = function(self, connections) + if not connections then + return + end - self.ConnectionMatrix:Show() + -- only reveal the matrix once we actually know of a peer; the initial + -- (empty) derivation should not flash an empty grid on screen + if next(AutolobbyModel.GetSingleton().ConnectionMatrix()) then + self.ConnectionMatrix:Show() + end self.ConnectionMatrix:UpdateConnections(connections) end, ---@param self UIAutolobbyInterface ---@param statuses UIAutolobbyStatus - UpdateLaunchStatuses = function(self, statuses) - self.State.Statuses = statuses + OnStatusesChanged = function(self, statuses) + if not statuses then + return + end - self.ConnectionMatrix:Show() + if next(statuses) then + self.ConnectionMatrix:Show() + end self.ConnectionMatrix:UpdateStatuses(statuses) end, ---@param self UIAutolobbyInterface - ---@param pathToScenarioInfo FileName + ---@param gameOptions UILobbyLaunchGameOptionsConfiguration ---@param playerOptions UIAutolobbyPlayer[] - UpdateScenario = function(self, pathToScenarioInfo, playerOptions) - self.State.PathToScenarioFile = pathToScenarioInfo - self.State.PlayerOptions = playerOptions + OnScenarioChanged = function(self, gameOptions, playerOptions) + local pathToScenarioInfo = gameOptions.ScenarioFile if pathToScenarioInfo and playerOptions then -- hide it for now until we have a better way to decipher its possible (negative) impact @@ -141,41 +193,14 @@ local AutolobbyInterface = Class(Group) { 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 + ---@param stamp UIAutolobbyAliveStamp | false + OnIsAliveChanged = function(self, stamp) + if not stamp then + return 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 + self.ConnectionMatrix:UpdateIsAliveTimestamp(stamp.Index) end, - - --#endregion } --- A trashbag that should be destroyed upon reload. @@ -221,8 +246,10 @@ end ---@param newModule any function __moduleinfo.OnReload(newModule) if AutolobbyInterfaceInstance then - local handle = newModule.SetupSingleton(AutolobbyInterfaceInstance.State.PlayerCount) - handle:RestoreState(AutolobbyInterfaceInstance.State) + -- the model survives the reload (it is its own singleton), so a fresh + -- interface restores itself: its observers read the current model + -- values on their first fire. No manual state replay is needed. + newModule.SetupSingleton(AutolobbyModel.GetSingleton().PlayerCount()) end end diff --git a/lua/ui/lobby/autolobby/AutolobbyMessages.lua b/lua/ui/lobby/autolobby/AutolobbyMessages.lua index 597daa97d81..52b32603299 100644 --- a/lua/ui/lobby/autolobby/AutolobbyMessages.lua +++ b/lua/ui/lobby/autolobby/AutolobbyMessages.lua @@ -26,6 +26,8 @@ -- function. If the message is accepted the handler is called, which is just a -- wrapper to another function in the autolobby. +local AutolobbyModel = import("/lua/ui/lobby/autolobby/autolobbymodel.lua") + ---@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 @@ -110,7 +112,7 @@ AutolobbyMessages = { end -- verify that the player is not already in the lobby - for _, otherPlayerOptions in lobby.PlayerOptions do + for _, otherPlayerOptions in AutolobbyModel.GetSingleton().PlayerOptions() do if otherPlayerOptions.OwnerID == data.SenderID then lobby:DebugWarn("Received duplicate message of type ", data.Type) return false diff --git a/lua/ui/lobby/autolobby/AutolobbyModel.lua b/lua/ui/lobby/autolobby/AutolobbyModel.lua new file mode 100644 index 00000000000..a9e160eeb4a --- /dev/null +++ b/lua/ui/lobby/autolobby/AutolobbyModel.lua @@ -0,0 +1,340 @@ +--****************************************************************************************************** +--** 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. +--****************************************************************************************************** + +-- Reactive state singleton for the automated lobby. This is the single source +-- of truth that the controller (`AutolobbyController.lua`) writes to and the +-- view (`AutolobbyInterface.lua`) observes via `Derive`. It holds no UI +-- references and no networking; it can be constructed with neither present. +-- +-- See `/lua/ui/game/chat/ChatModel.lua` for the pattern this mirrors, and +-- `/lua/ui/CLAUDE.md` for the reactivity rules (notably: never mutate a held +-- table in place — build a new table and `:Set` it). + +local Create = import("/lua/lazyvar.lua").Create + +------------------------------------------------------------------------------- +--#region Pure derivation helpers +-- +-- These were methods on the controller class; they are stateless (they operate +-- only on their arguments), so they live here as free functions. They are used +-- both by the derived LazyVars below and by the controller's networking threads. + +--- 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. +---@param playerOptions UIAutolobbyPlayer[] +---@param peerId UILobbyPeerId +---@return number | false +function PeerIdToIndex(playerOptions, peerId) + if type(peerId) ~= 'string' then + WARN("Autolobby model: invalid peer id ", tostring(peerId)) + return false + end + + 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 + +---@param playerOptions UIAutolobbyPlayer[] +---@param connectionMatrix table +---@param playerCount number +---@return UIAutolobbyConnections +function CreateConnectionsMatrix(playerOptions, connectionMatrix, playerCount) + ---@type UIAutolobbyConnections + local connections = {} + + -- initial setup + for y = 1, playerCount do + connections[y] = {} + for x = 1, playerCount do + connections[y][x] = false + end + end + + -- populate the matrix + for peerId, establishedPeers in connectionMatrix do + for _, peerConnectedToId in establishedPeers do + local peerIdNumber = PeerIdToIndex(playerOptions, peerId) + local peerConnectedToIdNumber = PeerIdToIndex(playerOptions, peerConnectedToId) + + -- connection works both ways + if peerIdNumber and peerConnectedToIdNumber then + if peerIdNumber > playerCount or peerConnectedToIdNumber > playerCount then + WARN("Autolobby model: invalid peer id ", peerIdNumber, peerConnectedToIdNumber) + else + connections[peerIdNumber][peerConnectedToIdNumber] = true + connections[peerConnectedToIdNumber][peerIdNumber] = true + end + end + end + end + + return connections +end + +---@param playerOptions UIAutolobbyPlayer[] +---@param statuses table +---@return UIAutolobbyStatus +function CreateConnectionStatuses(playerOptions, statuses) + local output = {} + for peerId, launchStatus in statuses do + local peerIdNumber = PeerIdToIndex(playerOptions, peerId) + if peerIdNumber then + output[peerIdNumber] = launchStatus + end + end + + return output +end + +---@param playerCount number +---@param localIndex number +---@return boolean[][] +function CreateOwnershipMatrix(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 connectionMatrix table +---@param playerCount number +---@return UIPeerLaunchStatus +function CreateLaunchStatus(connectionMatrix, playerCount) + -- check number of peers + local validPeerCount = playerCount - 1 + if table.getsize(connectionMatrix) < validPeerCount then + return 'Missing local peers' + end + + return 'Ready' +end + +---@param playerOptions UIAutolobbyPlayer[] +---@return table +function CreateRatingsTable(playerOptions) + ---@type table + local allRatings = {} + + for slot, options in pairs(playerOptions) do + if options.Human and options.PL then + allRatings[options.PlayerName] = options.PL + end + end + + return allRatings +end + +---@param playerOptions UIAutolobbyPlayer[] +---@return table +function CreateDivisionsTable(playerOptions) + ---@type table + local allDivisions = {} + + for slot, options in pairs(playerOptions) do + if options.Human and options.PL then + if options.DIV ~= "unlisted" then + local division = options.DIV + if options.SUBDIV and options.SUBDIV ~= "" then + division = division .. ' ' .. options.SUBDIV + end + allDivisions[options.PlayerName] = division + end + end + end + + return allDivisions +end + +---@param playerOptions UIAutolobbyPlayer[] +---@return table +function CreateClanTagsTable(playerOptions) + local allClanTags = {} + + for slot, options in pairs(playerOptions) do + if options.PlayerClan then + allClanTags[options.PlayerName] = options.PlayerClan + end + end + + return allClanTags +end + +--- Verifies whether we can launch the game. +---@param peerStatus UIAutolobbyStatus +---@param playerCount number +---@return boolean +function CanLaunch(peerStatus, playerCount) + -- check if we know of all peers + if table.getsize(peerStatus) ~= 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 + +--#endregion + +------------------------------------------------------------------------------- +--#region Reactive model + +--- Lightweight "we just heard from this peer" pulse. A fresh table is set on +--- every receive so the held value's identity always changes, which fires the +--- view observer even for repeated pulses from the same peer index. +---@class UIAutolobbyAliveStamp +---@field Index number +---@field Time number + +--- Reactive autolobby-state singleton: the single source of truth shared by +--- the controller (writer) and the interface (reader). +---@class UIAutolobbyModel +---@field PlayerCount LazyVar # originates from the command line; sizes the grid and every derivation +---@field LocalPeerId LazyVar # local peer id; feeds the Ownership derivation +---@field PlayerOptions LazyVar # synced player slots +---@field GameOptions LazyVar # synced game options (carries ScenarioFile) +---@field GameMods LazyVar # synced game mods +---@field ConnectionMatrix LazyVar> # raw established-peers map +---@field LaunchStatutes LazyVar> # raw per-peer launch status +---@field IsAliveStamp LazyVar # alive pulse (see UIAutolobbyAliveStamp) +---@field Connections LazyVar # derived from PlayerOptions + ConnectionMatrix + PlayerCount +---@field Statuses LazyVar # derived from PlayerOptions + LaunchStatutes +---@field Ownership LazyVar # derived from PlayerCount + PeerIdToIndex(PlayerOptions, LocalPeerId) + +--- Singleton handle; nil until `SetupSingleton` (or `GetSingleton`) builds the model. +---@type UIAutolobbyModel | nil +local ModelInstance = nil + +--- Allocates a fresh model singleton, replacing any existing instance. Rejoin +--- relies on this resetting the state: a new lobby must not inherit stale +--- connection / launch state from the previous one. +---@param playerCount? number +---@return UIAutolobbyModel +function SetupSingleton(playerCount) + playerCount = playerCount or 8 + + ---@type UIAutolobbyModel + local model = { + PlayerCount = Create(playerCount), + LocalPeerId = Create("-2"), + PlayerOptions = Create({}), + GameOptions = Create({}), + GameMods = Create({}), + ConnectionMatrix = Create({}), + LaunchStatutes = Create({}), + IsAliveStamp = Create(false), + Connections = Create(), + Statuses = Create(), + Ownership = Create(), + } + + -- Derived values. Reading the raw LazyVars inside these compute functions + -- registers the dependency edges, so any `:Set` on a raw var re-fires the + -- view observers subscribed to these derived vars. + model.Connections:Set(function() + return CreateConnectionsMatrix(model.PlayerOptions(), model.ConnectionMatrix(), model.PlayerCount()) + end) + + model.Statuses:Set(function() + return CreateConnectionStatuses(model.PlayerOptions(), model.LaunchStatutes()) + end) + + model.Ownership:Set(function() + local localIndex = PeerIdToIndex(model.PlayerOptions(), model.LocalPeerId()) + if not localIndex then + return false + end + return CreateOwnershipMatrix(model.PlayerCount(), localIndex) + end) + + ModelInstance = model + return model +end + +--- Returns the model singleton, creating it on first access. +---@return UIAutolobbyModel +function GetSingleton() + if not ModelInstance then + SetupSingleton() + end + return ModelInstance --[[@as UIAutolobbyModel]] +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Debugging + +--- Hot-reload hook: rebuilds the singleton on the new module and copies the +--- current raw LazyVar values across so observers don't see a state reset. The +--- derived vars (Connections / Statuses / Ownership) are rebuilt with their +--- compute functions by `SetupSingleton` and must not be copied. `IsAliveStamp` +--- is a transient pulse and is intentionally not carried over. +---@param newModule any +function __moduleinfo.OnReload(newModule) + if ModelInstance then + local handle = newModule.SetupSingleton(ModelInstance.PlayerCount()) + handle.LocalPeerId:Set(ModelInstance.LocalPeerId()) + handle.PlayerOptions:Set(ModelInstance.PlayerOptions()) + handle.GameOptions:Set(ModelInstance.GameOptions()) + handle.GameMods:Set(ModelInstance.GameMods()) + handle.ConnectionMatrix:Set(ModelInstance.ConnectionMatrix()) + handle.LaunchStatutes:Set(ModelInstance.LaunchStatutes()) + end +end + +--- Hot-reload hook: re-imports this module after a couple of frames. +function __moduleinfo.OnDirty() + ForkThread( + function() + WaitFrames(2) + import(__moduleinfo.name) + end + ) +end + +--#endregion diff --git a/lua/ui/lobby/autolobby/CLAUDE.md b/lua/ui/lobby/autolobby/CLAUDE.md new file mode 100644 index 00000000000..30bc3666c4e --- /dev/null +++ b/lua/ui/lobby/autolobby/CLAUDE.md @@ -0,0 +1,120 @@ +# Autolobby — Architecture Guide + +The automated lobby (used for matchmaking / automatch games) follows the same +reactive MVC structure as the in-game chat: a reactive **model**, a dumb +**view** that observes it, and a **controller** that is the only writer. + +> **Read first:** [`/lua/ui/CLAUDE.md`](/lua/ui/CLAUDE.md) for project-wide UI +> patterns (`__init` vs `__post_init`, LazyVars and `Derive`, `TrashBag`) and +> [`/lua/ui/game/chat/CLAUDE.md`](/lua/ui/game/chat/CLAUDE.md) for the chat +> refactor this mirrors. This doc only covers what is autolobby-specific. + +--- + +## Architecture + +``` +engine ──callbacks──► AutolobbyController (moho.lobby_methods) + │ writes (:Set) + ▼ + AutolobbyModel (LazyVars, singleton) + │ OnDirty / Derive + ▼ + AutolobbyInterface (view, reads) +``` + +| Role | File | Responsibility | +|------|------|----------------| +| Model | [AutolobbyModel.lua](AutolobbyModel.lua) | Reactive singleton: raw synced state (player/game options, connection matrix, launch statuses) + derived view-models (`Connections`, `Statuses`, `Ownership`) + the pure derivation helpers. No UI, no networking. | +| View | [AutolobbyInterface.lua](AutolobbyInterface.lua) | Observes the model via `Derive` and feeds the child controls ([AutolobbyMapPreview.lua](AutolobbyMapPreview.lua), [AutolobbyConnectionMatrix.lua](AutolobbyConnectionMatrix.lua)). Never writes the model. | +| Controller | [AutolobbyController.lua](AutolobbyController.lua) | `AutolobbyCommunications`, the engine's lobby object. The only writer to the model and the only place that talks to peers / the lobby server. | +| Entry point | [/lua/ui/lobby/autolobby.lua](/lua/ui/lobby/autolobby.lua) | Engine-facing wrapper: `CreateLobby` / `HostGame` / `JoinGame` / `ConnectToPeer` / `DisconnectFromPeer`. Bootstraps model + view + controller in that order. | + +--- + +## The key difference from chat + +The chat controller is a module of free functions. **The autolobby controller +cannot be** — it is a `moho.lobby_methods` subclass that the *engine* +instantiates via `InternalCreateLobby` (in [autolobby.lua](/lua/ui/lobby/autolobby.lua)) +and whose callbacks (`Hosting`, `DataReceived`, `EstablishedPeers`, …) the +engine calls. So: + +- The controller stays a C-bound class. We extracted only its **state** (into + the model) and its **view coupling** (it used to push into the view via + `interface:Update*`; it now writes the model and the view reacts). +- **The controller does not hot-reload.** Unlike `ChatController.Init`, which + re-binds via `__moduleinfo.OnReload`, the live C object keeps its bound + methods and threads across a reload of this file. Edits to + `AutolobbyController.lua` only take effect on the next `CreateLobby`. The + model and the view *do* hot-reload (own `__moduleinfo` hooks), and because + state now lives in the model, a view reload restores itself by re-reading the + surviving model — no manual state replay. + +--- + +## Model + +`UIAutolobbyModel` ([AutolobbyModel.lua](AutolobbyModel.lua)) is a flat set of +`LazyVar`s plus a few derived ones. + +**Raw** (written by the controller): `PlayerCount`, `LocalPeerId`, +`PlayerOptions`, `GameOptions`, `GameMods`, `ConnectionMatrix`, `LaunchStatutes`, +`IsAliveStamp`. + +**Derived** (computed via `:Set(function() … end)`, never written directly): + +| Derived | From | Replaces the old push | +|---------|------|-----------------------| +| `Connections` | `PlayerOptions`, `ConnectionMatrix`, `PlayerCount` | `interface:UpdateConnections` | +| `Statuses` | `PlayerOptions`, `LaunchStatutes` | `interface:UpdateLaunchStatuses` | +| `Ownership` | `PlayerCount`, `PeerIdToIndex(PlayerOptions, LocalPeerId)`; `false` until the local index is known | `interface:UpdateOwnership` | + +The scenario preview is not a derived var — the view observes `GameOptions` +(for `ScenarioFile`) and `PlayerOptions` together. + +The pure derivation helpers (`PeerIdToIndex`, `CreateConnectionsMatrix`, +`CreateConnectionStatuses`, `CreateOwnershipMatrix`, `CreateLaunchStatus`, +`CanLaunch`, `CreateRatingsTable`, `CreateDivisionsTable`, +`CreateClanTagsTable`) are free functions in the model module. The model uses +them for its derived vars; the controller calls the launch-flow / alive-stamp +ones via `AutolobbyModel.`. + +`LocalPlayerName`, `HostID` and the rejoin parameters +(`LobbyParameters` / `HostParameters` / `JoinParameters`) stay +controller-internal — no view reads them and no derivation depends on them. + +--- + +## Rules + +- **The controller is the only writer to the model.** The view only reads + (subscribes via `Derive`) and the child controls only render. +- **Never mutate a held table in place.** The synced tables (`PlayerOptions`, + `ConnectionMatrix`, `LaunchStatutes`, `GameOptions`) are LazyVar values: + `table.copy` → mutate the copy → `:Set` it, or dependents never go dirty. See + [`/lua/ui/CLAUDE.md § 2`](/lua/ui/CLAUDE.md). +- **`IsAliveStamp` is a pulse, not state.** Set a fresh `{Index, Time}` table on + every receive so the value identity always changes and the observer fires even + for repeated pulses from the same peer. +- **Bootstrap order matters.** `CreateLobby` sets up the model *before* the view + so the view subscribes against it, and *before* the controller so the + controller's `__init` seeds an existing model. Rejoin re-runs `CreateLobby`, + which calls `SetupSingleton` and thus resets the model to fresh state — a new + lobby must not inherit stale connection / launch state. + +--- + +## Verifying changes + +Lobby ↔ server traffic can't be exercised with the local launch script. Use the +two-client procedure documented in +[components/AutolobbyServerCommunicationsComponent.lua](components/AutolobbyServerCommunicationsComponent.lua) +(two FAF clients against the test server, command-line format +`"%s" /init init_local_development.lua`, both on the same PR, both searching). + +Smoke checklist: the connection matrix fills as peers connect; launch statuses +progress (`Connecting` → `Missing local peers` → `Ready`); the map preview +appears; alive pings blink on incoming traffic; the game launches ~5s after all +peers are `Ready` with correct army numbering; rejoin rebuilds the lobby with +clean state. From 0d7af2b56236aaa3983d15ca7947ac662162f76e Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sat, 20 Jun 2026 21:07:29 +0200 Subject: [PATCH 02/98] DIrectly reference the model --- lua/ui/lobby/autolobby.lua | 11 +- .../autolobby/AutolobbyConnectionMatrix.lua | 108 ++++++++++++++- .../lobby/autolobby/AutolobbyController.lua | 49 +++---- lua/ui/lobby/autolobby/AutolobbyInterface.lua | 127 +----------------- .../lobby/autolobby/AutolobbyMapPreview.lua | 32 +++++ lua/ui/lobby/autolobby/AutolobbyModel.lua | 94 +++++++++++++ lua/ui/lobby/autolobby/CLAUDE.md | 66 ++++++--- 7 files changed, 305 insertions(+), 182 deletions(-) diff --git a/lua/ui/lobby/autolobby.lua b/lua/ui/lobby/autolobby.lua index 8d93659a410..5bb396bf239 100644 --- a/lua/ui/lobby/autolobby.lua +++ b/lua/ui/lobby/autolobby.lua @@ -87,12 +87,11 @@ function HostGame(gameName, scenarioFileName, singlePlayer) AutolobbyCommunicationsInstance.HostParameters.ScenarioFile = scenarioFileName AutolobbyCommunicationsInstance.HostParameters.SinglePlayer = singlePlayer - -- the synced game options live on the model; copy-then-Set so the - -- view's scenario observer reacts and the host sees the map preview - local model = import("/lua/ui/lobby/autolobby/autolobbymodel.lua").GetSingleton() - local gameOptions = table.copy(model.GameOptions()) - gameOptions.ScenarioFile = string.gsub(scenarioFileName, ".v%d%d%d%d_scenario.lua", "_scenario.lua") - model.GameOptions:Set(gameOptions) + -- the synced game options live on the model; the scenario observer + -- reacts and the host sees the map preview + local AutolobbyModel = import("/lua/ui/lobby/autolobby/autolobbymodel.lua") + local scenarioFile = string.gsub(scenarioFileName, ".v%d%d%d%d_scenario.lua", "_scenario.lua") --[[@as FileName]] + AutolobbyModel.SetScenarioFile(AutolobbyModel.GetSingleton(), scenarioFile) AutolobbyCommunicationsInstance:HostGame() end end diff --git a/lua/ui/lobby/autolobby/AutolobbyConnectionMatrix.lua b/lua/ui/lobby/autolobby/AutolobbyConnectionMatrix.lua index 620c6051b81..edbf4c49d96 100644 --- a/lua/ui/lobby/autolobby/AutolobbyConnectionMatrix.lua +++ b/lua/ui/lobby/autolobby/AutolobbyConnectionMatrix.lua @@ -26,18 +26,31 @@ local LayoutHelpers = import("/lua/maui/layouthelpers.lua") local Group = import("/lua/maui/group.lua").Group local AutolobbyConnectionMatrixDot = import("/lua/ui/lobby/autolobby/autolobbyconnectionmatrixdot.lua") +local AutolobbyModel = import("/lua/ui/lobby/autolobby/autolobbymodel.lua") + +local LazyVarDerive = import("/lua/lazyvar.lua").Derive ---@class UIAutolobbyConnectionMatrix : Group +---@field Trash TrashBag ---@field PlayerCount number +---@field Border any +---@field Background Bitmap ---@field Elements UIAutolobbyConnectionMatrixDot[][] +---@field ConnectionsObserver LazyVar +---@field StatusesObserver LazyVar +---@field OwnershipObserver LazyVar +---@field IsAliveObserver LazyVar local AutolobbyConnectionMatrix = Class(Group) { ---@param self UIAutolobbyConnectionMatrix ---@param parent Control - __init = function(self, parent, playerCount) + __init = function(self, parent) Group.__init(self, parent, "AutolobbyConnectionMatrix") - self.PlayerCount = playerCount + self.Trash = TrashBag() + + local model = AutolobbyModel.GetSingleton() + self.PlayerCount = model.PlayerCount() self.Border = UIUtil.SurroundWithBorder(self, '/scx_menu/lan-game-lobby/frame/') self.Background = UIUtil.CreateBitmapColor(self, '99000000') @@ -50,6 +63,34 @@ local AutolobbyConnectionMatrix = Class(Group) { self.Elements[y][x] = AutolobbyConnectionMatrixDot.Create(self) end end + + -- hidden until we know of a peer; the observers below reveal it once + -- there is something to show + self:Hide() + + -- subscribe to the model directly: each handler reads its LazyVar + -- (establishing the dependency edge) and feeds the dot grid + self.ConnectionsObserver = self.Trash:Add( + LazyVarDerive(model.Connections, function(connectionsLazy) + self:OnConnectionsChanged(connectionsLazy()) + end)) + self.StatusesObserver = self.Trash:Add( + LazyVarDerive(model.Statuses, function(statusesLazy) + self:OnStatusesChanged(statusesLazy()) + end)) + self.OwnershipObserver = self.Trash:Add( + LazyVarDerive(model.Ownership, function(ownershipLazy) + self:OnOwnershipChanged(ownershipLazy()) + end)) + self.IsAliveObserver = self.Trash:Add( + LazyVarDerive(model.IsAliveStamp, function(stampLazy) + self:OnIsAliveChanged(stampLazy()) + end)) + end, + + ---@param self UIAutolobbyConnectionMatrix + OnDestroy = function(self) + self.Trash:Destroy() end, ---@param self UIAutolobbyConnectionMatrix @@ -118,17 +159,74 @@ local AutolobbyConnectionMatrix = Class(Group) { ---@param self UIAutolobbyConnectionMatrix ---@param id number UpdateIsAliveTimestamp = function(self, id) + -- a StartSpot can fall outside the grid; guard the row lookup + if not self.Elements[id] then + return + end ---@type UIAutolobbyConnectionMatrixDot local dot = self.Elements[id][id] if dot then dot:SetIsAliveTimestamp(GetSystemTimeSeconds()) end end, + + --------------------------------------------------------------------------- + --#region Model observers + + ---@param self UIAutolobbyConnectionMatrix + ---@param connections UIAutolobbyConnections + OnConnectionsChanged = function(self, connections) + if not connections then + return + end + + -- reveal the matrix only once we actually know of a peer; the initial + -- (empty) derivation should not flash an empty grid on screen + if next(AutolobbyModel.GetSingleton().ConnectionMatrix()) then + self:Show() + end + self:UpdateConnections(connections) + end, + + ---@param self UIAutolobbyConnectionMatrix + ---@param statuses UIAutolobbyStatus + OnStatusesChanged = function(self, statuses) + if not statuses then + return + end + + if next(statuses) then + self:Show() + end + self:UpdateStatuses(statuses) + end, + + ---@param self UIAutolobbyConnectionMatrix + ---@param ownership boolean[][] | false + OnOwnershipChanged = function(self, ownership) + if not ownership then + return + end + + self:Show() + self:UpdateOwnership(ownership) + end, + + ---@param self UIAutolobbyConnectionMatrix + ---@param stamp UIAutolobbyAliveStamp | false + OnIsAliveChanged = function(self, stamp) + if not stamp then + return + end + + self:UpdateIsAliveTimestamp(stamp.Index) + end, + + --#endregion } ---@param parent Control ----@param count number ---@return UIAutolobbyConnectionMatrix -Create = function(parent, count) - return AutolobbyConnectionMatrix(parent, count) +Create = function(parent) + return AutolobbyConnectionMatrix(parent) end diff --git a/lua/ui/lobby/autolobby/AutolobbyController.lua b/lua/ui/lobby/autolobby/AutolobbyController.lua index 3e9e0b291af..19c879d1b5a 100644 --- a/lua/ui/lobby/autolobby/AutolobbyController.lua +++ b/lua/ui/lobby/autolobby/AutolobbyController.lua @@ -363,9 +363,7 @@ AutolobbyCommunications = Class(MohoLobbyMethods, AutolobbyServerCommunicationsC local model = AutolobbyModel.GetSingleton() local launchStatus = AutolobbyModel.CreateLaunchStatus(model.ConnectionMatrix(), model.PlayerCount()) - local statuses = table.copy(model.LaunchStatutes()) - statuses[model.LocalPeerId()] = launchStatus - model.LaunchStatutes:Set(statuses) + AutolobbyModel.SetPeerStatus(model, model.LocalPeerId(), launchStatus) -- update peers self:BroadcastData({ Type = "UpdateLaunchStatus", LaunchStatus = launchStatus }) @@ -405,13 +403,10 @@ AutolobbyCommunications = Class(MohoLobbyMethods, AutolobbyServerCommunicationsC self:SendPlayerOptionToServer(ownerId, 'Faction', options.Faction) end - -- tuck them into the game options. By all means a hack, but - -- this way they are available in both the sim and the UI - local gameOptions = table.copy(model.GameOptions()) - gameOptions.Ratings = AutolobbyModel.CreateRatingsTable(playerOptions) - gameOptions.Divisions = AutolobbyModel.CreateDivisionsTable(playerOptions) - gameOptions.ClanTags = AutolobbyModel.CreateClanTagsTable(playerOptions) - model.GameOptions:Set(gameOptions) + -- tuck the rating / division / clan tables into the game + -- options. By all means a hack, but this way they are + -- available in both the sim and the UI + local gameOptions = AutolobbyModel.StampLaunchTables(model, playerOptions) -- create game configuration local gameConfiguration = { @@ -455,17 +450,15 @@ AutolobbyCommunications = Class(MohoLobbyMethods, AutolobbyServerCommunicationsC -- TODO: verify that the StartSpot is not occupied -- put the player where it belongs - local players = table.copy(model.PlayerOptions()) - players[playerOptions.StartSpot] = playerOptions - model.PlayerOptions:Set(players) + AutolobbyModel.SetPlayer(model, playerOptions.StartSpot, playerOptions) -- sync game options with the connected peer self:SendData(data.SenderID, { Type = "UpdateGameOptions", GameOptions = model.GameOptions() }) -- sync player options to all connected peers - self:BroadcastData({ Type = "UpdatePlayerOptions", PlayerOptions = players }) + self:BroadcastData({ Type = "UpdatePlayerOptions", PlayerOptions = model.PlayerOptions() }) - -- the view's scenario + ownership observers react to the PlayerOptions change + -- the scenario + ownership observers react to the PlayerOptions change end, ---@param self UIAutolobbyCommunications @@ -496,12 +489,8 @@ AutolobbyCommunications = Class(MohoLobbyMethods, AutolobbyServerCommunicationsC ---@param self UIAutolobbyCommunications ---@param data UIAutolobbyUpdateLaunchStatusMessage ProcessUpdateLaunchStatusMessage = function(self, data) - local model = AutolobbyModel.GetSingleton() - local statuses = table.copy(model.LaunchStatutes()) - statuses[data.SenderID] = data.LaunchStatus - model.LaunchStatutes:Set(statuses) - - -- the view's Statuses observer reacts to the LaunchStatutes change + -- the matrix' Statuses observer reacts to the LaunchStatutes change + AutolobbyModel.SetPeerStatus(AutolobbyModel.GetSingleton(), data.SenderID, data.LaunchStatus) end, --#endregion @@ -723,9 +712,7 @@ AutolobbyCommunications = Class(MohoLobbyMethods, AutolobbyServerCommunicationsC local hostPlayerOptions = self:CreateLocalPlayer() hostPlayerOptions.OwnerID = localPeerId hostPlayerOptions.PlayerName = self:MakeValidPlayerName(localPeerId, self.LocalPlayerName) - local players = table.copy(model.PlayerOptions()) - players[hostPlayerOptions.StartSpot] = hostPlayerOptions - model.PlayerOptions:Set(players) + AutolobbyModel.SetPlayer(model, hostPlayerOptions.StartSpot, hostPlayerOptions) -- occasionally send data over the network to create pings on screen self.Trash:Add(ForkThread(self.ShareLaunchStatusThread, self)) @@ -785,16 +772,10 @@ AutolobbyCommunications = Class(MohoLobbyMethods, AutolobbyServerCommunicationsC local model = AutolobbyModel.GetSingleton() - -- seed an initial status for the peer if we don't have one yet; the - -- view's Statuses observer reacts to the change - local statuses = table.copy(model.LaunchStatutes()) - statuses[peerId] = statuses[peerId] or 'Unknown' - model.LaunchStatutes:Set(statuses) - - -- update the connection matrix; the view's Connections observer reacts - local connectionMatrix = table.copy(model.ConnectionMatrix()) - connectionMatrix[peerId] = peerConnectedTo - model.ConnectionMatrix:Set(connectionMatrix) + -- seed an initial status for the peer and record its connections; the + -- matrix' Statuses / Connections observers react to the changes + AutolobbyModel.EnsurePeerStatus(model, peerId, 'Unknown') + AutolobbyModel.SetPeerConnections(model, peerId, peerConnectedTo) end, --#endregion diff --git a/lua/ui/lobby/autolobby/AutolobbyInterface.lua b/lua/ui/lobby/autolobby/AutolobbyInterface.lua index 3178e6fe3d7..5c70d5544fd 100644 --- a/lua/ui/lobby/autolobby/AutolobbyInterface.lua +++ b/lua/ui/lobby/autolobby/AutolobbyInterface.lua @@ -36,20 +36,11 @@ local AutolobbyMapPreview = import("/lua/ui/lobby/autolobby/autolobbymappreview. local AutolobbyConnectionMatrix = import("/lua/ui/lobby/autolobby/autolobbyconnectionmatrix.lua") local AutolobbyModel = import("/lua/ui/lobby/autolobby/autolobbymodel.lua") -local LazyVarDerive = import("/lua/lazyvar.lua").Derive - ---@class UIAutolobbyInterface : Group ----@field Trash TrashBag ---@field BackgroundTextures string[] ---@field Background Bitmap ---@field Preview UIAutolobbyMapPreview ---@field ConnectionMatrix UIAutolobbyConnectionMatrix ----@field GameOptionsObserver LazyVar ----@field PlayerOptionsObserver LazyVar ----@field ConnectionsObserver LazyVar ----@field StatusesObserver LazyVar ----@field OwnershipObserver LazyVar ----@field IsAliveObserver LazyVar local AutolobbyInterface = Class(Group) { BackgroundTextures = { @@ -60,60 +51,18 @@ local AutolobbyInterface = Class(Group) { "/menus02/background-paint05_bmp.dds", }, + -- Pure composition root: it builds and lays out the background, the map + -- preview and the connection matrix. It holds no model subscriptions — + -- each child subscribes to the model itself and owns its own visibility. ---@param self UIAutolobbyInterface ---@param parent Control - __init = function(self, parent, playerCount) + __init = function(self, parent) Group.__init(self, parent, "AutolobbyInterface") - self.Trash = TrashBag() - 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) - - -- Subscribe to the model. The handlers read the current value from the - -- model and feed the existing child controls, replacing the imperative - -- `Update*` pushes the controller used to make. The scenario preview - -- depends on both the scenario file (carried in GameOptions) and the - -- player options, so both feed `OnScenarioChanged`. - local model = AutolobbyModel.GetSingleton() - - -- Each handler must read its own LazyVar (`gameOptionsLazy()` / - -- `playerOptionsLazy()`) so the dependency edge is (re)established and - -- later `:Set` calls re-fire it; the other half is read straight from - -- the model. Reading neither would leave the edge unformed and the - -- observer would only ever fire once, during construction. - self.GameOptionsObserver = self.Trash:Add( - LazyVarDerive(model.GameOptions, function(gameOptionsLazy) - self:OnScenarioChanged(gameOptionsLazy(), model.PlayerOptions()) - end)) - self.PlayerOptionsObserver = self.Trash:Add( - LazyVarDerive(model.PlayerOptions, function(playerOptionsLazy) - self:OnScenarioChanged(model.GameOptions(), playerOptionsLazy()) - end)) - - self.ConnectionsObserver = self.Trash:Add( - LazyVarDerive(model.Connections, function(connectionsLazy) - self:OnConnectionsChanged(connectionsLazy()) - end)) - self.StatusesObserver = self.Trash:Add( - LazyVarDerive(model.Statuses, function(statusesLazy) - self:OnStatusesChanged(statusesLazy()) - end)) - self.OwnershipObserver = self.Trash:Add( - LazyVarDerive(model.Ownership, function(ownershipLazy) - self:OnOwnershipChanged(ownershipLazy()) - end)) - self.IsAliveObserver = self.Trash:Add( - LazyVarDerive(model.IsAliveStamp, function(stampLazy) - self:OnIsAliveChanged(stampLazy()) - end)) - end, - - ---@param self UIAutolobbyInterface - OnDestroy = function(self) - self.Trash:Destroy() + self.ConnectionMatrix = AutolobbyConnectionMatrix.Create(self) end, ---@param self UIAutolobbyInterface @@ -127,80 +76,18 @@ local AutolobbyInterface = Class(Group) { :Fill(self) :End() + -- position / size only; the preview and matrix manage their own + -- visibility from the model 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[][] | false - OnOwnershipChanged = function(self, ownership) - if not ownership then - return - end - - self.ConnectionMatrix:Show() - self.ConnectionMatrix:UpdateOwnership(ownership) - end, - - ---@param self UIAutolobbyInterface - ---@param connections UIAutolobbyConnections - OnConnectionsChanged = function(self, connections) - if not connections then - return - end - - -- only reveal the matrix once we actually know of a peer; the initial - -- (empty) derivation should not flash an empty grid on screen - if next(AutolobbyModel.GetSingleton().ConnectionMatrix()) then - self.ConnectionMatrix:Show() - end - self.ConnectionMatrix:UpdateConnections(connections) - end, - - ---@param self UIAutolobbyInterface - ---@param statuses UIAutolobbyStatus - OnStatusesChanged = function(self, statuses) - if not statuses then - return - end - - if next(statuses) then - self.ConnectionMatrix:Show() - end - self.ConnectionMatrix:UpdateStatuses(statuses) - end, - - ---@param self UIAutolobbyInterface - ---@param gameOptions UILobbyLaunchGameOptionsConfiguration - ---@param playerOptions UIAutolobbyPlayer[] - OnScenarioChanged = function(self, gameOptions, playerOptions) - local pathToScenarioInfo = gameOptions.ScenarioFile - - if pathToScenarioInfo and playerOptions then - -- hide it for now until we have a better way to decipher its possible (negative) impact - self.Preview:Show() - self.Preview:UpdateScenario(pathToScenarioInfo, playerOptions) - end - end, - - ---@param self UIAutolobbyInterface - ---@param stamp UIAutolobbyAliveStamp | false - OnIsAliveChanged = function(self, stamp) - if not stamp then - return - end - - self.ConnectionMatrix:UpdateIsAliveTimestamp(stamp.Index) - end, } --- A trashbag that should be destroyed upon reload. diff --git a/lua/ui/lobby/autolobby/AutolobbyMapPreview.lua b/lua/ui/lobby/autolobby/AutolobbyMapPreview.lua index eaadee5eae3..32897d0e687 100644 --- a/lua/ui/lobby/autolobby/AutolobbyMapPreview.lua +++ b/lua/ui/lobby/autolobby/AutolobbyMapPreview.lua @@ -27,8 +27,12 @@ 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") +local AutolobbyModel = import("/lua/ui/lobby/autolobby/autolobbymodel.lua") + +local LazyVarDerive = import("/lua/lazyvar.lua").Derive ---@class UIAutolobbyMapPreview : Group +---@field Trash TrashBag ---@field Preview MapPreview ---@field Overlay Bitmap ---@field PathToScenarioFile? FileName @@ -39,6 +43,7 @@ local AutolobbyMapPreviewSpawn = import("/lua/ui/lobby/autolobby/autolobbymappre ---@field WreckageIcon Bitmap # Acts as a pool ---@field IconTrash TrashBag # Trashbag that contains all icons ---@field SpawnIcons UIAutolobbyMapPreviewSpawn[] +---@field ScenarioObserver LazyVar local AutolobbyMapPreview = ClassUI(Group) { ---@param self UIAutolobbyMapPreview @@ -46,6 +51,8 @@ local AutolobbyMapPreview = ClassUI(Group) { __init = function(self, parent) Group.__init(self, parent) + self.Trash = TrashBag() + self.Preview = MapPreview(self) -- D:\SteamLibrary\steamapps\common\Supreme Commander Forged Alliance\gamedata\textures\textures\ui\common\game\mini-map-glow-brd ? @@ -59,6 +66,31 @@ local AutolobbyMapPreview = ClassUI(Group) { UIUtil.CreateDialogBrackets(self, 30, 24, 30, 24) self.IconTrash = TrashBag() + + -- subscribe to the model's scenario bundle directly; reading the lazy + -- establishes the dependency edge so later changes re-fire + self.ScenarioObserver = self.Trash:Add( + LazyVarDerive(AutolobbyModel.GetSingleton().Scenario, function(scenarioLazy) + self:OnScenarioChanged(scenarioLazy()) + end)) + end, + + ---@param self UIAutolobbyMapPreview + OnDestroy = function(self) + self.Trash:Destroy() + end, + + --- Reacts to the model's scenario bundle: show + render the preview once a + --- scenario file is known, hide it otherwise. + ---@param self UIAutolobbyMapPreview + ---@param scenario UIAutolobbyScenario + OnScenarioChanged = function(self, scenario) + if scenario.ScenarioFile and scenario.PlayerOptions then + self:Show() + self:UpdateScenario(scenario.ScenarioFile, scenario.PlayerOptions) + else + self:Hide() + end end, ---@param self UIAutolobbyMapPreview diff --git a/lua/ui/lobby/autolobby/AutolobbyModel.lua b/lua/ui/lobby/autolobby/AutolobbyModel.lua index a9e160eeb4a..f653d6b0789 100644 --- a/lua/ui/lobby/autolobby/AutolobbyModel.lua +++ b/lua/ui/lobby/autolobby/AutolobbyModel.lua @@ -127,6 +127,13 @@ function CreateOwnershipMatrix(playerCount, localIndex) end end + -- a StartSpot can in theory fall outside the grid; guard against indexing a + -- non-existent row/column rather than erroring out the whole derivation + if localIndex < 1 or localIndex > playerCount then + WARN("Autolobby model: local index out of range ", localIndex) + return output + end + for k = 1, playerCount do output[localIndex][k] = true output[k][localIndex] = true @@ -244,6 +251,13 @@ end ---@field Connections LazyVar # derived from PlayerOptions + ConnectionMatrix + PlayerCount ---@field Statuses LazyVar # derived from PlayerOptions + LaunchStatutes ---@field Ownership LazyVar # derived from PlayerCount + PeerIdToIndex(PlayerOptions, LocalPeerId) +---@field Scenario LazyVar # derived bundle of { ScenarioFile, PlayerOptions } for the map preview + +--- The scenario preview depends on two raw vars; bundling them into one derived +--- var lets the preview subscribe with a single observer (one LazyVar to read). +---@class UIAutolobbyScenario +---@field ScenarioFile? FileName +---@field PlayerOptions UIAutolobbyPlayer[] --- Singleton handle; nil until `SetupSingleton` (or `GetSingleton`) builds the model. ---@type UIAutolobbyModel | nil @@ -270,6 +284,7 @@ function SetupSingleton(playerCount) Connections = Create(), Statuses = Create(), Ownership = Create(), + Scenario = Create(), } -- Derived values. Reading the raw LazyVars inside these compute functions @@ -291,6 +306,10 @@ function SetupSingleton(playerCount) return CreateOwnershipMatrix(model.PlayerCount(), localIndex) end) + model.Scenario:Set(function() + return { ScenarioFile = model.GameOptions().ScenarioFile, PlayerOptions = model.PlayerOptions() } + end) + ModelInstance = model return model end @@ -306,6 +325,81 @@ end --#endregion +------------------------------------------------------------------------------- +--#region Write helpers +-- +-- The synced tables are LazyVar values, so a write must build a NEW table and +-- `:Set` it — mutating the held table in place never marks dependents dirty +-- (see /lua/ui/CLAUDE.md § 2). These helpers encapsulate that copy-then-Set +-- discipline so call sites in the controller can't get it wrong. + +--- Places (or replaces) a player at a start spot. +---@param model UIAutolobbyModel +---@param startSpot number +---@param options UIAutolobbyPlayer +function SetPlayer(model, startSpot, options) + local players = table.copy(model.PlayerOptions()) + players[startSpot] = options + model.PlayerOptions:Set(players) +end + +--- Sets the launch status of a peer. +---@param model UIAutolobbyModel +---@param peerId UILobbyPeerId +---@param status UIPeerLaunchStatus +function SetPeerStatus(model, peerId, status) + local statuses = table.copy(model.LaunchStatutes()) + statuses[peerId] = status + model.LaunchStatutes:Set(statuses) +end + +--- Seeds a launch status for a peer only if we don't have one yet. +---@param model UIAutolobbyModel +---@param peerId UILobbyPeerId +---@param status UIPeerLaunchStatus +function EnsurePeerStatus(model, peerId, status) + if model.LaunchStatutes()[peerId] then + return + end + SetPeerStatus(model, peerId, status) +end + +--- Records the peers a peer is connected to. +---@param model UIAutolobbyModel +---@param peerId UILobbyPeerId +---@param peers UILobbyPeerId[] +function SetPeerConnections(model, peerId, peers) + local connectionMatrix = table.copy(model.ConnectionMatrix()) + connectionMatrix[peerId] = peers + model.ConnectionMatrix:Set(connectionMatrix) +end + +--- Sets the scenario file on the game options. +---@param model UIAutolobbyModel +---@param scenarioFile FileName +function SetScenarioFile(model, scenarioFile) + local gameOptions = table.copy(model.GameOptions()) + gameOptions.ScenarioFile = scenarioFile + model.GameOptions:Set(gameOptions) +end + +--- Tucks the rating / division / clan tables into a copy of the game options +--- and sets it. Returns the new game options so the caller can reuse them in the +--- launch configuration. +---@param model UIAutolobbyModel +---@param playerOptions UIAutolobbyPlayer[] +---@return UILobbyLaunchGameOptionsConfiguration +function StampLaunchTables(model, playerOptions) + local gameOptions = table.copy(model.GameOptions()) + gameOptions.Ratings = CreateRatingsTable(playerOptions) + gameOptions.Divisions = CreateDivisionsTable(playerOptions) + gameOptions.ClanTags = CreateClanTagsTable(playerOptions) + model.GameOptions:Set(gameOptions) + return gameOptions +end + +--#endregion + ------------------------------------------------------------------------------- --#region Debugging diff --git a/lua/ui/lobby/autolobby/CLAUDE.md b/lua/ui/lobby/autolobby/CLAUDE.md index 30bc3666c4e..ceff6772a08 100644 --- a/lua/ui/lobby/autolobby/CLAUDE.md +++ b/lua/ui/lobby/autolobby/CLAUDE.md @@ -25,11 +25,16 @@ engine ──callbacks──► AutolobbyController (moho.lobby_methods) | Role | File | Responsibility | |------|------|----------------| -| Model | [AutolobbyModel.lua](AutolobbyModel.lua) | Reactive singleton: raw synced state (player/game options, connection matrix, launch statuses) + derived view-models (`Connections`, `Statuses`, `Ownership`) + the pure derivation helpers. No UI, no networking. | -| View | [AutolobbyInterface.lua](AutolobbyInterface.lua) | Observes the model via `Derive` and feeds the child controls ([AutolobbyMapPreview.lua](AutolobbyMapPreview.lua), [AutolobbyConnectionMatrix.lua](AutolobbyConnectionMatrix.lua)). Never writes the model. | +| Model | [AutolobbyModel.lua](AutolobbyModel.lua) | Reactive singleton: raw synced state (player/game options, connection matrix, launch statuses) + derived view-models (`Connections`, `Statuses`, `Ownership`, `Scenario`) + the pure derivation helpers + the copy-then-`Set` write helpers. No UI, no networking. | +| Composition root | [AutolobbyInterface.lua](AutolobbyInterface.lua) | Builds and lays out the children; holds **no** model subscriptions. | +| Components | [AutolobbyConnectionMatrix.lua](AutolobbyConnectionMatrix.lua), [AutolobbyMapPreview.lua](AutolobbyMapPreview.lua) | Each subscribes to the model via `Derive`, feeds its dot grid / preview, and owns its own visibility. Never write the model. | | Controller | [AutolobbyController.lua](AutolobbyController.lua) | `AutolobbyCommunications`, the engine's lobby object. The only writer to the model and the only place that talks to peers / the lobby server. | | Entry point | [/lua/ui/lobby/autolobby.lua](/lua/ui/lobby/autolobby.lua) | Engine-facing wrapper: `CreateLobby` / `HostGame` / `JoinGame` / `ConnectToPeer` / `DisconnectFromPeer`. Bootstraps model + view + controller in that order. | +Like the chat (`ChatLinesInterface`, `ChatEditInterface`, … each subscribe to the +model themselves), the components own their reactivity. `AutolobbyInterface` is a +pure composition root, not a push hub. + --- ## The key difference from chat @@ -64,14 +69,18 @@ engine calls. So: **Derived** (computed via `:Set(function() … end)`, never written directly): -| Derived | From | Replaces the old push | -|---------|------|-----------------------| -| `Connections` | `PlayerOptions`, `ConnectionMatrix`, `PlayerCount` | `interface:UpdateConnections` | -| `Statuses` | `PlayerOptions`, `LaunchStatutes` | `interface:UpdateLaunchStatuses` | -| `Ownership` | `PlayerCount`, `PeerIdToIndex(PlayerOptions, LocalPeerId)`; `false` until the local index is known | `interface:UpdateOwnership` | +| Derived | From | Consumed by | +|---------|------|-------------| +| `Connections` | `PlayerOptions`, `ConnectionMatrix`, `PlayerCount` | matrix | +| `Statuses` | `PlayerOptions`, `LaunchStatutes` | matrix | +| `Ownership` | `PlayerCount`, `PeerIdToIndex(PlayerOptions, LocalPeerId)`; `false` until the local index is known | matrix | +| `Scenario` | `{ ScenarioFile = GameOptions().ScenarioFile, PlayerOptions }` | preview | -The scenario preview is not a derived var — the view observes `GameOptions` -(for `ScenarioFile`) and `PlayerOptions` together. +`Scenario` is a bundle so the preview can subscribe with **one** observer that +reads **one** LazyVar — the same shape as every other observer. (An earlier +two-observer version that read the model directly instead of its LazyVar never +formed the dependency edge and silently stopped firing; bundling removes that +footgun.) The pure derivation helpers (`PeerIdToIndex`, `CreateConnectionsMatrix`, `CreateConnectionStatuses`, `CreateOwnershipMatrix`, `CreateLaunchStatus`, @@ -80,6 +89,13 @@ The pure derivation helpers (`PeerIdToIndex`, `CreateConnectionsMatrix`, them for its derived vars; the controller calls the launch-flow / alive-stamp ones via `AutolobbyModel.`. +### Write helpers + +Writes to the synced tables go through helpers that encapsulate the copy-then-`Set` +discipline, so call sites can't accidentally mutate in place: `SetPlayer`, +`SetPeerStatus`, `EnsurePeerStatus`, `SetPeerConnections`, `SetScenarioFile`, +`StampLaunchTables`. The controller calls these instead of building tables by hand. + `LocalPlayerName`, `HostID` and the rejoin parameters (`LobbyParameters` / `HostParameters` / `JoinParameters`) stay controller-internal — no view reads them and no derivation depends on them. @@ -88,12 +104,17 @@ controller-internal — no view reads them and no derivation depends on them. ## Rules -- **The controller is the only writer to the model.** The view only reads - (subscribes via `Derive`) and the child controls only render. -- **Never mutate a held table in place.** The synced tables (`PlayerOptions`, - `ConnectionMatrix`, `LaunchStatutes`, `GameOptions`) are LazyVar values: - `table.copy` → mutate the copy → `:Set` it, or dependents never go dirty. See - [`/lua/ui/CLAUDE.md § 2`](/lua/ui/CLAUDE.md). +- **The controller is the only writer to the model.** Components only read + (subscribe via `Derive`) and render. +- **Write through the model helpers, never mutate a held table in place.** The + synced tables (`PlayerOptions`, `ConnectionMatrix`, `LaunchStatutes`, + `GameOptions`) are LazyVar values — mutating in place never marks dependents + dirty (see [`/lua/ui/CLAUDE.md § 2`](/lua/ui/CLAUDE.md)). The write helpers + encapsulate the `table.copy` → mutate → `:Set` dance; use them. +- **A `Derive` handler must read its own LazyVar.** `Derive` only forms the + dependency edge when the handler calls `lv()`. A handler that reads the model + some other way fires once at construction and then never again. Always + `function(fooLazy) self:OnFoo(fooLazy()) end`. - **`IsAliveStamp` is a pulse, not state.** Set a fresh `{Index, Time}` table on every receive so the value identity always changes and the observer fires even for repeated pulses from the same peer. @@ -107,8 +128,19 @@ controller-internal — no view reads them and no derivation depends on them. ## Verifying changes -Lobby ↔ server traffic can't be exercised with the local launch script. Use the -two-client procedure documented in +For a quick visual check of the UI without any networking, mount it against a +fake-populated model from the console: + +``` +UI_Lua import("/lua/ui/lobby/autolobby/autolobbyinterface.lua").OpenDebug() +``` + +`OpenDebug` builds a 4-player model with statuses and a fully-connected matrix; +`CloseDebug()` tears it down. (The map preview stays hidden unless you also +`AutolobbyModel.SetScenarioFile` with an installed scenario.) + +For the real flow, lobby ↔ server traffic can't be exercised with the local +launch script. Use the two-client procedure documented in [components/AutolobbyServerCommunicationsComponent.lua](components/AutolobbyServerCommunicationsComponent.lua) (two FAF clients against the test server, command-line format `"%s" /init init_local_development.lua`, both on the same PR, both searching). From 0b2c5258772719048c15bc2dd1f9398258475c0b Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 21 Jun 2026 08:24:15 +0200 Subject: [PATCH 03/98] Separate the controller from the lobby methods instance --- lua/ui/lobby/autolobby.lua | 60 +- .../lobby/autolobby/AutolobbyController.lua | 1244 ++++++----------- lua/ui/lobby/autolobby/AutolobbyInstance.lua | 617 ++++++++ lua/ui/lobby/autolobby/AutolobbyMessages.lua | 51 +- lua/ui/lobby/autolobby/AutolobbyModel.lua | 105 +- lua/ui/lobby/autolobby/CLAUDE.md | 69 +- .../components/AutolobbyArguments.lua | 8 +- ...AutolobbyServerCommunicationsComponent.lua | 14 +- 8 files changed, 1136 insertions(+), 1032 deletions(-) create mode 100644 lua/ui/lobby/autolobby/AutolobbyInstance.lua diff --git a/lua/ui/lobby/autolobby.lua b/lua/ui/lobby/autolobby.lua index 5bb396bf239..40428409fb9 100644 --- a/lua/ui/lobby/autolobby.lua +++ b/lua/ui/lobby/autolobby.lua @@ -31,8 +31,8 @@ -- 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 +---@type UIAutolobbyInstance | false +local LobbyInstance = false --- Creates the lobby communications, called (indirectly) by the engine to setup the module state. ---@param protocol UILobbyProtocol @@ -40,7 +40,7 @@ local AutolobbyCommunicationsInstance = false ---@param desiredPlayerName string ---@param localPlayerUID UILobbyPeerId ---@param natTraversalProvider any ----@return UIAutolobbyCommunications +---@return UIAutolobbyInstance function CreateLobby(protocol, localPort, desiredPlayerName, localPlayerUID, natTraversalProvider) -- we intentionally do not log the 'natTraversalProvider' parameter as it can cause issues due to being an uninitialized C object LOG("CreateLobby", protocol, localPort, desiredPlayerName, localPlayerUID) @@ -54,21 +54,21 @@ function CreateLobby(protocol, localPort, desiredPlayerName, localPlayerUID, nat -- create the lobby local maxConnections = 16 - AutolobbyCommunicationsInstance = InternalCreateLobby( - import("/lua/ui/lobby/autolobby/autolobbycontroller.lua").AutolobbyCommunications, + LobbyInstance = InternalCreateLobby( + import("/lua/ui/lobby/autolobby/autolobbyinstance.lua").AutolobbyInstance, 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 + LobbyInstance.LobbyParameters = LobbyInstance.LobbyParameters or {} + LobbyInstance.LobbyParameters.Protocol = protocol + LobbyInstance.LobbyParameters.LocalPort = localPort + LobbyInstance.LobbyParameters.MaxConnections = maxConnections + LobbyInstance.LobbyParameters.DesiredPlayerName = desiredPlayerName + LobbyInstance.LobbyParameters.LocalPlayerPeerId = localPlayerUID + LobbyInstance.LobbyParameters.NatTraversalProvider = natTraversalProvider - return AutolobbyCommunicationsInstance + return LobbyInstance end --- Instantiates a lobby instance by hosting one. @@ -80,19 +80,19 @@ end function HostGame(gameName, scenarioFileName, singlePlayer) LOG("HostGame", gameName, scenarioFileName, singlePlayer) - if AutolobbyCommunicationsInstance then + if LobbyInstance then - AutolobbyCommunicationsInstance.HostParameters = AutolobbyCommunicationsInstance.HostParameters or {} - AutolobbyCommunicationsInstance.HostParameters.GameName = gameName - AutolobbyCommunicationsInstance.HostParameters.ScenarioFile = scenarioFileName - AutolobbyCommunicationsInstance.HostParameters.SinglePlayer = singlePlayer + LobbyInstance.HostParameters = LobbyInstance.HostParameters or {} + LobbyInstance.HostParameters.GameName = gameName + LobbyInstance.HostParameters.ScenarioFile = scenarioFileName + LobbyInstance.HostParameters.SinglePlayer = singlePlayer -- the synced game options live on the model; the scenario observer -- reacts and the host sees the map preview local AutolobbyModel = import("/lua/ui/lobby/autolobby/autolobbymodel.lua") local scenarioFile = string.gsub(scenarioFileName, ".v%d%d%d%d_scenario.lua", "_scenario.lua") --[[@as FileName]] AutolobbyModel.SetScenarioFile(AutolobbyModel.GetSingleton(), scenarioFile) - AutolobbyCommunicationsInstance:HostGame() + LobbyInstance:HostGame() end end @@ -108,13 +108,13 @@ local rejoinTest = false function JoinGame(address, asObserver, playerName, uid) LOG("JoinGame", address, asObserver, playerName, 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) + if LobbyInstance then + LobbyInstance.JoinParameters = LobbyInstance.JoinParameters or {} + LobbyInstance.JoinParameters.Address = address + LobbyInstance.JoinParameters.AsObserver = asObserver + LobbyInstance.JoinParameters.DesiredPlayerName = playerName + LobbyInstance.JoinParameters.DesiredPeerId = uid + LobbyInstance:JoinGame(address, playerName, uid) end end @@ -125,8 +125,8 @@ end function ConnectToPeer(addressAndPort, name, uid) LOG("ConnectToPeer", addressAndPort, name, uid) - if AutolobbyCommunicationsInstance then - AutolobbyCommunicationsInstance:ConnectToPeer(addressAndPort, name, uid) + if LobbyInstance then + LobbyInstance:ConnectToPeer(addressAndPort, name, uid) end end @@ -136,7 +136,7 @@ end function DisconnectFromPeer(uid, doNotUpdateView) LOG("DisconnectFromPeer", uid, doNotUpdateView) - if AutolobbyCommunicationsInstance then - AutolobbyCommunicationsInstance:DisconnectFromPeer(uid) + if LobbyInstance then + LobbyInstance:DisconnectFromPeer(uid) end end diff --git a/lua/ui/lobby/autolobby/AutolobbyController.lua b/lua/ui/lobby/autolobby/AutolobbyController.lua index 19c879d1b5a..f5ab7888ff7 100644 --- a/lua/ui/lobby/autolobby/AutolobbyController.lua +++ b/lua/ui/lobby/autolobby/AutolobbyController.lua @@ -20,910 +20,460 @@ --** 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 AutolobbyArgumentsComponent = import("/lua/ui/lobby/autolobby/components/autolobbyarguments.lua").AutolobbyArgumentsComponent - -local AutolobbyMessages = import("/lua/ui/lobby/autolobby/autolobbymessages.lua").AutolobbyMessages +-- Lobby logic for the automated lobby, as a module of free functions. +-- +-- The engine instantiates a `moho.lobby_methods` object — `AutolobbyInstance` +-- (see AutolobbyInstance.lua) — and calls its callbacks. That instance is a thin +-- shell: it forwards the callbacks with real behaviour to the functions here and +-- passes itself as the first argument. This split keeps the engine-ABI surface +-- tiny and makes the logic a plain module that **hot-reloads** (the instance's +-- forwarders resolve through the live module table) and is testable with a mock +-- instance. The running threads are the exception — a forked thread holds the +-- function it started with, so thread edits only take effect on the next lobby. +-- +-- All writes go to `AutolobbyModel` (the reactive singleton the UI observes), +-- through its write helpers so the copy-then-`Set` discipline stays in one place. +local MapUtil = import("/lua/ui/maputil.lua") local AutolobbyModel = import("/lua/ui/lobby/autolobby/autolobbymodel.lua") -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.", -} - --- associated textures are in `/textures/divisions/ .png` --- Make note of the space, which isn't there for "grandmaster" and "unlisted" divisions - ----@alias Division ----| "bronze" ----| "silver" ----| "gold" ----| "diamond" ----| "master" ----| "grandmaster" ----| "unlisted" - ----@alias Subdivision ----| "I" ----| "II" ----| "III" ----| "IV" ----| "V" ----| "" # when Division is grandmaster or unlisted - ----@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 Division # Related to rating/divisions ----@field SUBDIV Subdivision # Related to rating/divisions ----@field PL number # Related to rating/divisions ----@field PlayerClan string - ----@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. ---- ---- The synced lobby state (player options, game options, connections, launch ---- statuses, ...) lives in `AutolobbyModel` — a reactive singleton the view ---- observes. This controller is the only writer to that model. The fields it ---- keeps here are purely controller-internal (identity + rejoin parameters) ---- and are not observed by the view. ----@class UIAutolobbyCommunications : moho.lobby_methods, DebugComponent, UIAutolobbyServerCommunicationsComponent, UIAutolobbyArgumentsComponent ----@field Trash TrashBag ----@field LocalPlayerName string # nickname ----@field HostID UILobbyPeerId ----@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, AutolobbyArgumentsComponent, DebugComponent) { - - ---@param self UIAutolobbyCommunications - __init = function(self) - self.Trash = TrashBag() - - self.LocalPlayerName = "Charlie" - self.HostID = "-2" - - -- The model singleton is created in `autolobby.lua > CreateLobby` - -- before the lobby (and thus this controller) is instantiated. Seed - -- the initial state here. - local model = AutolobbyModel.GetSingleton() - model.LocalPeerId:Set("-2") - model.GameMods:Set({}) - model.GameOptions:Set(self:CreateLocalGameOptions()) - model.PlayerOptions:Set({}) - model.LaunchStatutes:Set({}) - model.ConnectionMatrix:Set({}) - 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 +------------------------------------------------------------------------------- +--#region Launch-flow computations +-- +-- Pure helpers used by the launch / status threads. They moved here from the +-- model because they are controller logic, not part of the reactive model's +-- derivations (which keep `PeerIdToIndex` / `Create*Matrix` in the model). + +--- Determines the launch status of the local peer. +---@param connectionMatrix table +---@param playerCount number +---@return UIPeerLaunchStatus +function CreateLaunchStatus(connectionMatrix, playerCount) + -- check number of peers + local validPeerCount = playerCount - 1 + if table.getsize(connectionMatrix) < validPeerCount then + return 'Missing local peers' + end + + return 'Ready' +end + +--- Verifies whether we can launch the game. +---@param peerStatus UIAutolobbyStatus +---@param playerCount number +---@return boolean +function CanLaunch(peerStatus, playerCount) + -- check if we know of all peers + if table.getsize(peerStatus) ~= 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 - -- retrieve team and start spot - info.Team = self:GetCommandLineArgumentNumber("/team", -1) - info.StartSpot = self:GetCommandLineArgumentNumber("/startspot", -1) - - -- determine army color based on start location - info.PlayerColor = GameColors.MapToWarmCold(info.StartSpot) - info.ArmyColor = GameColors.MapToWarmCold(info.StartSpot) - - -- retrieve rating - info.DEV = self:GetCommandLineArgumentNumber("/deviation", 500) - info.MEAN = self:GetCommandLineArgumentNumber("/mean", 1500) - info.NG = self:GetCommandLineArgumentNumber("/numgames", 0) - info.DIV = self:GetCommandLineArgumentString("/division", "") - info.SUBDIV = self:GetCommandLineArgumentString("/subdivision", "") - info.PL = math.floor(info.MEAN - 3 * info.DEV) - info.PlayerClan = self:GetCommandLineArgumentString("/clan", "") - - 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', - DisconnectShare = 'SameAsShare', - DisconnectShareCommanders = 'Explode', - TeamShareOverflow = "enabled", - - -- yep, great - Ranked = true, - Unranked = 'No', - } - - -- process game options from the command line - for name, value in self:GetCommandLineArgumentArray("/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 true +end - return options - end, - - --------------------------------------------------------------------------- - --#region Utilities - -- - -- The pure derivation helpers that used to live here (CreateConnectionsMatrix, - -- CreateConnectionStatuses, CreateOwnershipMatrix, CreateLaunchStatus, - -- CreateRatingsTable, CreateDivisionsTable, CreateClanTagsTable, CanLaunch, - -- PeerIdToIndex) moved to `AutolobbyModel` as free functions. The model - -- uses them to compute its derived LazyVars; this controller calls the few - -- it still needs (launch flow, alive stamp) via `AutolobbyModel.`. - - --- 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 +---@param playerOptions UIAutolobbyPlayer[] +---@return table +function CreateRatingsTable(playerOptions) + ---@type table + local allRatings = {} - local scenarioFile = MapUtil.LoadScenario(gameOptions.ScenarioFile) - if not scenarioFile then - -- ??? - return + for slot, options in pairs(playerOptions) do + if options.Human and options.PL then + allRatings[options.PlayerName] = options.PL 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 + + return allRatings +end + +---@param playerOptions UIAutolobbyPlayer[] +---@return table +function CreateDivisionsTable(playerOptions) + ---@type table + local allDivisions = {} + + for slot, options in pairs(playerOptions) do + if options.Human and options.PL then + if options.DIV ~= "unlisted" then + local division = options.DIV + if options.SUBDIV and options.SUBDIV ~= "" then + division = division .. ' ' .. options.SUBDIV + end + allDivisions[options.PlayerName] = division end - ) - end, - - - --------------------------------------------------------------------------- - --#region Threads - - ---@param self UIAutolobbyCommunications - CheckForRejoinThread = function(self) + end + end - local rejoinThreshold = 3 - local rejoinCount = 0 + return allDivisions +end - while not IsDestroyed(self) do +---@param playerOptions UIAutolobbyPlayer[] +---@return table +function CreateClanTagsTable(playerOptions) + local allClanTags = {} - local model = AutolobbyModel.GetSingleton() - local launchStatutes = model.LaunchStatutes() + for slot, options in pairs(playerOptions) do + if options.PlayerClan then + allClanTags[options.PlayerName] = options.PlayerClan + end + end - -- check if we're ready to launch - if launchStatutes[model.LocalPeerId()] ~= 'Ready' then + return allClanTags +end - -- if we're not, check the status of peers - local onePeerIsRejoining = false - local onePeerIsReady = false - for k, launchStatus in launchStatutes do - onePeerIsReady = onePeerIsReady or (launchStatus == 'Ready') - onePeerIsRejoining = onePeerIsRejoining or (launchStatus == 'Rejoining') - end +--- Prefetches a scenario to try and reduce the loading screen time. +---@param gameOptions UILobbyLaunchGameOptionsConfiguration +---@param gameMods UILobbyLaunchGameModsConfiguration[] +function Prefetch(gameOptions, gameMods) + local scenarioPath = gameOptions.ScenarioFile + if not scenarioPath then + return + end - if onePeerIsReady then - rejoinCount = rejoinCount + 1 - end + local scenarioFile = MapUtil.LoadScenario(gameOptions.ScenarioFile) + if not scenarioFile then + -- ??? + return + end - -- try to not rejoin at the same time - if onePeerIsRejoining then - rejoinCount = 0 - end - else - rejoinCount = 0 - end + PrefetchSession(scenarioFile.map, gameMods, true) +end - -- if we reached the threshold, time to rejoin! - if rejoinCount > rejoinThreshold then - self:Rejoin(self.LobbyParameters, self.JoinParameters) - end +--#endregion - WaitSeconds(1.0 + 1 * Random()) - end - end, +------------------------------------------------------------------------------- +--#region Rejoin - --- Passes the local launch status to all peers. - ---@param self UIAutolobbyCommunications - ShareLaunchStatusThread = function(self) - while not IsDestroyed(self) do - local model = AutolobbyModel.GetSingleton() - local launchStatus = AutolobbyModel.CreateLaunchStatus(model.ConnectionMatrix(), model.PlayerCount()) +---@param instance UIAutolobbyInstance +---@param lobbyParameters UIAutolobbyParameters +---@param joinParameters UIAutolobbyJoinParameters +function Rejoin(instance, lobbyParameters, joinParameters) + local autolobbyModule = import("/lua/ui/lobby/autolobby.lua") - AutolobbyModel.SetPeerStatus(model, model.LocalPeerId(), launchStatus) + -- start disposing threads to prevent race conditions + instance.Trash:Destroy() - -- update peers - self:BroadcastData({ Type = "UpdateLaunchStatus", LaunchStatus = launchStatus }) + ForkThread( + function() + instance:SendLaunchStatusToServer('Rejoining') - -- update server - self:SendLaunchStatusToServer(launchStatus) + -- prevent race condition on network + WaitSeconds(1.0) - WaitSeconds(2.0) - end - end, - - ---@param self UIAutolobbyCommunications - LaunchThread = function(self) - while not IsDestroyed(self) do - local model = AutolobbyModel.GetSingleton() - if AutolobbyModel.CanLaunch(model.LaunchStatutes(), model.PlayerCount()) then - - WaitSeconds(5.0) - if (not IsDestroyed(self)) and AutolobbyModel.CanLaunch(model.LaunchStatutes(), model.PlayerCount()) then - - local playerOptions = model.PlayerOptions() - - -- Army numbers need to be calculated: they are numbered incrementally in slot order. - local slots = {} - for slotIndex, _ in pairs(playerOptions) do - table.insert(slots, slotIndex) - end - table.sort(slots) - - -- send player options to the server - for armyIndex, slotIndex in ipairs(slots) do - local options = playerOptions[slotIndex] - local ownerId = options.OwnerID - self:SendPlayerOptionToServer(ownerId, 'Team', options.Team) - self:SendPlayerOptionToServer(ownerId, 'Army', armyIndex) - self:SendPlayerOptionToServer(ownerId, 'StartSpot', options.StartSpot) - self:SendPlayerOptionToServer(ownerId, 'Faction', options.Faction) - end - - -- tuck the rating / division / clan tables into the game - -- options. By all means a hack, but this way they are - -- available in both the sim and the UI - local gameOptions = AutolobbyModel.StampLaunchTables(model, playerOptions) - - -- create game configuration - local gameConfiguration = { - GameMods = model.GameMods(), - GameOptions = gameOptions, - PlayerOptions = playerOptions, - Observers = {}, - } - - -- send it to all players and tell them to launch with the configuration - self:BroadcastData({ Type = "Launch", GameConfig = gameConfiguration }) - self:LaunchGame(gameConfiguration) - end - end + -- inform peers and server that we're rejoining + instance:BroadcastData({ Type = "UpdateLaunchStatus", LaunchStatus = 'Rejoining' }) + -- prevent race condition on network WaitSeconds(1.0) - end - end, - --#endregion + -- create a new lobby + instance:Destroy() - --------------------------------------------------------------------------- - --#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. + -- prevent race conditions + WaitSeconds(1.0) + autolobbyModule.CreateLobby( + lobbyParameters.Protocol, + lobbyParameters.LocalPort, + lobbyParameters.DesiredPlayerName, + lobbyParameters.LocalPlayerPeerId, + lobbyParameters.NatTraversalProvider + ) + + -- wait a bit before we join + WaitSeconds(1.0) - ---@param self UIAutolobbyCommunications - ---@param data UIAutolobbyAddPlayerMessage - ProcessAddPlayerMessage = function(self, data) - ---@type UIAutolobbyPlayer - local playerOptions = data.PlayerOptions + autolobbyModule.JoinGame(joinParameters.Address, joinParameters.AsObserver, + joinParameters.DesiredPlayerName, + joinParameters.DesiredPeerId) + end + ) +end - -- override some data - playerOptions.OwnerID = data.SenderID - playerOptions.PlayerName = self:MakeValidPlayerName(playerOptions.OwnerID, playerOptions.PlayerName) +--#endregion - local model = AutolobbyModel.GetSingleton() +------------------------------------------------------------------------------- +--#region Threads - -- TODO: verify that the StartSpot is not occupied - -- put the player where it belongs - AutolobbyModel.SetPlayer(model, playerOptions.StartSpot, playerOptions) +---@param instance UIAutolobbyInstance +function CheckForRejoinThread(instance) - -- sync game options with the connected peer - self:SendData(data.SenderID, { Type = "UpdateGameOptions", GameOptions = model.GameOptions() }) + local rejoinThreshold = 3 + local rejoinCount = 0 - -- sync player options to all connected peers - self:BroadcastData({ Type = "UpdatePlayerOptions", PlayerOptions = model.PlayerOptions() }) + while not IsDestroyed(instance) do - -- the scenario + ownership observers react to the PlayerOptions change - end, + local model = AutolobbyModel.GetSingleton() + local launchStatutes = model.LaunchStatutes() - ---@param self UIAutolobbyCommunications - ---@param data UIAutolobbyUpdatePlayerOptionsMessage - ProcessUpdatePlayerOptionsMessage = function(self, data) - -- a fresh table straight off the network; replace wholesale. The view's - -- scenario + ownership observers react to the change. - AutolobbyModel.GetSingleton().PlayerOptions:Set(data.PlayerOptions) - end, + -- check if we're ready to launch + if launchStatutes[model.LocalPeerId()] ~= 'Ready' then - ---@param self UIAutolobbyCommunications - ---@param data UIAutolobbyUpdateGameOptionsMessage - ProcessUpdateGameOptionsMessage = function(self, data) - local model = AutolobbyModel.GetSingleton() - model.GameOptions:Set(data.GameOptions) - - self:Prefetch(model.GameOptions(), model.GameMods()) - - -- the view's scenario observer reacts to the GameOptions change - 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) - -- the matrix' Statuses observer reacts to the LaunchStatutes change - AutolobbyModel.SetPeerStatus(AutolobbyModel.GetSingleton(), data.SenderID, data.LaunchStatus) - 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 + -- if we're not, check the status of peers + local onePeerIsRejoining = false + local onePeerIsReady = false + for k, launchStatus in launchStatutes do + onePeerIsReady = onePeerIsReady or (launchStatus == 'Ready') + onePeerIsRejoining = onePeerIsRejoining or (launchStatus == 'Rejoining') + end - -- validate message format - if not message.Validate(self, data) then - self:DebugWarn("Blocked broadcasting malformed message of type", data.Type) - return - end + if onePeerIsReady then + rejoinCount = rejoinCount + 1 + 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 })) - - 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 + -- try to not rejoin at the same time + if onePeerIsRejoining then + rejoinCount = 0 + end + else + rejoinCount = 0 end - -- validate message type - if not message.Validate(self, data) then - self:DebugWarn("Blocked sending malformed message of type", data.Type, "to", peerId) - return + -- if we reached the threshold, time to rejoin! + if rejoinCount > rejoinThreshold then + Rejoin(instance, instance.LobbyParameters, instance.JoinParameters) 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") + WaitSeconds(1.0 + 1 * Random()) + end +end +--- Passes the local launch status to all peers. +---@param instance UIAutolobbyInstance +function ShareLaunchStatusThread(instance) + while not IsDestroyed(instance) do local model = AutolobbyModel.GetSingleton() + local launchStatus = CreateLaunchStatus(model.ConnectionMatrix(), model.PlayerCount()) + + AutolobbyModel.SetPeerStatus(model, model.LocalPeerId(), launchStatus) - local localPeerId = self:GetLocalPlayerID() - model.LocalPeerId:Set(localPeerId) - self.LocalPlayerName = self:GetLocalPlayerName() - self.HostID = localPeerId - - -- give ourself a seat at the table - local hostPlayerOptions = self:CreateLocalPlayer() - hostPlayerOptions.OwnerID = localPeerId - hostPlayerOptions.PlayerName = self:MakeValidPlayerName(localPeerId, self.LocalPlayerName) - AutolobbyModel.SetPlayer(model, 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(model.GameOptions(), model.GameMods()) - - self:SendLaunchStatusToServer('Hosting') - - -- the view's scenario observer reacts to the PlayerOptions change - 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 - AutolobbyModel.GetSingleton().LocalPeerId:Set(localPeerId) - self.HostID = hostPeerId - - -- 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 peers + instance:BroadcastData({ Type = "UpdateLaunchStatus", LaunchStatus = launchStatus }) -- update server - self:SendEstablishedPeer(peerId) + instance:SendLaunchStatusToServer(launchStatus) - local model = AutolobbyModel.GetSingleton() + WaitSeconds(2.0) + end +end - -- seed an initial status for the peer and record its connections; the - -- matrix' Statuses / Connections observers react to the changes - AutolobbyModel.EnsurePeerStatus(model, peerId, 'Unknown') - AutolobbyModel.SetPeerConnections(model, peerId, peerConnectedTo) - 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) - -- make it more convenient to debug malicious traffic - SPEW(string.format("Received data of type %s from %s (%s)", tostring(data.Type), tostring(data.SenderID), tostring(data.SenderName))) - - -- signal UI that we received something; a fresh stamp table fires the - -- view's IsAlive observer even for repeated pulses from the same peer +---@param instance UIAutolobbyInstance +function LaunchThread(instance) + while not IsDestroyed(instance) do local model = AutolobbyModel.GetSingleton() - local peerIndex = AutolobbyModel.PeerIdToIndex(model.PlayerOptions(), data.SenderID) - if peerIndex then - model.IsAliveStamp:Set({ Index = peerIndex, Time = GetSystemTimeSeconds() }) - 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 + if CanLaunch(model.LaunchStatutes(), model.PlayerCount()) then - SPEW("Autolobby communications", unpack(arg)) - end, + WaitSeconds(5.0) + if (not IsDestroyed(instance)) and CanLaunch(model.LaunchStatutes(), model.PlayerCount()) then + local playerOptions = model.PlayerOptions() - ---@param self UIAutolobbyCommunications - ---@param ... any - DebugLog = function(self, ...) - if not self.EnabledLogging then - return - end + -- Army numbers need to be calculated: they are numbered incrementally in slot order. + local slots = {} + for slotIndex, _ in pairs(playerOptions) do + table.insert(slots, slotIndex) + end + table.sort(slots) + + -- send player options to the server + for armyIndex, slotIndex in ipairs(slots) do + local options = playerOptions[slotIndex] + local ownerId = options.OwnerID + instance:SendPlayerOptionToServer(ownerId, 'Team', options.Team) + instance:SendPlayerOptionToServer(ownerId, 'Army', armyIndex) + instance:SendPlayerOptionToServer(ownerId, 'StartSpot', options.StartSpot) + instance:SendPlayerOptionToServer(ownerId, 'Faction', options.Faction) + end - LOG("Autolobby communications", unpack(arg)) - end, + -- tuck the rating / division / clan tables into the game + -- options. By all means a hack, but this way they are + -- available in both the sim and the UI + local gameOptions = AutolobbyModel.StampLaunchTables( + model, + CreateRatingsTable(playerOptions), + CreateDivisionsTable(playerOptions), + CreateClanTagsTable(playerOptions) + ) - ---@param self UIAutolobbyCommunications - ---@param ... any - DebugWarn = function(self, ...) - if not self.EnabledWarnings then - return + -- create game configuration + local gameConfiguration = { + GameMods = model.GameMods(), + GameOptions = gameOptions, + PlayerOptions = playerOptions, + Observers = {}, + } + + -- send it to all players and tell them to launch with the configuration + instance:BroadcastData({ Type = "Launch", GameConfig = gameConfiguration }) + instance:LaunchGame(gameConfiguration) + end end - WARN("Autolobby communications", unpack(arg)) - end, - - ---@param self UIAutolobbyCommunications - ---@param ... any - DebugError = function(self, ...) - if not self.EnabledErrors then - return - end + WaitSeconds(1.0) + end +end + +--#endregion - local message = "Autolobby communications" - for _, arg in ipairs(arg) do - message = message .. "\t" .. tostring(arg) +------------------------------------------------------------------------------- +--#region Message handlers +-- +-- Invoked by `AutolobbyMessages..Handler` after the message has been +-- validated and accepted. They run asynchronously on each client. + +---@param instance UIAutolobbyInstance +---@param data UIAutolobbyAddPlayerMessage +function ProcessAddPlayerMessage(instance, data) + ---@type UIAutolobbyPlayer + local playerOptions = data.PlayerOptions + + -- override some data + playerOptions.OwnerID = data.SenderID + playerOptions.PlayerName = instance:MakeValidPlayerName(playerOptions.OwnerID, playerOptions.PlayerName) + + local model = AutolobbyModel.GetSingleton() + + -- TODO: verify that the StartSpot is not occupied + -- put the player where it belongs + AutolobbyModel.SetPlayer(model, playerOptions.StartSpot, playerOptions) + + -- sync game options with the connected peer + instance:SendData(data.SenderID, { Type = "UpdateGameOptions", GameOptions = model.GameOptions() }) + + -- sync player options to all connected peers + instance:BroadcastData({ Type = "UpdatePlayerOptions", PlayerOptions = model.PlayerOptions() }) + + -- the scenario + ownership observers react to the PlayerOptions change +end + +---@param instance UIAutolobbyInstance +---@param data UIAutolobbyUpdatePlayerOptionsMessage +function ProcessUpdatePlayerOptionsMessage(instance, data) + -- a fresh table straight off the network; replace wholesale. The scenario + + -- ownership observers react to the change. + AutolobbyModel.GetSingleton().PlayerOptions:Set(data.PlayerOptions) +end + +---@param instance UIAutolobbyInstance +---@param data UIAutolobbyUpdateGameOptionsMessage +function ProcessUpdateGameOptionsMessage(instance, data) + local model = AutolobbyModel.GetSingleton() + model.GameOptions:Set(data.GameOptions) + + Prefetch(model.GameOptions(), model.GameMods()) + + -- the scenario observer reacts to the GameOptions change +end + +---@param instance UIAutolobbyInstance +---@param data UIAutolobbyLaunchMessage +function ProcessLaunchMessage(instance, data) + instance:LaunchGame(data.GameConfig) +end + +---@param instance UIAutolobbyInstance +---@param data UIAutolobbyUpdateLaunchStatusMessage +function ProcessUpdateLaunchStatusMessage(instance, data) + -- the matrix' Statuses observer reacts to the LaunchStatutes change + AutolobbyModel.SetPeerStatus(AutolobbyModel.GetSingleton(), data.SenderID, data.LaunchStatus) +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Connection events +-- +-- The behaviour behind the engine callbacks of the same name. The instance's +-- callbacks forward here, passing themselves as `instance`. + +--- Called as we're trying to host a lobby. +---@param instance UIAutolobbyInstance +function OnHosting(instance) + local model = AutolobbyModel.GetSingleton() + + local localPeerId = instance:GetLocalPlayerID() + model.LocalPeerId:Set(localPeerId) + instance.LocalPlayerName = instance:GetLocalPlayerName() + instance.HostID = localPeerId + + -- give ourself a seat at the table + local hostPlayerOptions = instance:CreateLocalPlayer() + hostPlayerOptions.OwnerID = localPeerId + hostPlayerOptions.PlayerName = instance:MakeValidPlayerName(localPeerId, instance.LocalPlayerName) + AutolobbyModel.SetPlayer(model, hostPlayerOptions.StartSpot, hostPlayerOptions) + + -- occasionally send data over the network to create pings on screen + instance.Trash:Add(ForkThread(ShareLaunchStatusThread, instance)) + instance.Trash:Add(ForkThread(LaunchThread, instance)) + + -- start prefetching the scenario + Prefetch(model.GameOptions(), model.GameMods()) + + instance:SendLaunchStatusToServer('Hosting') + + -- the scenario observer reacts to the PlayerOptions change +end + +--- Called when the connection succeeds with the host. +---@param instance UIAutolobbyInstance +---@param localPeerId UILobbyPeerId +---@param newLocalName string +---@param hostPeerId string +function OnConnectionToHostEstablished(instance, localPeerId, newLocalName, hostPeerId) + instance.LocalPlayerName = newLocalName + AutolobbyModel.GetSingleton().LocalPeerId:Set(localPeerId) + instance.HostID = hostPeerId + + -- occasionally send data over the network to create pings on screen + instance.Trash:Add(ForkThread(ShareLaunchStatusThread, instance)) + -- instance.Trash:Add(ForkThread(CheckForRejoinThread, instance)) -- disabled, for now + + instance:SendData(instance.HostID, { Type = "AddPlayer", PlayerOptions = instance:CreateLocalPlayer() }) +end + +--- Called when a peer establishes a connection. +---@param instance UIAutolobbyInstance +---@param peerId UILobbyPeerId +---@param peerConnectedTo UILobbyPeerId[] # all established connections for the given player +function OnEstablishedPeers(instance, peerId, peerConnectedTo) + -- update server + instance:SendEstablishedPeer(peerId) + + local model = AutolobbyModel.GetSingleton() + + -- seed an initial status for the peer and record its connections; the + -- matrix' Statuses / Connections observers react to the changes + AutolobbyModel.EnsurePeerStatus(model, peerId, 'Unknown') + AutolobbyModel.SetPeerConnections(model, peerId, peerConnectedTo) +end + +--- Called when the connection fails. +---@param instance UIAutolobbyInstance +function OnConnectionFailed(instance) + -- try to rejoin + Rejoin(instance, instance.LobbyParameters, instance.JoinParameters) +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Debugging + +--- Hot-reload hook: re-imports this module after a couple of frames so the +--- instance's forwarders resolve to the fresh functions. +function __moduleinfo.OnDirty() + ForkThread( + function() + WaitFrames(2) + import(__moduleinfo.name) end + ) +end - error(message) - end, - - --#endregion -} +--#endregion diff --git a/lua/ui/lobby/autolobby/AutolobbyInstance.lua b/lua/ui/lobby/autolobby/AutolobbyInstance.lua new file mode 100644 index 00000000000..5b4de09810a --- /dev/null +++ b/lua/ui/lobby/autolobby/AutolobbyInstance.lua @@ -0,0 +1,617 @@ +--****************************************************************************************************** +--** 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. +--****************************************************************************************************** + +-- The `moho.lobby_methods` object the engine instantiates (via `InternalCreateLobby` +-- in autolobby.lua) and whose callbacks it calls. It is a thin shell: +-- +-- - Engine-ABI wrappers (BroadcastData / SendData / ConnectToPeer / ...) stay here +-- because they override `moho` methods and add validation / debug spew. +-- - Command-line / engine-coupled creators (CreateLocalPlayer / CreateLocalGameOptions) +-- stay here because they lean on the mixed-in argument component. +-- - Callbacks that carry real behaviour forward to `AutolobbyController`, passing +-- `self`. That keeps the logic in a hot-reloadable, testable free-function module. +-- +-- This object does NOT hot-reload — the live C object keeps its bound methods and +-- threads across a reload of this file, so edits here only take effect on the next +-- `CreateLobby`. Keep it thin; put behaviour in the controller. + +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 AutolobbyArgumentsComponent = import("/lua/ui/lobby/autolobby/components/autolobbyarguments.lua").AutolobbyArgumentsComponent + +local AutolobbyMessages = import("/lua/ui/lobby/autolobby/autolobbymessages.lua").AutolobbyMessages + +local AutolobbyModel = import("/lua/ui/lobby/autolobby/autolobbymodel.lua") +local AutolobbyController = import("/lua/ui/lobby/autolobby/autolobbycontroller.lua") + +-- associated textures are in `/textures/divisions/ .png` +-- Make note of the space, which isn't there for "grandmaster" and "unlisted" divisions + +---@alias Division +---| "bronze" +---| "silver" +---| "gold" +---| "diamond" +---| "master" +---| "grandmaster" +---| "unlisted" + +---@alias Subdivision +---| "I" +---| "II" +---| "III" +---| "IV" +---| "V" +---| "" # when Division is grandmaster or unlisted + +---@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 Division # Related to rating/divisions +---@field SUBDIV Subdivision # Related to rating/divisions +---@field PL number # Related to rating/divisions +---@field PlayerClan string + +---@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 + +--- The engine's lobby object. Owns the engine ABI; forwards behaviour to +--- `AutolobbyController`. The synced state lives in `AutolobbyModel`; the fields +--- kept here are controller-internal (identity + rejoin parameters). +---@class UIAutolobbyInstance : moho.lobby_methods, DebugComponent, UIAutolobbyServerCommunicationsComponent, UIAutolobbyArgumentsComponent +---@field Trash TrashBag +---@field LocalPlayerName string # nickname +---@field HostID UILobbyPeerId +---@field LobbyParameters? UIAutolobbyParameters # Used for rejoining functionality +---@field HostParameters? UIAutolobbyHostParameters # Used for rejoining functionality +---@field JoinParameters? UIAutolobbyJoinParameters # Used for rejoining functionality +AutolobbyInstance = Class(MohoLobbyMethods, AutolobbyServerCommunicationsComponent, AutolobbyArgumentsComponent, DebugComponent) { + + ---@param self UIAutolobbyInstance + __init = function(self) + self.Trash = TrashBag() + + self.LocalPlayerName = "Charlie" + self.HostID = "-2" + + -- The model singleton is created in `autolobby.lua > CreateLobby` + -- before the lobby (and thus this instance) is instantiated. Seed + -- the initial state here. + local model = AutolobbyModel.GetSingleton() + model.LocalPeerId:Set("-2") + model.GameMods:Set({}) + model.GameOptions:Set(self:CreateLocalGameOptions()) + model.PlayerOptions:Set({}) + model.LaunchStatutes:Set({}) + model.ConnectionMatrix:Set({}) + end, + + ---@param self UIAutolobbyInstance + __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 UIAutolobbyInstance + ---@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 = self:GetCommandLineArgumentNumber("/team", -1) + info.StartSpot = self:GetCommandLineArgumentNumber("/startspot", -1) + + -- determine army color based on start location + info.PlayerColor = GameColors.MapToWarmCold(info.StartSpot) + info.ArmyColor = GameColors.MapToWarmCold(info.StartSpot) + + -- retrieve rating + info.DEV = self:GetCommandLineArgumentNumber("/deviation", 500) + info.MEAN = self:GetCommandLineArgumentNumber("/mean", 1500) + info.NG = self:GetCommandLineArgumentNumber("/numgames", 0) + info.DIV = self:GetCommandLineArgumentString("/division", "") + info.SUBDIV = self:GetCommandLineArgumentString("/subdivision", "") + info.PL = math.floor(info.MEAN - 3 * info.DEV) + info.PlayerClan = self:GetCommandLineArgumentString("/clan", "") + + return info + end, + + --- Creates a table that represents the local game options. + ---@param self UIAutolobbyInstance + ---@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', + DisconnectShare = 'SameAsShare', + DisconnectShareCommanders = 'Explode', + TeamShareOverflow = "enabled", + + -- yep, great + Ranked = true, + Unranked = 'No', + } + + -- process game options from the command line + for name, value in self:GetCommandLineArgumentArray("/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 Engine interface + + --- Broadcasts data to all (connected) peers. + ---@param self UIAutolobbyInstance + ---@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 UIAutolobbyInstance + ---@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 UIAutolobbyInstance + ---@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 UIAutolobbyInstance + ---@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 UIAutolobbyInstance + ---@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 UIAutolobbyInstance + ---@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 UIAutolobbyInstance + ---@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 UIAutolobbyInstance + ---@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 UIAutolobbyInstance + GetPeers = function(self) + -- self:DebugSpew("GetPeers") + return MohoLobbyMethods.GetPeers(self) + end, + + --- Transforms the lobby to be discoveryable and joinable for other players. + ---@param self UIAutolobbyInstance + ---@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 UIAutolobbyInstance + ---@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 UIAutolobbyInstance + ---@param gameConfig UILobbyLaunchConfiguration + ---@return nil + LaunchGame = function(self, gameConfig) + self:DebugSpew("LaunchGame") + self:DebugSpew(reprs(gameConfig, { depth = 10 })) + + return MohoLobbyMethods.LaunchGame(self, gameConfig) + end, + + --- Returns a valid game name. + ---@param self UIAutolobbyInstance + ---@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 UIAutolobbyInstance + ---@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 UIAutolobbyInstance + ---@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 + -- + -- Callbacks with real behaviour forward to `AutolobbyController`; the + -- trivial ones (just a server status update) stay inline. + + --- Called by the engine as we're trying to host a lobby. + ---@param self UIAutolobbyInstance + Hosting = function(self) + self:DebugSpew("Hosting") + AutolobbyController.OnHosting(self) + end, + + --- Called by the engine as we're trying to join a lobby. + ---@param self UIAutolobbyInstance + Connecting = function(self) + self:DebugSpew("Connecting") + self:SendLaunchStatusToServer('Connecting') + end, + + --- Called by the engine when the connection fails. + ---@param self UIAutolobbyInstance + ---@param reason string # reason for connection failure, populated by the engine + ConnectionFailed = function(self, reason) + self:DebugSpew("ConnectionFailed", reason) + AutolobbyController.OnConnectionFailed(self) + end, + + --- Called by the engine when the connection succeeds with the host. + ---@param self UIAutolobbyInstance + ---@param localPeerId UILobbyPeerId + ---@param newLocalName string + ---@param hostPeerId string + ConnectionToHostEstablished = function(self, localPeerId, newLocalName, hostPeerId) + self:DebugSpew("ConnectionToHostEstablished", localPeerId, newLocalName, hostPeerId) + AutolobbyController.OnConnectionToHostEstablished(self, localPeerId, newLocalName, hostPeerId) + end, + + --- Called by the engine when a peer establishes a connection. + ---@param self UIAutolobbyInstance + ---@param peerId UILobbyPeerId + ---@param peerConnectedTo UILobbyPeerId[] # all established conenctions for the given player + EstablishedPeers = function(self, peerId, peerConnectedTo) + self:DebugSpew("EstablishedPeers", peerId, reprs(peerConnectedTo)) + AutolobbyController.OnEstablishedPeers(self, peerId, peerConnectedTo) + end, + + --#endregion + + --------------------------------------------------------------------------- + --#region Lobby events + + --- Called by the engine when you are ejected from a lobby. + ---@param self UIAutolobbyInstance + ---@param reason string # reason for disconnection, populated by the host + Ejected = function(self, reason) + self:DebugSpew("Ejected", reason) + self:SendLaunchStatusToServer('Ejected') + end, + + --- ??? + ---@param self UIAutolobbyInstance + ---@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 UIAutolobbyInstance + ---@param data UILobbyReceivedMessage + DataReceived = function(self, data) + -- make it more convenient to debug malicious traffic + SPEW(string.format("Received data of type %s from %s (%s)", tostring(data.Type), tostring(data.SenderID), tostring(data.SenderName))) + + -- signal UI that we received something; a fresh stamp table fires the + -- view's IsAlive observer even for repeated pulses from the same peer + local model = AutolobbyModel.GetSingleton() + local peerIndex = AutolobbyModel.PeerIdToIndex(model.PlayerOptions(), data.SenderID) + if peerIndex then + model.IsAliveStamp:Set({ Index = peerIndex, Time = GetSystemTimeSeconds() }) + 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 (the handler routes to `AutolobbyController`) + message.Handler(self, data) + end, + + --- Called by the engine when the game configuration is requested by the discovery service. + ---@param self UIAutolobbyInstance + GameConfigRequested = function(self) + self:DebugSpew("GameConfigRequested") + end, + + --- Called by the engine when a peer disconnects. + ---@param self UIAutolobbyInstance + ---@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 UIAutolobbyInstance + 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 UIAutolobbyInstance + ---@param reasonKey string + LaunchFailed = function(self, reasonKey) + self:DebugSpew("LaunchFailed", LOC(reasonKey)) + self:SendLaunchStatusToServer('Failed') + end, + + --#endregion + + --#region Debugging + + ---@param self UIAutolobbyInstance + ---@param ... any + DebugSpew = function(self, ...) + if not self.EnabledSpewing then + return + end + + SPEW("Autolobby instance", unpack(arg)) + end, + + + ---@param self UIAutolobbyInstance + ---@param ... any + DebugLog = function(self, ...) + if not self.EnabledLogging then + return + end + + LOG("Autolobby instance", unpack(arg)) + end, + + ---@param self UIAutolobbyInstance + ---@param ... any + DebugWarn = function(self, ...) + if not self.EnabledWarnings then + return + end + + WARN("Autolobby instance", unpack(arg)) + end, + + ---@param self UIAutolobbyInstance + ---@param ... any + DebugError = function(self, ...) + if not self.EnabledErrors then + return + end + + local message = "Autolobby instance" + for _, arg in ipairs(arg) do + message = message .. "\t" .. tostring(arg) + end + + error(message) + end, + + --#endregion +} diff --git a/lua/ui/lobby/autolobby/AutolobbyMessages.lua b/lua/ui/lobby/autolobby/AutolobbyMessages.lua index 52b32603299..b3853719383 100644 --- a/lua/ui/lobby/autolobby/AutolobbyMessages.lua +++ b/lua/ui/lobby/autolobby/AutolobbyMessages.lua @@ -27,19 +27,20 @@ -- wrapper to another function in the autolobby. local AutolobbyModel = import("/lua/ui/lobby/autolobby/autolobbymodel.lua") +local AutolobbyController = import("/lua/ui/lobby/autolobby/autolobbycontroller.lua") ---@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 +---@field Validate fun(lobby: UIAutolobbyInstance, data: UILobbyReceivedMessage): boolean # Responsible for filtering out non-sense +---@field Accept fun(lobby: UIAutolobbyInstance, data: UILobbyReceivedMessage): boolean # Responsible for filtering out malicous messages +---@field Handler fun(lobby: UIAutolobbyInstance, data: UILobbyReceivedMessage) # Responsible for handling the message ----@param lobby UIAutolobbyCommunications +---@param lobby UIAutolobbyInstance ---@param data UILobbyReceivedMessage local function IsFromHost(lobby, data) return data.SenderID == lobby.HostID end ----@param lobby UIAutolobbyCommunications +---@param lobby UIAutolobbyInstance ---@param data UILobbyReceivedMessage local function IsHost(lobby, data) return lobby:IsHost() @@ -52,7 +53,7 @@ AutolobbyMessages = { ---@class UIAutolobbyUpdateLaunchStatusMessage : UILobbyReceivedMessage ---@field LaunchStatus UIPeerLaunchStatus - ---@param lobby UIAutolobbyCommunications + ---@param lobby UIAutolobbyInstance ---@param data UIAutolobbyUpdateLaunchStatusMessage ---@return boolean Validate = function(lobby, data) @@ -63,17 +64,17 @@ AutolobbyMessages = { return true end, - ---@param lobby UIAutolobbyCommunications + ---@param lobby UIAutolobbyInstance ---@param data UIAutolobbyUpdateLaunchStatusMessage ---@return boolean Accept = function(lobby, data) return true end, - ---@param lobby UIAutolobbyCommunications + ---@param lobby UIAutolobbyInstance ---@param data UIAutolobbyUpdateLaunchStatusMessage Handler = function(lobby, data) - lobby:ProcessUpdateLaunchStatusMessage(data) + AutolobbyController.ProcessUpdateLaunchStatusMessage(lobby, data) end }, @@ -83,7 +84,7 @@ AutolobbyMessages = { ---@class UIAutolobbyAddPlayerMessage : UILobbyReceivedMessage ---@field PlayerOptions UIAutolobbyPlayer - ---@param lobby UIAutolobbyCommunications + ---@param lobby UIAutolobbyInstance ---@param data UIAutolobbyAddPlayerMessage ---@return boolean Validate = function(lobby, data) @@ -94,7 +95,7 @@ AutolobbyMessages = { return true end, - ---@param lobby UIAutolobbyCommunications + ---@param lobby UIAutolobbyInstance ---@param data UIAutolobbyAddPlayerMessage ---@return boolean Accept = function(lobby, data) @@ -122,10 +123,10 @@ AutolobbyMessages = { return true end, - ---@param lobby UIAutolobbyCommunications + ---@param lobby UIAutolobbyInstance ---@param data UIAutolobbyAddPlayerMessage Handler = function(lobby, data) - lobby:ProcessAddPlayerMessage(data) + AutolobbyController.ProcessAddPlayerMessage(lobby, data) end }, @@ -134,7 +135,7 @@ AutolobbyMessages = { ---@class UIAutolobbyUpdatePlayerOptionsMessage : UILobbyReceivedMessage ---@field PlayerOptions UIAutolobbyPlayer[] - ---@param lobby UIAutolobbyCommunications + ---@param lobby UIAutolobbyInstance ---@param data UIAutolobbyUpdatePlayerOptionsMessage ---@return boolean Validate = function(lobby, data) @@ -145,7 +146,7 @@ AutolobbyMessages = { return true end, - ---@param lobby UIAutolobbyCommunications + ---@param lobby UIAutolobbyInstance ---@param data UIAutolobbyUpdatePlayerOptionsMessage ---@return boolean Accept = function(lobby, data) @@ -157,10 +158,10 @@ AutolobbyMessages = { return true end, - ---@param lobby UIAutolobbyCommunications + ---@param lobby UIAutolobbyInstance ---@param data UIAutolobbyUpdatePlayerOptionsMessage Handler = function(lobby, data) - lobby:ProcessUpdatePlayerOptionsMessage(data) + AutolobbyController.ProcessUpdatePlayerOptionsMessage(lobby, data) end }, @@ -169,7 +170,7 @@ AutolobbyMessages = { ---@class UIAutolobbyUpdateGameOptionsMessage : UILobbyReceivedMessage ---@field GameOptions UILobbyLaunchGameOptionsConfiguration - ---@param lobby UIAutolobbyCommunications + ---@param lobby UIAutolobbyInstance ---@param data UIAutolobbyUpdateGameOptionsMessage ---@return boolean Validate = function(lobby, data) @@ -180,7 +181,7 @@ AutolobbyMessages = { return true end, - ---@param lobby UIAutolobbyCommunications + ---@param lobby UIAutolobbyInstance ---@param data UIAutolobbyUpdateGameOptionsMessage ---@return boolean Accept = function(lobby, data) @@ -194,10 +195,10 @@ AutolobbyMessages = { return true end, - ---@param lobby UIAutolobbyCommunications + ---@param lobby UIAutolobbyInstance ---@param data UIAutolobbyUpdateGameOptionsMessage Handler = function(lobby, data) - lobby:ProcessUpdateGameOptionsMessage(data) + AutolobbyController.ProcessUpdateGameOptionsMessage(lobby, data) end }, @@ -206,7 +207,7 @@ AutolobbyMessages = { ---@class UIAutolobbyLaunchMessage : UILobbyReceivedMessage ---@field GameConfig UILobbyLaunchConfiguration - ---@param lobby UIAutolobbyCommunications + ---@param lobby UIAutolobbyInstance ---@param data UIAutolobbyLaunchMessage ---@return boolean Validate = function(lobby, data) @@ -224,7 +225,7 @@ AutolobbyMessages = { return true end, - ---@param lobby UIAutolobbyCommunications + ---@param lobby UIAutolobbyInstance ---@param data UIAutolobbyLaunchMessage ---@return boolean Accept = function(lobby, data) @@ -238,10 +239,10 @@ AutolobbyMessages = { return true end, - ---@param lobby UIAutolobbyCommunications + ---@param lobby UIAutolobbyInstance ---@param data UIAutolobbyLaunchMessage Handler = function(lobby, data) - lobby:ProcessLaunchMessage(data) + AutolobbyController.ProcessLaunchMessage(lobby, data) end } } diff --git a/lua/ui/lobby/autolobby/AutolobbyModel.lua b/lua/ui/lobby/autolobby/AutolobbyModel.lua index f653d6b0789..dade3313528 100644 --- a/lua/ui/lobby/autolobby/AutolobbyModel.lua +++ b/lua/ui/lobby/autolobby/AutolobbyModel.lua @@ -141,89 +141,9 @@ function CreateOwnershipMatrix(playerCount, localIndex) return output end ---- Determines the launch status of the local peer. ----@param connectionMatrix table ----@param playerCount number ----@return UIPeerLaunchStatus -function CreateLaunchStatus(connectionMatrix, playerCount) - -- check number of peers - local validPeerCount = playerCount - 1 - if table.getsize(connectionMatrix) < validPeerCount then - return 'Missing local peers' - end - - return 'Ready' -end - ----@param playerOptions UIAutolobbyPlayer[] ----@return table -function CreateRatingsTable(playerOptions) - ---@type table - local allRatings = {} - - for slot, options in pairs(playerOptions) do - if options.Human and options.PL then - allRatings[options.PlayerName] = options.PL - end - end - - return allRatings -end - ----@param playerOptions UIAutolobbyPlayer[] ----@return table -function CreateDivisionsTable(playerOptions) - ---@type table - local allDivisions = {} - - for slot, options in pairs(playerOptions) do - if options.Human and options.PL then - if options.DIV ~= "unlisted" then - local division = options.DIV - if options.SUBDIV and options.SUBDIV ~= "" then - division = division .. ' ' .. options.SUBDIV - end - allDivisions[options.PlayerName] = division - end - end - end - - return allDivisions -end - ----@param playerOptions UIAutolobbyPlayer[] ----@return table -function CreateClanTagsTable(playerOptions) - local allClanTags = {} - - for slot, options in pairs(playerOptions) do - if options.PlayerClan then - allClanTags[options.PlayerName] = options.PlayerClan - end - end - - return allClanTags -end - ---- Verifies whether we can launch the game. ----@param peerStatus UIAutolobbyStatus ----@param playerCount number ----@return boolean -function CanLaunch(peerStatus, playerCount) - -- check if we know of all peers - if table.getsize(peerStatus) ~= 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 +-- Launch-flow computations (CreateLaunchStatus, CanLaunch, CreateRatingsTable, +-- CreateDivisionsTable, CreateClanTagsTable) live in `AutolobbyController` — they +-- are controller logic, not part of the reactive model's derivations. --#endregion @@ -383,17 +303,20 @@ function SetScenarioFile(model, scenarioFile) model.GameOptions:Set(gameOptions) end ---- Tucks the rating / division / clan tables into a copy of the game options ---- and sets it. Returns the new game options so the caller can reuse them in the ---- launch configuration. +--- Tucks the (pre-computed) rating / division / clan tables into a copy of the +--- game options and sets it. The tables are computed by the controller (the +--- launch-flow logic lives there); this helper only keeps the copy-then-`Set` +--- discipline. Returns the new game options so the caller can reuse them. ---@param model UIAutolobbyModel ----@param playerOptions UIAutolobbyPlayer[] +---@param ratings table +---@param divisions table +---@param clanTags table ---@return UILobbyLaunchGameOptionsConfiguration -function StampLaunchTables(model, playerOptions) +function StampLaunchTables(model, ratings, divisions, clanTags) local gameOptions = table.copy(model.GameOptions()) - gameOptions.Ratings = CreateRatingsTable(playerOptions) - gameOptions.Divisions = CreateDivisionsTable(playerOptions) - gameOptions.ClanTags = CreateClanTagsTable(playerOptions) + gameOptions.Ratings = ratings + gameOptions.Divisions = divisions + gameOptions.ClanTags = clanTags model.GameOptions:Set(gameOptions) return gameOptions end diff --git a/lua/ui/lobby/autolobby/CLAUDE.md b/lua/ui/lobby/autolobby/CLAUDE.md index ceff6772a08..1bde8456910 100644 --- a/lua/ui/lobby/autolobby/CLAUDE.md +++ b/lua/ui/lobby/autolobby/CLAUDE.md @@ -14,22 +14,26 @@ reactive MVC structure as the in-game chat: a reactive **model**, a dumb ## Architecture ``` -engine ──callbacks──► AutolobbyController (moho.lobby_methods) - │ writes (:Set) +engine ──callbacks──► AutolobbyInstance (moho.lobby_methods, thin shell) + │ forwards (passing self) + ▼ + AutolobbyController (logic, free functions) + │ writes via model helpers ▼ AutolobbyModel (LazyVars, singleton) │ OnDirty / Derive ▼ - AutolobbyInterface (view, reads) + AutolobbyInterface (composition) → matrix / preview (read) ``` | Role | File | Responsibility | |------|------|----------------| -| Model | [AutolobbyModel.lua](AutolobbyModel.lua) | Reactive singleton: raw synced state (player/game options, connection matrix, launch statuses) + derived view-models (`Connections`, `Statuses`, `Ownership`, `Scenario`) + the pure derivation helpers + the copy-then-`Set` write helpers. No UI, no networking. | +| Model | [AutolobbyModel.lua](AutolobbyModel.lua) | Reactive singleton: raw synced state + derived view-models (`Connections`, `Statuses`, `Ownership`, `Scenario`) + the pure derivation helpers + the copy-then-`Set` write helpers. No UI, no networking. | +| Instance | [AutolobbyInstance.lua](AutolobbyInstance.lua) | The `moho.lobby_methods` object the engine instantiates. Thin shell: engine-ABI wrappers + validation/dispatch + command-line creators stay here; callbacks with behaviour forward to the controller. | +| Controller | [AutolobbyController.lua](AutolobbyController.lua) | The lobby logic as **free functions** (`OnHosting`, `OnEstablishedPeers`, `Process*`, threads, `Rejoin`, launch-flow). The only writer to the model. Hot-reloadable. | | Composition root | [AutolobbyInterface.lua](AutolobbyInterface.lua) | Builds and lays out the children; holds **no** model subscriptions. | | Components | [AutolobbyConnectionMatrix.lua](AutolobbyConnectionMatrix.lua), [AutolobbyMapPreview.lua](AutolobbyMapPreview.lua) | Each subscribes to the model via `Derive`, feeds its dot grid / preview, and owns its own visibility. Never write the model. | -| Controller | [AutolobbyController.lua](AutolobbyController.lua) | `AutolobbyCommunications`, the engine's lobby object. The only writer to the model and the only place that talks to peers / the lobby server. | -| Entry point | [/lua/ui/lobby/autolobby.lua](/lua/ui/lobby/autolobby.lua) | Engine-facing wrapper: `CreateLobby` / `HostGame` / `JoinGame` / `ConnectToPeer` / `DisconnectFromPeer`. Bootstraps model + view + controller in that order. | +| Entry point | [/lua/ui/lobby/autolobby.lua](/lua/ui/lobby/autolobby.lua) | Engine-facing wrapper: `CreateLobby` / `HostGame` / `JoinGame` / `ConnectToPeer` / `DisconnectFromPeer`. Bootstraps model + view + instance in that order. | Like the chat (`ChatLinesInterface`, `ChatEditInterface`, … each subscribe to the model themselves), the components own their reactivity. `AutolobbyInterface` is a @@ -37,24 +41,33 @@ pure composition root, not a push hub. --- -## The key difference from chat - -The chat controller is a module of free functions. **The autolobby controller -cannot be** — it is a `moho.lobby_methods` subclass that the *engine* -instantiates via `InternalCreateLobby` (in [autolobby.lua](/lua/ui/lobby/autolobby.lua)) -and whose callbacks (`Hosting`, `DataReceived`, `EstablishedPeers`, …) the -engine calls. So: - -- The controller stays a C-bound class. We extracted only its **state** (into - the model) and its **view coupling** (it used to push into the view via - `interface:Update*`; it now writes the model and the view reacts). -- **The controller does not hot-reload.** Unlike `ChatController.Init`, which - re-binds via `__moduleinfo.OnReload`, the live C object keeps its bound - methods and threads across a reload of this file. Edits to - `AutolobbyController.lua` only take effect on the next `CreateLobby`. The - model and the view *do* hot-reload (own `__moduleinfo` hooks), and because - state now lives in the model, a view reload restores itself by re-reading the - surviving model — no manual state replay. +## Instance vs Controller + +The chat controller is a module of free functions; the engine doesn't own it. The +autolobby's engine object (`AutolobbyInstance`) *is* owned by the engine — it +instantiates it via `InternalCreateLobby` and calls its callbacks. So the +autolobby uses a **humble-object** split: + +- **`AutolobbyInstance`** is the C-bound shell. It keeps what is genuinely + engine-adjacent: the `moho` overrides (`BroadcastData` / `SendData` / …, with + their `AutolobbyMessages` validation), `DataReceived`'s validate→accept→dispatch, + and the command-line creators (`CreateLocalPlayer` / `CreateLocalGameOptions`, + which lean on the mixed-in argument component). Trivial callbacks (a single + `SendXToServer`) stay inline; callbacks with behaviour forward to the controller. +- **`AutolobbyController`** holds the behaviour as free functions taking the + instance as their first argument (`OnHosting(instance)`, etc.). `AutolobbyMessages` + routes each validated message straight to `AutolobbyController.Process*`. + +**Why:** the logic becomes **hot-reloadable** — the instance's forwarders resolve +through the live controller module table, so editing a handler takes effect +without re-hosting. It is also testable with a mock instance. Two caveats: + +- The **instance does not hot-reload** (the live C object keeps its bound methods + across a reload of `AutolobbyInstance.lua`). Keep it thin; edits there need a new + `CreateLobby`. The view and model hot-reload on their own. +- The **threads don't hot-reload** either — a forked `ShareLaunchStatusThread` / + `LaunchThread` holds the function it started with, so thread edits take effect + on the next lobby. The message/event handlers do reload. --- @@ -97,8 +110,8 @@ discipline, so call sites can't accidentally mutate in place: `SetPlayer`, `StampLaunchTables`. The controller calls these instead of building tables by hand. `LocalPlayerName`, `HostID` and the rejoin parameters -(`LobbyParameters` / `HostParameters` / `JoinParameters`) stay -controller-internal — no view reads them and no derivation depends on them. +(`LobbyParameters` / `HostParameters` / `JoinParameters`) live on the +**instance** — no view reads them and no derivation depends on them. --- @@ -119,8 +132,8 @@ controller-internal — no view reads them and no derivation depends on them. every receive so the value identity always changes and the observer fires even for repeated pulses from the same peer. - **Bootstrap order matters.** `CreateLobby` sets up the model *before* the view - so the view subscribes against it, and *before* the controller so the - controller's `__init` seeds an existing model. Rejoin re-runs `CreateLobby`, + so the view subscribes against it, and *before* the instance so the instance's + `__init` seeds an existing model. Rejoin re-runs `CreateLobby`, which calls `SetupSingleton` and thus resets the model to fresh state — a new lobby must not inherit stale connection / launch state. diff --git a/lua/ui/lobby/autolobby/components/AutolobbyArguments.lua b/lua/ui/lobby/autolobby/components/AutolobbyArguments.lua index 19aff423ca0..0a94e052b4f 100644 --- a/lua/ui/lobby/autolobby/components/AutolobbyArguments.lua +++ b/lua/ui/lobby/autolobby/components/AutolobbyArguments.lua @@ -57,7 +57,7 @@ AutolobbyArgumentsComponent = ClassSimple { }, --- Verifies that it is an expected command line argument - ---@param self UIAutolobbyArgumentsComponent | UIAutolobbyCommunications + ---@param self UIAutolobbyArgumentsComponent | UIAutolobbyInstance ---@param option string ---@return boolean ValidCommandLineKey = function(self, option) @@ -70,7 +70,7 @@ AutolobbyArgumentsComponent = ClassSimple { end, --- Attempts to retrieve a string-like command line argument - ---@param self UIAutolobbyArgumentsComponent | UIAutolobbyCommunications + ---@param self UIAutolobbyArgumentsComponent | UIAutolobbyInstance ---@param option string ---@param default string ---@return string @@ -89,7 +89,7 @@ AutolobbyArgumentsComponent = ClassSimple { end, --- Attempts to retrieve a number-like command line argument - ---@param self UIAutolobbyArgumentsComponent | UIAutolobbyCommunications + ---@param self UIAutolobbyArgumentsComponent | UIAutolobbyInstance ---@param option string ---@param default number ---@return number @@ -114,7 +114,7 @@ AutolobbyArgumentsComponent = ClassSimple { end, --- Attempts to retrieve a table-like command line argument - ---@param self UIAutolobbyArgumentsComponent | UIAutolobbyCommunications + ---@param self UIAutolobbyArgumentsComponent | UIAutolobbyInstance ---@param option string ---@return table GetCommandLineArgumentArray = function(self, option) diff --git a/lua/ui/lobby/autolobby/components/AutolobbyServerCommunicationsComponent.lua b/lua/ui/lobby/autolobby/components/AutolobbyServerCommunicationsComponent.lua index 95e3b62beb8..c6eb27714ab 100644 --- a/lua/ui/lobby/autolobby/components/AutolobbyServerCommunicationsComponent.lua +++ b/lua/ui/lobby/autolobby/components/AutolobbyServerCommunicationsComponent.lua @@ -90,7 +90,7 @@ local GpgNetSend = GpgNetSend AutolobbyServerCommunicationsComponent = ClassSimple { --- Sends a message to the server to update relevant army options of a player. - ---@param self UIAutolobbyServerCommunicationsComponent | UIAutolobbyCommunications + ---@param self UIAutolobbyServerCommunicationsComponent | UIAutolobbyInstance ---@param peerId UILobbyPeerId ---@param key 'Team' | 'Army' | 'StartSpot' | 'Faction' ---@param value any @@ -105,7 +105,7 @@ AutolobbyServerCommunicationsComponent = ClassSimple { end, --- Sends a message to the server to update relevant army options of an AI. - ---@param self UIAutolobbyServerCommunicationsComponent | UIAutolobbyCommunications + ---@param self UIAutolobbyServerCommunicationsComponent | UIAutolobbyInstance ---@param aiName string ---@param key 'Team' | 'Army' | 'StartSpot' | 'Faction' ---@param value any @@ -120,7 +120,7 @@ AutolobbyServerCommunicationsComponent = ClassSimple { end, --- Sends a message to the server to update relevant game options. - ---@param self UIAutolobbyServerCommunicationsComponent | UIAutolobbyCommunications + ---@param self UIAutolobbyServerCommunicationsComponent | UIAutolobbyInstance ---@param key 'Slots' | any ---@param value any SendGameOptionToServer = function(self, key, value) @@ -134,7 +134,7 @@ AutolobbyServerCommunicationsComponent = ClassSimple { end, --- Sends a message to the server indicating what the status of the lobby as a whole. - ---@param self UIAutolobbyServerCommunicationsComponent | UIAutolobbyCommunications + ---@param self UIAutolobbyServerCommunicationsComponent | UIAutolobbyInstance ---@param value UILobbyState SendGameStateToServer = function(self, value) -- any other value seriously messes with the lobby protocol on the server @@ -146,21 +146,21 @@ AutolobbyServerCommunicationsComponent = ClassSimple { end, --- sends a message to the server about the status of the local peer. - ---@param self UIAutolobbyServerCommunicationsComponent | UIAutolobbyCommunications + ---@param self UIAutolobbyServerCommunicationsComponent | UIAutolobbyInstance ---@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 self UIAutolobbyServerCommunicationsComponent | UIAutolobbyInstance ---@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 self UIAutolobbyServerCommunicationsComponent | UIAutolobbyInstance ---@param peerId UILobbyPeerId SendDisconnectedPeer = function(self, peerId) GpgNetSend('DisconnectedPeer', peerId) From ddf02fdb1e789f15caf885def8f2e425ef6934e5 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 21 Jun 2026 20:57:27 +0200 Subject: [PATCH 04/98] Add a launch script for the custom lobby --- scripts/LaunchCustomLobby.ps1 | 125 ++++++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 scripts/LaunchCustomLobby.ps1 diff --git a/scripts/LaunchCustomLobby.ps1 b/scripts/LaunchCustomLobby.ps1 new file mode 100644 index 00000000000..2bca45a1c50 --- /dev/null +++ b/scripts/LaunchCustomLobby.ps1 @@ -0,0 +1,125 @@ +# ****************************************************************************************************** +# ** Copyright (c) 2026 FAForever +# ** +# ** 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. +# ****************************************************************************************************** + +# Launches one or more instances straight into the REGULAR custom-game lobby (not the +# autolobby), so the lobby UI can be developed and debugged quickly. +# +# How the lobby is reached (see lua\ui\uimain.lua): +# - host -> /hostgame -> StartHostLobbyUI +# - join -> /joingame
-> StartJoinLobbyUI +# Both pick the AUTOLOBBY only when `/players >= 2` is present. This script deliberately +# omits `/players`, so they fall through to the regular lobby (lua\ui\lobby\lobby.lua). +# +# Quick UI iteration: .\LaunchCustomLobby.ps1 -players 1 (host only, single window) +# Networked lobby test: .\LaunchCustomLobby.ps1 -players 2 (host + 1 joining client) +# +# Style and conventions follow scripts\LaunchBotSession.ps1 and scripts\LaunchFAInstances.ps1. + +param ( + [int]$players = 2, # 1 = host only (fast UI debug); 2+ = host + joining clients + [string]$map = "/maps/scmp_009/SCMP_009_scenario.lua", # default map: Seton's Clutch + [int]$port = 15000 # host listen port +) + +# Base path to the bin directory +$binPath = "C:\ProgramData\FAForever\bin" + +# Paths to the potential executables within the base path +$debuggerExecutable = Join-Path $binPath "FAFDebugger.exe" +$regularExecutable = Join-Path $binPath "ForgedAlliance.exe" + +# Check for the existence of the executables and choose accordingly +if (Test-Path $debuggerExecutable) { + $gameExecutable = $debuggerExecutable + Write-Output "Using debugger executable: $gameExecutable" +} elseif (Test-Path $regularExecutable) { + $gameExecutable = $regularExecutable + Write-Output "Debugger not found, using regular executable: $gameExecutable" +} else { + Write-Output "Neither debugger nor regular executable found in $binPath. Exiting script." + exit 1 +} + +$hostProtocol = "udp" +$hostPlayerName = "HostPlayer_1" +$gameName = "DevLobby" + +# Arguments shared by every instance. Note: NO `/players` argument — that is what keeps us +# on the regular lobby instead of the autolobby. +$commonArgs = @( + "/init", "init_local_development.lua", + "/nobugreport", + "/EnableDiskWatch", + "/nomovie", + "/showlog" +) + +# Window grid layout (so instances don't stack). The session refuses to launch below 1024x768. +Add-Type -AssemblyName System.Windows.Forms +$screenWidth = [System.Windows.Forms.Screen]::PrimaryScreen.WorkingArea.Width +$screenHeight = [System.Windows.Forms.Screen]::PrimaryScreen.WorkingArea.Height +$columns = [math]::Ceiling([math]::Sqrt($players)) +$rows = [math]::Ceiling($players / $columns) +$windowWidth = [math]::Max([math]::Floor($screenWidth / $columns), 1024) +$windowHeight = [math]::Max([math]::Floor($screenHeight / $rows), 768) + +function Launch-LobbyInstance { + param ( + [int]$instanceNumber, + [int]$xPos, + [int]$yPos, + [string[]]$arguments + ) + + $arguments += @("/position", $xPos, $yPos, "/size", $windowWidth, $windowHeight) + + try { + Start-Process -FilePath $gameExecutable -ArgumentList $arguments -NoNewWindow + Write-Host "Launched instance $instanceNumber at ($xPos, $yPos) size ($windowWidth, $windowHeight)" + } catch { + Write-Host "Failed to launch instance ${instanceNumber}: $_" + } +} + +# Host instance (top-left). Positional order for /hostgame is protocol, port, name, gameName, map. +$hostArgs = @( + "/log", "host_lobby_1.log", + "/hostgame", $hostProtocol, $port, $hostPlayerName, $gameName, $map +) + $commonArgs +Launch-LobbyInstance -instanceNumber 1 -xPos 0 -yPos 0 -arguments $hostArgs + +# Joining client instances. Positional order for /joingame is protocol, address, name. +for ($i = 1; $i -lt $players; $i++) { + $row = [math]::Floor($i / $columns) + $col = $i % $columns + $xPos = $col * $windowWidth + $yPos = $row * $windowHeight + + $clientName = "ClientPlayer_$($i + 1)" + $clientArgs = @( + "/log", "client_lobby_$($i + 1).log", + "/joingame", $hostProtocol, "localhost:$port", $clientName + ) + $commonArgs + Launch-LobbyInstance -instanceNumber ($i + 1) -xPos $xPos -yPos $yPos -arguments $clientArgs +} + +Write-Host "$players instance(s) launched into the regular custom lobby. Host on port $port." From 18230429ce41d3851bbd8487b6bef54a88e6b93f Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 21 Jun 2026 21:46:51 +0200 Subject: [PATCH 05/98] Bare-bone custom lobby that is easy to test --- lua/ui/lobby/FEATURES.md | 328 + lua/ui/lobby/TARGET_ARCHITECTURE.md | 303 + lua/ui/lobby/USER_STORIES.md | 154 + lua/ui/lobby/customlobby/CLAUDE.md | 51 + .../customlobby/CustomLobbyController.lua | 264 + .../lobby/customlobby/CustomLobbyInstance.lua | 175 + .../customlobby/CustomLobbyInterface.lua | 215 + .../lobby/customlobby/CustomLobbyMessages.lua | 93 + lua/ui/lobby/customlobby/CustomLobbyModel.lua | 230 + .../customlobby/CustomLobbySlotInterface.lua | 175 + lua/ui/lobby/lobby-old.lua | 7386 ++++++++++++++++ lua/ui/lobby/lobby.lua | 7454 +---------------- lua/ui/uimain.lua | 4 +- 13 files changed, 9474 insertions(+), 7358 deletions(-) create mode 100644 lua/ui/lobby/FEATURES.md create mode 100644 lua/ui/lobby/TARGET_ARCHITECTURE.md create mode 100644 lua/ui/lobby/USER_STORIES.md create mode 100644 lua/ui/lobby/customlobby/CLAUDE.md create mode 100644 lua/ui/lobby/customlobby/CustomLobbyController.lua create mode 100644 lua/ui/lobby/customlobby/CustomLobbyInstance.lua create mode 100644 lua/ui/lobby/customlobby/CustomLobbyInterface.lua create mode 100644 lua/ui/lobby/customlobby/CustomLobbyMessages.lua create mode 100644 lua/ui/lobby/customlobby/CustomLobbyModel.lua create mode 100644 lua/ui/lobby/customlobby/CustomLobbySlotInterface.lua create mode 100644 lua/ui/lobby/lobby-old.lua diff --git a/lua/ui/lobby/FEATURES.md b/lua/ui/lobby/FEATURES.md new file mode 100644 index 00000000000..88d21160a77 --- /dev/null +++ b/lua/ui/lobby/FEATURES.md @@ -0,0 +1,328 @@ +# Custom-Game Lobby — Feature Inventory (parity yardstick) + +This catalogs **everything the current custom-game lobby does**, so a ground-up +MVC rebuild has a measurable parity target. It is a behaviour spec, not a design. + +> Line references are **approximate** (gathered by code exploration; the files +> drift). Treat them as "look near here", and re-confirm against current source. +> Primary sources: [`/lua/ui/lobby/lobby.lua`](/lua/ui/lobby/lobby.lua) (~7400 lines), +> [`/lua/ui/lobby/lobbyComm.lua`](/lua/ui/lobby/lobbyComm.lua), +> [`/lua/ui/lobby/lobbyOptions.lua`](/lua/ui/lobby/lobbyOptions.lua), +> [`/lua/ui/lobby/ModsManager.lua`](/lua/ui/lobby/ModsManager.lua), +> [`/lua/ui/lobby/UnitsManager.lua`](/lua/ui/lobby/UnitsManager.lua), +> [`/lua/ui/lobby/presets.lua`](/lua/ui/lobby/presets.lua), +> [`/lua/ui/lobby/gameselect.lua`](/lua/ui/lobby/gameselect.lua), +> [`/lua/ui/dialogs/mapselect.lua`](/lua/ui/dialogs/mapselect.lua), +> [`/lua/ui/lobby/data/gamedata.lua`](/lua/ui/lobby/data/gamedata.lua), +> [`/lua/ui/lobby/data/playerdata.lua`](/lua/ui/lobby/data/playerdata.lua). + +## What we're replacing (scale) + +- `lobby.lua` alone is ~7,400 lines; the lobby ecosystem ~10,700 lines. +- Central state `gameInfo` is a hybrid: plain tables (`GameOptions`, `ClosedSlots`, + `SpawnMex`, `GameMods`, `AutoTeams`) + reactive `WatchedValueArray` for + `PlayerOptions` / `Observers`. **The reactivity primitive already exists** and + there's a standing TODO to replace `SetSlotInfo` sledgehammers with lazy callbacks. +- Networking: **host-authority**. Non-hosts send requests; host validates and + broadcasts authoritative state. ~30 message types. Full `GameInfo` dump on join. +- UI is driven by ~10 "refresh-everything" functions (`SetSlotInfo`, + `RefreshOptionDisplayData`, `refreshObserverList`, …) called after each change. + +--- + +## A. Lifecycle & entry + +| Feature | Who | Trigger / function | Notes | +|---|---|---|---| +| Game discovery list | all | `gameselect.lua` LAN/IP discovery | shows map, name, players | +| Create game | host | `gameselect.lua` → `gamecreate` | nickname validation 2–32 chars | +| Direct IP connect | all | IP+port input, stored in prefs | | +| Host a lobby | host | `HostGame` (lobby.lua ~780); `lobbyComm.Hosting` (~5706) | loads last scenario/options from prefs, host into slot 1, keep-alive thread (30s) | +| Join a lobby | client | `JoinGame` (~788); `ConnectionToHostEstablished` (~5596) | sends `SetAvailableMods` + `AddPlayer`; host replies with full `GameInfo` | +| Leave / abort | all | `ReturnToMenu` (~1878), Escape handler (~669) | confirm dialog; destroys `lobbyComm`/`GUI`; quit-to-desktop if `/gpgnet` | +| Rehost | host | `/rehost` flag (~179, ~5812) | restores last-game preset, reassigns players/AIs to prior slots | +| Launch transition | host | `GameLaunched` (~5677) | saves loading faction, kills threads, destroys UI + comm | + +--- + +## B. Slots & players + +**Slot states & context menu** (`GetSlotMenuData` ~246, `GetSlotMenuTables` ~341, `DoSlotBehavior` ~548): + +| Slot state | Host menu entries | Client menu entries | +|---|---|---| +| Open | Close, Close-spawn-mex (adaptive), Occupy | Occupy | +| Closed | Open, Close-spawn-mex (adaptive) | — | +| Human-occupied | Whisper, Move to Observer, Kick, Move to slot N | Whisper | +| AI-occupied | Remove (Kick), AI personality list | — | + +**Actions:** + +| Feature | Who | Function | Message | State | +|---|---|---|---|---| +| Open/close slot | host | `HostUtils.SetSlotClosed` (~6839) | `SlotClosed` | `ClosedSlots[slot]` | +| Close + spawn-mex (adaptive) | host | `SetSlotClosedSpawnMex` (~6859) | `SlotClosedSpawnMex` | `ClosedSlots`+`SpawnMex` | +| Occupy empty slot | player | host: `MovePlayerToEmptySlot` (~7039); client → host | `MovePlayer`→`SlotMove` | move player, force unready | +| Move player to slot | host | `SwapPlayers` (~7078) | `SlotMove`/`SwapPlayers` | relocate, unready | +| Swap two players | host | `SwapPlayers`/`DoSlotSwap` (~6762) | `SwapPlayers` | swap incl. team, unready both | +| Kick player | host | `EjectPeer` + confirm dialog (~590) | engine eject + `SystemMessage` | slot cleared; kick msg saved to prefs | + +**Slot row display** (`CreateSlotsUI` ~2718, `SetSlotInfo` ~1032, `ClearSlotInfo` ~1245): +slot #, country flag, rating (R: mean±3·dev tooltip), games (G:), name (context-menu +combo, colour-coded host/player/you/AI), colour combo, faction combo, team combo, +CPU bar, ping bar, ready checkbox. `SetSlotInfo` recomputes the whole row on any change. + +--- + +## C. Per-player settings + +| Feature | Who | Function | Message | Notes | +|---|---|---|---|---| +| Faction | player (own) / host (AI) | faction combo + large selector `CreateUI_Faction_Selector` (~6375) | `PlayerOptions{Faction}` | per-map availability; random=5 resolved at launch; sets skin; saved to prefs | +| Colour | player (own) / host | colour combo; `Check_Availaible_Color` (~6706), `IsColorFree` (~1304) | client `RequestColor` → host `SetColor` | **scarcity**: taken colours hidden; host validates/reverts | +| Team | player (own) / host | team combo | `PlayerOptions{Team}` | disabled when AutoTeams active or ready; backend team = UI team+1 | +| Ready | player (own) | ready checkbox (~2991) | `PlayerOptions{Ready}` | disables own controls; gates launch; host can force-unready (`SetPlayerNotReady`) | +| Start position | player/host | map-preview click/drag `ConfigureMapListeners` (~4931) | `MovePlayer`/`SlotMove`/`AutoTeams` | fixed-spawn: move self; host swap mode; manual AutoTeams via map clicks | + +--- + +## D. AI management (host-only) + +| Feature | Function | Message | Notes | +|---|---|---|---| +| Add AI | `HostUtils.AddAI` → `TryAddPlayer` (~7186) | `SlotAssigned` | personality list from `aitypes.lua` (`GetAItypes`); AI auto-ready, `Human=false` | +| Remove AI | `HostUtils.RemoveAI` (~6988) | `ClearSlot` | | +| AI rating | `ComputeAIRating` (~456), `GetAIPlayerData` (~503) | — | from cheat/build/map multipliers + omni bonus (per `AILobbyProperties`) | +| AI faction/colour/team | via PlayerData | `PlayerOptions` | colour from `GetAvailableColor`/inherited; team via AutoTeams or explicit | +| Move/swap AI | `SwapPlayers`/`MovePlayerToEmptySlot` | `SlotMove`/`SwapPlayers` | same path as players | +| Fill slots | "Fill Slots" + AI combo (~3210) | `SlotAssigned` ×N | | + +--- + +## E. Observers + +| Feature | Who | Function | Message | +|---|---|---|---| +| Join as observer | auto (slots full / requested) | `TryAddObserver` (~7113) | `ObserverAdded` | +| Player → observer | host or player-request | `ConvertPlayerToObserver` (~6879) / `RequestConvertToObserver` | `ConvertPlayerToObserver` | +| Observer → player | host or observer-request | `ConvertObserverToPlayer` (~6923) / `RequestConvertToPlayer` (opt. slot) | `ConvertObserverToPlayer` | +| Observer list UI | all | `refreshObserverList` (~926) | name + rating + ping + CPU; team ratings if >2 teams | +| Kick observer | host | `KickObservers` (~7379) | engine eject; also auto-kick at launch if observers disallowed | +| Observer chat | observer | `AddChatText` greys observer names | `PublicChat`/`PrivateChat` | +| Rehost slot negotiation | host | `FindRehostSlotForID` (~841) | observers reclaim prior slots on rehost | + +--- + +## F. Host authority & validation + +- **Pattern**: client mutates own slot → broadcasts `PlayerOptions` (host `Accept` + checks `SenderID == OwnerID`); non-owned mutations go request→host-validate→broadcast. + Host-only gate: `AmHost` (~5067) on relevant handlers. +- **Optimistic UI**: client applies colour locally, host may revert (~5357). +- **Ready-gating**: `RefreshButtonEnabledness` (~7248) disables host option buttons + while any human is not ready; launch enabled only when all ready (or single-player / host-observes). +- **Force unready**: `SetPlayerNotReady` (one), `SetAllPlayerNotReady` (all, on option reset). +- **Race guards**: e.g. ignore a move request if the player became ready meanwhile (~5323). + +--- + +## G. Game options (complete enumeration — `lobbyOptions.lua`) + +### Team options (~72–226) +| Key | Values | Default | +|---|---|---| +| `TeamSpawn` | fixed, penguin_autobalance, random, balanced, balanced_flex, random_reveal, balanced_reveal, balanced_reveal_mirrored, balanced_flex_reveal | fixed | +| `TeamLock` | locked, unlocked | locked | +| `AutoTeams` | none, tvsb, lvsr, pvsi, manual | none | +| `CommonArmy` | Off, UnionWhenDisconnected, Union, Common | Off | +| `TeamShareOverflow` | enabled, disabled | enabled | + +### Global options (~229–706) +| Key | Values | Default | +|---|---|---| +| `Share` | FullShare, ShareUntilDeath, PartialShare, TransferToKiller, Defectors, CivilianDeserter | FullShare | +| `DisconnectShare` | SameAsShare | SameAsShare | +| `DisconnectShareCommanders` | Explode | Explode | +| `ShareUnitCap` | none, allies, all | allies | +| `ManualUnitShare` | all, no_builders, none | all | +| `RestrictedCategories` | set (Unit Manager) | none | +| `Victory` | demoralization, decapitation, domination, eradication, sandbox | demoralization | +| `FogOfWar` | explored, none | explored | +| `GameSpeed` | normal, fast, adjustable | normal | +| `CheatsEnabled` | false, true | false | +| `CivilianAlliance` | enemy, neutral, removed | enemy | +| `RevealCivilians` | Yes, No | Yes | +| `PrebuiltUnits` | Off, On | Off | +| `NoRushOption` | Off, 1..5,10,15,…,60 | Off | +| `RandomMap` | Off, Official, All | Off | +| `Score` | yes, no | yes | +| `UnitCap` | 125,250,…,1000,1250,1500 | 1000 | +| `AllowObservers` | true/false | true | +| `Timeouts` | 0, 3, -1 (MP only) | 3 | +| `DisconnectionDelay02` | 10, 30, 90 (MP only) | 90 | +| `Unranked` | No, Yes | No | + +### AI options (~709–814) +| Key | Values | Default | +|---|---|---| +| `CheatMult` | 0.5–5.9 | 1.0 | +| `BuildMult` | 0.5–5.9 | 1.0 | +| `TMLRandom` | 0–20% | 0 | +| `LandExpansionsAllowed` | 0–8, 99999 | 6 | +| `NavalExpansionsAllowed` | 0–8, 99999 | 5 | +| `OmniCheat` | on, off | on | + +Plus **map-specific options** (`mapname_options.lua`, validated by `maputil`) and +**custom AI options** from mods (`/lua/AI/LobbyOptions/`). Storage: `gameInfo.GameOptions[key]` ++ `Prefs LobbyOpt_`; host writes via `SetGameOption(s)` (~5938) → `GameOptions` broadcast ++ GpgNet (`RestrictedCategories`→bool, `ScenarioFile`, `Slots`). Reset via `OptionUtils.SetDefaults` (~2592). +Display: `RefreshOptionDisplayData` (~4543), scrollable `GUI.OptionContainer`, optional "show changed only". + +--- + +## H. Auto-teams & spawn + +- **AutoTeams** (`AssignAutoTeams` ~1802): none / tvsb / lvsr / pvsi / manual (host clicks map markers, requires random spawn). +- **Spawn variants** (`TeamSpawn`): fixed, random, balanced (+flex, +reveal, +mirrored), penguin_autobalance. +- **Spawn-mex** (adaptive maps): per-slot `SpawnMex` toggle; embedded into GameOptions at launch. +- **Launch-time** (`TryLaunch` ~2077): `AssignAutoTeams`, `AssignRandomFactions`, `FixFactionIndexes`, + `AssignRandomStartSpots`, `AssignAINames`, flatten gameInfo, build Ratings/ClanTags tables. + +--- + +## I. Map selection (`mapselect.lua`) + +- Map browser dialog `CreateDialog` (~453): list + preview + filters + options panel + Mods/Unit-Manager buttons. +- **Filters** (~117): supported players (2–16), size (5–81 km), type (official/custom), AI markers, hide-obsolete; persisted in prefs. Name search. +- **Preview**: `ResourceMapPreview` (terrain, water/cliff/buildable masks, mass/hydro markers, start spots). +- **Metadata** (`maputil` `LoadScenario` ~53): name, map/save/script/preview, size, reclaim, type, version, AdaptiveMap, Configurations (teams/armies). +- **Enumeration**: `EnumerateSkirmishScenarios` (~312) over `/maps/*` + mod map dirs; playable skirmish only. +- Hardcoded official-maps list for the "official" filter. + +--- + +## J. Mods manager (`ModsManager.lua`) + +- Dialog `CreateDialog(parent, isHost, availableMods, saveBehaviour)` (~160). +- **Types**: GAME (sim, network-replicated, disables rating), UI (local only), BLACKLISTED, LOCAL (host has, peers don't), NO_DEPENDENCY. +- **Browse**: grid (expand/collapse), sort (status/type/name/author/version), free-text search; prefs-persisted. +- **Favorites**: per-mod star; "Activate Favorites" (host: sim+UI, client: UI only). +- **Dependencies**: forward auto-activate, backward auto-deactivate, conflict prompts (`Mods.GetDependencies`). +- **Peer availability**: `availableMods[peerID]`; `EveryoneHasMod`; sim mods auto-disabled / peers kicked if missing. +- **Apply**: split sim/UI, `saveBehaviour(simMods, uiMods)` → host `UpdateMods` → `ModsChanged` broadcast; `Mods.SetSelectedMods`. + +--- + +## K. Unit restrictions manager (`UnitsManager.lua` + `UnitsRestrictions.lua`) + +- Dialog `CreateDialog(parent, initial, OnOk, OnCancel, isHost)` (~216); scales to ~90% screen. +- **150+ presets** in ~15 groups (tech levels, unit types, spam, snipes, anti-air, torpedo, direct-fire, defensive/intel, SCU/ACU, economy, missiles, engineers). +- **Expression system**: category algebra (`*` AND, `+` OR, `-` NOT, `()`), UPPERCASE categories + lowercase unit ids + enhancement names. +- Grid: presets + per-faction × category unit matrix; host editable, client read-only. +- Stored in `GameOptions.RestrictedCategories`; broadcast + GpgNet bool; saved in presets. + +--- + +## L. Chat + +| Feature | Trigger / function | Message | +|---|---|---| +| Public chat | `GUI.chatEdit` Enter → `PublicChat` (~1924) | `PublicChat` | +| Whisper | `/whisper`,`/pm`,`/w`,`/private` → `ParseWhisper` (~192) → `PrivateChat` (~1934) | `PrivateChat` | +| Unknown command | "Command Not Known" | — | +| Join/leave & connection notices | `AddPlayer`, `Peer_Really_Disconnected`, `CalcConnectionStatus` | `SystemMessage` | +| Rating in chat/list | `refreshObserverList` | — | +| Clickable names | `chatarea.lua` author click → prefill `/w name` | — | +| Input history | up/down over `commandQueue` | — | +| Scroll / auto-bottom | `AddChatText` (~4819) | — | + +--- + +## M. Connectivity & health + +| Feature | Function | Message | Notes | +|---|---|---|---| +| CPU benchmark | `UpdateBenchmark`/`StressCPU` (~6226) | `CPUBenchmark` | auto on connect + manual button; 0–450; cached in prefs; host re-broadcasts | +| CPU bar | `SetSlotCPUBar` (~6300) | — | green/yellow/red thresholds | +| Ping bar | ping thread (~4406) | — | shown if >500ms; status texture 1–4 | +| Connection tracking | `CalcConnectionStatus` (~4742) | — | ConnectionEstablished/CurrentConnection/ConnectedWithProxy | +| Pre-launch connectivity | `EveryoneHasEstablishedConnections` (~4785) | — | every important peer must reach every other | +| Peer disconnect | `PeerDisconnected` (~5833) | `Peer_Really_Disconnected` | clears slot/observer; chat notice | +| Host keep-alive/timeout | thread (~5616), `quietTimeout` 30s | — | "Keep Trying / Give Up" dialog | + +--- + +## N. Compatibility & launch validation + +- **Version check**: host `AddPlayer.Accept` (~5272) compares Version/GameType/Commit → reject + eject on mismatch. +- **Missing map**: `MissingMap` → `PlayerMissingMapAlert` (~7190) sets `BadMap`, system message; local fallback to default scenario; `ClearBadMapFlags` on map change. +- **Mods exchange**: `SetAvailableMods` → `availableMods[peer]`; `IsModAvailable` requires all peers; missing → kick with list. +- **Launch blockers** (`TryLaunch` ~2077): no players; host-as-observer when observers disallowed; observers present → confirm-kick; missing connections; map missing (warn, proceeds with fallback). + +--- + +## O. Presets & rehost (`presets.lua`) + +- Save preset (map, options, mods, restrictions, player options) to profile; load/apply (`ApplyGameSettings` ~6683); delete; rename. +- Auto-save "lastGame" preset at launch (`SaveLastGamePreset`). +- Rehost (`/rehost`): restore last-game settings; reassign returning players to prior slots (evicting AIs / displacing to observer), restore AIs, relocate host. +- Presets dialog UI: list + detail + Load/Create/Save/Delete/Help/Quit. + +--- + +## P. Lobby preferences dialog (`ShowLobbyOptionsDialog` ~6519) + +Local, non-game-affecting: background mode (factions/concept/screenshot/map/none), +chat font size, snowflake count, windowed lobby, stretch background, faction font colour, +player-colour-in-chat. Applied immediately; stored in prefs. + +## Q. Other dialogs + +- Briefing dialog (campaign/scenario), large map preview (water/cliff/buildable masks), + save/load dialog (skirmish), kick/reset confirm popups. + +--- + +## Network message catalog (~30) + +`AddPlayer`, `PlayerOptions`, `MovePlayer`, `RequestColor`, `SetColor`, +`RequestConvertToObserver`, `RequestConvertToPlayer`, `ConvertPlayerToObserver`, +`ConvertObserverToPlayer`, `ObserverAdded`, `SlotAssigned`, `SlotMove`, `SwapPlayers`, +`ClearSlot`, `SlotClosed`, `SlotClosedSpawnMex`, `AutoTeams`, `GameOptions`, +`ModsChanged`, `SetAvailableMods`, `MissingMap`, `GameInfo` (full sync on join), +`Launch`, `PublicChat`, `PrivateChat`, `SystemMessage`, `CPUBenchmark`, +`SetPlayerNotReady`, `SetAllPlayerNotReady`, `Peer_Really_Disconnected`. + +Engine callbacks: `Hosting`, `ConnectionToHostEstablished`, `PeerDisconnected`, +`DataReceived`, `GameLaunched`, `Ejected`, `ConnectionFailed`, `GameConfigRequested`. + +--- + +## Data model + +- **`gameInfo`** (`gamedata.lua`): `GameOptions` (table), `PlayerOptions` / + `Observers` (`WatchedValueArray`), `ClosedSlots`, `SpawnMex`, `GameMods`, `AutoTeams`, `AdaptiveMap`. +- **`PlayerData`** (`playerdata.lua`): OwnerID, PlayerName, Human, Faction, PlayerColor, + ArmyColor, Team, StartSpot, Ready, PL/MEAN/DEV/NG, PlayerClan, Country, AIPersonality, + AILobbyProperties, Version/GameType/Commit, Civilian, BadMap, ObserverListIndex. +- Module globals: `gameInfo`, `lobbyComm`, `GUI`, `localPlayerID`, `hostID`, + `availableMods`, `selectedSimMods`/`selectedUIMods`, `CPU_Benchmarks`, `rehostPlayerOptions`. + +## "Refresh-everything" functions to replace with reactive subscriptions + +`SetSlotInfo` (~1032), `ClearSlotInfo` (~1245), `UpdateGame` (~2302), +`refreshObserverList` (~926), `RefreshOptionDisplayData` (~4543), +`UpdateFactionSelector` (~6445), `RefreshButtonEnabledness` (~7248), +`Check_Availaible_Color` (~6706), `RefreshLobbyBackground`, `RefreshMapPositionForAllControls`. + +--- + +## Reuse vs rebuild + +**Relatively portable** (self-contained): map preview (`ResourceMapPreview`), mods +manager dialog, unit manager dialog, options definitions (`lobbyOptions.lua` data), +faction selector, CPU benchmark, chat (the new chat-MVC), presets storage. + +**Deeply tangled core** (rebuild against the model): slot/player sync (`SetSlotInfo` ++ `HostUtils`), the 30-message dispatch, host-authority request/validate/broadcast, +connection tracking. These are where the MVC rebuild's value — and risk — concentrate. diff --git a/lua/ui/lobby/TARGET_ARCHITECTURE.md b/lua/ui/lobby/TARGET_ARCHITECTURE.md new file mode 100644 index 00000000000..366be61a111 --- /dev/null +++ b/lua/ui/lobby/TARGET_ARCHITECTURE.md @@ -0,0 +1,303 @@ +# Custom-Game Lobby — Target Architecture (rebuild sketch) + +A design sketch for rebuilding the custom-game lobby on the reactive-MVC pattern +proven in [`autolobby/`](autolobby/CLAUDE.md) and [`game/chat/`](/lua/ui/game/chat/CLAUDE.md). +Read alongside [`FEATURES.md`](FEATURES.md) (what it must do) and +[`USER_STORIES.md`](USER_STORIES.md) (from whose perspective). + +This is a sketch, not final code. Representative code shapes are shown only for the +two load-bearing parts: the model and the host-authority flow. + +--- + +## 1. Principles (carried from autolobby / chat) + +- **Reactive model = single source of truth.** Replaces `gameInfo` + the ~10 + `SetSlotInfo`/`Refresh*` sledgehammers. Views subscribe via `Derive`; nobody pushes. +- **Thin `moho` Instance, logic in a free-function Controller.** The engine owns the + `moho.lobby_methods` object; it forwards callbacks to a hot-reloadable controller + (exactly the autolobby `AutolobbyInstance` ↔ `AutolobbyController` split). +- **Components own their reactivity and visibility.** A slot row subscribes to its + own slot; the options panel to the options; etc. No parent push-hub. +- **Write discipline in the model.** All mutations go through model write-helpers + (copy-then-`Set`), so in-place mutation can't silently break reactivity. +- **Standardize on `LazyVar`.** Drop `WatchedValueArray`/`WatchedValueTable`; use a + per-slot array of `LazyVar`s for players/observers (per-slot granularity replaces + per-field — a slot row is small enough to rebuild on any of its fields changing). + +**The one thing the autolobby does NOT teach: host authority.** The autolobby is +single-writer (host computes everything). The custom lobby is request→validate→ +broadcast. That shapes the Controller (§5) and is the main new design work. + +--- + +## 2. Layer overview + +``` + engine callbacks + │ + ┌───────────▼────────────┐ + │ LobbyInstance │ moho.lobby_methods, thin shell + │ (engine ABI + dispatch)│ validation via LobbyMessages + └───────────┬────────────┘ + forwards (passing self) ▲ BroadcastData / SendData + │ │ + ┌───────────▼────────────┐ │ + │ LobbyController │──────┘ the only writer to the model + │ intents · host-ops · │ and the only one talking to peers + │ wire-handlers │ + └───────────┬────────────┘ + writes via helpers + │ + ┌───────────▼────────────┐ + │ LobbyModel (+ submodels)│ reactive LazyVars + derived + write helpers + └───────────┬────────────┘ + OnDirty / Derive + │ + ┌───────────────┼───────────────────────────┐ + ▼ ▼ ▼ + Components Sub-dialogs (mini-MVC) Reused widgets + (slots, options, (map select, mods, units, (map preview, chat-MVC, + observers, …) presets, lobby prefs) faction selector, …) +``` + +UI never writes the model. UI calls **controller intents**; the controller writes +the model (host) or sends a request (client); wire-handlers apply authoritative state. + +--- + +## 3. Model decomposition + +`gameInfo` becomes a small package of focused reactive models (like chat's +`ChatModel` + `ChatConfigModel`). Suggested split: + +### `LobbyModel` — the synced game state (the gameInfo replacement) + +| LazyVar | Replaces | Notes | +|---|---|---| +| `Players[1..N]` (array of LazyVars) | `gameInfo.PlayerOptions` | each slot a LazyVar of `PlayerData\|false` | +| `Observers[1..M]` (array of LazyVars) | `gameInfo.Observers` | | +| `ClosedSlots`, `SpawnMex` | same | slot-flag tables | +| `GameOptions` | `gameInfo.GameOptions` | one table; write via helper | +| `GameMods` | `gameInfo.GameMods` | selected sim/ui set | +| `AutoTeams` | `gameInfo.AutoTeams` | slot→team | +| `ScenarioFile` | `GameOptions.ScenarioFile` | split out so the preview observes it directly | +| `LocalPeerId`, `HostID`, `IsHost`, `LocalPlayerName` | globals | identity | + +**Derived** (computed, never written): `Scenario` (bundle `{ScenarioFile, Players}`), +`AvailableColors[slot]`, `PlayersNotReady`, `CanLaunch`, `GameQuality`, +`SlotMenu[slot]` (the right-click model), `PlayerCount`. These replace +`Check_Availaible_Color`, `GetPlayersNotReady`, `RefreshButtonEnabledness`, +`ShowGameQuality`, `GetSlotMenuTables`. + +### `LobbyConnectivityModel` — local, high-frequency, NOT part of the synced game state + +`ConnectionStatus[peer]`, `Ping[peer]`, `CPUBenchmarks[name]`, `AvailableMods[peer]`, +`BadMap[peer]`. Kept separate so frequent ping/CPU churn doesn't dirty game state. + +### `LobbyPrefsModel` — local UI preferences + +`Committed` / `Pending` like [`ChatConfigModel`](/lua/ui/game/chat/config/ChatConfigModel.lua): +background mode, chat font, snowflakes, windowed, stretch, chat colours. + +### Chat — reuse the chat MVC + +Lobby chat is the same shape as in-game chat; reuse the model/controller, or a thin +lobby-scoped mirror. Do not reinvent. + +Model write-helpers (mirroring autolobby's `SetPlayer`/`SetPeerStatus`/…): +`SetPlayer(slot, data)`, `ClearPlayer(slot)`, `SetObserver`, `SetPlayerField(slot, key, value)`, +`SetGameOption(key, value)`, `SetClosed(slot, …)`, `SetMods(…)`, `SetScenario(file)`. + +```lua +-- shape (per autolobby AutolobbyModel) +function SetPlayerField(model, slot, key, value) + local p = table.copy(model.Players[slot]() or {}) + p[key] = value + model.Players[slot]:Set(p) -- only slot `slot`'s subscribers re-fire +end +``` + +--- + +## 4. `LobbyInstance` — the thin `moho` shell + +Same role as [`AutolobbyInstance`](autolobby/AutolobbyInstance.lua): the C-bound +object the engine instantiates. Keeps engine-ABI wrappers (`BroadcastData`, +`SendData`, `EjectPeer`, `LaunchGame`, `GetPeers`, …, with their `LobbyMessages` +validation), the command-line/identity creators, and `DataReceived`'s +validate→accept→dispatch. Every callback with behaviour forwards to the controller: + +```lua +Hosting = function(self) LobbyController.OnHosting(self) end, +DataReceived = function(self, data) ... validate ... LobbyMessages[data.Type].Handler(self, data) end, +PeerDisconnected = function(self, name, id) LobbyController.OnPeerDisconnected(self, name, id) end, +GameLaunched = function(self) LobbyController.OnGameLaunched(self) end, +``` + +Does **not** hot-reload (live C object) — keep it thin; behaviour lives in the controller. + +--- + +## 5. `LobbyController` — the logic (and host authority) + +A package of free-function modules (split by domain, like chat's `commands/` and +`config/`). Each function takes the `instance` as its first arg (for +`BroadcastData`/`SendData`/`IsHost`). Three faces: + +### 5a. Local intents (called by the UI) +What the local user asks for. Each decides host vs client: + +```lua +-- PlayerController +function RequestSetColor(instance, slot, color) + if instance:IsHost() then + HostSetColor(instance, slot, color) -- authoritative path + else + instance:SendData(LobbyModel.GetSingleton().HostID(), + { Type = 'RequestColor', Slot = slot, Color = color }) + end +end +``` + +### 5b. Host operations (host-only authority: validate → apply → broadcast) + +```lua +function HostSetColor(instance, slot, color) + if not LobbyModel.IsColorFree(LobbyModel.GetSingleton(), color, slot) then return end + LobbyModel.SetPlayerField(LobbyModel.GetSingleton(), slot, 'PlayerColor', color) + instance:BroadcastData({ Type = 'SetColor', Slot = slot, Color = color }) +end +``` + +### 5c. Wire handlers (apply authoritative state arriving from the network) + +```lua +function OnSetColor(instance, data) -- registered as LobbyMessages.SetColor.Handler + LobbyModel.SetPlayerField(LobbyModel.GetSingleton(), data.Slot, 'PlayerColor', data.Color) +end +``` + +### The host-authority data flow (the defining difference) + +``` +client UI ── intent ──► RequestX ──(not host)── SendData ─► host + │ HostX: validate +host UI ── intent ──► RequestX ──(host)──► HostX ◄────────────┘ + │ apply to model + BroadcastData + ▼ + every client: OnX wire-handler ─► apply to model ─► views react +``` + +Validation lives in the **message registry** `LobbyMessages.lua` (mirroring +[`AutolobbyMessages`](autolobby/AutolobbyMessages.lua)): a table of +`{ Validate, Accept, Handler }` per type, `Handler → LobbyController.OnX`. `Accept` +encodes `IsFromHost` / `AmHost` / ownership checks. ~30 entries (the full set in +[`FEATURES.md`](FEATURES.md) § "Network message catalog"). + +Suggested controller modules: `LobbyController` (lifecycle, dispatch, launch), +`PlayerController` (slots/players/AI/observers — intents + host-ops + handlers), +`OptionsController`, `ModsController`, `ConnectivityController`. + +--- + +## 6. Components (views) + +Each subscribes to its slice and feeds dumb children — replacing the refresh sledgehammers. + +| Component | Subscribes to | Calls (intents) | Replaces | +|---|---|---|---| +| `LobbySlotInterface` (per slot) | `Players[slot]`, `AvailableColors[slot]`, `SlotMenu[slot]`, connectivity `Ping/CPU` | faction/colour/team/ready/move/kick/AI intents | per-slot part of `SetSlotInfo` | +| `LobbySlotsInterface` | `PlayerCount`, slot structure | — | `CreateSlotsUI` | +| `LobbyObserverListInterface` | `Observers`, connectivity | become-player / kick | `refreshObserverList` | +| `LobbyOptionsPanelInterface` | `GameOptions` (+ derived display) | open options/map/mods dialogs (host) | `RefreshOptionDisplayData` | +| `LobbyMapPreviewInterface` | `Scenario` | start-position intents | map preview wiring | +| `LobbyFooterInterface` | `CanLaunch`, `PlayersNotReady`, `IsHost` | launch, ready, leave | `RefreshButtonEnabledness` | +| `LobbyChatInterface` | chat model | chat controller | chat area | + +`LobbyInterface` is the **composition root** (build + lay out children, no +subscriptions) — exactly like `AutolobbyInterface`. + +--- + +## 7. Sub-dialogs as mini-MVC + +Each heavy dialog is its own small MVC, reading the model and calling controller +intents on apply (like chat's `config/` trio): + +| Dialog | Reads | On apply → controller | Reuse | +|---|---|---|---| +| Map select | map list, `Scenario` | `HostSetScenario` | `ResourceMapPreview` | +| Mods manager | `AvailableMods`, `GameMods` | `HostSetMods` | `ModsManager.lua` (port) | +| Unit manager | `GameOptions.RestrictedCategories` | `HostSetRestrictedCategories` | `UnitsManager.lua` (port) | +| Presets | preset storage | `ApplyGameSettings` via controller | `presets.lua` storage | +| Lobby prefs | `LobbyPrefsModel.Pending` | `LobbyPrefsController` Apply/Set | pattern from `ChatConfig` | + +--- + +## 8. Reuse vs rebuild + +- **Port mostly as-is**: `ResourceMapPreview`, `ModsManager`, `UnitsManager`, + `lobbyOptions.lua` (option *data*), faction selector, CPU benchmark, presets + storage, chat-MVC. Give each a model-backed input + a controller callback. +- **Rebuild against the model**: slot/player sync (`SetSlotInfo` + `HostUtils`), + the message dispatch, host-authority validation, connection tracking. This is + where the value and the risk concentrate. + +--- + +## 9. Bootstrap & lifecycle + +Order (from autolobby's lesson): in the entry wrapper (`lobby.lua`'s `CreateLobby` +equivalent) set up **models → views → instance**, so views subscribe to a live +model and the instance's `__init` seeds an existing model. On the connecting client, +the host's full `GameInfo` message seeds the model (one wire-handler that bulk-sets). + +Hot-reload: model + views + controller reload (own `__moduleinfo` hooks); the +`LobbyInstance` C object does not — keep it thin. Long-running threads (ping/CPU +loops) reload only on the next lobby, as in autolobby. + +A debug `OpenDebug()` (fake-populated model + views, no networking) lets the whole +lobby be inspected without a real game — the standalone-invocation principle. + +--- + +## 10. Domain → architecture mapping (A–Q from FEATURES.md) + +| Domain | Model | Controller | View / Dialog | +|---|---|---|---| +| A Lifecycle/entry | identity fields | `LobbyController` lifecycle + `OnHosting`/`OnConnect`/`OnGameLaunched` | entry wrapper, composition root | +| B Slots & players | `Players`, `ClosedSlots`, `SpawnMex`, derived `SlotMenu` | `PlayerController` (move/swap/kick/close) | `LobbySlotInterface` | +| C Per-player settings | `Players[slot]` fields, `AvailableColors` | `PlayerController` intents + host-ops | slot row controls | +| D AI | `Players[slot]` (AI) | `PlayerController` AddAI/RemoveAI | slot menu | +| E Observers | `Observers` | `PlayerController` convert/kick | `LobbyObserverListInterface` | +| F Host authority | `IsHost`, derived gates | all host-ops + `LobbyMessages.Accept` | footer enable/disable | +| G Game options | `GameOptions` | `OptionsController` | `LobbyOptionsPanelInterface`, options dialog | +| H Auto-teams/spawn | `AutoTeams`, `GameOptions.TeamSpawn` | `OptionsController` + launch-time resolves | options panel, map preview | +| I Map selection | `ScenarioFile`, `Scenario` | `HostSetScenario` | map select dialog + preview | +| J Mods | `GameMods`, conn `AvailableMods` | `ModsController` | mods manager dialog | +| K Unit restrictions | `GameOptions.RestrictedCategories` | `OptionsController` | unit manager dialog | +| L Chat | chat model | chat controller | `LobbyChatInterface` (reuse) | +| M Connectivity | `LobbyConnectivityModel` | `ConnectivityController` (CPU/ping/connection) | ping/CPU bars, observer/slot rows | +| N Compatibility/validation | conn `BadMap`, identity | `LobbyMessages.Accept`, launch checks | launch blockers, system chat | +| O Presets/rehost | `GameOptions`/`Players` snapshot | `LobbyController` apply/rehost | presets dialog | +| P Lobby prefs | `LobbyPrefsModel` | `LobbyPrefsController` | lobby prefs dialog | +| Q Other dialogs | (various) | host-ops | briefing, big preview, save/load | + +--- + +## 11. Open decisions & risks + +- **Migration**: parallel package (e.g. `/lua/ui/lobby/custom/`) behind a toggle vs + in-place strangler of `lobby.lua`. A parallel rebuild needs a parity period measured + against `FEATURES.md`/`USER_STORIES.md`; the strangler keeps it shippable throughout. +- **Reactivity granularity**: per-slot LazyVars (proposed) vs per-field. Per-slot is + simpler and enough; revisit only if a row's rebuild cost ever shows up. +- **Connectivity model boundary**: keep ping/CPU out of the synced game state (proposed) + to avoid churn dirtying game state — confirm no view needs them coupled. +- **Host migration / reconnect**: the current lobby's reconnect/keep-alive and + rehost edge-cases are the least-understood surface; inventory them deeply before + committing (the highest parity risk). +- **One model vs submodels**: proposed split is `LobbyModel` + `Connectivity` + + `Prefs` + reused chat. Resist over-splitting the core game state — it's one + consistent snapshot the host broadcasts. diff --git a/lua/ui/lobby/USER_STORIES.md b/lua/ui/lobby/USER_STORIES.md new file mode 100644 index 00000000000..29402d8143c --- /dev/null +++ b/lua/ui/lobby/USER_STORIES.md @@ -0,0 +1,154 @@ +# Custom-Game Lobby — User Stories + +Companion to [`FEATURES.md`](FEATURES.md). Same A–Q structure, so each story +traces back to a catalogued feature. Format: **As a ``, I want ``, so +that ``** — with *when/given* conditions where they matter. + +Roles: **Host**, **Player** (human in a slot), **Observer**, **Joining client** +(connecting peer), **System** (automatic / engine-driven). + +--- + +## A. Lifecycle & entry + +- As a **player**, I want to browse discovered games (map, name, player count), so that I can pick one to join. +- As a **host**, I want to create a game with a name and a starting map, so that others can find and join it. *(Given a valid nickname, 2–32 non-space chars.)* +- As a **player**, I want to join by direct IP:port, so that I can connect when discovery doesn't list the game. +- As a **joining client**, I want to receive the full lobby state on connect, so that I immediately see the current map, slots, options and mods. +- As a **player**, I want to leave the lobby with a confirmation prompt, so that I don't exit by accident; *and when launched via `/gpgnet`*, leaving quits to desktop instead of the menu. +- As a **host**, I want to rehost my last game, so that the previous map/options/mods and player slots are restored automatically. +- As a **player**, I want my lobby UI to tear down cleanly when the game launches, so that no stale threads or windows linger. + +## B. Slots & players + +- As a **host**, I want to open or close an empty slot, so that I control how many players can join. +- As a **host on an adaptive map**, I want to close a slot as "spawn-mex", so that the position yields mass instead of an ACU. +- As a **player**, I want to occupy an empty open slot, so that I can join the game; *when* I'm not already marked ready. +- As a **host**, I want to move a player to another slot or swap two players, so that I can arrange start positions and teams. +- As a **host**, I want to kick a player with an optional message, so that I can remove someone; *and* the message is remembered for next time. +- As a **player**, I want each slot row to show name, rating, country, faction, colour, team, ping, CPU and ready state, so that I can assess the lobby at a glance. +- As a **player**, I want a slot's controls to reflect who owns it (me / another player / host / AI) via colour and an appropriate right-click menu, so that I only see actions I'm allowed to take. + +## C. Per-player settings + +- As a **player**, I want to choose my faction (including Random), so that I play what I want; *given* the map allows it and I'm not ready. +- As a **player**, I want to pick a colour, so that I'm visually distinct; *when* the colour is free — taken colours are hidden and the host reverts conflicts. +- As a **player**, I want to set my team, so that I'm allied correctly; *when* AutoTeams is off and I'm not ready. +- As a **player**, I want to toggle Ready, so that I signal I'm set; *and when* ready, my own controls lock until I unready. +- As a **host**, I want to force a player not-ready, so that I can change settings that affect them. +- As a **player**, I want to pick my start position on the map preview, so that I control where I spawn; *when* spawn is fixed (host gets swap/assign tools otherwise). +- As a **player**, I want my last faction/colour remembered, so that I don't re-pick every lobby. + +## D. AI management (host) + +- As a **host**, I want to add an AI of a chosen personality to a slot, so that I can fill the game; *and* the AI is auto-ready. +- As a **host**, I want the AI personality list to include built-in and mod-provided AIs, so that all installed AIs are selectable. +- As a **host**, I want to remove an AI from a slot, so that I can free it. +- As a **host**, I want AIs to get a computed rating from cheat/build/map multipliers, so that game quality estimates stay meaningful. +- As a **host**, I want to set an AI's faction/colour/team and move/swap it like a player, so that I can arrange AI-inclusive setups. +- As a **host**, I want a quick "fill slots" action, so that I can populate remaining slots with AIs at once. + +## E. Observers + +- As a **joining client**, I want to enter as an observer when no slot is free or when I request it, so that I can still watch. +- As a **player**, I want to become an observer (or request it from the host), so that I can step out of the game without leaving. +- As an **observer**, I want to become a player / request a specific slot, so that I can join in; *given* a slot is available (host may evict an AI). +- As a **player**, I want to see the observer list with rating, ping and CPU, so that I know who's spectating. +- As a **host**, I want to kick an observer, so that I can manage spectators; *and* observers are auto-kicked at launch if observers are disallowed. +- As an **observer**, I want to use public and private chat (shown greyed), so that I can still communicate. + +## F. Host authority & validation + +- As a **host**, I want to be the single source of truth, so that all clients converge on one authoritative state. +- As a **player**, I want my own-slot changes applied optimistically and then confirmed by the host, so that the UI feels responsive but stays consistent. +- As a **host**, I want option-changing buttons disabled while any human is not ready, so that settings can't shift after people commit. +- As a **host**, I want the launch button enabled only when everyone is ready (or single-player / I'm observing), so that I can't start prematurely. +- As a **system**, I want to ignore a move request if the player became ready in the meantime, so that races don't corrupt slot state. + +## G. Game options (host) + +- As a **host**, I want to set the victory condition (assassination / decapitation / supremacy / annihilation / sandbox), so that the win rule fits the game. +- As a **host**, I want to configure share conditions (full / until-death / partial / transfer-to-killer / defectors / civilian-deserter) and share-unit-cap, so that resource/unit transfer on death matches our preference. +- As a **host**, I want to set unit cap, fog of war, game speed, no-rush timer, prebuilt units, civilians, score and cheats, so that the match rules are tuned. +- As a **host**, I want to mark the game unranked, so that it doesn't affect ratings. +- As a **host (multiplayer)**, I want to set timeouts and disconnection delay, so that disconnect handling fits the context (tournament/quick/regular). +- As a **host**, I want to set AI multipliers (cheat/build), omni, TML randomization and expansion limits, so that AIx difficulty is tuned. +- As a **host**, I want a "reset to defaults" action, so that I can clear all options at once (which also unreadies everyone). +- As a **host**, I want map-specific and mod-provided options surfaced alongside the standard ones, so that nothing is hidden. +- As a **player**, I want to see the active (and optionally only the changed) options, so that I understand the ruleset. + +## H. Auto-teams & spawn + +- As a **host**, I want AutoTeams modes (top/bottom, left/right, odd/even, manual), so that teams are assigned by position without manual fiddling. +- As a **host**, I want manual AutoTeams by clicking map markers, so that I can hand-place teams on random spawn. +- As a **host**, I want spawn variants (fixed, random, balanced/flex/reveal, penguin-autobalance), so that start placement matches the desired fairness/secrecy. +- As a **host on an adaptive map**, I want per-slot spawn-mex, so that closed positions still contribute economy. +- As a **system**, I want random factions/start spots/AI names and the ratings/clan tables resolved at launch, so that the final config is complete and fair. + +## I. Map selection (host) + +- As a **host**, I want to browse maps with a preview, so that I can choose a map informedly. +- As a **host**, I want to filter maps by player count, size, type (official/custom), AI markers and hide-obsolete, and search by name, so that I find the right map quickly; *and* my filters persist. +- As a **host**, I want a resource-aware preview (mass/hydro, water/cliff/buildable masks, start spots), so that I understand the map layout. +- As a **host**, I want to pick a random map, so that I can play something fresh. +- As a **player**, I want clear warnings when a map's files are missing, so that I'm not stuck on an unplayable map. + +## J. Mods manager + +- As a **host**, I want to enable/disable sim mods, so that gameplay-affecting mods apply to everyone; *and* doing so disables ranking. +- As a **player**, I want to enable UI mods locally, so that my client UI changes without affecting others. +- As a **player**, I want to browse, sort, search, expand/collapse and favorite mods, so that I can manage a large mod list; *and* my preferences persist. +- As a **player**, I want "activate favorites" in one click, so that I quickly enable my usual set (host: sim+UI, client: UI only). +- As a **player**, I want mod dependencies and conflicts handled automatically, so that I don't end up with a broken set. +- As a **host**, I want to see which peers lack a sim mod, so that missing-mod peers are flagged/auto-disabled or kicked rather than causing desyncs. + +## K. Unit restrictions (host) + +- As a **host**, I want to apply unit-restriction presets (tech levels, types, spam/snipe/anti-air/etc.), so that I can shape allowed units fast. +- As a **host**, I want to toggle individual units per faction, so that I can fine-tune beyond presets. +- As a **player**, I want a read-only view of the restrictions, so that I know what's banned. +- As a **host**, I want restrictions saved with presets and reflected in rating eligibility, so that custom rulesets are reusable and correctly rated. + +## L. Chat + +- As a **player/observer**, I want to send public chat, so that everyone in the lobby sees it. +- As a **player/observer**, I want to whisper via `/whisper` `/pm` `/w` `/private`, so that I can message one person privately. +- As a **player**, I want clear feedback on an unknown command or an invalid whisper target, so that I know it didn't send. +- As a **player**, I want join/leave/connection notices in chat, so that I'm aware of lobby changes. +- As a **player**, I want to click a name in chat to prefill a whisper, so that replying is quick. +- As a **player**, I want command history (up/down) and auto-scroll with a "new message" indicator, so that chatting is comfortable. +- As a **player**, I want ratings shown alongside names, so that I gauge the lobby's skill. + +## M. Connectivity & health + +- As a **player**, I want my CPU benchmarked (auto on connect, and on demand), so that others can see my performance; *and* the result is cached and re-broadcast by the host. +- As a **player**, I want CPU and ping bars per slot (ping shown when poor), so that I can spot weak links. +- As a **system**, I want to track per-peer connection status, so that the UI reflects who is fully connected. +- As a **host**, I want launch blocked until every important peer is connected to every other, so that the game doesn't start with broken links. +- As a **player**, I want a peer's disconnect to clear its slot and notify chat, so that the lobby stays accurate. +- As a **client**, I want a "host timed out — keep trying / give up" prompt, so that I can decide what to do on a stalled connection. + +## N. Compatibility & launch validation + +- As a **host**, I want to reject peers on a different game version/type/commit, so that desyncs are prevented (with a clear ejection reason). +- As a **player**, I want missing-map situations flagged (and a fallback used), so that the lobby communicates the problem instead of silently breaking. +- As a **host**, I want peers' available mods exchanged and validated, so that everyone has the required sim mods before launch. +- As a **host**, I want launch blocked with a clear reason (no players, host-as-observer when disallowed, observers present, missing connections), so that I know exactly what to fix. + +## O. Presets & rehost + +- As a **host**, I want to save the current setup (map, options, mods, restrictions, players) as a named preset, so that I can reuse it. +- As a **host**, I want to load/delete/rename presets, so that I can manage reusable configurations. +- As a **system**, I want the last game auto-saved as a preset at launch, so that rehost can restore it. +- As a **host**, I want rehost to reseat returning players to their prior slots (displacing AIs/others as needed), so that a replay setup reconstitutes quickly. + +## P. Lobby preferences (per user) + +- As a **player**, I want to choose the lobby background mode, chat font size, snowflake count, windowed mode, background stretch and chat colours, so that the lobby looks how I like; *and* changes apply immediately and persist. + +## Q. Other dialogs + +- As a **host**, I want a briefing dialog for scenarios that have one, so that players see the operation context. +- As a **player**, I want a large map preview (with terrain/resource masks), so that I can study the map closely. +- As a **host (skirmish)**, I want a save/load dialog, so that I can resume saved games. +- As a **host**, I want confirmation prompts for destructive actions (kick, reset options), so that I don't trigger them by mistake. diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md new file mode 100644 index 00000000000..eb3cb9a286a --- /dev/null +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -0,0 +1,51 @@ +# Custom Lobby — rebuild (work in progress) + +A ground-up, reactive-MVC rebuild of the custom-games lobby (the player-driven +counterpart to the matchmaker [`autolobby/`](../autolobby/CLAUDE.md)). It replaces +the organically-grown [`/lua/ui/lobby/lobby.lua`](../lobby.lua). + +> **Read first**, in order: +> - [`/lua/ui/CLAUDE.md`](/lua/ui/CLAUDE.md) — UI patterns (LazyVar/`Derive`, `__init`/`__post_init`, `TrashBag`). +> - [`/lua/ui/lobby/autolobby/CLAUDE.md`](../autolobby/CLAUDE.md) — the proven Model / Instance / Controller / components split this mirrors. +> - [`/lua/ui/lobby/TARGET_ARCHITECTURE.md`](../TARGET_ARCHITECTURE.md) — the design for this rebuild. +> - [`/lua/ui/lobby/FEATURES.md`](../FEATURES.md) / [`USER_STORIES.md`](../USER_STORIES.md) — the parity target. + +## Naming + +Folder `customlobby/`, class/module prefix `CustomLobby` — pairs with `autolobby/` / +`Autolobby*`. "Custom" is FAF's own term for non-matchmaker games (the autolobby is +the automated/matchmaker path). + +## Status — first vertical slice + +Built so far (no networking yet — driven by the model + a debug entry point): + +| File | Role | +|------|------| +| [CustomLobbyModel.lua](CustomLobbyModel.lua) | reactive model singleton. `Players` is an **array of per-slot LazyVars** so one slot's change re-fires only that row. Write helpers (`SetPlayer`, `SetPlayerField`, …) keep the copy-then-`Set` discipline. | +| [CustomLobbySlotInterface.lua](CustomLobbySlotInterface.lua) | one slot row; subscribes to `model.Players[slot]`, renders it (read-only for now). | +| [CustomLobbyInterface.lua](CustomLobbyInterface.lua) | composition root (no subscriptions of its own); lays out the slot rows; `OpenDebug()` / `SetupSingleton` / hot-reload. | + +Inspect it without networking: + +``` +UI_Lua import("/lua/ui/lobby/customlobby/customlobbyinterface.lua").OpenDebug() +``` + +`CloseDebug()` tears it down. For the real flow, `scripts/LaunchCustomLobby.ps1` +launches host + clients into the regular lobby. + +## Next slices (per TARGET_ARCHITECTURE.md) + +1. `CustomLobbyController` (free functions) + `CustomLobbyInstance` (thin `moho` shell) + + `CustomLobbyMessages` registry — the host-authority request→validate→broadcast flow. +2. Make slot controls interactive (faction/colour/team/ready → controller **intents**). +3. Options panel, map preview, observer list, footer, chat — each its own subscribing component. +4. Sub-dialogs (map select, mods, units, presets, prefs) as mini-MVC. + +## Rules (same as autolobby) + +- Components subscribe via `Derive`; they never write the model. +- The controller is the only writer, through the model write-helpers (never mutate a held table in place). +- A `Derive` handler must read its own LazyVar (`function(xLazy) self:OnX(xLazy()) end`). +- FAF is Lua 5.0 — no `%` operator; use `math.mod`. diff --git a/lua/ui/lobby/customlobby/CustomLobbyController.lua b/lua/ui/lobby/customlobby/CustomLobbyController.lua new file mode 100644 index 00000000000..31c79f2fd40 --- /dev/null +++ b/lua/ui/lobby/customlobby/CustomLobbyController.lua @@ -0,0 +1,264 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- Lobby logic for the custom lobby, as free functions (the autolobby pattern). The +-- engine instantiates the thin CustomLobbyInstance and forwards its callbacks here, +-- passing itself as `instance`. All state goes to CustomLobbyModel via its write +-- helpers; the host is authoritative. +-- +-- Barebones host-authority flow: +-- host : OnHosting -> seats itself in slot 1 +-- client : OnConnectionToHostEstablished -> SendData(host, AddPlayer) +-- host : ProcessAddPlayer -> seats the peer -> broadcasts SetPlayers (full snapshot) +-- everyone: ProcessSetPlayers -> applies the snapshot -> slot rows react +-- ready : RequestSetReady (intent) -> host applies + broadcasts SetPlayers +-- +-- See /lua/ui/lobby/TARGET_ARCHITECTURE.md § 5. + +local CustomLobbyModel = import("/lua/ui/lobby/customlobby/customlobbymodel.lua") + +--- The live lobby object, set by the first engine callback. UI-triggered intents +--- (RequestSetReady, …) reach the network through it without threading it everywhere. +---@type UICustomLobbyInstance | false +local LobbyInstance = false + +------------------------------------------------------------------------------- +--#region Helpers + +--- First empty player slot within the active slot count, or nil. +---@param model UICustomLobbyModel +---@return number | nil +local function FindFreeSlot(model) + for slot = 1, model.SlotCount() do + if not model.Players[slot]() then + return slot + end + end + return nil +end + +--- The slot a peer occupies, or nil. +---@param model UICustomLobbyModel +---@param ownerId UILobbyPeerId +---@return number | nil +local function FindSlotForOwner(model, ownerId) + for slot = 1, CustomLobbyModel.MaxSlots do + local player = model.Players[slot]() + if player and player.OwnerID == ownerId then + return slot + end + end + return nil +end + +--- A plain per-slot snapshot of all players (false for empty), for the wire. +---@param model UICustomLobbyModel +---@return table +local function GatherPlayers(model) + local players = {} + for slot = 1, CustomLobbyModel.MaxSlots do + players[slot] = model.Players[slot]() or false + end + return players +end + +--- Host broadcasts the authoritative player snapshot to everyone. +---@param instance UICustomLobbyInstance +---@param model UICustomLobbyModel +local function BroadcastPlayers(instance, model) + instance:BroadcastData({ Type = 'SetPlayers', Players = GatherPlayers(model) }) +end + +--- Builds the local player's options from the profile / engine name. +---@param instance UICustomLobbyInstance +---@return UICustomLobbyPlayer +function CreateLocalPlayer(instance) + local name = instance:GetLocalPlayerName() or import("/lua/user/prefs.lua").GetFromCurrentProfile('Name') or "Player" + return import("/lua/ui/lobby/lobbycomm.lua").GetDefaultPlayerOptions(name) +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Connection events + +--- Called when we become the host. +---@param instance UICustomLobbyInstance +function OnHosting(instance) + LobbyInstance = instance + + local model = CustomLobbyModel.GetSingleton() + local id = instance:GetLocalPlayerID() + model.LocalPeerId:Set(id) + model.HostID:Set(id) + model.IsHost:Set(true) + + local player = CreateLocalPlayer(instance) + player.OwnerID = id + player.StartSpot = 1 + player.PlayerColor = 1 + player.ArmyColor = 1 + CustomLobbyModel.SetPlayer(model, 1, player) +end + +--- Called when our connection to the host succeeds. +---@param instance UICustomLobbyInstance +---@param localId UILobbyPeerId +---@param localName string +---@param hostId UILobbyPeerId +function OnConnectionToHostEstablished(instance, localId, localName, hostId) + LobbyInstance = instance + + local model = CustomLobbyModel.GetSingleton() + model.LocalPeerId:Set(localId) + model.HostID:Set(hostId) + model.IsHost:Set(false) + + local player = CreateLocalPlayer(instance) + player.OwnerID = localId + player.PlayerName = localName or player.PlayerName + + instance:SendData(hostId, { Type = 'AddPlayer', PlayerOptions = player }) +end + +--- Called when the connection fails. +---@param instance UICustomLobbyInstance +---@param reason string +function OnConnectionFailed(instance, reason) + LOG("CustomLobby: connection failed: " .. tostring(reason)) +end + +--- Called when a peer disconnects. The host clears its slot and re-broadcasts. +---@param instance UICustomLobbyInstance +---@param peerName string +---@param uid UILobbyPeerId +function OnPeerDisconnected(instance, peerName, uid) + local model = CustomLobbyModel.GetSingleton() + if not model.IsHost() then + return + end + local slot = FindSlotForOwner(model, uid) + if slot then + CustomLobbyModel.ClearPlayer(model, slot) + BroadcastPlayers(instance, model) + end +end + +--- Called when the game launches. Barebones: not wired yet. +---@param instance UICustomLobbyInstance +function OnGameLaunched(instance) + LOG("CustomLobby: GameLaunched (launch flow not implemented yet)") +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Message handlers (run after Validate + Accept) + +--- Host seats a connecting peer and broadcasts the new snapshot. +---@param instance UICustomLobbyInstance +---@param data table +function ProcessAddPlayer(instance, data) + local model = CustomLobbyModel.GetSingleton() + local slot = FindFreeSlot(model) + if not slot then + WARN("CustomLobby: no free slot for joining peer " .. tostring(data.SenderID)) + return + end + + ---@type UICustomLobbyPlayer + local player = data.PlayerOptions + player.OwnerID = data.SenderID + player.StartSpot = slot + player.PlayerColor = slot + player.ArmyColor = slot + CustomLobbyModel.SetPlayer(model, slot, player) + + BroadcastPlayers(instance, model) +end + +--- Everyone applies the host's authoritative snapshot. +---@param instance UICustomLobbyInstance +---@param data table +function ProcessSetPlayers(instance, data) + local model = CustomLobbyModel.GetSingleton() + for slot = 1, CustomLobbyModel.MaxSlots do + model.Players[slot]:Set(data.Players[slot] or false) + end +end + +--- Host flips a peer's ready flag and re-broadcasts. +---@param instance UICustomLobbyInstance +---@param data table +function ProcessSetReady(instance, data) + local model = CustomLobbyModel.GetSingleton() + local slot = FindSlotForOwner(model, data.SenderID) + if slot then + CustomLobbyModel.SetPlayerField(model, slot, 'Ready', data.Ready and true or false) + BroadcastPlayers(instance, model) + end +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Intents (called by the UI) + +--- The local player toggles their ready flag. Host applies + broadcasts; a client +--- asks the host to. +---@param ready boolean +function RequestSetReady(ready) + local instance = LobbyInstance + if not instance then + return + end + + local model = CustomLobbyModel.GetSingleton() + if model.IsHost() then + local slot = FindSlotForOwner(model, model.LocalPeerId()) + if slot then + CustomLobbyModel.SetPlayerField(model, slot, 'Ready', ready and true or false) + BroadcastPlayers(instance, model) + end + else + instance:SendData(model.HostID(), { Type = 'SetReady', Ready = ready and true or false }) + end +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Debugging + +--- Hot-reload hook: re-imports this module so the instance's forwarders resolve to +--- the fresh functions. (Note: the stored `LobbyInstance` resets to false on reload +--- and is re-set on the next engine callback.) +function __moduleinfo.OnDirty() + ForkThread( + function() + WaitFrames(2) + import(__moduleinfo.name) + end + ) +end + +--#endregion diff --git a/lua/ui/lobby/customlobby/CustomLobbyInstance.lua b/lua/ui/lobby/customlobby/CustomLobbyInstance.lua new file mode 100644 index 00000000000..bdd6ac0e4af --- /dev/null +++ b/lua/ui/lobby/customlobby/CustomLobbyInstance.lua @@ -0,0 +1,175 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- The moho.lobby_methods object the engine instantiates (via InternalCreateLobby in +-- /lua/ui/lobby/lobby.lua). Thin shell: it validates/dispatches network traffic and +-- forwards behavioural callbacks to CustomLobbyController. Engine-ABI methods we +-- don't override (HostGame, JoinGame, GetLocalPlayerID, IsHost, ConnectToPeer, …) +-- are inherited from moho.lobby_methods. +-- +-- Like AutolobbyInstance, this object does NOT hot-reload — keep behaviour in the +-- controller. See /lua/ui/lobby/customlobby/CLAUDE.md. + +local MohoLobbyMethods = moho.lobby_methods + +local CustomLobbyMessages = import("/lua/ui/lobby/customlobby/customlobbymessages.lua").CustomLobbyMessages +local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") + +---@class UICustomLobbyInstance : moho.lobby_methods +---@field Trash TrashBag +CustomLobbyInstance = Class(MohoLobbyMethods) { + + ---@param self UICustomLobbyInstance + __init = function(self) + self.Trash = TrashBag() + end, + + --------------------------------------------------------------------------- + --#region Engine interface (validated wrappers) + + --- Broadcasts data to all connected peers, after validating it. + ---@param self UICustomLobbyInstance + ---@param data table + BroadcastData = function(self, data) + local message = CustomLobbyMessages[data.Type] + if not message then + WARN("CustomLobby: blocked broadcast of unknown message type " .. tostring(data.Type)) + return + end + if not message.Validate(self, data) then + WARN("CustomLobby: blocked broadcast of malformed message " .. tostring(data.Type)) + return + end + return MohoLobbyMethods.BroadcastData(self, data) + end, + + --- Sends data to a single peer, after validating it. + ---@param self UICustomLobbyInstance + ---@param peerId UILobbyPeerId + ---@param data table + SendData = function(self, peerId, data) + local message = CustomLobbyMessages[data.Type] + if not message then + WARN("CustomLobby: blocked send of unknown message type " .. tostring(data.Type)) + return + end + if not message.Validate(self, data) then + WARN("CustomLobby: blocked send of malformed message " .. tostring(data.Type)) + return + end + return MohoLobbyMethods.SendData(self, peerId, data) + end, + + --#endregion + + --------------------------------------------------------------------------- + --#region Engine callbacks (forwarded to the controller) + + ---@param self UICustomLobbyInstance + Hosting = function(self) + CustomLobbyController.OnHosting(self) + end, + + ---@param self UICustomLobbyInstance + ---@param localId UILobbyPeerId + ---@param localName string + ---@param hostId UILobbyPeerId + ConnectionToHostEstablished = function(self, localId, localName, hostId) + CustomLobbyController.OnConnectionToHostEstablished(self, localId, localName, hostId) + end, + + ---@param self UICustomLobbyInstance + ---@param reason string + ConnectionFailed = function(self, reason) + CustomLobbyController.OnConnectionFailed(self, reason) + end, + + ---@param self UICustomLobbyInstance + ---@param peerId UILobbyPeerId + ---@param peerConnectedTo UILobbyPeerId[] + EstablishedPeers = function(self, peerId, peerConnectedTo) + -- barebones: connectivity matrix not modelled yet + end, + + ---@param self UICustomLobbyInstance + ---@param peerName string + ---@param uid UILobbyPeerId + PeerDisconnected = function(self, peerName, uid) + CustomLobbyController.OnPeerDisconnected(self, peerName, uid) + end, + + ---@param self UICustomLobbyInstance + GameLaunched = function(self) + CustomLobbyController.OnGameLaunched(self) + end, + + ---@param self UICustomLobbyInstance + ---@param reason string + Ejected = function(self, reason) + LOG("CustomLobby: ejected: " .. tostring(reason)) + end, + + ---@param self UICustomLobbyInstance + ---@param text string + SystemMessage = function(self, text) + LOG("CustomLobby system: " .. tostring(text)) + end, + + ---@param self UICustomLobbyInstance + GameConfigRequested = function(self) + end, + + ---@param self UICustomLobbyInstance + ---@param reasonKey string + LaunchFailed = function(self, reasonKey) + WARN("CustomLobby: launch failed: " .. tostring(reasonKey)) + end, + + --- Validates, authorises and routes an incoming message. + ---@param self UICustomLobbyInstance + ---@param data table + DataReceived = function(self, data) + local message = CustomLobbyMessages[data.Type] + if not message then + WARN("CustomLobby: ignoring unknown message type " .. tostring(data.Type) .. " from " .. tostring(data.SenderID)) + return + end + if not message.Validate(self, data) then + WARN("CustomLobby: ignoring malformed message " .. tostring(data.Type) .. " from " .. tostring(data.SenderID)) + return + end + if not message.Accept(self, data) then + WARN("CustomLobby: rejected message " .. tostring(data.Type) .. " from " .. tostring(data.SenderID)) + return + end + message.Handler(self, data) + end, + + --- Destroys the C-object and the trash bag. + ---@param self UICustomLobbyInstance + Destroy = function(self) + self.Trash:Destroy() + return MohoLobbyMethods.Destroy(self) + end, + + --#endregion +} diff --git a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua new file mode 100644 index 00000000000..ea65e78be9d --- /dev/null +++ b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua @@ -0,0 +1,215 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- Composition root for the custom-games lobby. It builds and lays out the children +-- and holds NO model subscriptions of its own — each child subscribes to the model +-- itself (see /lua/ui/lobby/TARGET_ARCHITECTURE.md). For now it lays out the slot +-- rows; the options panel, map preview, observer list, footer and chat are added in +-- later slices. + +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 +local CustomLobbyModel = import("/lua/ui/lobby/customlobby/customlobbymodel.lua") +local CustomLobbySlotInterface = import("/lua/ui/lobby/customlobby/customlobbyslotinterface.lua") + +local LazyVarDerive = import("/lua/lazyvar.lua").Derive + +local Layouter = LayoutHelpers.ReusedLayoutFor + +local SlotHeight = 24 +local PanelWidth = 520 + +---@class UICustomLobbyInterface : Group +---@field Trash TrashBag +---@field Background Bitmap +---@field Title Text +---@field SlotsPanel Group +---@field Slots UICustomLobbySlotInterface[] +---@field SlotCountObserver LazyVar +local CustomLobbyInterface = Class(Group) { + + ---@param self UICustomLobbyInterface + ---@param parent Control + __init = function(self, parent) + Group.__init(self, parent, "CustomLobbyInterface") + + self.Trash = TrashBag() + + self.Background = Bitmap(self) + self.Background:SetSolidColor('ff0a0a0a') + self.Background:DisableHitTest() + + self.Title = UIUtil.CreateText(self, "Custom Lobby (barebones)", 20, UIUtil.titleFont) + + self.SlotsPanel = Group(self, "CustomLobbySlotsPanel") + + -- One row per possible slot; the SlotCount observer reveals the active ones. + self.Slots = {} + for slot = 1, CustomLobbyModel.MaxSlots do + self.Slots[slot] = CustomLobbySlotInterface.Create(self.SlotsPanel, slot) + end + + local model = CustomLobbyModel.GetSingleton() + self.SlotCountObserver = self.Trash:Add( + LazyVarDerive(model.SlotCount, function(slotCountLazy) + self:OnSlotCountChanged(slotCountLazy()) + end)) + end, + + ---@param self UICustomLobbyInterface + ---@param parent Control + __post_init = function(self, parent) + Layouter(self):Fill(parent):End() + Layouter(self.Background):Fill(self):End() + + Layouter(self.Title):AtLeftTopIn(self, 40, 36):End() + + Layouter(self.SlotsPanel) + :AtLeftTopIn(self, 40, 80) + :Width(PanelWidth) + :Height(CustomLobbyModel.MaxSlots * SlotHeight) + :End() + + -- stack the rows top-to-bottom inside the panel via sibling anchoring + for slot = 1, CustomLobbyModel.MaxSlots do + local row = self.Slots[slot] + local builder = Layouter(row) + :AtLeftIn(self.SlotsPanel) + :AtRightIn(self.SlotsPanel) + :Height(SlotHeight) + if slot == 1 then + builder:AtTopIn(self.SlotsPanel) + else + builder:AnchorToBottom(self.Slots[slot - 1], 0) + end + builder:End() + end + end, + + --- Shows the active slots (1..count) and hides the rest. + ---@param self UICustomLobbyInterface + ---@param count number + OnSlotCountChanged = function(self, count) + for slot = 1, CustomLobbyModel.MaxSlots do + if slot <= count then + self.Slots[slot]:Show() + else + self.Slots[slot]:Hide() + end + end + end, + + ---@param self UICustomLobbyInterface + OnDestroy = function(self) + self.Trash:Destroy() + end, +} + +------------------------------------------------------------------------------- +--#region Singleton + +--- A trashbag destroyed on reload. +local ModuleTrash = TrashBag() + +---@type UICustomLobbyInterface | false +local Instance = false + +--- Returns the interface singleton, creating it on first access. +---@return UICustomLobbyInterface +function GetSingleton() + if Instance then + return Instance + end + Instance = CustomLobbyInterface(GetFrame(0)) + ModuleTrash:Add(Instance) + return Instance +end + +--- Allocates a fresh interface singleton, replacing any existing one. +---@return UICustomLobbyInterface +function SetupSingleton() + if Instance then + Instance:Destroy() + end + Instance = CustomLobbyInterface(GetFrame(0)) + ModuleTrash:Add(Instance) + return Instance +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Debugging + +--- Standalone entry point: mounts the lobby against a model populated with fake +--- players so the UI can be inspected without networking. From the console: +--- `UI_Lua import("/lua/ui/lobby/customlobby/customlobbyinterface.lua").OpenDebug()` +function OpenDebug() + local slotCount = 6 + local model = CustomLobbyModel.SetupSingleton(slotCount) + model.LocalPeerId:Set("1") + model.IsHost:Set(true) + + -- four players in the first four slots, last two left open + for slot = 1, 4 do + CustomLobbyModel.SetPlayer(model, slot, { + PlayerName = "Player " .. slot, + OwnerID = tostring(slot), + Human = slot ~= 4, -- slot 4 is an AI, to show the AI colour + Faction = math.mod(slot - 1, 4) + 1, + PlayerColor = slot, + ArmyColor = slot, + Team = math.mod(slot - 1, 2) + 2, -- alternating teams 1/2 + StartSpot = slot, + Ready = slot <= 2, + PL = 1000 + slot * 100, + AIPersonality = slot == 4 and "rush" or nil, + }) + end + + SetupSingleton() +end + +--- Tears down the debug lobby. +function CloseDebug() + ModuleTrash:Destroy() + Instance = false +end + +--- Called by the module manager when this module is reloaded. +---@param newModule any +function __moduleinfo.OnReload(newModule) + if Instance then + newModule.SetupSingleton() + end +end + +--- Called by the module manager when this module becomes dirty. +function __moduleinfo.OnDirty() + ModuleTrash:Destroy() + import(__moduleinfo.name) +end + +--#endregion diff --git a/lua/ui/lobby/customlobby/CustomLobbyMessages.lua b/lua/ui/lobby/customlobby/CustomLobbyMessages.lua new file mode 100644 index 00000000000..e4a24ac1c8b --- /dev/null +++ b/lua/ui/lobby/customlobby/CustomLobbyMessages.lua @@ -0,0 +1,93 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- The valid peer-to-peer messages for the custom lobby. Mirrors the autolobby's +-- AutolobbyMessages registry: each entry has Validate (drop nonsense), Accept (drop +-- unauthorised) and Handler (route to the controller). The instance's DataReceived / +-- BroadcastData / SendData run these before anything happens. +-- +-- This is intentionally a SMALL set for the barebones lobby: enough to see players +-- join and to round-trip one interactive change (ready). + +local CustomLobbyModel = import("/lua/ui/lobby/customlobby/customlobbymodel.lua") +local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") + +---@param lobby UICustomLobbyInstance +---@return boolean +local function AmHost(lobby) + return lobby:IsHost() +end + +---@param lobby UICustomLobbyInstance +---@param data table +---@return boolean +local function IsFromHost(lobby, data) + return data.SenderID == CustomLobbyModel.GetSingleton().HostID() +end + +---@class UICustomLobbyMessageHandler +---@field Validate fun(lobby: UICustomLobbyInstance, data: table): boolean +---@field Accept fun(lobby: UICustomLobbyInstance, data: table): boolean +---@field Handler fun(lobby: UICustomLobbyInstance, data: table) + +---@type table +CustomLobbyMessages = { + + -- A connecting client announces itself to the host. + AddPlayer = { + Validate = function(lobby, data) + return data.PlayerOptions ~= nil + end, + Accept = function(lobby, data) + return AmHost(lobby) + end, + Handler = function(lobby, data) + CustomLobbyController.ProcessAddPlayer(lobby, data) + end, + }, + + -- The host's authoritative snapshot of all slots, broadcast on any change. + SetPlayers = { + Validate = function(lobby, data) + return type(data.Players) == 'table' + end, + Accept = function(lobby, data) + return IsFromHost(lobby, data) + end, + Handler = function(lobby, data) + CustomLobbyController.ProcessSetPlayers(lobby, data) + end, + }, + + -- A client asks the host to flip its ready flag. + SetReady = { + Validate = function(lobby, data) + return type(data.Ready) == 'boolean' + end, + Accept = function(lobby, data) + return AmHost(lobby) + end, + Handler = function(lobby, data) + CustomLobbyController.ProcessSetReady(lobby, data) + end, + }, +} diff --git a/lua/ui/lobby/customlobby/CustomLobbyModel.lua b/lua/ui/lobby/customlobby/CustomLobbyModel.lua new file mode 100644 index 00000000000..ebbf7caf790 --- /dev/null +++ b/lua/ui/lobby/customlobby/CustomLobbyModel.lua @@ -0,0 +1,230 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- Reactive state singleton for the (custom-games) lobby — the single source of truth +-- that the controller writes to and the components observe via `Derive`. It holds no +-- UI references and no networking. +-- +-- This is the gameInfo replacement from TARGET_ARCHITECTURE.md. Players live as an +-- array of per-slot LazyVars so a change to one slot only re-fires that slot's row +-- (replacing the legacy `SetSlotInfo` whole-row sledgehammer). +-- +-- Patterns mirror /lua/ui/lobby/autolobby/AutolobbyModel.lua and +-- /lua/ui/game/chat/ChatModel.lua. See /lua/ui/CLAUDE.md for the reactivity rules +-- (notably: never mutate a held table in place — copy then `:Set`). + +local Create = import("/lua/lazyvar.lua").Create + +--- Maximum number of player slots the engine supports. +MaxSlots = 16 + +------------------------------------------------------------------------------- +--#region Shapes + +--- A player (human or AI) occupying a slot. Mirrors the fields the legacy lobby's +--- PlayerData carries; trimmed to what the UI reads. +---@class UICustomLobbyPlayer +---@field PlayerName string +---@field OwnerID UILobbyPeerId +---@field Human boolean +---@field Faction number # 1=UEF 2=Aeon 3=Cybran 4=Seraphim 5=Random +---@field PlayerColor number +---@field ArmyColor number +---@field Team number # 1 = no team (FFA), 2..9 = teams 1..8 +---@field StartSpot number +---@field Ready boolean +---@field PL? number # rating +---@field MEAN? number +---@field DEV? number +---@field NG? number # number of games +---@field PlayerClan? string +---@field Country? string +---@field AIPersonality? string + +--#endregion + +------------------------------------------------------------------------------- +--#region Reactive model + +--- Reactive lobby-state singleton. +---@class UICustomLobbyModel +---@field SlotCount LazyVar # player slots the current map supports +---@field Players LazyVar[] # one LazyVar per slot (1..MaxSlots); false = empty +---@field Observers LazyVar # observer list +---@field ClosedSlots LazyVar> +---@field SpawnMex LazyVar> +---@field AutoTeams LazyVar> +---@field GameOptions LazyVar +---@field GameMods LazyVar
+---@field ScenarioFile LazyVar +---@field LocalPeerId LazyVar +---@field HostID LazyVar +---@field IsHost LazyVar + +---@type UICustomLobbyModel | nil +local ModelInstance = nil + +--- Allocates a fresh model singleton, replacing any existing instance. +---@param slotCount? number +---@return UICustomLobbyModel +function SetupSingleton(slotCount) + slotCount = slotCount or 8 + + local players = {} + for slot = 1, MaxSlots do + players[slot] = Create(false) + end + + ---@type UICustomLobbyModel + local model = { + SlotCount = Create(slotCount), + Players = players, + Observers = Create({}), + ClosedSlots = Create({}), + SpawnMex = Create({}), + AutoTeams = Create({}), + GameOptions = Create({}), + GameMods = Create({}), + ScenarioFile = Create(false), + LocalPeerId = Create("-1"), + HostID = Create("-1"), + IsHost = Create(false), + } + + ModelInstance = model + return model +end + +--- Returns the model singleton, creating it on first access. +---@return UICustomLobbyModel +function GetSingleton() + if not ModelInstance then + SetupSingleton() + end + return ModelInstance --[[@as UICustomLobbyModel]] +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Write helpers +-- +-- The synced tables are LazyVar values, so a write must build a NEW table/value and +-- `:Set` it — mutating in place never marks dependents dirty (see /lua/ui/CLAUDE.md +-- § 2). These helpers keep that discipline in one place so the controller can't get +-- it wrong. + +--- Places (or replaces) a player at a slot. +---@param model UICustomLobbyModel +---@param slot number +---@param player UICustomLobbyPlayer +function SetPlayer(model, slot, player) + model.Players[slot]:Set(player) +end + +--- Empties a slot. +---@param model UICustomLobbyModel +---@param slot number +function ClearPlayer(model, slot) + model.Players[slot]:Set(false) +end + +--- Sets a single field on the player in a slot (copy-then-Set on that slot only). +---@param model UICustomLobbyModel +---@param slot number +---@param key string +---@param value any +function SetPlayerField(model, slot, key, value) + local current = model.Players[slot]() + if not current then + return + end + local player = table.copy(current) + player[key] = value + model.Players[slot]:Set(player) +end + +--- Sets a single game option (copy-then-Set). +---@param model UICustomLobbyModel +---@param key string +---@param value any +function SetGameOption(model, key, value) + local options = table.copy(model.GameOptions()) + options[key] = value + model.GameOptions:Set(options) +end + +--- Sets the closed flag for a slot (copy-then-Set). +---@param model UICustomLobbyModel +---@param slot number +---@param closed boolean +function SetClosed(model, slot, closed) + local closedSlots = table.copy(model.ClosedSlots()) + closedSlots[slot] = closed + model.ClosedSlots:Set(closedSlots) +end + +--- Sets the scenario file. +---@param model UICustomLobbyModel +---@param scenarioFile FileName | false +function SetScenario(model, scenarioFile) + model.ScenarioFile:Set(scenarioFile) +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Debugging + +--- Hot-reload hook: rebuilds the singleton on the new module and copies the current +--- raw LazyVar values across so observers don't see a state reset. +---@param newModule any +function __moduleinfo.OnReload(newModule) + if ModelInstance then + local handle = newModule.SetupSingleton(ModelInstance.SlotCount()) + for slot = 1, MaxSlots do + handle.Players[slot]:Set(ModelInstance.Players[slot]()) + end + handle.Observers:Set(ModelInstance.Observers()) + handle.ClosedSlots:Set(ModelInstance.ClosedSlots()) + handle.SpawnMex:Set(ModelInstance.SpawnMex()) + handle.AutoTeams:Set(ModelInstance.AutoTeams()) + handle.GameOptions:Set(ModelInstance.GameOptions()) + handle.GameMods:Set(ModelInstance.GameMods()) + handle.ScenarioFile:Set(ModelInstance.ScenarioFile()) + handle.LocalPeerId:Set(ModelInstance.LocalPeerId()) + handle.HostID:Set(ModelInstance.HostID()) + handle.IsHost:Set(ModelInstance.IsHost()) + end +end + +--- Hot-reload hook: re-imports this module after a couple of frames. +function __moduleinfo.OnDirty() + ForkThread( + function() + WaitFrames(2) + import(__moduleinfo.name) + end + ) +end + +--#endregion diff --git a/lua/ui/lobby/customlobby/CustomLobbySlotInterface.lua b/lua/ui/lobby/customlobby/CustomLobbySlotInterface.lua new file mode 100644 index 00000000000..bdbfe19d6ac --- /dev/null +++ b/lua/ui/lobby/customlobby/CustomLobbySlotInterface.lua @@ -0,0 +1,175 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- A single slot row. Subscribes to its own slot LazyVar (`model.Players[slot]`) and +-- renders it; a change to one slot re-fires only this row. Read-only for now — +-- interactive controls (faction/colour/team/ready) call controller intents in a +-- later slice. See /lua/ui/lobby/TARGET_ARCHITECTURE.md § 6. + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") +local GameColors = import("/lua/gamecolors.lua").GameColors + +local Group = import("/lua/maui/group.lua").Group +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap +local CustomLobbyModel = import("/lua/ui/lobby/customlobby/customlobbymodel.lua") +local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") + +local LazyVarDerive = import("/lua/lazyvar.lua").Derive + +local Layouter = LayoutHelpers.ReusedLayoutFor + +--- Short faction label for a faction index (5 = Random has no factions.lua entry). +---@param faction number +---@return string +local function FactionLabel(faction) + local factions = import("/lua/factions.lua").Factions + local data = factions[faction] + if data then + return data.DisplayName or data.Key or tostring(faction) + end + return "Random" +end + +---@class UICustomLobbySlotInterface : Group +---@field Trash TrashBag +---@field SlotIndex number +---@field Background Bitmap +---@field ClickArea Bitmap +---@field SlotNumber Text +---@field ColorSwatch Bitmap +---@field Name Text +---@field Faction Text +---@field Team Text +---@field Ready Text +---@field PlayerObserver LazyVar +---@field CurrentPlayer UICustomLobbyPlayer | false +local CustomLobbySlotInterface = Class(Group) { + + ---@param self UICustomLobbySlotInterface + ---@param parent Control + ---@param slotIndex number + __init = function(self, parent, slotIndex) + Group.__init(self, parent, "CustomLobbySlot" .. tostring(slotIndex)) + + self.Trash = TrashBag() + self.SlotIndex = slotIndex + self.CurrentPlayer = false + + self.Background = Bitmap(self) + self.Background:SetSolidColor('22ffffff') + self.Background:DisableHitTest() + + self.SlotNumber = UIUtil.CreateText(self, tostring(slotIndex), 14, UIUtil.bodyFont) + self.ColorSwatch = Bitmap(self) + self.ColorSwatch:SetSolidColor('00000000') + self.Name = UIUtil.CreateText(self, "", 14, UIUtil.bodyFont) + self.Faction = UIUtil.CreateText(self, "", 14, UIUtil.bodyFont) + self.Team = UIUtil.CreateText(self, "", 14, UIUtil.bodyFont) + self.Ready = UIUtil.CreateText(self, "", 14, UIUtil.bodyFont) + + -- transparent overlay that catches clicks on the whole row; for now a click + -- on your own slot toggles ready (the one interactive message this far) + self.ClickArea = Bitmap(self) + self.ClickArea:SetSolidColor('00000000') + self.ClickArea.HandleEvent = function(control, event) + if event.Type == 'ButtonPress' then + self:OnClicked() + return true + end + return false + end + + local model = CustomLobbyModel.GetSingleton() + self.PlayerObserver = self.Trash:Add( + LazyVarDerive(model.Players[slotIndex], function(playerLazy) + self:OnPlayerChanged(playerLazy()) + end)) + end, + + ---@param self UICustomLobbySlotInterface + ---@param parent Control + __post_init = function(self, parent) + Layouter(self.Background):Fill(self):End() + Layouter(self.ClickArea):Fill(self):Over(self, 10):End() + + Layouter(self.SlotNumber):AtLeftIn(self, 6):AtVerticalCenterIn(self):End() + Layouter(self.ColorSwatch):AnchorToRight(self.SlotNumber, 8):AtVerticalCenterIn(self):Width(14):Height(14):End() + Layouter(self.Name):AnchorToRight(self.ColorSwatch, 8):AtVerticalCenterIn(self):End() + Layouter(self.Ready):AtRightIn(self, 8):AtVerticalCenterIn(self):End() + Layouter(self.Team):AnchorToLeft(self.Ready, 12):AtVerticalCenterIn(self):End() + Layouter(self.Faction):AnchorToLeft(self.Team, 12):AtVerticalCenterIn(self):End() + end, + + --- Renders the slot from its player (or the empty state). + ---@param self UICustomLobbySlotInterface + ---@param player UICustomLobbyPlayer | false + OnPlayerChanged = function(self, player) + self.CurrentPlayer = player + + if not player then + self.ColorSwatch:SetSolidColor('00000000') + self.Name:SetText("- open -") + self.Name:SetColor('ff888888') + self.Faction:SetText("") + self.Team:SetText("") + self.Ready:SetText("") + return + end + + local colorHex = GameColors.PlayerColors[player.PlayerColor] or 'ffffffff' + self.ColorSwatch:SetSolidColor(colorHex) + + self.Name:SetText(player.PlayerName or "?") + self.Name:SetColor(player.Human and 'ffffffff' or 'ffd9c97a') + + self.Faction:SetText(FactionLabel(player.Faction)) + self.Team:SetText(player.Team and player.Team > 1 and ("T" .. (player.Team - 1)) or "-") + self.Ready:SetText(player.Ready and "ready" or "") + self.Ready:SetColor(player.Ready and 'ff7ad97a' or 'ff888888') + end, + + --- Click on the row. For now, clicking your own slot toggles your ready flag + --- (a controller intent — the host applies and broadcasts it). + ---@param self UICustomLobbySlotInterface + OnClicked = function(self) + local player = self.CurrentPlayer + if not player then + return + end + if player.OwnerID == CustomLobbyModel.GetSingleton().LocalPeerId() then + CustomLobbyController.RequestSetReady(not player.Ready) + end + end, + + ---@param self UICustomLobbySlotInterface + OnDestroy = function(self) + self.Trash:Destroy() + end, +} + +---@param parent Control +---@param slotIndex number +---@return UICustomLobbySlotInterface +Create = function(parent, slotIndex) + return CustomLobbySlotInterface(parent, slotIndex) +end diff --git a/lua/ui/lobby/lobby-old.lua b/lua/ui/lobby/lobby-old.lua new file mode 100644 index 00000000000..a76654e3527 --- /dev/null +++ b/lua/ui/lobby/lobby-old.lua @@ -0,0 +1,7386 @@ +--***************************************************************************** +--* File: lua/modules/ui/lobby/lobby.lua +--* Author: Chris Blackwell +--* Summary: Game selection UI +--* +--* Copyright © 2005 Gas Powered Games, Inc. All rights reserved. +--***************************************************************************** +local GameVersion = import("/lua/version.lua").GetVersion +local UIUtil = import("/lua/ui/uiutil.lua") +local MenuCommon = import("/lua/ui/menus/menucommon.lua") +local Prefs = import("/lua/user/prefs.lua") +local MapUtil = import("/lua/ui/maputil.lua") +local Group = import("/lua/maui/group.lua").Group +local RadioButton = import("/lua/ui/controls/radiobutton.lua").RadioButton +local ResourceMapPreview = import("/lua/ui/controls/resmappreview.lua").ResourceMapPreview +local Popup = import("/lua/ui/controls/popups/popup.lua").Popup +local Slider = import("/lua/maui/slider.lua").Slider +local PlayerData = import("/lua/ui/lobby/data/playerdata.lua").PlayerData +local GameInfo = import("/lua/ui/lobby/data/gamedata.lua") +local WatchedValueArray = import("/lua/ui/lobby/data/watchedvalue/watchedvaluearray.lua").WatchedValueArray +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap +local Button = import("/lua/maui/button.lua").Button +local ToggleButton = import("/lua/ui/controls/togglebutton.lua").ToggleButton +local Edit = import("/lua/maui/edit.lua").Edit +local LobbyComm = import("/lua/ui/lobby/lobbycomm.lua") +local Tooltip = import("/lua/ui/game/tooltip.lua") +local Mods = import("/lua/mods.lua") +local FactionData = import("/lua/factions.lua") +local TextArea = import("/lua/ui/controls/textarea.lua").TextArea +local Presets = import("/lua/ui/lobby/presets.lua") + +local utils = import("/lua/system/utils.lua") + +local Trueskill = import("/lua/ui/lobby/trueskill.lua") +local Player = Trueskill.Player +local Rating = Trueskill.Rating +local ModBlacklist = import("/etc/faf/blacklist.lua").Blacklist +local Teams = Trueskill.Teams +local EscapeHandler = import("/lua/ui/dialogs/eschandler.lua") +local CountryTooltips = import("/lua/ui/help/tooltips-country.lua").tooltip +local SetUtils = import("/lua/system/setutils.lua") +local JSON = import("/lua/system/dkson.lua").json +local UTF = import("/lua/utf.lua") +-- Uveso - aitypes inside aitypes.lua are now also available as a function. +local aitypes +local AIKeys = {} +local AIStrings = {} +local AITooltips = {} + + + +function GetAITypes() + AIKeys = {} + AIStrings = {} + AITooltips = {} + aitypes = import("/lua/ui/lobby/aitypes.lua").GetAItypes() + for _, aidata in aitypes do + table.insert(AIKeys, aidata.key) + table.insert(AIStrings, aidata.name) + table.insert(AITooltips, 'aitype_'..aidata.key) + end +end +GetAITypes() + +--This is a special table that allows us to pass data to blueprints.lua, before the rest of the game is loaded. +-- do not use this for anything that doesnt do blueprint modding, use GameOptions for that instead, which will load it into sim. + +local IsSyncReplayServer = false + +local AddUnicodeCharToEditText = import("/lua/utf.lua").AddUnicodeCharToEditText + +if HasCommandLineArg("/syncreplay") and HasCommandLineArg("/gpgnet") then + IsSyncReplayServer = true +end + +local globalOpts = import("/lua/ui/lobby/lobbyoptions.lua").globalOpts +local teamOpts = import("/lua/ui/lobby/lobbyoptions.lua").teamOptions +local AIOpts = import("/lua/ui/lobby/lobbyoptions.lua").AIOpts +local gameColors = import("/lua/gamecolors.lua").GameColors + +local numOpenSlots = LobbyComm.maxPlayerSlots + +-- Add lobby options from AI mods +function ImportModAIOptions() + local simMods = import("/lua/mods.lua").AllMods() + local OptionData + local alreadyStored + for Index, ModData in simMods do + if exists(ModData.location..'/lua/AI/LobbyOptions/lobbyoptions.lua') then + OptionData = import(ModData.location..'/lua/AI/LobbyOptions/lobbyoptions.lua').AIOpts + for s, t in OptionData do + -- check, if we have this option already stored + alreadyStored = false + for k, v in AIOpts do + if v.key == t.key then + alreadyStored = true + break + end + end + if not alreadyStored then + table.insert(AIOpts, t) + end + end + end + end +end +ImportModAIOptions() + +-- Maps faction identifiers to their names. +local FACTION_NAMES = {[1] = "uef", [2] = "aeon", [3] = "cybran", [4] = "seraphim", [5] = "random" } + +local rehostPlayerOptions = {} -- Player options loaded from preset, used for rehosting + +local formattedOptions = {} +local nonDefaultFormattedOptions = {} +local LrgMap = false + +local HostUtils +local mapPreviewSlotSwapFrom = 0 +local mapPreviewSlotSwap = false + +teamIcons = { + '/lobby/team_icons/team_no_icon.dds', + '/lobby/team_icons/team_1_icon.dds', + '/lobby/team_icons/team_2_icon.dds', + '/lobby/team_icons/team_3_icon.dds', + '/lobby/team_icons/team_4_icon.dds', + '/lobby/team_icons/team_5_icon.dds', + '/lobby/team_icons/team_6_icon.dds', + '/lobby/team_icons/team_7_icon.dds', + '/lobby/team_icons/team_8_icon.dds', +} + +DebugEnabled = Prefs.GetFromCurrentProfile('LobbyDebug') or '' +local HideDefaultOptions = Prefs.GetFromCurrentProfile('LobbyHideDefaultOptions') == 'true' + +local connectedTo = {} -- by UID +CurrentConnection = {} -- by Name +ConnectionEstablished = {} -- by Name +ConnectedWithProxy = {} -- by UID + + +allAvailableFactionsList = {} + +local availableMods = {} -- map from peer ID to set of available mods; each set is a map from "mod id"->true +local selectedSimMods = {} -- Similar map for activated sim mods +local selectedUIMods = {} -- Similar map for activated UI mods + +local CPU_Benchmarks = {} -- Stores CPU benchmark data + +local function parseCommandlineArguments() + -- Set of all possible command line option keys. + -- The client sometimes gives us empty-string as some args, which gets interpreted as that key + -- having as value the name of the next key. This set lets us interpret that case using the + -- default option. + local CMDLINE_ARGUMENT_KEYS = { + ["/init"] = true, + ["/country"] = true, + ["/numgames"] = true, + ["/mean"] = true, + ["/clan"] = true, + ["/deviation"] = true, + ["/joincustom"] = true, + ["/gpgnet"] = true, + } + + local function GetCommandLineArgOrDefault(argname, default) + local arg = GetCommandLineArg(argname, 1) + if arg and not CMDLINE_ARGUMENT_KEYS[arg[1]] then + return arg[1] + end + + return default + end + + return { + PrefLanguage = tostring(string.lower(GetCommandLineArgOrDefault("/country", "world"))), + isRehost = HasCommandLineArg("/rehost"), + initName = GetCommandLineArgOrDefault("/init", ""), + numGames = tonumber(GetCommandLineArgOrDefault("/numgames", 0)), + playerMean = tonumber(GetCommandLineArgOrDefault("/mean", 1500)), + playerClan = tostring(GetCommandLineArgOrDefault("/clan", "")), + playerDeviation = tonumber(GetCommandLineArgOrDefault("/deviation", 500)), + debugLobby = HasCommandLineArg("/debugLobby"), -- Used by LaunchFAInstances script to set players as ready by default + } +end +local argv = parseCommandlineArguments() + +local playerRating = math.floor(Trueskill.round2((argv.playerMean - 3 * argv.playerDeviation) / 100.0) * 100) + +local function ParseWhisper(params) + local delimStart = string.find(params, " ") + if delimStart then + local name = string.sub(params, 1, delimStart-1) + local targID = FindIDForName(name) + if targID then + PrivateChat(targID, string.sub(params, delimStart+1)) + else + AddChatText(LOC("Invalid whisper target.")) + end + end +end + +local commands = { + pm = ParseWhisper, + private = ParseWhisper, + w = ParseWhisper, + whisper = ParseWhisper, +} + +local Strings = LobbyComm.Strings + +---@type LobbyComm +local lobbyComm = false +local localPlayerName = "" +local gameName = "" +local hostID = false +local singlePlayer = false +---@type Group +local GUI = false +local localPlayerID = false +---@type GameData | WatchedGameData +local gameInfo = false +local lastKickMessage = UTF.UnescapeString(Prefs.GetFromCurrentProfile('lastKickMessage') or "") + +local defaultMode =(HasCommandLineArg("/windowed") and "windowed") or Prefs.GetFromCurrentProfile('options').primary_adapter +local windowedMode = defaultMode == "windowed" or (HasCommandLineArg("/windowed")) + +function SetWindowedLobby(windowed) + -- Dont change resolution if user already using windowed mode + if windowed == windowedMode or defaultMode == 'windowed' then + return + end + + if windowed then + ConExecute('SC_PrimaryAdapter windowed') + else + ConExecute('SC_PrimaryAdapter ' .. tostring(defaultMode)) + end + + windowedMode = windowed +end + +-- String from which to build the various "Move player to slot" labels. +local slotMenuStrings = { + open = "Open", + close = "Close", + closed = "Closed", + occupy = "Occupy", + pm = "Private Message", + remove_to_kik = "Kick Player", + remove_to_observer = "Move Player to Observer", + close_spawn_mex = "Close - spawn mex", + closed_spawn_mex = "Closed - spawn mex", +} +local slotMenuData = { + open = { + host = { + 'close', + 'occupy', + 'ailist', + }, + client = { + 'occupy', + }, + }, + closed = { + host = { + 'open', + }, + client = { + }, + }, + player = { + host = { + 'pm', + 'remove_to_observer', + 'remove_to_kik', + 'move' + }, + client = { + 'pm', + }, + }, + ai = { + host = { + 'remove_to_kik', + 'ailist', + }, + client = { + }, + }, +} + +function GetNumAvailStartSpots() + local numAvailStartSpots = nil + local scenarioInfo = nil + if gameInfo.GameOptions.ScenarioFile and (gameInfo.GameOptions.ScenarioFile ~= "") then + scenarioInfo = MapUtil.LoadScenario(gameInfo.GameOptions.ScenarioFile) + end + if scenarioInfo then + local armyTable = MapUtil.GetArmies(scenarioInfo) + if armyTable then + if gameInfo.GameOptions['RandomMap'] == 'Off' then + numAvailStartSpots = table.getn(armyTable) + else + numAvailStartSpots = numberOfPlayers + end + end + else + WARN("Can't assign random start spots, no scenario selected.") + end + return numAvailStartSpots +end + +local function GetSlotMenuData() + if gameInfo.AdaptiveMap then + if not slotMenuData.closed_spawn_mex then + slotMenuData.closed_spawn_mex = { + host = { + 'open', + 'close', + }, + client = { + }, + } + table.insert(slotMenuData.open.host, 2, 'close_spawn_mex') + table.insert(slotMenuData.closed.host, 2, 'close_spawn_mex') + end + else + if slotMenuData.closed_spawn_mex then + slotMenuData.closed_spawn_mex = nil + table.remove(slotMenuData.open.host, 2) + table.remove(slotMenuData.closed.host, 2) + end + end + return slotMenuData +end + +local function GetSlotMenuTables(stateKey, hostKey, slotNum) + local keys = {} + local strings = {} + local tooltips = {} + + if not GetSlotMenuData()[stateKey] then + WARN("Invalid slot menu state selected: " .. tostring(stateKey)) + return nil + end + + if not GetSlotMenuData()[stateKey][hostKey] then + WARN("Invalid slot menu host key selected: " .. tostring(hostKey)) + return nil + end + + local isPlayerReady = false + local localPlayerSlot = FindSlotForID(localPlayerID) + if localPlayerSlot then + if gameInfo.PlayerOptions[localPlayerSlot].Ready then + isPlayerReady = true + end + end + + for index, key in GetSlotMenuData()[stateKey][hostKey] do + if key == 'ailist' then + if slotNum then + for i = 1, numOpenSlots, 1 do + if i ~= slotNum then + table.insert(keys, 'move_player_to_slot' .. i) + table.insert(strings, LOCF("Move AI to slot %s", i)) + table.insert(tooltips, nil) + end + end + end + table.destructiveCat(keys, AIKeys) + table.destructiveCat(strings, AIStrings) + table.destructiveCat(tooltips, AITooltips) + elseif key == 'move' then + -- Generate the "move player to slot X" entries. + for i = 1, numOpenSlots, 1 do + if i ~= slotNum then + table.insert(keys, 'move_player_to_slot' .. i) + table.insert(strings, LOCF("Move Player to slot %s", i)) + table.insert(tooltips, nil) + end + end + else + if not (isPlayerReady and key == 'occupy') then + table.insert(keys, key) + table.insert(strings, slotMenuStrings[key]) + -- Add a tooltip key here if we ever get any interesting options. + table.insert(tooltips, nil) + end + end + end + + return keys, strings, tooltips +end + +--- Get the value of the LastColor, sanitised in case it's an unsafe value. +-- In case a new patch removes a color +function GetSanitisedLastColor() + local lastColor = Prefs.GetFromCurrentProfile('LastColorFAF') or 1 + if lastColor > table.getn(gameColors.PlayerColors) or lastColor < 1 then + lastColor = 1 + end + + return lastColor +end + +--- Get the value of the LastFaction, sanitised in case it's an unsafe value. +-- +-- This means when some retarded mod (*cough*Nomads*cough*) writes a large number to LastFaction, we +-- don't catch fire. +function GetSanitisedLastFaction() + local lastFaction = Prefs.GetFromCurrentProfile('LastFaction') or 1 + if lastFaction > table.getn(FactionData.Factions) + 1 or lastFaction < 1 then + lastFaction = 1 + end + + return lastFaction +end + +--- Get a PlayerData object for the local player, configured using data from their profile. +function GetLocalPlayerData() + + local version, gametype, commit = import("/lua/version.lua").GetVersionData() + + return PlayerData( + { + PlayerName = localPlayerName, + OwnerID = localPlayerID, + Human = true, + PlayerColor = GetSanitisedLastColor(), + Faction = GetSanitisedLastFaction(), + PlayerClan = argv.playerClan, + PL = playerRating, + NG = argv.numGames, + MEAN = argv.playerMean, + DEV = argv.playerDeviation, + Country = argv.PrefLanguage, + + Version = version, + GameType = gametype, + Commit = commit, + + Ready = argv.debugLobby, + } +) +end + +--- Compute an estimation of the rating of the given AI. The values originate from 'aitypes.lua' +---@param gameOptions table +---@param aiLobbyProperties AILobbyProperties +---@return number +function ComputeAIRating(gameOptions, aiLobbyProperties) + + if not aiLobbyProperties then + return 0 + end + + if not aiLobbyProperties.rating then + return 0 + end + + if not gameInfo.GameOptions.ScenarioFile then + return 0 + end + + -- try and take into account map + local scenarioInfo = MapUtil.LoadScenario(gameInfo.GameOptions.ScenarioFile) + if not (scenarioInfo and scenarioInfo.size and scenarioInfo.size[1] and scenarioInfo.size[2]) then + return 0 + end + + -- clamp the value + local maparea = math.max(scenarioInfo.size[1], scenarioInfo.size[2]) + if maparea < 256 then + maparea = 256 + elseif maparea > 4096 then + maparea = 4096 + end + + -- process various multipliers to determine rating + local mapMultiplier = aiLobbyProperties.ratingMapMultiplier[maparea] or 1.0 + local cheatBuildMultiplier = (tonumber(gameOptions.BuildMult) or 1.0) - 1.0 + local cheatResourceMultiplier = (tonumber(gameOptions.CheatMult) or 1.0) - 1.0 + + -- compute the rating + local cheatBuildValue = (aiLobbyProperties.ratingBuildMultiplier or 0.0) * cheatBuildMultiplier + local cheatResourceValue = (aiLobbyProperties.ratingCheatMultiplier or 0.0) * cheatResourceMultiplier + local cheatOmniValue = (gameOptions.OmniCheat == 'on' and aiLobbyProperties.ratingOmniBonus) or 0.0 + local rating = mapMultiplier * (aiLobbyProperties.rating + cheatBuildValue + cheatResourceValue + cheatOmniValue) + + -- prevent very low numbers + if rating < aiLobbyProperties.ratingNegativeThreshold then + rating = aiLobbyProperties.ratingNegativeThreshold + (rating - aiLobbyProperties.ratingNegativeThreshold) * 0.2 + end + + return math.floor(rating) +end + +function GetAIPlayerData(name, AIPersonality, slot) + local AIColor + -- gets the color of the player/AI occupying the slot directly prior if available + for i = 1, LobbyComm.maxPlayerSlots do + if gameInfo.PlayerOptions[i].StartSpot == slot then + if IsColorFree(gameInfo.PlayerOptions[i].PlayerColor, slot) then + AIColor = gameInfo.PlayerOptions[i].PlayerColor + end + break + end + end + if not AIColor then + AIColor = GetAvailableColor() + end + + -- retrieve properties from AI table + ---@type AILobbyProperties | nil + local aiLobbyProperties = nil + for k, entry in aitypes do + if entry.key == AIPersonality then + aiLobbyProperties = entry + end + end + local iRating = ComputeAIRating(gameInfo.GameOptions, aiLobbyProperties) + + return PlayerData( + { + OwnerID = hostID, + PlayerName = name, + Ready = true, + Human = false, + AIPersonality = AIPersonality, + PlayerColor = AIColor, + ArmyColor = AIColor, + + PL = iRating, + MEAN = iRating, + DEV = 0, + + -- keep track of the AI lobby properties for easier access + AILobbyProperties = aiLobbyProperties, + } +) +end + +local function DoSlotBehavior(slot, key, name) + if key == 'open' then + HostUtils.SetSlotClosed(slot, false) + elseif key == 'close' then + HostUtils.SetSlotClosed(slot, true) + elseif key == 'close_spawn_mex' then + HostUtils.SetSlotClosedSpawnMex(slot) + elseif key == 'occupy' then + if IsPlayer(localPlayerID) and not gameInfo.PlayerOptions[FindSlotForID(localPlayerID)].Ready then + if lobbyComm:IsHost() then + HostUtils.MovePlayerToEmptySlot(FindSlotForID(localPlayerID), slot) + else + lobbyComm:SendData(hostID, {Type = 'MovePlayer', RequestedSlot = slot}) + end + elseif IsObserver(localPlayerID) then + if lobbyComm:IsHost() then + local requestedFaction = GetSanitisedLastFaction() + HostUtils.ConvertObserverToPlayer(FindObserverSlotForID(localPlayerID), slot) + else + lobbyComm:SendData( + hostID, + { + Type = 'RequestConvertToPlayer', + ObserverSlot = FindObserverSlotForID(localPlayerID), + PlayerSlot = slot + } + ) + end + end + UpdateFactionSelector() + elseif key == 'pm' then + if gameInfo.PlayerOptions[slot].Human then + GUI.chatEdit:SetText(string.format("/whisper %s ", gameInfo.PlayerOptions[slot].PlayerName)) + end + -- Handle the various "Move to slot X" options. + elseif string.sub(key, 1, 19) == 'move_player_to_slot' then + HostUtils.SwapPlayers(slot, tonumber(string.sub(key, 20))) + elseif key == 'remove_to_observer' then + local playerInfo = gameInfo.PlayerOptions[slot] + if playerInfo.Human then + HostUtils.ConvertPlayerToObserver(slot) + end + elseif key == 'remove_to_kik' then + if gameInfo.PlayerOptions[slot].Human then + local kickMessage = function(self, str) + local msg + + msg = "\n Kicked by host. \n Reason: " .. str + + SendSystemMessage("lobui_0756", gameInfo.PlayerOptions[slot].PlayerName) + lobbyComm:EjectPeer(gameInfo.PlayerOptions[slot].OwnerID, msg) + + -- Save message for next time + Prefs.SetToCurrentProfile('lastKickMessage', UTF.EscapeString(str)) + lastKickMessage = str + end + + CreateInputDialog(GUI, "Are you sure?", kickMessage, lastKickMessage) + else + HostUtils.RemoveAI(slot) + end + else + -- We're adding an AI of some sort. + if lobbyComm:IsHost() then + HostUtils.AddAI(name, key, slot) + end + end +end + +local function IsModAvailable(modId) + for k,v in availableMods do + if not v[modId] then + return false + end + end + return true +end + + +function Reset() + lobbyComm = false + localPlayerName = "" + gameName = "" + hostID = false + singlePlayer = false + GUI = false + localPlayerID = false + availableMods = {} + selectedUIMods = Mods.GetSelectedUIMods() + selectedSimMods = Mods.GetSelectedSimMods() + numOpenSlots = LobbyComm.maxPlayerSlots + gameInfo = GameInfo.CreateGameInfo(LobbyComm.maxPlayerSlots) +end + +--- Create a new, unconnected lobby. +function ReallyCreateLobby(protocol, localPort, desiredPlayerName, localPlayerUID, natTraversalProvider, over, exitBehavior, playerHasSupcom) + Reset() + + -- Among other things, this clears uimain's override escape handler, allowing our escape + -- handler manager to work. + MenuCommon.MenuCleanup() + + if GUI then + WARN('CreateLobby called twice for UI construction (Should be unreachable)') + GUI:Destroy() + return + end + + -- Make sure we have a profile + if not GetPreference("profile.current") then + Prefs.CreateProfile("FAF_"..desiredPlayerName) + end + + GUI = UIUtil.CreateScreenGroup(over, "CreateLobby ScreenGroup") + + GUI.exitBehavior = exitBehavior + + GUI.optionControls = {} + GUI.slots = {} + + -- Set up the base escape handler first: want this one at the bottom of the stack. + GUI.exitLobbyEscapeHandler = function() + GUI.chatEdit:AbandonFocus() + local quitDialog = UIUtil.QuickDialog(GUI, + "Exit game lobby?", + "", function() + EscapeHandler.PopEscapeHandler() + if HasCommandLineArg("/gpgnet") then + -- Quit to desktop + EscapeHandler.SafeQuit() + else + -- Back to main menu + ReturnToMenu(false) + end + end, + + -- Fight to keep our focus on the chat input box, to prevent keybinding madness. + "", function() + GUI.chatEdit:AcquireFocus() + end, + nil, nil, + true, + {escapeButton = 2, enterButton = 1, worldCover = true} + ) + end + EscapeHandler.PushEscapeHandler(GUI.exitLobbyEscapeHandler) + + GUI.connectdialog = UIUtil.ShowInfoDialog(GUI, Strings.TryingToConnect, Strings.AbortConnect, ReturnToMenu) + -- Prevent the dialog from being closed due to user action. + GUI.connectdialog.OnEscapePressed = function() end + GUI.connectdialog.OnShadowClicked = function() end + + InitLobbyComm(protocol, localPort, desiredPlayerName, localPlayerUID, natTraversalProvider) + + -- Store off the validated playername + localPlayerName = lobbyComm:GetLocalPlayerName() + local Prefs = import("/lua/user/prefs.lua") + local windowed = Prefs.GetFromCurrentProfile('WindowedLobby') or 'false' + SetWindowedLobby(windowed == 'true') + +end + +-- A map from message types to functions that process particular message types. +local MESSAGE_HANDLERS = { + -- TODO: Finalise signature and semantics. + ConnectivityState = function() + end +} + +--- Handle an incoming message from the FAF client via the GPGNet protocol. +-- +-- @param jsonBlob A JSON string containing the message to process. +-- Messages are JSON strings containing two fields: +-- command_id: A string identifying the type of message. This string is used as a key into +-- MESSAGE_HANDLERS to find the function to use to process this message. +-- arguments: An array of arguments that should be passed to the handler function. +function HandleGPGNetMessage(jsonBlob) + local jsonObj = JSON.decode(jsonBlob) + table.print(jsonObj) + local handler = MESSAGE_HANDLERS[jsonObj.command_id] + if not handler then + WARN("Incomprehensible JSON message: \n" .. jsonBlob) + return + end + + handler(unpack(jsonObj.arguments)) +end + +--- Start a synchronous replay session +-- +-- @param replayID The ID of the replay to download and play. +function StartSyncReplaySession(replayID) + SetFrontEndData('syncreplayid', replayID) + local dl = UIUtil.QuickDialog(GetFrame(0), "Downloading the replay file...") + LaunchReplaySession('gpgnet://' .. GetCommandLineArg('/gpgnet',1)[1] .. '/' .. import("/lua/user/prefs.lua").GetFromCurrentProfile('Name')) + dl:Destroy() + UIUtil.QuickDialog(GetFrame(0), "You dont have this map.", "Exit", function() ExitApplication() end) +end + +--- Create a new unconnected lobby/Entry point for processing messages sent from the FAF lobby. +-- +-- This function is called exactly once by the game when a new lobby should be created. +-- @see ReallyCreateLobby +-- +-- This function is called whenever the FAF lobby sends a message into the game, with the message +-- in the desiredPlayerName parameter as a JSON string with a length no greater than 4061 bytes. +-- This madness is justified by this being one of the smallish number of functions we can have +-- called from outside. +-- @see HandleGPGNetMessage +-- +-- This function is also called by the sync replay server when a session should be started. (this +-- should probably be refactored to use the JSON messenger protocol) +-- @see StartSyncReplaySession +function CreateLobby(protocol, localPort, desiredPlayerName, localPlayerUID, natTraversalProvider, over, exitBehavior, playerHasSupcom) + -- Is this an incoming GPGNet message? + if localPort == -1 then + HandleGPGNetMessage(desiredPlayerName) + return + end + + -- Special-casing for sync-replay. + -- TODO: Consider replacing this with a gpgnet message type. + if IsSyncReplayServer then + StartSyncReplaySession(localPlayerUID) + return + end + + -- Okay, so we actually are creating a lobby, instead of doing some ridiculous hack. + ReallyCreateLobby(protocol, localPort, desiredPlayerName, localPlayerUID, natTraversalProvider, over, exitBehavior, playerHasSupcom) +end + +-- create the lobby as a host +function HostGame(desiredGameName, scenarioFileName, inSinglePlayer) + singlePlayer = inSinglePlayer + gameName = lobbyComm:MakeValidGameName(desiredGameName) + lobbyComm.desiredScenario = string.gsub(scenarioFileName, ".v%d%d%d%d_scenario.lua", "_scenario.lua") + lobbyComm:HostGame() +end + +-- join an already existing lobby +function JoinGame(address, asObserver, playerName, uid) + 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) + LOG("ConnectToPeer (name=" .. name .. ", uid=" .. uid .. ", address=" .. addressAndPort ..", USE PROXY)") + table.insert(ConnectedWithProxy, uid) + end + lobbyComm:ConnectToPeer(addressAndPort,name,uid) +end + +function DisconnectFromPeer(uid) + LOG("DisconnectFromPeer (uid=" .. uid ..")") + if wasConnected(uid) then + table.remove(connectedTo, uid) + end + GpgNetSend('Disconnected', string.format("%d", uid)) + lobbyComm:DisconnectFromPeer(uid) +end + +function SetHasSupcom(cmd) + -- TODO: Refactor SyncReplayServer gubbins to use generalised JSON protocol. + if IsSyncReplayServer then + if cmd == 0 then + SessionResume() + elseif cmd == 1 then + SessionRequestPause() + end + end +end + +function SetHasForgedAlliance(speed) + if IsSyncReplayServer then + if GetGameSpeed() ~= speed then + SetGameSpeed(speed) + end + end +end + +-- TODO: These functions are dumb. We have these things called "hashmaps". +function FindSlotForID(id) + for k, player in gameInfo.PlayerOptions:pairs() do + if player.OwnerID == id and player.Human then + return k + end + end + return nil +end + +function FindRehostSlotForID(id) + for index, player in ipairs(rehostPlayerOptions) do + if player.OwnerID == id and player.Human then + return player.StartSpot + end + end + return nil +end + +function FindNameForID(id) + if (IsObserver(id)) then + return (FindObserverNameForID(id)) + end + + for k, player in gameInfo.PlayerOptions:pairs() do + if player.OwnerID == id and player.Human then + return player.PlayerName + end + end + return nil +end + +function FindIDForName(name) + for k, player in gameInfo.PlayerOptions:pairs() do + if player.PlayerName == name and player.Human then + return player.OwnerID + end + end + return nil +end + +function FindObserverSlotForID(id) + for k, observer in gameInfo.Observers:pairs() do + if observer.OwnerID == id then + return k + end + end + + return nil +end + +function FindObserverNameForID(id) + for k, observer in gameInfo.Observers:pairs() do + if observer.OwnerID == id then + return observer.PlayerName + end + end + return nil +end + +function IsLocallyOwned(slot) + return gameInfo.PlayerOptions[slot].OwnerID == localPlayerID +end + +function IsPlayer(id) + return FindSlotForID(id) ~= nil +end + +function IsObserver(id) + return FindObserverSlotForID(id) ~= nil +end + +function UpdateSlotBackground(slotIndex) + if gameInfo.ClosedSlots[slotIndex] then + GUI.slots[slotIndex].SlotBackground:SetTexture(UIUtil.UIFile('/SLOT/slot-dis.dds')) + else + if gameInfo.PlayerOptions[slotIndex] then + GUI.slots[slotIndex].SlotBackground:SetTexture(UIUtil.UIFile('/SLOT/slot-player.dds')) + else + GUI.slots[slotIndex].SlotBackground:SetTexture(UIUtil.UIFile('/SLOT/slot-player_other.dds')) + end + end +end + +function GetPlayerDisplayName(playerInfo) + local playerName = playerInfo.PlayerName + local displayName = "" + if playerInfo.PlayerClan ~= "" then + return string.format("[%s] %s", playerInfo.PlayerClan, playerInfo.PlayerName) + else + return playerInfo.PlayerName + end +end + +-- Refresh (with a sledgehammer) all the items in the observer list. +local function refreshObserverList() + GUI.observerList:DeleteAllItems() + + -- create the table that will hold the data for displaying team rating information + local teamRatings = {} + local numTeams = 0 + -- calculate/display team ratings if spawns are fixed + if gameInfo.GameOptions['TeamSpawn'] == 'fixed' then + + -- cycle through each player + for i, player in gameInfo.PlayerOptions:pairs() do + + -- get the team number (which is 1 higher on the backend) + local team = player.Team - 1 + -- add the player's rating information if the player is on a team + if team > 0 then + -- make sure the team is included in the teamRatings table + if teamRatings[team] == nil then + -- initialize the team's rating in this table as having 0 mean and 0 deviation, respectively + teamRatings[team] = {0, 0} + end + -- add the player's rating information (mean and deviation) to the its team's totals + teamRatings[team] = {teamRatings[team][1] + player.MEAN, teamRatings[team][2] + player.DEV} + end + end + + for i, team in teamRatings do + numTeams = numTeams + 1 + end + + -- if there are 1 or 2 teams, list them before observers + if numTeams == 1 or numTeams == 2 then + if not lobbyComm:IsHost() then + GUI.observerList:AddItem(LOC('Team Ratings:')) + end + for i, rating in teamRatings do + GUI.observerList:AddItem( + LOCF('Team %d: %d (%d+/-%d)', i, math.round(rating[1] - rating[2] * 3), math.round(rating[1]), math.round(rating[2] * 3)) + ) + end + if not lobbyComm:IsHost() then + GUI.observerList:AddItem('') + end + end + end + + + local observers = false + + for slot, observer in gameInfo.Observers:pairs() do + + if not observers then + observers = true + if not lobbyComm:IsHost() then + GUI.observerList:AddItem(LOC('Observers')..':') + end + end + + observer.ObserverListIndex = GUI.observerList:GetItemCount() -- Pin-head William made this zero-based + + -- Create a label for this observer of the form: + -- PlayerName (R:xxx, P:xxx, C:xxx) + -- Such conciseness is necessary as the field in the UI is rather narrow... + local observer_label = observer.PlayerName .. " (R:" .. observer.PL + + -- Add the ping only if this entry refers to a different client. + if observer and (observer.OwnerID ~= localPlayerID) and observer.ObserverListIndex then + local peer = lobbyComm:GetPeer(observer.OwnerID) + + local ping = 0 + if peer.ping ~= nil then + ping = math.floor(peer.ping) + end + + observer_label = observer_label .. ", P:" .. ping + end + + -- Add the CPU score if one is available. + local score_CPU = CPU_Benchmarks[observer.PlayerName] + if score_CPU then + observer_label = observer_label .. ", C:" .. score_CPU + end + observer_label = observer_label .. ")" + + GUI.observerList:AddItem(observer_label) + end + + -- if there are more than 2 teams (and slots are fixed), list them after observers + if numTeams > 2 then + if not lobbyComm:IsHost() then + GUI.observerList:AddItem('') + GUI.observerList:AddItem(LOC('Team Ratings:')) + end + for i, rating in teamRatings do + GUI.observerList:AddItem( + LOCF('Team %d: %d (%d+/-%d)', i, math.round(rating[1] - rating[2] * 3), math.round(rating[1]), math.round(rating[2] * 3)) + ) + end + end +end + +local WVT = import("/lua/ui/lobby/data/watchedvalue/watchedvaluetable.lua") + +-- update the data in a player slot +-- TODO: With lazyvars, this function should be eliminated. Lazy-value-callbacks should be used +-- instead to incrementaly update things. +function SetSlotInfo(slotNum, playerInfo) + -- Remove the ConnectDialog. It probably makes more sense to do this when we get the game state. + if GUI.connectdialog then + GUI.connectdialog:Close() + GUI.connectdialog = nil + + -- ChangelogDialog, if necessary. + local changelogDialogManager = import("/lua/ui/lobby/changelog/changelogdialog.lua") + if changelogDialogManager.ShouldOpenChangelog() then + changelogDialogManager.CreateChangelogDialog(GetFrame(0)) + end + end + + playerInfo.StartSpot = slotNum + + local slot = GUI.slots[slotNum] + local isHost = lobbyComm:IsHost() + local isLocallyOwned = IsLocallyOwned(slotNum) + + -- Set enabledness of controls according to host privelage etc. + -- Yeah, we set it twice. No, it's not brilliant. Blurgh. + local facColEnabled = isLocallyOwned or (isHost and not playerInfo.Human) + UIUtil.setEnabled(slot.faction, facColEnabled) + UIUtil.setEnabled(slot.color, facColEnabled) + + -- Possibly override it due to the ready box. + if isLocallyOwned then + if playerInfo.Ready and playerInfo.Human then + DisableSlot(slotNum, true) + else + EnableSlot(slotNum) + end + else + DisableSlot(slotNum) + end + + --- Returns true if the team selector for this slot should be enabled. + -- + -- The predicate was getting unpleasantly long to read. + local function teamSelectionEnabled(autoTeams, ready, locallyOwned, isHost) + -- If autoteams has control, no selector for you. + if autoTeams ~= 'none' then + return false + end + + if isHost and not playerInfo.Human then + return true + end + + -- You can control your own one when you're not ready. + if locallyOwned then + return not ready + end + + if isHost then + -- The host can control the team of others, provided he's not ready himself. + local slot = FindSlotForID(localPlayerID) + local is_ready = slot and gameInfo.PlayerOptions[slot].Ready -- could be observer + + return not is_ready + end + end + + -- Disable team selection if "auto teams" is controlling it. Moderatelty ick. + local autoTeams = gameInfo.GameOptions.AutoTeams + UIUtil.setEnabled(slot.team, teamSelectionEnabled(autoTeams, playerInfo.Ready, isLocallyOwned, isHost)) + + local hostKey + if isHost then + hostKey = 'host' + else + hostKey = 'client' + end + + -- These states are used to select the appropriate strings with GetSlotMenuTables. + local slotState + if not playerInfo.Human then + slot.ratingText:Hide() + slotState = 'ai' + elseif not isLocallyOwned then + slotState = 'player' + else + slotState = nil + end + + slot.name:ClearItems() + + if slotState then + slot.name:Enable() + local slotKeys, slotStrings, slotTooltips = GetSlotMenuTables(slotState, hostKey, slotNum) + slot.name.slotKeys = slotKeys + + if not table.empty(slotKeys) then + slot.name:AddItems(slotStrings) + slot.name:Enable() + Tooltip.AddComboTooltip(slot.name, slotTooltips) + else + slot.name.slotKeys = nil + slot.name:Disable() + Tooltip.RemoveComboTooltip(slot.name) + end + else + -- no slotState indicate this must be ourself, and you can't do anything to yourself + slot.name.slotKeys = nil + slot.name:Disable() + end + + slot.ratingText:Show() + slot.ratingText:SetText(playerInfo.PL) + slot.ratingText:SetColor("ffffffff") + + -- dynamic tooltip to show rating and deviation for each player + local tooltipText = {} + tooltipText['text'] = LOC("Rating") + tooltipText['body'] = LOCF("%s's TrueSkill Rating is %s +/- %s", playerInfo.PlayerName, math.round(playerInfo.MEAN), math.ceil(playerInfo.DEV * 3)) + slot.tooltiprating = Tooltip.AddControlTooltip(slot.ratingText, tooltipText) + + slot.numGamesText:Show() + slot.numGamesText:SetText(playerInfo.NG) + + slot.name:Show() + -- Change name colour according to the state of the slot. + if slotState == 'ai' then + slot.name:SetTitleTextColor("dbdbb9") -- Beige Color for AI + slot.name._text:SetFont('Arial Gras', 12) + elseif FindSlotForID(hostID) == slotNum then + slot.name:SetTitleTextColor("ffc726") -- Orange Color for Host + slot.name._text:SetFont('Arial Gras', 15) + elseif slotState == 'player' then + slot.name:SetTitleTextColor("64d264") -- Green Color for Players + slot.name._text:SetFont('Arial Gras', 15) + elseif isLocallyOwned then + slot.name:SetTitleTextColor("6363d2") -- Blue Color for You + slot.name._text:SetFont('Arial Gras', 15) + else + slot.name:SetTitleTextColor(UIUtil.fontColor) -- Normal Color for Other + slot.name._text:SetFont('Arial Gras', 12) + end + + local playerName = playerInfo.PlayerName + if wasConnected(playerInfo.OwnerID) or isLocallyOwned or not playerInfo.Human then + slot.name:SetTitleText(GetPlayerDisplayName(playerInfo)) + slot.name._text:SetFont('Arial Gras', 15) + if not table.find(ConnectionEstablished, playerName) then + if playerInfo.Human and not isLocallyOwned then + AddChatText(LOCF("Connection to %s established.", playerName)) + + table.insert(ConnectionEstablished, playerName) + for k, v in CurrentConnection do + if v == playerName then + CurrentConnection[k] = nil + break + end + end + end + end + else + slot.name:SetTitleText(LOCF('Connecting to %s...', playerName)) + slot.name._text:SetFont('Arial Gras', 11) + end + + slot.faction:Show() + + -- Check if faction is possible for that slot, if not set to random + -- For example: AIs always start with faction 5, so that needs to be adjusted to fit in slot.Faction + if table.getn(slot.AvailableFactions) < playerInfo.Faction then + playerInfo.Faction = table.getn(slot.AvailableFactions) + end + slot.faction:SetItem(playerInfo.Faction) + + slot.color:Show() + Check_Availaible_Color(slotNum) + + slot.team:Show() + slot.team:SetItem(playerInfo.Team) + + -- Send team data to the server + if isHost then + HostUtils.SendPlayerSettingsToServer(slotNum) + end + + UIUtil.setVisible(slot.ready, playerInfo.Human and not singlePlayer) + slot.ready:SetCheck(playerInfo.Ready, true) + + if isLocallyOwned and playerInfo.Human then + Prefs.SetToCurrentProfile('LastColorFAF', playerInfo.PlayerColor) + Prefs.SetToCurrentProfile('LastFaction', playerInfo.Faction) + end + + -- Show the player's nationality + if not playerInfo.Country then + slot.KinderCountry:Hide() + else + slot.KinderCountry:Show() + slot.KinderCountry:SetTexture(UIUtil.UIFile('/countries/'..playerInfo.Country..'.dds')) + + Tooltip.AddControlTooltip(slot.KinderCountry, {text=LOC("Country"), body=LOC(CountryTooltips[playerInfo.Country])}) + end + + UpdateSlotBackground(slotNum) + + -- Set the CPU bar + SetSlotCPUBar(slotNum, playerInfo) + + ShowGameQuality() + RefreshMapPositionForAllControls(slotNum) + + if isHost then + HostUtils.RefreshButtonEnabledness() + end + refreshObserverList() +end + +function ClearSlotInfo(slotIndex) + local slot = GUI.slots[slotIndex] + + local hostKey + if lobbyComm:IsHost() then + GpgNetSend('ClearSlot', slotIndex) + hostKey = 'host' + else + hostKey = 'client' + end + + local stateKey + local stateText + if gameInfo.ClosedSlots[slotIndex] and gameInfo.SpawnMex[slotIndex] and gameInfo.AdaptiveMap then + stateKey = 'closed_spawn_mex' + stateText = slotMenuStrings.closed_spawn_mex + elseif gameInfo.ClosedSlots[slotIndex] then + gameInfo.SpawnMex[slotIndex] = false + stateKey = 'closed' + stateText = slotMenuStrings.closed + else + stateKey = 'open' + stateText = slotMenuStrings.open + end + + local slotKeys, slotStrings, slotTooltips = GetSlotMenuTables(stateKey, hostKey) + + -- set the text appropriately + slot.name:ClearItems() + slot.name:SetTitleText(LOC(stateText)) + if not table.empty(slotKeys) then + slot.name.slotKeys = slotKeys + slot.name:AddItems(slotStrings) + Tooltip.AddComboTooltip(slot.name, slotTooltips) + slot.name:Enable() + else + slot.name.slotKeys = nil + slot.name:Disable() + Tooltip.RemoveComboTooltip(slot.name) + end + + slot.name._text:SetFont('Arial Gras', 12) + if stateKey == 'closed' then + slot.name:SetTitleTextColor("Crimson") + elseif stateKey == 'closed_spawn_mex' then + slot.name:SetTitleTextColor("2c7f33") + else + slot.name:SetTitleTextColor('B9BFB9') + end + + slot:HideControls() + + UpdateSlotBackground(slotIndex) + ShowGameQuality() + RefreshMapPositionForAllControls(slotIndex) + Check_Availaible_Color() + refreshObserverList() +end + +function IsColorFree(colorIndex, currentSlotNumber) + for id, player in gameInfo.PlayerOptions:pairs() do + if player.PlayerColor == colorIndex then + if currentSlotNumber then + if player.StartSpot != currentSlotNumber then + return false + end + else + return false + end + end + end + + return true +end + +function GetPlayerCount() + local numPlayers = 0 + for k,player in gameInfo.PlayerOptions:pairs() do + if player then + numPlayers = numPlayers + 1 + end + end + return numPlayers +end + +local function GetPlayersNotReady() + local notReady = false + for k,v in gameInfo.PlayerOptions:pairs() do + if v.Human and not v.Ready then + if not notReady then + notReady = {} + end + table.insert(notReady, v.PlayerName) + end + end + + return notReady +end + +local function GetRandomFactionIndex(slotNumber) + local randomfaction = nil + local counter = 50 + while counter > 0 do + counter = (counter - 1) + randomfaction = math.random(1, table.getn(GUI.slots[slotNumber].AvailableFactions) - 1) + end + return randomfaction +end + +local function AssignRandomFactions() + for index, player in gameInfo.PlayerOptions do + -- No random if there is only 1 option + if table.getn(GUI.slots[index].AvailableFactions) >= 2 then + local randomFactionID = table.getn(GUI.slots[index].AvailableFactions) + -- note that this doesn't need to be aware if player has supcom or not since they would only be able to select + -- the random faction ID if they have supcom + if player.Faction >= randomFactionID then + player.Faction = GetRandomFactionIndex(index) + end + end + end +end + +-- Convert the local (slot dependend) faction indexes to the global faction indexes +local function FixFactionIndexes() + for index, player in gameInfo.PlayerOptions do + local playerFaction = GUI.slots[index].AvailableFactions[player.Faction] + for i,v in allAvailableFactionsList do + if v == playerFaction then + player.Faction = i + continue + end + end + end + +end + +--------------------------- +-- autobalance functions -- +--------------------------- +local function team_sort_by_sum(t1, t2) + return t1['sum'] < t2['sum'] +end + +local function autobalance_bestworst(players, teams_arg) + local players = table.deepcopy(players) + local result = {} + local best = true + local teams = {} + + for t, slots in teams_arg do + table.insert(teams, {team=t, slots=table.deepcopy(slots), sum=0}) + end + + -- teams first picks best player and then worst player, repeat + while not table.empty(players) do + for i, t in teams do + local team = t['team'] + local slots = t['slots'] + local slot = table.remove(slots, 1) + if not slot then continue end + local player + + if best then + player = table.remove(players, 1) + else + player = table.remove(players) + end + + if not player then break end + + teams[i]['sum'] = teams[i]['sum'] + player['rating'] + table.insert(result, {player=player['pos'], rating=player['rating'], team=team, slot=slot}) + end + + best = not best + if best then + table.sort(teams, team_sort_by_sum) + end + end + + return result +end + +local function autobalance_avg(players, teams_arg) + local players = table.deepcopy(players) + local result = {} + local teams = {} + local max_sum = 0 + + for t, slots in teams_arg do + table.insert(teams, {team=t, slots=table.deepcopy(slots), sum=0}) + end + + while not table.empty(players) do + local first_team = true + for i, t in teams do + local team = t['team'] + local slots = t['slots'] + local slot = table.remove(slots, 1) + if not slot then continue end + local player + local player_key + + for j, p in players do + player_key = j + if first_team or t['sum'] + p['rating'] <= max_sum then + break + end + end + + player = table.remove(players, player_key) + if not player then break end + + teams[i]['sum'] = teams[i]['sum'] + player['rating'] + max_sum = math.max(max_sum, teams[i]['sum']) + table.insert(result, {player=player['pos'], rating=player['rating'], team=team, slot=slot}) + first_team = false + end + + table.sort(teams, team_sort_by_sum) + end + + return result +end + +local function autobalance_rr(players, teams) + local players = table.deepcopy(players) + local teams = table.deepcopy(teams) + local result = {} + + local team_picks = {} + local i = 1 + for team, slots in teams do + table.insert(team_picks, {team=team, sum=i}) + i = i + 1 + end + + while not table.empty(players) do + for i, pick in team_picks do + local slot = table.remove(teams[pick.team], 1) + if not slot then continue end + local player = table.remove(players, 1) + if not player then break end + pick.sum = pick.sum + i + + table.insert(result, {player=player.pos, rating=player.rating, team=pick.team, slot=slot}) + end + + table.sort(team_picks, function(a, b) return a.sum > b.sum end) + end + + return result +end + +local function autobalance_random(players, teams_arg) + local players = table.deepcopy(players) + local result = {} + local teams = {} + + players = table.shuffle(players) + + for t, slots in teams_arg do + table.insert(teams, {team=t, slots=table.deepcopy(slots)}) + end + + while not table.empty(players) do + for _, t in teams do + local team = t['team'] + local slot = table.remove(t['slots'], 1) + if not slot then continue end + local player = table.remove(players, 1) + + if not player then break end + + table.insert(result, {player=player['pos'], rating=player['rating'], team=team, slot=slot}) + end + end + + return result +end + +function autobalance_quality(players) + local teams = nil + local quality = 0 + + for _, p in players do + local i = p['player'] + local team = p['team'] + local playerInfo = gameInfo.PlayerOptions[i] + local player = Player.create(playerInfo.PlayerName, + Rating.create(playerInfo.MEAN or 1500, playerInfo.DEV or 500)) + + if not teams then + teams = Teams.create() + end + + teams:addPlayer(team, player) + end + + if teams and table.getn(teams:getTeams()) > 1 then + quality = Trueskill.computeQuality(teams) + end + + return quality +end + +--- If the game is full, GPGNetSend about it so the client can do a fancy popup if it has focus. +function PossiblyAnnounceGameFull() + -- Search for an empty non-closed slot. + for i = 1, numOpenSlots do + if not gameInfo.ClosedSlots[i] then + if not gameInfo.PlayerOptions[i] then + return + end + end + end + + -- Game is full, let's tell the client. + GpgNetSend("GameFull") +end + +local function AssignRandomStartSpots() + local teamSpawn = gameInfo.GameOptions['TeamSpawn'] + + if teamSpawn == 'fixed' or teamSpawn == 'penguin_autobalance' then + return + end + + function teamsAddSpot(teams, team, spot) + if not teams[team] then + teams[team] = {} + end + table.insert(teams[team], spot) + end + + -- rearrange players according to the provided setup + function rearrangePlayers(data) + gameInfo.GameOptions['Quality'] = data.quality + + -- Copy a reference to each of the PlayerData objects indexed by their original slots. + local orgPlayerOptions = {} + for k, p in gameInfo.PlayerOptions do + orgPlayerOptions[k] = p + end + + local mirrored = string.find(teamSpawn, 'mirrored') + if mirrored then + local rating_cmp = function(a,b) return a.rating > b.rating end + local slot_cmp = function(a,b) return a.slot < b.slot end + + function getMasterOrder(sortedSlots) + local masterOrder = {} + + local slot2nr = {} + for k, p in sortedSlots.byNr do + slot2nr[p.slot] = k + end + + for k, p in sortedSlots.byRating do + table.insert(masterOrder, slot2nr[p.slot]) + end + + return masterOrder + end + + function teamsSameSize(slots) + local size + + for t, sorted in slots do + local s = table.getn(sorted.byNr) + if not size then size = s end + + if size ~= s then return false end + end + + return true + end + + function reorderSlots(sortedSlots, masterOrder) + local newSlots = {} + for i, j in masterOrder do + table.insert(newSlots, sortedSlots.byNr[j].slot) + end + + for i, s in newSlots do + sortedSlots.byRating[i].slot = s + end + end + + local slots = {} + local masterTeam + + for _, p in data.setup do + if not slots[p.team] then slots[p.team] = {} end + if not slots[p.team].byNr then slots[p.team].byNr = {} end + if not slots[p.team].byRating then slots[p.team].byRating = {} end + if not masterTeam then masterTeam = p.team end + + table.binsert(slots[p.team].byNr, p, slot_cmp) + table.binsert(slots[p.team].byRating, p, rating_cmp) + end + + -- abort mirroring if team sizes differ + if not teamsSameSize(slots) then + WARN("Mirroring disabled due to teams not having the same number of players") + else + local masterOrder = getMasterOrder(slots[masterTeam]) + for t, sorted in slots do + reorderSlots(sorted, masterOrder) + end + end + end + + -- Rearrange the players in the slots to match the chosen configuration. The result object + -- maps old slots to new slots, and we use orgPlayerOptions to avoid losing a reference to + -- an object (and because swapping is too much like hard work). + gameInfo.PlayerOptions = {} + for _, r in data.setup do + local playerOptions = orgPlayerOptions[r.player] + playerOptions.Team = r.team + 1 + playerOptions.StartSpot = r.slot + gameInfo.PlayerOptions[r.slot] = playerOptions + + -- Send team data to the server + local playerInfo = gameInfo.PlayerOptions[r.slot] + HostUtils.SendPlayerSettingsToServer(r.slot) + end + end + + local numAvailStartSpots = GetNumAvailStartSpots() + + local AutoTeams = gameInfo.GameOptions.AutoTeams + local positionGroups = {} + local teams = {} + + -- Used to actualise the virtual teams produced by the "Team -" no-team team. + local synthesizedTeamCounter = 9 + for i = 1, numAvailStartSpots do + if not gameInfo.ClosedSlots[i] then + local team = nil + local group = nil + + if AutoTeams == 'lvsr' then + local midLine = GUI.mapView.Left() + (GUI.mapView.Width() / 2) + local markerPos = GUI.mapView.startPositions[i].Left() + + if markerPos < midLine then + team = 2 + else + team = 3 + end + elseif AutoTeams == 'tvsb' then + local midLine = GUI.mapView.Top() + (GUI.mapView.Height() / 2) + local markerPos = GUI.mapView.startPositions[i].Top() + + if markerPos < midLine then + team = 2 + else + team = 3 + end + elseif AutoTeams == 'pvsi' then + if math.mod(i, 2) ~= 0 then + team = 2 + else + team = 3 + end + elseif AutoTeams == 'manual' then + team = gameInfo.AutoTeams[i] + else -- none + team = gameInfo.PlayerOptions[i].Team + group = 1 + end + + group = group or team + if not positionGroups[group] then + positionGroups[group] = {} + end + table.insert(positionGroups[group], i) + + if team ~= nil then + -- Team 1 secretly represents "No team", so give them a real team (but one that + -- nobody else can possibly have) + if team == 1 then + team = synthesizedTeamCounter + synthesizedTeamCounter = synthesizedTeamCounter + 1 + end + teamsAddSpot(teams, team, i) + end + end + end + gameInfo.GameOptions.RandomPositionGroups = positionGroups + + -- shuffle the array for randomness. + for i, team in teams do + teams[i] = table.shuffle(team) + end + teams = table.shuffle(teams) + + local ratingTable = {} + for i = 1, numAvailStartSpots do + local playerInfo = gameInfo.PlayerOptions[i] + if playerInfo then + table.insert(ratingTable, { pos=i, rating = playerInfo.MEAN - playerInfo.DEV * 3 }) + end + end + + if teamSpawn == 'random' or teamSpawn == 'random_reveal' then + s = autobalance_random(ratingTable, teams) + q = autobalance_quality(s) + rearrangePlayers{setup=s, quality=q} + return + end + + ratingTable = table.shuffle(ratingTable) -- random order for people with same rating + table.sort(ratingTable, function(a, b) return a['rating'] > b['rating'] end) + + local setups = {} + local functions = { + rr=autobalance_rr, + bestworst=autobalance_bestworst, + avg=autobalance_avg, + } + + local cmp = function(a, b) return a.quality > b.quality end + local s, q + for fname, f in functions do + s = f(ratingTable, teams) + if s then + q = autobalance_quality(s) + table.binsert(setups, {setup=s, quality=q}, cmp) + end + end + + local n_random = 0 + local frac = (teamSpawn == 'balanced_flex' or teamSpawn == 'balanced_flex_reveal') and 0.95 or 1 + -- add 100 random compositions and keep 3 with at least of best quality + for i=1, 100 do + s = autobalance_random(ratingTable, teams) + q = autobalance_quality(s) + + if q > setups[1].quality * frac then + table.binsert(setups, {setup=s, quality=q}, cmp) + n_random = n_random + 1 + if n_random > 2 then break end + end + end + + if teamSpawn == 'balanced_flex' or teamSpawn == 'balanced_flex_reveal' then + setups = table.shuffle(setups) + end + + best = table.remove(setups, 1) + rearrangePlayers(best) +end + + +local function AssignAutoTeams() + -- A function to take a player index and return the team they should be on. + local getTeam + if gameInfo.GameOptions.AutoTeams == 'lvsr' then + local midLine = GUI.mapView.Left() + (GUI.mapView.Width() / 2) + local startPositions = GUI.mapView.startPositions + + getTeam = function(playerIndex) + local markerPos = startPositions[playerIndex].Left() + if markerPos < midLine then + return 2 + else + return 3 + end + end + elseif gameInfo.GameOptions.AutoTeams == 'tvsb' then + local midLine = GUI.mapView.Top() + (GUI.mapView.Height() / 2) + local startPositions = GUI.mapView.startPositions + + getTeam = function(playerIndex) + local markerPos = startPositions[playerIndex].Top() + if markerPos < midLine then + return 2 + else + return 3 + end + end + elseif gameInfo.GameOptions.AutoTeams == 'pvsi' or gameInfo.GameOptions['RandomMap'] ~= 'Off' then + getTeam = function(playerIndex) + if math.mod(playerIndex, 2) ~= 0 then + return 2 + else + return 3 + end + end + elseif gameInfo.GameOptions.AutoTeams == 'manual' then + getTeam = function(playerIndex) + return gameInfo.AutoTeams[playerIndex] or 1 + end + else + return + end + + for i = 1, LobbyComm.maxPlayerSlots do + if not gameInfo.ClosedSlots[i] and gameInfo.PlayerOptions[i] then + local correctTeam = getTeam(i) + if gameInfo.PlayerOptions[i].Team ~= correctTeam then + SetPlayerOption(i, "Team", correctTeam, true) + SetSlotInfo(i, gameInfo.PlayerOptions[i]) + end + end + end +end + +local function AssignAINames() + local aiNames = import("/lua/ui/lobby/ainames.lua").ainames + local nameSlotsTaken = {} + for index, faction in FactionData.Factions do + nameSlotsTaken[index] = {} + end + for index, player in gameInfo.PlayerOptions do + if not player.Human then + local playerFaction = player.Faction + local factionNames = aiNames[FactionData.Factions[playerFaction].Key] + local ranNum + repeat + ranNum = math.random(1, table.getn(factionNames)) + until nameSlotsTaken[playerFaction][ranNum] == nil + nameSlotsTaken[playerFaction][ranNum] = true + player.PlayerName = factionNames[ranNum] .. " (" .. player.PlayerName .. ")" + end + end +end + + +-- call this whenever the lobby needs to exit and not go in to the game +function ReturnToMenu(reconnect) + if lobbyComm then + lobbyComm:Destroy() + lobbyComm = false + end + + local exitfn = GUI.exitBehavior + + GUI:Destroy() + GUI = false + + if not reconnect then + exitfn() + else + local ipnumber = GetCommandLineArg("/joincustom", 1)[1] + import("/lua/ui/uimain.lua").StartJoinLobbyUI("UDP", ipnumber, localPlayerName) + end +end + +function PrintSystemMessage(id, parameters) + AddChatText(LOCF("Unknown system message. Check localisation file", unpack(parameters))) +end + +function SendSystemMessage(id, ...) + local data = { + Type = "SystemMessage", + Id = id, + Args = arg + } + + lobbyComm:BroadcastData(data) + PrintSystemMessage(id, arg) +end + +function SendPersonalSystemMessage(targetID, id, ...) + if targetID ~= localPlayerID then + local data = { + Type = "SystemMessage", + Id = id, + Args = arg + } + + lobbyComm:SendData(targetID, data) + end +end + +function PublicChat(text) + lobbyComm:BroadcastData( + { + Type = "PublicChat", + Text = text, + } + ) + AddChatText(text, localPlayerID, true) +end + +function PrivateChat(targetID,text) + if targetID ~= localPlayerID then + lobbyComm:SendData( + targetID, + { + Type = 'PrivateChat', + Text = text, + } + ) + end + local targetName = FindNameForID(targetID) + if targetName then + AddChatText("<<"..LOCF("To %s", targetName)..">> " .. text) + end +end + +function UpdateAvailableSlots(numAvailStartSpots, scenario) + if numAvailStartSpots > LobbyComm.maxPlayerSlots then + WARN("Lobby requests " .. numAvailStartSpots .. " but there are only " .. LobbyComm.maxPlayerSlots .. " available") + end + + for i = 1, numAvailStartSpots do + local availableFactionsForSpotI = FACTION_NAMES + if scenario.Configurations.standard.factions then + availableFactionsForSpotI = scenario.Configurations.standard.factions[i] + end + + local factionBmps = {} + local factionTooltips = {} + local factionList = {} + for index, factionKey in availableFactionsForSpotI do + for _, tbl in FactionData.Factions do + if factionKey == tbl.Key then + factionBmps[index] = tbl.SmallIcon + factionTooltips[index] = tbl.TooltipID + factionList[index] = tbl.Key + break + end + end + end + if table.getn(factionBmps) > 1 then + table.insert(factionBmps, "/faction_icon-sm/random_ico.dds") + table.insert(factionTooltips, 'lob_random') + table.insert(factionList, 'random') + end + + local oldAvailableFactions = GUI.slots[i].AvailableFactions + GUI.slots[i].AvailableFactions = factionList + + local diff = table.getn(factionList) ~= table.getn(oldAvailableFactions) + for k = 1,table.getn(factionList) do + if oldAvailableFactions[k] ~= factionList[k] then + diff = true + break + end + end + if not diff then + continue + end + + GUI.slots[i].faction:ChangeBitmapArray(factionBmps) + Tooltip.AddComboTooltip(GUI.slots[i].faction, factionTooltips) + + if gameInfo.PlayerOptions[i] then + local playerFactionIndex = table.getn(factionList) + for index,key in factionList do + if key == oldAvailableFactions[gameInfo.PlayerOptions[i].Faction] then + playerFactionIndex = index + break + end + end + if FindSlotForID(localPlayerID) == i then + local fact = factionList[playerFactionIndex] + for index,value in allAvailableFactionsList do + if fact == value then + GUI.factionSelector:SetSelected(index) + break + end + end + UpdateFactionSelector() + else + GUI.slots[i].faction:SetItem(playerFactionIndex) + gameInfo.PlayerOptions[i].Faction = playerFactionIndex + end + end + end + + -- if number of available slots has changed, update it + if gameInfo.firstUpdateAvailableSlotsDone and numOpenSlots == numAvailStartSpots then + -- Remove closed_spawn_mex if necessary + if not gameInfo.AdaptiveMap then + for i = 1, numAvailStartSpots do + if gameInfo.ClosedSlots[i] and gameInfo.SpawnMex[i] then + ClearSlotInfo(i) + gameInfo.SpawnMex[i] = nil + end + end + end + return + end + + -- reopen slots in case the new map has more startpositions then the previous map. + if numOpenSlots < numAvailStartSpots then + for i = numOpenSlots + 1, numAvailStartSpots do + gameInfo.ClosedSlots[i] = nil + gameInfo.SpawnMex[i] = nil + GUI.slots[i]:Show() + ClearSlotInfo(i) + DisableSlot(i) + end + end + numOpenSlots = numAvailStartSpots + + for i = 1, numAvailStartSpots do + if gameInfo.ClosedSlots[i] then + GUI.slots[i]:Show() + if not gameInfo.PlayerOptions[i] then + ClearSlotInfo(i) + end + if not gameInfo.PlayerOptions[i].Ready then + EnableSlot(i) + end + end + end + + for i = numAvailStartSpots + 1, LobbyComm.maxPlayerSlots do + if lobbyComm:IsHost() and gameInfo.PlayerOptions[i] then + local info = gameInfo.PlayerOptions[i] + if info.Human then + HostUtils.ConvertPlayerToObserver(i) + else + HostUtils.RemoveAI(i) + end + end + DisableSlot(i) + GUI.slots[i]:Hide() + gameInfo.ClosedSlots[i] = true + gameInfo.SpawnMex[i] = nil + end + + gameInfo.firstUpdateAvailableSlotsDone = true +end + +local function TryLaunch(skipNoObserversCheck) + if not singlePlayer then + local notReady = GetPlayersNotReady() + if notReady then + for k,v in notReady do + AddChatText(LOCF("%s isn't ready.",v)) + end + return + end + end + + local teamsPresent = {} + + -- make sure there are some players (could all be observers?) + -- Also count teams. There needs to be at least 2 teams (or all FFA) represented + local numPlayers = 0 + local numHumanPlayers = 0 + local numTeams = 0 + for slot, player in gameInfo.PlayerOptions:pairs() do + if player then + numPlayers = numPlayers + 1 + + if player.Human then + numHumanPlayers = numHumanPlayers + 1 + end + + -- Make sure to increment numTeams for people in the special "-" team, represented by 1. + if not teamsPresent[player.Team] or player.Team == 1 then + teamsPresent[player.Team] = true + numTeams = numTeams + 1 + end + end + end + + -- Ensure, for a non-sandbox game, there are some teams to fight. + if gameInfo.GameOptions['Victory'] ~= 'sandbox' and numTeams < 2 then + --AddChatText(LOC("There must be more than one player or team or the Victory Condition must be set to Sandbox.")) + -- In case we start a game as single player we set the game temporarily to Sandbox mode. This will not change the lobby option itself! + SPEW('GameOptions[\'Victory\'] changed temporarily from "'..gameInfo.GameOptions['Victory']..'" to "sandbox"') + gameInfo.GameOptions['Victory'] = 'sandbox' + end + + if numPlayers == 0 then + AddChatText(LOC("There are no players assigned to player slots, can not continue")) + return + end + + if not gameInfo.GameOptions.AllowObservers then + + -- if observers are not allowed, and team spawn is set to penguin_autobalance, and there are + -- an odd number of players, then make the last player an observer now if human + -- (before the check(s)/prompt(s) for having observer(s) when they're not allowed) + if gameInfo.GameOptions.TeamSpawn == 'penguin_autobalance' then + if math.mod(numPlayers, 2) == 1 then + for i = 16, 1, -1 do + -- this gets the last occupied slot + if gameInfo.PlayerOptions[i] then + LOG(gameInfo.PlayerOptions[i].Human) + if gameInfo.PlayerOptions[i].Human then + HostUtils.ConvertPlayerToObserver(i) + end + break + end + end + end + end + + local hostIsObserver = false + local anyOtherObservers = false + for k, observer in gameInfo.Observers:pairs() do + if observer.OwnerID == localPlayerID then + hostIsObserver = true + else + anyOtherObservers = true + end + end + + if hostIsObserver then + AddChatText(LOC("Cannot launch if the host isn't assigned a slot and observers are not allowed.")) + return + end + + if anyOtherObservers and not skipNoObserversCheck then + UIUtil.QuickDialog(GUI, "Launching will kick observers because \"allow observers\" is disabled. Continue?", + "", function() TryLaunch(true) end, + "", nil, nil, nil, true, + {worldCover = false, enterButton = 1, escapeButton = 2}) + return + end + + HostUtils.KickObservers("GameLaunched") + end + + if not EveryoneHasEstablishedConnections(gameInfo.GameOptions.AllowObservers) then + return + end + + numberOfPlayers = numPlayers + local function LaunchGame() + + if gameInfo.GameOptions.TeamSpawn == 'penguin_autobalance' then + GUI.PenguinAutoBalance.OnClick() + end + + -- These two things must happen before the flattening step, mostly for terrible reasons. + -- This isn't ideal, as it leads to redundant UI repaints :/ + AssignAutoTeams() + + -- Force observers to start with the UEF skin to prevent them from launching as "random". + if IsObserver(localPlayerID) then + UIUtil.SetCurrentSkin("uef") + end + + -- Eliminate the WatchedValue structures. + gameInfo = GameInfo.Flatten(gameInfo) + + if gameInfo.GameOptions['RandomMap'] ~= 'Off' then + autoRandMap = true + autoMap() + end + + SetFrontEndData('NextOpBriefing', nil) + -- assign random factions just as game is launched + AssignRandomFactions() + -- fix faction indexes + FixFactionIndexes() + AssignRandomStartSpots() + AssignAINames() + local allRatings = {} + local clanTags = {} + for k, player in gameInfo.PlayerOptions do + if player.PL then + allRatings[player.PlayerName] = player.PL + clanTags[player.PlayerName] = player.PlayerClan + + if not player.Human then + allRatings[player.PlayerName] = ComputeAIRating(gameInfo.GameOptions, player.AILobbyProperties) + end + end + + if player.OwnerID == localPlayerID then + UIUtil.SetCurrentSkin(FACTION_NAMES[player.Faction]) + end + end + gameInfo.GameOptions['Ratings'] = allRatings + gameInfo.GameOptions['ClanTags'] = clanTags + + scenarioInfo = MapUtil.LoadScenario(gameInfo.GameOptions.ScenarioFile) + + -- Load in the default map options if they are not set manually + + -- Not all maps have options + if scenarioInfo.options then + + -- If we don't validate them first then the people using the default + -- as a value instead of the index of the value will mess us up + MapUtil.ValidateScenarioOptions(scenarioInfo.options) + + -- For every option, if it's not set yet then add its default value + for _, option in scenarioInfo.options do + if not gameInfo.GameOptions[option.key] then + -- When the value data of the option is formatted as: + -- values = { + -- { text = "Easy", help = "We'll have sufficient time to start building up our defense strategy.", key = 1, }, + -- { text = "Normal", help = "There's sufficient time - but we'll need to hurry up.", key = 2, }, + -- { text = "Heroic", help = "There's little time - no space for errors.", key = 3, }, + -- { text = "Legendary", help = "We're being dropped in the middle of it - we knew it was a suicide mission when we signed up for it.", key = 4, }, + -- }, + local keyVersion = option.values[option.default].key + + -- When the value data of the option is formatted as: + -- values = { + -- '1','2','3','4','5','6','7','8','9','10','11','12','13','14','15','16','17','18','19','20' + -- } + local valueVersion = option.values[option.default] + + -- Expect a key version, fall back on a value version + gameInfo.GameOptions[option.key] = keyVersion or valueVersion + + -- Can be removed once this code leaves the develop branch + SPEW("Loading default map option: " .. tostring (option.key) .. " = " .. tostring (gameInfo.GameOptions[option.key])) + end + end + end + + if scenarioInfo.AdaptiveMap then + gameInfo.GameOptions["SpawnMex"] = gameInfo.SpawnMex + end + if gameInfo.GameOptions["CheatsEnabled"] == "true" and singlePlayer then + gameInfo.GameOptions["GameSpeed"] = "adjustable" + end + + HostUtils.SendArmySettingsToServer() + + -- Tell everyone else to launch and then launch ourselves. + -- TODO: Sending gamedata here isn't necessary unless lobbyComm is fucking stupid and allows + -- out-of-order message delivery. + -- Downlord: I use this in clients now to store the rehost preset. So if you're going to remove this, please + -- check if rehosting still works for non-host players. + lobbyComm:BroadcastData({ Type = 'Launch', GameInfo = gameInfo }) + + -- set the mods + gameInfo.GameMods = Mods.GetGameMods(gameInfo.GameMods) + + SetWindowedLobby(false) + + Presets.SaveLastGamePreset() + + -- launch the game + lobbyComm:LaunchGame(gameInfo) + + + end + + LaunchGame() +end + +local function AlertHostMapMissing() + if lobbyComm:IsHost() then + HostUtils.PlayerMissingMapAlert(localPlayerID) + else + lobbyComm:SendData(hostID, {Type = 'MissingMap'}) + end +end + +local function UpdateGame() + -- This allows us to assume the existence of UI elements throughout. + if not GUI.uiCreated then + WARN(debug.traceback(nil, "UpdateGame() pointlessly called before UI creation!")) + return + end + + local scenarioInfo + + if gameInfo.GameOptions.ScenarioFile and (gameInfo.GameOptions.ScenarioFile ~= "") then + scenarioInfo = MapUtil.LoadScenario(gameInfo.GameOptions.ScenarioFile) + + -- update AI rating as game settings change + for k = 1, 16 do + local playerOptions = gameInfo.PlayerOptions[k] + if playerOptions then + if not playerOptions.Human then + playerOptions.PL = ComputeAIRating(gameInfo.GameOptions, playerOptions.AILobbyProperties); + playerOptions.MEAN = playerOptions.PL + playerOptions.DEV = 0 + end + end + end + + if scenarioInfo and scenarioInfo.map and scenarioInfo.map ~= '' then + GUI.mapView:SetScenario(scenarioInfo) + ShowMapPositions(GUI.mapView, scenarioInfo) + ConfigureMapListeners(GUI.mapView, scenarioInfo) + + -- Briefing button takes priority over the patch notes if the map has a briefing + if scenarioInfo.hasBriefing then + GUI.briefingButton:Show() + GUI.patchnotesButton:Hide() + else + GUI.briefingButton:Hide() + GUI.patchnotesButton:Show() + end + + -- contains information that is available during blueprint loading + local preGameData = {} + + -- MAP ASSETS LOADING -- + + -- store the (selected) map directory so that we can load individual blueprints from it + preGameData.CurrentMapDir = Dirname(gameInfo.GameOptions.ScenarioFile) + + -- STRATEGIC ICON REPLACEMENT -- + + -- icon replacements + local iconReplacements = { } + + -- retrieve all (selected) mods + local allMods = Mods.AllMods() + local selectedMods = Mods.GetSelectedMods() + + -- loop over selected mods identifiers + for uid, _ in selectedMods do + + -- get the mod, determine path to icon configuration file + local mod = allMods[uid] + + -- check for mod integrity + if not (mod.name and mod.author) then + WARN("Unable to load icons from mod '" .. uid .. "', the mod_info.lua file is not properly defined. It needs a name and author field.") + end + + -- path to configuration file + local iconConfigurationPath = mod.location .. "/mod_icons.lua" + + -- see if it exists + if DiskGetFileInfo(iconConfigurationPath) then + + -- see if we can import it + local ok, msg = pcall( + function() + + -- attempt to load the file + local env = { } + doscript(iconConfigurationPath, env) + + -- syntax errors are caught internally and instead it just returns the table untouched + if not (env.UnitIconAssignments or env.ScriptedIconAssignments) then + error("Lobby.lua - can not import the icon configuration file at '" .. iconConfigurationPath .. "'. This could be due to missing functionality functionality or a parsing error.") + end + end + ) + + -- if it passes this basic check, then continue + if ok then + local info = { } + info.Name = mod.name + info.Author = mod.author + info.Location = mod.location + info.Identifier = string.lower(utils.StringSplit(mod.location, '/')[2]) + info.UID = uid + table.insert(iconReplacements, info) + -- tell us (and then spam the author, not the dev) if it failed + else + WARN("Unable to load icons from mod '" .. tostring(mod.name) .. "' with uid '" .. tostring(uid) .. "'. Please inform the author: " .. tostring(mod.author)) + WARN(msg) + end + end + end + + preGameData.IconReplacements = iconReplacements + + -- try and set the preferences - it may crash when running multiple instances on a single machine that all try and start at the same time. + local ok, msg = pcall( + function() + -- store in preferences so that we can retrieve it during blueprint loading + SetPreference('PreGameData', preGameData) + end + ) + + if not ok then + WARN("Unable to update preferences. Are you running multiple instances on the same machine?" ) + WARN(msg) + end + + -- PREFETCHING -- + + -- Note that the PreGameData is not properly updated, + -- hence we can not rely on mod and / or lobby option + -- changes to be present. + + -- local mods = Mods.GetGameMods(gameInfo.GameMods) + -- PrefetchSession(scenarioInfo.map, mods, true) + + else + AlertHostMapMissing() + GUI.mapView:Clear() + end + end + + local isHost = lobbyComm:IsHost() + + local localPlayerSlot = FindSlotForID(localPlayerID) + if localPlayerSlot then + local playerOptions = gameInfo.PlayerOptions[localPlayerSlot] + + -- Disable some controls if the user is ready. + local notReady = not playerOptions.Ready + + UIUtil.setEnabled(GUI.becomeObserver, notReady) + UIUtil.setEnabled(GUI.briefingButton, notReady) + -- This button is enabled for all non-host players to view the configuration, and for the + -- host to select presets (rather confusingly, one object represents both potential buttons) + UIUtil.setEnabled(GUI.restrictedUnitsOrPresetsBtn, not isHost or notReady) + + UIUtil.setEnabled(GUI.factionSelector, notReady) + if notReady then + UpdateFactionSelector() + end + else + UIUtil.setEnabled(GUI.factionSelector, false) + end + + gameInfo.AdaptiveMap = scenarioInfo.AdaptiveMap + + local numPlayers = GetPlayerCount() + + local numAvailStartSpots = LobbyComm.maxPlayerSlots + if scenarioInfo then + local armyTable = MapUtil.GetArmies(scenarioInfo) + if armyTable then + numAvailStartSpots = table.getn(armyTable) + end + end + + UpdateAvailableSlots(numAvailStartSpots, scenarioInfo) + + -- Update all slots. + for i = 1, LobbyComm.maxPlayerSlots do + if gameInfo.ClosedSlots[i] then + UpdateSlotBackground(i) + else + if gameInfo.PlayerOptions[i] then + SetSlotInfo(i, gameInfo.PlayerOptions[i]) + else + ClearSlotInfo(i) + end + end + end + + if isHost then + HostUtils.RefreshButtonEnabledness() + end + RefreshOptionDisplayData(scenarioInfo) + + -- Update the map background to reflect the possibly-changed map. + if Prefs.GetFromCurrentProfile('LobbyBackground') == 4 then + RefreshLobbyBackground() + end + + -- Set the map name at the top right corner in lobby + if scenarioInfo.name then + GUI.MapNameLabel:StreamText(scenarioInfo.name, 20) + end + + -- Add Tooltip info on Map Name Label + if scenarioInfo then + local TTips_map_version = scenarioInfo.map_version or "1" + local TTips_army = table.getsize(scenarioInfo.Configurations.standard.teams[1].armies) + local TTips_sizeX = scenarioInfo.size[1] / 51.2 + local TTips_sizeY = scenarioInfo.size[2] / 51.2 + + local mapTooltip = { + text = scenarioInfo.name, + body = '- '..LOC("Map version")..' : '..TTips_map_version..'\n '.. + '- '..LOC("Max Players")..' : '..TTips_army..' max'..'\n '.. + '- '..LOC("Map Size")..' : '..TTips_sizeX..'km x '..TTips_sizeY..'km' + } + + Tooltip.AddControlTooltip(GUI.MapNameLabel, mapTooltip) + Tooltip.AddControlTooltip(GUI.GameQualityLabel, mapTooltip) + end + + -- If the large map is shown, update it. + RefreshLargeMap() + + SetRuleTitleText(gameInfo.GameOptions.GameRules or "") + SetGameTitleText(gameInfo.GameOptions.Title or LOC("FAF Game Lobby")) + + if isHost and GUI.autoTeams then + GUI.autoTeams:SetState(gameInfo.GameOptions.AutoTeams,true) + Tooltip.DestroyMouseoverDisplay() + end +end + +--- Update the game quality display +function ShowGameQuality() + GUI.GameQualityLabel:SetText("") + + -- Can't compute a game quality for random spawns! + if gameInfo.GameOptions.TeamSpawn ~= 'fixed' then + return + end + + local teams = Teams.create() + + -- Everything catches fire if the teams aren't numbered sequentially from 1. + -- I hope it is not the case that everything catches fire when there are >2 teams, but in + -- principle that should work... + + -- Start by creating a map from each *used* team to an element from an ascending set of integers. + local tsTeam = 1 + local teamMap = {} + for i = 1, LobbyComm.maxPlayerSlots do + local playerOptions = gameInfo.PlayerOptions[i] + -- Team 1 represents "No team", so these people are all singleton teams. + if playerOptions and (teamMap[playerOptions.Team] == nil or playerOptions.Team == 1) then + teamMap[playerOptions.Team] = tsTeam + tsTeam = tsTeam + 1 + end + end + + -- Now we just use the map to relate real teams to trueSkill teams. + for i = 1, LobbyComm.maxPlayerSlots do + local playerOptions = gameInfo.PlayerOptions[i] + if playerOptions then + -- Can't do it for AI, either, not sensibly. + if not playerOptions.Human and (playerOptions.MEAN or 0) == 0 then + return + end + + local player = Player.create( + playerOptions.PlayerName, + Rating.create(playerOptions.MEAN, playerOptions.DEV) + ) + + teams:addPlayer(teamMap[playerOptions.Team], player) + end + end + + -- Rating only meaningful in games with 2 teams + if table.getsize(teams:getTeams()) ~= 2 then + return + end + + local quality = Trueskill.computeQuality(teams) + + if quality > 0 then + gameInfo.GameOptions.Quality = quality + GUI.GameQualityLabel:StreamText(LOCF("Game quality: %s%%", string.format("%.2f",quality)), 20) + end +end + +-- Holds some utility functions to do with game option management. +local OptionUtils = { + -- Set all game options to their default values. + SetDefaults = function() + local options = {} + for index, option in teamOpts do + options[option.key] = option.values[option.default].key or option.values[option.default] + end + for index, option in globalOpts do + -- Exception to make AllowObservers work because the engine requires + -- the keys to be bool. Custom options should use 'True' or 'False' + if option.key == 'AllowObservers' then + options[option.key] = option.values[option.default].key + else + options[option.key] = option.values[option.default].key or option.values[option.default] + end + end + + for index, option in AIOpts do + options[option.key] = option.values[option.default].key or option.values[option.default] + end + + options.RestrictedCategories = {} + + SetGameOptions(options) + end +} + +-- callback when Mod Manager dialog finishes (modlist==nil on cancel) +-- FIXME: The mod manager should be given a list of game mods set by the host, which +-- clients can look at but not changed, and which don't get saved in our local prefs. +function OnModsChanged(simMods, UIMods, ignoreRefresh) + -- We depend upon ModsManager to not allow the user to change mods they shouldn't be able to + selectedSimMods = simMods + selectedUIMods = UIMods + + Mods.SetSelectedMods(SetUtils.Union(selectedSimMods, selectedUIMods)) + if lobbyComm:IsHost() then + HostUtils.UpdateMods() + end + + if not ignoreRefresh then + -- reload AI types in case we have enable or disable an AI mod. + GetAITypes() + GUI.AIFillCombo:ClearItems() + GUI.AIFillCombo:AddItems(AIStrings) + GUI.AIFillCombo:SetTitleText(LOC('Choose AI for autofilling')) + UpdateGame() + end +end + +function GetAvailableColor() + for i = 1, LobbyComm.maxPlayerSlots do + if IsColorFree(gameColors.LobbyColorOrder[i]) then + return gameColors.LobbyColorOrder[i] + end + end + WARN('Error: No available colors found.') +end + +--- This function is retarded. +-- Unfortunately, we're stuck with it. +-- The game requires both ArmyColor and PlayerColor be set. We don't want to have to write two fields +-- all the time, and the magic that makes PlayerData work precludes adding member functions to it. +-- So, we have this. Tough shit. :P +function SetPlayerColor(playerData, newColor) + playerData.ArmyColor = newColor + playerData.PlayerColor = newColor +end + +function autoMap() + local randomAutoMap + if gameInfo.GameOptions['RandomMap'] == 'Official' then + randomAutoMap = import("/lua/ui/dialogs/mapselect.lua").randomAutoMap(true) + else + randomAutoMap = import("/lua/ui/dialogs/mapselect.lua").randomAutoMap(false) + end +end + +function ClientsMissingMap() + local ret = nil + + for index, player in gameInfo.PlayerOptions:pairs() do + if player.BadMap then + if not ret then ret = {} end + table.insert(ret, player.PlayerName) + end + end + + for index, observer in gameInfo.Observers:pairs() do + if observer.BadMap then + if not ret then ret = {} end + table.insert(ret, observer.PlayerName) + end + end + + return ret +end + +function ClearBadMapFlags() + for index, player in gameInfo.PlayerOptions:pairs() do + player.BadMap = false + end + + for index, observer in gameInfo.Observers:pairs() do + observer.BadMap = false + end +end + +function EnableSlot(slot) + GUI.slots[slot].team:Enable() + GUI.slots[slot].color:Enable() + GUI.slots[slot].faction:Enable() + GUI.slots[slot].ready:Enable() +end + +function DisableSlot(slot, exceptReady) + GUI.slots[slot].team:Disable() + GUI.slots[slot].color:Disable() + GUI.slots[slot].faction:Disable() + if not exceptReady then + GUI.slots[slot].ready:Disable() + end +end + +-- Used for the quick-swap feature +local playersToSwap = false + +-- set up player "slots" which is the line representing a player and player specific options +function CreateSlotsUI(makeLabel) + local Combo = import("/lua/ui/controls/combo.lua").Combo + local BitmapCombo = import("/lua/ui/controls/combo.lua").BitmapCombo + local StatusBar = import("/lua/maui/statusbar.lua").StatusBar + local ColumnLayout = import("/lua/ui/controls/columnlayout.lua").ColumnLayout + + -- The dimensions of the columns used for slot UI controls. + local COLUMN_POSITIONS = {1, 21, 47, 91, 133, 395, 465, 535, 605, 677, 749} + local COLUMN_WIDTHS = {20, 20, 45, 45, 257, 59, 59, 59, 62, 62, 51} + + local labelGroup = ColumnLayout(GUI.playerPanel, COLUMN_POSITIONS, COLUMN_WIDTHS) + + GUI.labelGroup = labelGroup + LayoutHelpers.SetDimensions(labelGroup, 791, 21) + LayoutHelpers.AtLeftTopIn(labelGroup, GUI.playerPanel, 5, 5) + + local slotLabel = makeLabel("--", 14) + labelGroup:AddChild(slotLabel) + + -- No label required for the second column (flag), so skip it. (Even eviler hack) + labelGroup.numChildren = labelGroup.numChildren + 1 + + local ratingLabel = makeLabel("R", 14) + labelGroup:AddChild(ratingLabel) + + local numGamesLabel = makeLabel("G", 14) + labelGroup:AddChild(numGamesLabel) + + local nameLabel = makeLabel(LOC("Nickname"), 14) + labelGroup:AddChild(nameLabel) + + local colorLabel = makeLabel(LOC("Color"), 14) + labelGroup:AddChild(colorLabel) + + local factionLabel = makeLabel(LOC("Faction"), 14) + labelGroup:AddChild(factionLabel) + + local teamLabel = makeLabel(LOC("Team"), 14) + labelGroup:AddChild(teamLabel) + + labelGroup:AddChild(makeLabel(LOC("CPU"), 14)) + + if not singlePlayer then + labelGroup:AddChild(makeLabel(LOC("Ping"), 14)) + labelGroup:AddChild(makeLabel(LOC("Ready"), 14)) + end + + for i= 1, LobbyComm.maxPlayerSlots do + -- Capture the index in the current closure so it's accessible on callbacks + local curRow = i + + -- The background is parented on the GUI so it doesn't vanish when we hide the slot. + local slotBackground = Bitmap(GUI, UIUtil.SkinnableFile("/SLOT/slot-dis.dds")) + + -- Inherit dimensions of the slot control from the background image. + local newSlot = ColumnLayout(GUI.playerPanel, COLUMN_POSITIONS, COLUMN_WIDTHS) + newSlot.Width:Set(slotBackground.Width) + newSlot.Height:Set(slotBackground.Height) + + LayoutHelpers.AtLeftTopIn(slotBackground, newSlot) + newSlot.SlotBackground = slotBackground + + -- Default mouse behaviours for the slot. + local defaultHandler = function(self, event) + if curRow > numOpenSlots then + return + end + + local associatedMarker = GUI.mapView.startPositions[curRow] + if event.Type == 'MouseEnter' then + if gameInfo.GameOptions['TeamSpawn'] == 'fixed' then + associatedMarker.indicator:Play() + end + elseif event.Type == 'MouseExit' then + associatedMarker.indicator:Stop() + elseif event.Type == 'ButtonDClick' then + DoSlotBehavior(curRow, 'occupy', '') + end + + return Group.HandleEvent(self, event) + end + newSlot.HandleEvent = defaultHandler + + -- Slot number + local slotNumber = UIUtil.CreateText(newSlot, tostring(i), 14, 'Arial') + newSlot.slotNumber = slotNumber + LayoutHelpers.SetWidth(slotNumber, COLUMN_WIDTHS[1]) + slotNumber.Height:Set(newSlot.Height) + newSlot:AddChild(slotNumber) + newSlot.tooltipnumber = Tooltip.AddControlTooltip(slotNumber, 'slot_number') + slotNumber.id = i + slotNumber.HandleEvent = function(self,event) + if lobbyComm:IsHost() then + if event.Type == 'ButtonPress' then + if playersToSwap then + --same number clicked + if self.id == playersToSwap then + playersToSwap = false + self:SetColor(UIUtil.fontColor) + elseif gameInfo.PlayerOptions[playersToSwap] then + HostUtils.SwapPlayers(playersToSwap, self.id) + GUI.slots[playersToSwap].slotNumber:SetColor(UIUtil.fontColor) + playersToSwap = false + elseif gameInfo.PlayerOptions[self.id] then + HostUtils.SwapPlayers(self.id, playersToSwap) + GUI.slots[playersToSwap].slotNumber:SetColor(UIUtil.fontColor) + playersToSwap = false + end + else + self:SetColor('ff00ffff') + playersToSwap = self.id + end + end + end + end + -- COUNTRY + -- Added a bitmap on the left of Rating, the bitmap is a Flag of Country + local flag = Bitmap(newSlot, UIUtil.SkinnableFile("/countries/world.dds")) + newSlot.KinderCountry = flag + LayoutHelpers.SetWidth(flag, COLUMN_WIDTHS[2]) + newSlot:AddChild(flag) + + -- TODO: Factorise this boilerplate. + -- Rating + local ratingText = UIUtil.CreateText(newSlot, "", 14, 'Arial') + newSlot.ratingText = ratingText + ratingText:SetColor('B9BFB9') + ratingText:SetDropShadow(true) + newSlot:AddChild(ratingText) + + -- NumGame + local numGamesText = UIUtil.CreateText(newSlot, "", 14, 'Arial') + newSlot.numGamesText = numGamesText + numGamesText:SetColor('B9BFB9') + numGamesText:SetDropShadow(true) + Tooltip.AddControlTooltip(numGamesText, 'num_games') + newSlot:AddChild(numGamesText) + + -- Name + local nameLabel = Combo(newSlot, 14, 16, true, nil, "UI_Tab_Rollover_01", "UI_Tab_Click_01") + newSlot.name = nameLabel + nameLabel._text:SetFont('Arial Gras', 15) + newSlot:AddChild(nameLabel) + LayoutHelpers.SetWidth(nameLabel, COLUMN_WIDTHS[5]) + -- left deal with name clicks + nameLabel.OnEvent = defaultHandler + nameLabel.OnClick = function(self, index, text) + DoSlotBehavior(curRow, self.slotKeys[index], text) + end + + -- Hide the marker when the dropdown is hidden + nameLabel.OnHide = function() + local associatedMarker = GUI.mapView.startPositions[curRow] + if associatedMarker then + associatedMarker.indicator:Stop() + end + end + + -- Color + local colorSelector = BitmapCombo(newSlot, gameColors.PlayerColors, 1, true, nil, "UI_Tab_Rollover_01", "UI_Tab_Click_01") + newSlot.color = colorSelector + + newSlot:AddChild(colorSelector) + LayoutHelpers.SetWidth(colorSelector, COLUMN_WIDTHS[6]) + colorSelector.OnClick = function(self, index) + if not lobbyComm:IsHost() then + lobbyComm:SendData(hostID, { Type = 'RequestColor', Color = index }) + SetPlayerColor(gameInfo.PlayerOptions[curRow], index) + UpdateGame() + else + if IsColorFree(index) then + lobbyComm:BroadcastData({ Type = 'SetColor', Color = index, Slot = curRow }) + SetPlayerColor(gameInfo.PlayerOptions[curRow], index) + UpdateGame() + else + self:SetItem(gameInfo.PlayerOptions[curRow].PlayerColor) + end + end + end + colorSelector.OnEvent = defaultHandler + Tooltip.AddControlTooltip(colorSelector, 'lob_color') + + -- Faction + -- builds the faction tables, and then adds random faction icon to the end + local factionBmps = {} + local factionTooltips = {} + local factionList = {} + for index, tbl in FactionData.Factions do + factionBmps[index] = tbl.SmallIcon + factionTooltips[index] = tbl.TooltipID + factionList[index] = tbl.Key + end + table.insert(factionBmps, "/faction_icon-sm/random_ico.dds") + table.insert(factionTooltips, 'lob_random') + table.insert(factionList, 'random') + allAvailableFactionsList = factionList + + local factionSelector = BitmapCombo(newSlot, factionBmps, table.getn(factionBmps), nil, nil, "UI_Tab_Rollover_01", "UI_Tab_Click_01") + newSlot.faction = factionSelector + newSlot.AvailableFactions = factionList + newSlot:AddChild(factionSelector) + LayoutHelpers.SetWidth(factionSelector, COLUMN_WIDTHS[7]) + factionSelector.OnClick = function(self, index) + SetPlayerOption(curRow, 'Faction', index) + if curRow == FindSlotForID(FindIDForName(localPlayerName)) then + local fact = GUI.slots[FindSlotForID(localPlayerID)].AvailableFactions[index] + for ind,value in allAvailableFactionsList do + if fact == value then + GUI.factionSelector:SetSelected(ind) + break + end + end + end + + Tooltip.DestroyMouseoverDisplay() + end + Tooltip.AddControlTooltip(factionSelector, 'lob_faction') + Tooltip.AddComboTooltip(factionSelector, factionTooltips) + factionSelector.OnEvent = defaultHandler + + -- Team + local teamSelector = Combo(newSlot, 17, 9, nil, nil, "UI_Tab_Rollover_01", "UI_Tab_Click_01") + teamSelector:AddItems({' - ', ' 1', ' 2', ' 3', ' 4', ' 5', ' 6', ' 7', ' 8'}) + teamSelector._text:SetFont('Arial', 14) + teamSelector._titleColor = 'White' + newSlot.team = teamSelector + newSlot:AddChild(teamSelector) + LayoutHelpers.SetWidth(teamSelector, COLUMN_WIDTHS[8]) + teamSelector.OnClick = function(self, index, text) + Tooltip.DestroyMouseoverDisplay() + SetPlayerOption(curRow, 'Team', index) + end + Tooltip.AddControlTooltip(teamSelector, 'lob_team') + teamSelector.OnEvent = defaultHandler + + -- CPU + local barMax = 450 + local barMin = 0 + local CPUGroup = Group(newSlot) + newSlot.CPUGroup = CPUGroup + LayoutHelpers.SetWidth(CPUGroup, COLUMN_WIDTHS[9]) + CPUGroup.Height:Set(newSlot.Height) + newSlot:AddChild(CPUGroup) + local CPUSpeedBar = StatusBar(CPUGroup, barMin, barMax, false, false, + UIUtil.UIFile('/game/unit_bmp/bar_black_bmp.dds'), + UIUtil.UIFile('/game/unit_bmp/bar_purple_bmp.dds'), + true) + newSlot.CPUSpeedBar = CPUSpeedBar + LayoutHelpers.AtTopIn(CPUSpeedBar, CPUGroup, 7) + LayoutHelpers.AtLeftIn(CPUSpeedBar, CPUGroup, 0) + LayoutHelpers.AtRightIn(CPUSpeedBar, CPUGroup, 0) + CPU_AddControlTooltip(CPUSpeedBar, 0, curRow) + CPUSpeedBar.CPUActualValue = 450 + CPUSpeedBar.barMax = barMax + + -- Ping + barMax = 1000 + barMin = 0 + local pingGroup = Group(newSlot) + newSlot.pingGroup = pingGroup + LayoutHelpers.SetWidth(pingGroup, COLUMN_WIDTHS[10]) + pingGroup.Height:Set(newSlot.Height) + newSlot:AddChild(pingGroup) + local pingStatus = StatusBar(pingGroup, barMin, barMax, false, false, + UIUtil.SkinnableFile('/game/unit_bmp/bar-back_bmp.dds'), + UIUtil.SkinnableFile('/game/unit_bmp/bar-01_bmp.dds'), + true) + newSlot.pingStatus = pingStatus + LayoutHelpers.AtTopIn(pingStatus, pingGroup, 7) + LayoutHelpers.AtLeftIn(pingStatus, pingGroup, 0) + LayoutHelpers.AtRightIn(pingStatus, pingGroup, 0) + Ping_AddControlTooltip(pingStatus, 0, curRow) + + -- Ready Checkbox + local readyBox = UIUtil.CreateCheckbox(newSlot, '/CHECKBOX/') + newSlot.ready = readyBox + newSlot:AddChild(readyBox) + readyBox.OnCheck = function(self, checked) + UIUtil.setEnabled(GUI.becomeObserver, not checked) + if checked then + DisableSlot(curRow, true) + else + EnableSlot(curRow) + end + SetPlayerOption(curRow, 'Ready', checked) + end + + newSlot.HideControls = function() + -- hide these to clear slot of visible data + flag:Hide() + ratingText:Hide() + numGamesText:Hide() + factionSelector:Hide() + colorSelector:Hide() + teamSelector:Hide() + CPUSpeedBar:Hide() + pingStatus:Hide() + readyBox:Hide() + end + newSlot.HideControls() + + if singlePlayer then + -- TODO: Use of groups may allow this to be simplified... + readyBox:Hide() + pingStatus:Hide() + end + + if i == 1 then + LayoutHelpers.Below(newSlot, GUI.labelGroup) + else + LayoutHelpers.Below(newSlot, GUI.slots[i - 1], 3) + end + + GUI.slots[i] = newSlot + end +end + +-- create UI won't typically be called directly by another module +function CreateUI(maxPlayers) + local ResourceMapPreview = import("/lua/ui/controls/resmappreview.lua").ResourceMapPreview + local ItemList = import("/lua/maui/itemlist.lua").ItemList + local Prefs = import("/lua/user/prefs.lua") + local Tooltip = import("/lua/ui/game/tooltip.lua") + local Combo = import("/lua/ui/controls/combo.lua") + + local isHost = lobbyComm:IsHost() + local lastFaction = GetSanitisedLastFaction() + UIUtil.SetCurrentSkin(FACTION_NAMES[lastFaction]) + + --------------------------------------------------------------------------- + -- Set up main control panels + --------------------------------------------------------------------------- + GUI.panel = Bitmap(GUI, UIUtil.SkinnableFile("/scx_menu/lan-game-lobby/lobby.dds")) + LayoutHelpers.AtCenterIn(GUI.panel, GUI) + GUI.panelWideLeft = Bitmap(GUI, UIUtil.SkinnableFile('/scx_menu/lan-game-lobby/wide.dds')) + LayoutHelpers.CenteredLeftOf(GUI.panelWideLeft, GUI.panel) + GUI.panelWideLeft.Left:Set(function() return GUI.Left() end) + GUI.panelWideRight = Bitmap(GUI, UIUtil.SkinnableFile('/scx_menu/lan-game-lobby/wide.dds')) + LayoutHelpers.CenteredRightOf(GUI.panelWideRight, GUI.panel) + GUI.panelWideRight.Right:Set(function() return GUI.Right() end) + + -- Create a label with a given size and initial text + local function makeLabel(text, size) + return UIUtil.CreateText(GUI.panel, text, size, 'Arial Gras', true) + end + + -- Map name label + GUI.MapNameLabel = makeLabel(LOC("Loading..."), 17) + LayoutHelpers.AtRightTopIn(GUI.MapNameLabel, GUI.panel, 5, 45) + + -- Game Quality Label + GUI.GameQualityLabel = makeLabel("", 11) + LayoutHelpers.AtRightTopIn(GUI.GameQualityLabel, GUI.panel, 5, 64) + + -- Title Label + GUI.titleText = makeLabel(LOC("FAF Game Lobby"), 17) + LayoutHelpers.AtLeftTopIn(GUI.titleText, GUI.panel, 5, 20) + + if isHost then + GUI.titleText.HandleEvent = function(self, event) + if event.Type == 'ButtonPress' then + ShowTitleDialog() + end + end + end + + -- Rule Label + local RuleLabel = TextArea(GUI.panel, 350, 34) + GUI.RuleLabel = RuleLabel + RuleLabel:SetFont('Arial Gras', 11) + RuleLabel:SetColors("B9BFB9", "00000000", "B9BFB9", "00000000") + LayoutHelpers.AtLeftTopIn(RuleLabel, GUI.panel, 5, 44) + RuleLabel:DeleteAllItems() + local tmptext + if isHost then + tmptext = LOC("No Rules: Click to add rules") + RuleLabel:SetColors("FFCC00") + else + tmptext = LOC("No rules") + end + + RuleLabel:SetText(tmptext) + if isHost then + RuleLabel.OnClick = function(self) + ShowRuleDialog() + end + end + + -- Mod Label + GUI.ModFeaturedLabel = makeLabel("", 13) + LayoutHelpers.AtLeftTopIn(GUI.ModFeaturedLabel, GUI.panel, 50, 61) + + -- Set the mod name to a value appropriate for the mod in use. + local modLabels = { + ["init_faf.lua"] = "FA Forever", + ["init_blackops.lua"] = "BlackOps", + ["init_coop.lua"] = "COOP", + ["init_balancetesting.lua"] = "Balance Testing", + ["init_gw.lua"] = "Galactic War", + ["init_labwars.lua"] = "Labwars", + ["init_ladder1v1.lua"] = "Ladder 1v1", + ["init_nomads.lua"] = "Nomads Mod", + ["init_phantomx.lua"] = "PhantomX", + ["init_supremedestruction.lua"] = "SupremeDestruction", + ["init_xtremewars.lua"] = "XtremeWars", + + } + GUI.ModFeaturedLabel:StreamText(modLabels[argv.initName] or "", 20) + + -- Lobby options panel + GUI.LobbyOptions = UIUtil.CreateButtonWithDropshadow(GUI.panel, '/BUTTON/medium/', LOC("Settings")) + LayoutHelpers.AtRightTopIn(GUI.LobbyOptions, GUI.panel, 44, 3) + GUI.LobbyOptions.OnClick = function() + ShowLobbyOptionsDialog() + end + Tooltip.AddButtonTooltip(GUI.LobbyOptions, 'lobby_click_Settings') + + -- Logo + GUI.logo = Bitmap(GUI, '/textures/ui/common/scx_menu/lan-game-lobby/logo.dds') + LayoutHelpers.AtLeftTopIn(GUI.logo, GUI, 1, 1) + + local version, gametype, commit = import("/lua/version.lua").GetVersionData() + GUI.gameVersionText = UIUtil.CreateText(GUI.panel, LOC('Game version ') .. version, 9, UIUtil.bodyFont) + GUI.gameVersionText:SetColor('677983') + GUI.gameVersionText:SetDropShadow(true) + + Tooltip.AddControlTooltipManual(GUI.gameVersionText, 'Version control', string.format( + LOC('Game version: %s\nGame type: %s\nCommit hash: %s'), version, gametype, commit:sub(1, 8) + )) + + LayoutHelpers.AtLeftTopIn(GUI.gameVersionText, GUI.panel, 70, 3) + + -- Player Slots + GUI.playerPanel = Group(GUI.panel, "playerPanel") + LayoutHelpers.AtLeftTopIn(GUI.playerPanel, GUI.panel, 6, 70) + LayoutHelpers.SetDimensions(GUI.playerPanel, 706, 307) + + -- Observer section + GUI.observerPanel = Group(GUI.panel, "observerPanel") + + -- Scale the observer panel according to the buttons we are showing. + local obsOffset + local obsHeight + if isHost then + obsHeight = 84--159 + obsOffset = 620--545 + else + obsHeight = 206 + obsOffset = 498 + end + LayoutHelpers.AtLeftTopIn(GUI.observerPanel, GUI.panel, 512, obsOffset) + LayoutHelpers.SetDimensions(GUI.observerPanel, 278, obsHeight) + UIUtil.SurroundWithBorder(GUI.observerPanel, '/scx_menu/lan-game-lobby/frame/') + + -- Chat + GUI.chatPanel = Group(GUI.panel, "chatPanel") + LayoutHelpers.AtLeftTopIn(GUI.chatPanel, GUI.panel, 11, 459) + LayoutHelpers.SetWidth(GUI.chatPanel, 478) + LayoutHelpers.SetHeight(GUI.chatPanel, 245) + UIUtil.SurroundWithBorder(GUI.chatPanel, '/scx_menu/lan-game-lobby/frame/') + + if isHost then + GUI.AIFillPanel = Group(GUI.panel) + GUI.AIFillPanel.Left:Set(GUI.observerPanel.Left) + GUI.AIFillPanel.Top:Set(GUI.chatPanel.Top) + LayoutHelpers.SetHeight(GUI.AIFillPanel, 60) + LayoutHelpers.SetWidth(GUI.AIFillPanel, 278) + UIUtil.SurroundWithBorder(GUI.AIFillPanel, '/scx_menu/lan-game-lobby/frame/') + GUI.AIFillCombo = Combo.Combo(GUI.AIFillPanel, 14, 12, false, nil) + LayoutHelpers.AtHorizontalCenterIn(GUI.AIFillCombo, GUI.AIFillPanel) + LayoutHelpers.AtTopIn(GUI.AIFillCombo, GUI.AIFillPanel, 5) + GUI.AIFillCombo.Width:Set(function() return GUI.AIFillPanel.Width() - LayoutHelpers.ScaleNumber(15) end) + GUI.AIFillCombo:AddItems(AIStrings) + GUI.AIFillCombo:SetTitleText(LOC('Choose AI for autofilling')) + Tooltip.AddComboTooltip(GUI.AIFillCombo, AITooltips) + GUI.AIFillButton = UIUtil.CreateButtonStd(GUI.AIFillCombo, '/BUTTON/medium/', LOC('Fill Slots'), 12) + LayoutHelpers.SetWidth(GUI.AIFillButton, 129) + LayoutHelpers.SetHeight(GUI.AIFillButton, 30) + LayoutHelpers.AtLeftTopIn(GUI.AIFillButton, GUI.AIFillCombo, -10, 20) + GUI.AIClearButton = UIUtil.CreateButtonStd(GUI.AIFillButton, '/BUTTON/medium/', LOC('Clear Slots'), 12) + GUI.AIClearButton.Width:Set(GUI.AIFillButton.Width) + GUI.AIClearButton.Height:Set(GUI.AIFillButton.Height) + LayoutHelpers.RightOf(GUI.AIClearButton, GUI.AIFillButton, -19) + GUI.TeamCountSelector = Combo.BitmapCombo(GUI.AIClearButton, teamIcons, 1, false, nil, "UI_Tab_Rollover_01", "UI_Tab_Click_01") + LayoutHelpers.SetWidth(GUI.TeamCountSelector, 44) + LayoutHelpers.AtTopIn(GUI.TeamCountSelector, GUI.AIClearButton, 5) + LayoutHelpers.AtRightIn(GUI.TeamCountSelector, GUI.AIFillPanel, 8) + local tooltipText = {} + tooltipText['text'] = 'Teams Count' + tooltipText['body'] = 'On how many teams share players?' + Tooltip.AddControlTooltip(GUI.TeamCountSelector, tooltipText, 0) + local ChangedSlots = {} + GUI.AIFillButton.OnClick = function() + local AIKeyIndex, AIName = GUI.AIFillCombo:GetItem() + if ChangedSlots[1] ~= nil then + for i = 1, table.getn(ChangedSlots) do + HostUtils.AddAI(AIName, AIKeys[AIKeyIndex], ChangedSlots[i]) + end + else + for Slot = 1, GetNumAvailStartSpots() do + if not (gameInfo.PlayerOptions[Slot] or gameInfo.ClosedSlots[Slot]) then + HostUtils.AddAI(AIName, AIKeys[AIKeyIndex], Slot) + table.insert(ChangedSlots, Slot) + end + end + end + if gameInfo.GameOptions.AutoTeams == 'none' then + GUI.TeamCountSelector.OnClick(nil, GUI.TeamCountSelector:GetItem(), nil) + else + AssignAutoTeams() + end + end + GUI.AIClearButton.OnClick = function() + for i = 1, table.getn(ChangedSlots) do + HostUtils.RemoveAI(ChangedSlots[i]) + end + ChangedSlots = {} + end + GUI.TeamCountSelector.OnClick = function(Self, Index, Text) + local OccupiedSlots = 0 + local AvailStartSpots = GetNumAvailStartSpots() + for Slot = 1, AvailStartSpots do + if gameInfo.PlayerOptions[Slot] ~= nil then + OccupiedSlots = OccupiedSlots + 1 + end + end + local PlayersPerTeam = 0 + if Index > 1 then + PlayersPerTeam = math.floor(OccupiedSlots / (Index - 1)) + end + local AssignedTeam = 2 + local Counter = 0 + for Slot = 1, AvailStartSpots do + if gameInfo.PlayerOptions[Slot] then + if AssignedTeam > Index then + SetPlayerOption(Slot, 'Team', 1, true) + else + SetPlayerOption(Slot, 'Team', AssignedTeam, true) + Counter = Counter + 1 + if Counter >= PlayersPerTeam then + AssignedTeam = AssignedTeam + 1 + Counter = 0 + end + end + SetSlotInfo(Slot, gameInfo.PlayerOptions[Slot]) + end + end + end + end + + -- Map Preview + GUI.mapPanel = Group(GUI.panel, "mapPanel") + LayoutHelpers.AtLeftTopIn(GUI.mapPanel, GUI.panel, 813, 88) + LayoutHelpers.SetDimensions(GUI.mapPanel, 198, 198) + LayoutHelpers.DepthOverParent(GUI.mapPanel, GUI.panel, 2) + UIUtil.SurroundWithBorder(GUI.mapPanel, '/scx_menu/lan-game-lobby/frame/') + + -- Map Preview Info Labels + local tooltipText = {} + tooltipText['text'] = LOC("Map Preview") + -- Map Preview Info Labels + if isHost then + tooltipText['body'] = LOCF("%s\n%s", "Left click ACU icon to move yourself or swap players.", "Right click ACU icon to close or open the slot.") + else + tooltipText['body'] = LOC("Left click ACU icon to move yourself.") + end + Tooltip.AddControlTooltip(GUI.mapPanel, tooltipText) + + GUI.optionsPanel = Group(GUI.panel, "optionsPanel") -- ORANGE Square in Screenshoot + LayoutHelpers.AtLeftTopIn(GUI.optionsPanel, GUI.panel, 813, 325) + LayoutHelpers.SetDimensions(GUI.optionsPanel, 198, 337) + LayoutHelpers.DepthOverParent(GUI.optionsPanel, GUI.panel, 2) + UIUtil.SurroundWithBorder(GUI.optionsPanel, '/scx_menu/lan-game-lobby/frame/') + + --------------------------------------------------------------------------- + -- set up map panel + --------------------------------------------------------------------------- + GUI.mapView = ResourceMapPreview(GUI.mapPanel, 200, 3, 5) + LayoutHelpers.AtLeftTopIn(GUI.mapView, GUI.mapPanel, -1, -1) + LayoutHelpers.DepthOverParent(GUI.mapView, GUI.mapPanel, -1) + + GUI.LargeMapPreview = UIUtil.CreateButtonWithDropshadow(GUI.mapPanel, '/BUTTON/zoom/', "") + LayoutHelpers.SetDimensions(GUI.LargeMapPreview, 30, 30) + LayoutHelpers.AtRightIn(GUI.LargeMapPreview, GUI.mapPanel, -1) + LayoutHelpers.AtBottomIn(GUI.LargeMapPreview, GUI.mapPanel, -1) + LayoutHelpers.DepthOverParent(GUI.LargeMapPreview, GUI.mapPanel, 2) + Tooltip.AddButtonTooltip(GUI.LargeMapPreview, 'lob_click_LargeMapPreview') + GUI.LargeMapPreview.OnClick = function() + CreateBigPreview(GUI) + end + + -- Checkbox Show changed Options + local cbox_ShowChangedOption = UIUtil.CreateCheckbox(GUI.optionsPanel, '/CHECKBOX/', LOC("Hide default options"), true, 11) + LayoutHelpers.AtLeftTopIn(cbox_ShowChangedOption, GUI.optionsPanel, 0, -32) + + Tooltip.AddCheckboxTooltip(cbox_ShowChangedOption, {text=LOC("Hide default options"), body=LOC("Show only changed Options and Advanced Map Options")}) + cbox_ShowChangedOption.OnCheck = function(self, checked) + HideDefaultOptions = checked + RefreshOptionDisplayData() + GUI.OptionContainer:ScrollSetTop('Vert', 0) + Prefs.SetToCurrentProfile('LobbyHideDefaultOptions', tostring(checked)) + end + + -- Patchnotes Button + GUI.patchnotesButton = UIUtil.CreateButtonWithDropshadow(GUI.panel, '/Button/medium/', "Patchnotes") + Tooltip.AddButtonTooltip(GUI.patchnotesButton, 'Lobby_patchnotes') + LayoutHelpers.AtBottomIn(GUI.patchnotesButton, GUI.optionsPanel, -51) + LayoutHelpers.AtHorizontalCenterIn(GUI.patchnotesButton, GUI.optionsPanel, -55) + GUI.patchnotesButton.OnClick = function(self, event) + import("/lua/ui/lobby/changelog/changelogdialog.lua").CreateChangelogDialog(GUI) + end + + -- Create mission briefing button + local briefingButton = UIUtil.CreateButtonWithDropshadow(GUI.optionsPanel, '/BUTTON/medium/', "Briefing") + GUI.briefingButton = briefingButton + LayoutHelpers.AtBottomIn(GUI.briefingButton, GUI.optionsPanel, -51) + LayoutHelpers.AtHorizontalCenterIn(GUI.briefingButton, GUI.optionsPanel, -55) + briefingButton.OnClick = function(self, modifiers) + GUI.briefing = Group(GUI) + GUI.briefing.Depth:Set(function() return GUI.Depth() + 20 end) + LayoutHelpers.FillParent(GUI.briefing, GUI) + import('/lua/ui/campaign/operationbriefing.lua').CreateUI(GUI.briefing, gameInfo.GameOptions.ScenarioFile) + end + + -- A buton that, for the host, is "game options", but for everyone else shows a ready-only mod + -- manager. + if isHost then + GUI.gameoptionsButton = UIUtil.CreateButtonWithDropshadow(GUI.optionsPanel, '/BUTTON/medium/', "") + Tooltip.AddButtonTooltip(GUI.gameoptionsButton, 'lob_select_map') + GUI.gameoptionsButton.OnClick = function(self) + local mapSelectDialog + + autoRandMap = false + local function selectBehavior(selectedScenario, changedOptions, restrictedCategories) + local options = {} + if autoRandMap then + options['ScenarioFile'] = selectedScenario.file + else + mapSelectDialog:Destroy() + GUI.chatEdit:AcquireFocus() + + -- remove old 'Advanced options incase of new map + if gameInfo.GameOptions.ScenarioFile and string.lower(selectedScenario.file) ~= string.lower(gameInfo.GameOptions.ScenarioFile) then + local scenario = MapUtil.LoadScenario(gameInfo.GameOptions.ScenarioFile) + if scenario.options then + for _,value in scenario.options do + gameInfo.GameOptions[value.key] = nil + end + end + end + + for optionKey, data in changedOptions do + options[optionKey] = data.value + end + options['ScenarioFile'] = selectedScenario.file + options['RestrictedCategories'] = restrictedCategories + + -- every new map, clear the flags, and clients will report if a new map is bad + ClearBadMapFlags() + HostUtils.UpdateMods() + SetGameOptions(options) + end + for optionKey, data in changedOptions do + if optionKey == 'AutoTeams' then + AssignAutoTeams() + end + end + end + + local function exitBehavior() + mapSelectDialog:Close() + GUI.chatEdit:AcquireFocus() + UpdateGame() + end + + GUI.chatEdit:AbandonFocus() + + mapSelectDialog = import("/lua/ui/dialogs/mapselect.lua").CreateDialog( + selectBehavior, + exitBehavior, + GUI, + singlePlayer, + gameInfo.GameOptions.ScenarioFile, + gameInfo.GameOptions, + availableMods, + OnModsChanged + ) + end + else + local modsManagerCallback = function(active_sim_mods, active_ui_mods) + import("/lua/mods.lua").SetSelectedMods(SetUtils.Union(active_sim_mods, active_ui_mods)) + RefreshOptionDisplayData() + GUI.chatEdit:AcquireFocus() + end + GUI.gameoptionsButton = UIUtil.CreateButtonWithDropshadow(GUI.optionsPanel, '/BUTTON/medium/', LOC("")) + GUI.gameoptionsButton.OnClick = function(self, modifiers) + import("/lua/ui/lobby/modsmanager.lua").CreateDialog(GUI, false, nil, modsManagerCallback) + end + Tooltip.AddButtonTooltip(GUI.gameoptionsButton, 'Lobby_Mods') + end + + LayoutHelpers.AtBottomIn(GUI.gameoptionsButton, GUI.optionsPanel, -51) + LayoutHelpers.AtHorizontalCenterIn(GUI.gameoptionsButton, GUI.optionsPanel, 53) + + --------------------------------------------------------------------------- + -- set up chat display + --------------------------------------------------------------------------- + + GUI.chatDisplay = import("/lua/ui/lobby/chatarea.lua").ChatArea( + GUI.chatPanel, + function() return GUI.chatPanel.Width() - 20 end, + function() return GUI.chatPanel.Height() - GUI.chatBG.Height() end + ) + LayoutHelpers.AtLeftTopIn(GUI.chatDisplay, GUI.chatPanel, 2) + LayoutHelpers.DepthUnderParent(GUI.chatDisplay, GUI.chatPanel) + + --------------------------------------------------------------------------- + -- set up all .*Scroll* functions for the chat panel + --------------------------------------------------------------------------- + GUI.chatPanel.top = 1 -- using 1-based index scrolling + + -- this function get index of 1st line on the last scroll page (when scroll all the way down) + GUI.chatPanel.GetScrollLastPage = function(self) + return table.getn(GUI.chatDisplay.ChatLines) - self.linesPerScrollPage + end + -- this function gets scrolling max range and current range + GUI.chatPanel.GetScrollValues = function(self, axis) + local max = table.getsize(GUI.chatDisplay.ChatLines) + local bottom = math.min(self.top + self.linesPerScrollPage, max) + return 1, max, self.top, bottom + end + -- this function controls how many lines to scroll when clicking on up/down arrows of the scrollbar + GUI.chatPanel.ScrollLines = function(self, axis, delta) + self:ScrollSetTop(axis, self.top + math.floor(delta)) + end + -- this function controls how many pages to scroll when clicking above/below thumb of the scrollbar + GUI.chatPanel.ScrollPages = function(self, axis, delta) + self:ScrollSetTop(axis, self.top + math.floor(delta) * self.linesPerScrollPage) + end + -- this function controls how to scroll to an item from top index + GUI.chatPanel.ScrollSetTop = function(self, axis, top) + top = math.floor(top) + if top == self.top then return end + local delta = self:GetScrollLastPage() + self.top = math.max(math.min(delta + 1, top), 1) + self.bottom = self.top + self.linesPerScrollPage + GUI.chatDisplay:ShowLines(self.top, self.bottom) + if self.top >= delta + 1 then + GUI.newMessageArrow:Disable() + end + end + -- this function triggers scrolling on mouse wheel event + GUI.chatPanel.HandleEvent = function(self, event) + if event.Type == 'WheelRotation' then + -- scroll chat panel by 1 line in up/down direction + local lines = event.WheelRotation > 0 and -1 or 1 + self:ScrollLines(nil, lines) + end + end + -- this function informs vertical scrollbar that the chat panel can be scrolled + GUI.chatPanel.IsScrollable = function(self, axis) + return true + end + GUI.chatPanel.ScrollToBottom = function(self) + self:ScrollSetTop(nil, self:GetScrollLastPage() + 1) + end + GUI.chatPanel.IsScrolledToBottom = function(self) + return self.top >= self:GetScrollLastPage() + end + -- this function set how many chat lines can fit per scroll page (chatPanel) + GUI.chatPanel.LinesOnPage = import("/lua/lazyvar.lua").Create() + GUI.chatPanel.LinesOnPage.OnDirty = function(var) + GUI.chatPanel.linesPerScrollPage = var() + end + -- --------- Chat Scrolling Functions ----------------------- + + -- this function sets font for all chat lines and re-creates them + GUI.chatPanel.SetFont = function(self, fontFamily, fontSize) + GUI.chatDisplay:SetFont(fontFamily, fontSize) + GUI.chatDisplay:ShowLines(self.top, self.bottom) + end + -- set initial scrolling based on chat font size + local fontSize = tonumber(Prefs.GetFromCurrentProfile('LobbyChatFontSize')) or 14 + + local newMessageArrow = Button(GUI.chatPanel, '/textures/ui/common/lobby/chat_arrow/arrow_up.dds', '/textures/ui/common/lobby/chat_arrow/arrow_down.dds', '/textures/ui/common/lobby/chat_arrow/arrow_down.dds','/textures/ui/common/lobby/chat_arrow/arrow_dis.dds', "UI_Arrow_Click") + GUI.newMessageArrow = newMessageArrow + -- newMessageArrow:SetTexture('/textures/ui/common/FACTIONSELECTOR/aeon/d_up.dds') + LayoutHelpers.AtBottomIn(newMessageArrow, GUI.chatDisplay, 5) + LayoutHelpers.AtRightIn(newMessageArrow, GUI.chatDisplay, 5) + LayoutHelpers.DepthOverParent(newMessageArrow, GUI.chatDisplay, 5) + newMessageArrow.Width:Set(25) + newMessageArrow.Height:Set(25) + GUI.newMessageArrow.OnClick = function(this, modifiers) + GUI.chatPanel:ScrollToBottom() + end + GUI.newMessageArrow:Disable() + + -- Annoying evil extra Bitmap to make chat box have padding inside its background. + local chatBG = Bitmap(GUI.chatPanel) + GUI.chatBG = chatBG + chatBG:SetSolidColor('FF212123') + LayoutHelpers.Below(chatBG, GUI.chatDisplay, 0) + LayoutHelpers.AtLeftIn(chatBG, GUI.chatDisplay, -2) + chatBG.Width:Set(GUI.chatPanel.Width) + LayoutHelpers.SetHeight(chatBG, 24) + + -- Set up the chat edit buttons and functions + setupChatEdit(GUI.chatPanel) + -- finally create chat lines + GUI.chatDisplay:CreateLines() + --------------------------------------------------------------------------- + -- Option display + --------------------------------------------------------------------------- + GUI.OptionContainer = Group(GUI.optionsPanel) + GUI.OptionContainer.Bottom:Set(function() return GUI.optionsPanel.Bottom() end) + + -- Leave space for the scrollbar. + GUI.OptionContainer.Width:Set(function() return GUI.optionsPanel.Width() - LayoutHelpers.ScaleNumber(18) end) + GUI.OptionContainer.top = 0 + LayoutHelpers.AtLeftTopIn(GUI.OptionContainer, GUI.optionsPanel, 1, 1) + LayoutHelpers.DepthOverParent(GUI.OptionContainer, GUI.optionsPanel, -1) + + GUI.OptionDisplay = {} + + function CreateOptionElements() + local function CreateElement(index) + local element = Group(GUI.OptionContainer) + + element.bg = Bitmap(element) + element.bg:SetSolidColor('ff333333') + element.bg.Left:Set(element.Left) + element.bg.Right:Set(element.Right) + element.bg.Bottom:Set(function() return element.value.Bottom() + 2 end) + element.bg.Top:Set(element.Top) + + element.bg2 = Bitmap(element) + element.bg2:SetSolidColor('ff000000') + element.bg2.Left:Set(function() return element.bg.Left() + 1 end) + element.bg2.Right:Set(function() return element.bg.Right() - 1 end) + element.bg2.Bottom:Set(function() return element.bg.Bottom() - 1 end) + element.bg2.Top:Set(function() return element.value.Top() + 0 end) + + LayoutHelpers.SetHeight(element, 36) + element.Width:Set(GUI.OptionContainer.Width) + element:DisableHitTest() + + element.text = UIUtil.CreateText(element, '', 14, "Arial") + element.text:SetColor(UIUtil.fontColor) + element.text:DisableHitTest() + LayoutHelpers.AtLeftTopIn(element.text, element, 5) + + element.value = UIUtil.CreateText(element, '', 14, "Arial") + element.value:SetColor(UIUtil.fontOverColor) + element.value:DisableHitTest() + LayoutHelpers.AtRightTopIn(element.value, element, 5, 16) + + GUI.OptionDisplay[index] = element + end + + CreateElement(1) + LayoutHelpers.AtLeftTopIn(GUI.OptionDisplay[1], GUI.OptionContainer) + + local index = 2 + while index ~= 10 do + CreateElement(index) + LayoutHelpers.Below(GUI.OptionDisplay[index], GUI.OptionDisplay[index-1]) + index = index + 1 + end + end + CreateOptionElements() + + local numLines = function() return table.getsize(GUI.OptionDisplay) end + + local function DataSize() + if HideDefaultOptions then + return table.getn(nonDefaultFormattedOptions) + else + return table.getn(formattedOptions) + end + end + + -- called when the scrollbar for the control requires data to size itself + -- GetScrollValues must return 4 values in this order: + -- rangeMin, rangeMax, visibleMin, visibleMax + -- aixs can be "Vert" or "Horz" + GUI.OptionContainer.GetScrollValues = function(self, axis) + local size = DataSize() + --LOG(size, ":", self.top, ":", math.min(self.top + numLines, size)) + return 0, size, self.top, math.min(self.top + numLines(), size) + end + + -- called when the scrollbar wants to scroll a specific number of lines (negative indicates scroll up) + GUI.OptionContainer.ScrollLines = function(self, axis, delta) + self:ScrollSetTop(axis, self.top + math.floor(delta)) + end + + -- called when the scrollbar wants to scroll a specific number of pages (negative indicates scroll up) + GUI.OptionContainer.ScrollPages = function(self, axis, delta) + self:ScrollSetTop(axis, self.top + math.floor(delta) * numLines()) + end + + -- called when the scrollbar wants to set a new visible top line + GUI.OptionContainer.ScrollSetTop = function(self, axis, top) + top = math.floor(top) + if top == self.top then return end + local size = DataSize() + self.top = math.max(math.min(size - numLines() , top), 0) + self:CalcVisible() + end + + -- called to determine if the control is scrollable on a particular access. Must return true or false. + GUI.OptionContainer.IsScrollable = function(self, axis) + return true + end + -- determines what controls should be visible or not + GUI.OptionContainer.CalcVisible = function(self) + local function SetTextLine(line, data, lineID) + if data.mod then + -- The special label at the top stating the number of mods. + line.text:SetColor('ffff7777') + LayoutHelpers.AtHorizontalCenterIn(line.text, line, 5) + LayoutHelpers.AtHorizontalCenterIn(line.value, line, 5, 16) + LayoutHelpers.ResetRight(line.value) + else + -- Game options. + line.text:SetColor(UIUtil.fontColor) + LayoutHelpers.AtLeftTopIn(line.text, line, 5) + LayoutHelpers.AtRightTopIn(line.value, line, 5, 16) + LayoutHelpers.ResetLeft(line.value) + end + line.text:SetText(LOCF(data.text, data.key)) + line.bg:Show() + line.value:SetText(LOCF(data.value, data.key)) + line.bg2:Show() + line.bg.HandleEvent = Group.HandleEvent + line.bg2.HandleEvent = Bitmap.HandleEvent + if data.tooltip then + Tooltip.AddControlTooltip(line.bg, data.tooltip) + Tooltip.AddControlTooltip(line.bg2, data.valueTooltip) + end + + if data.manualTooltipTitle then + Tooltip.AddControlTooltipManual(line.bg, data.manualTooltipTitle, data.manualTooltipDescription ) + Tooltip.AddControlTooltipManual(line.bg2, data.manualTooltipTitle, data.manualTooltipDescription ) + end + + end + + local optionsToUse + if HideDefaultOptions then + optionsToUse = nonDefaultFormattedOptions + else + optionsToUse = formattedOptions + end + + for i, v in GUI.OptionDisplay do + if optionsToUse[i + self.top] then + SetTextLine(v, optionsToUse[i + self.top], i + self.top) + else + v.text:SetText('') + v.value:SetText('') + v.bg:Hide() + v.bg2:Hide() + end + end + end + + GUI.OptionContainer.HandleEvent = function(self, event) + if event.Type == 'WheelRotation' then + local lines = 1 + if event.WheelRotation > 0 then + lines = -1 + end + self:ScrollLines(nil, lines) + end + end + + RefreshOptionDisplayData() + + GUI.OptionContainerScroll = UIUtil.CreateLobbyVertScrollbar(GUI.OptionContainer, 2) + LayoutHelpers.DepthOverParent(GUI.OptionContainerScroll, GUI.OptionContainer, 2) + + -- Launch Button + local launchGameButton = UIUtil.CreateButtonWithDropshadow(GUI.chatPanel, '/BUTTON/large/', LOC("Launch Game")) + GUI.launchGameButton = launchGameButton + LayoutHelpers.AtHorizontalCenterIn(launchGameButton, GUI) + LayoutHelpers.AtBottomIn(launchGameButton, GUI.panel, -8) + Tooltip.AddButtonTooltip(launchGameButton, 'Lobby_Launch') + UIUtil.setVisible(launchGameButton, isHost) + launchGameButton.OnClick = function(self) + TryLaunch(false) + end + + -- Create skirmish mode's "load game" button. + local loadButton = UIUtil.CreateButtonWithDropshadow(GUI.optionsPanel, '/BUTTON/medium/',"Load") + GUI.loadButton = loadButton + UIUtil.setVisible(loadButton, singlePlayer) + LayoutHelpers.AtVerticalCenterIn(GUI.loadButton, launchGameButton, 7) + LayoutHelpers.AtHorizontalCenterIn(GUI.loadButton, GUI.optionsPanel) + loadButton.OnClick = function(self, modifiers) + import("/lua/ui/dialogs/saveload.lua").CreateLoadDialog(GUI) + end + Tooltip.AddButtonTooltip(loadButton, 'Lobby_Load') + + -- Create the "Lobby presets" button for the host. If not the host, the same field is occupied + -- instead by the read-only "Unit Manager" button. + GUI.restrictedUnitsOrPresetsBtn = UIUtil.CreateButtonWithDropshadow(GUI.optionsPanel, '/BUTTON/medium/', "") + + if singlePlayer then + GUI.restrictedUnitsOrPresetsBtn:Hide() + elseif isHost then + GUI.restrictedUnitsOrPresetsBtn.label:SetText(LOC("Presets")) + GUI.restrictedUnitsOrPresetsBtn.OnClick = function(self, modifiers) + Presets.CreateUI(GUI) + end + Tooltip.AddButtonTooltip(GUI.restrictedUnitsOrPresetsBtn, 'Lobby_presetDescription') + else + GUI.restrictedUnitsOrPresetsBtn.label:SetText(LOC("Unit Manager")) + GUI.restrictedUnitsOrPresetsBtn.OnClick = function(self, modifiers) + import("/lua/ui/lobby/unitsmanager.lua").CreateDialog(GUI.panel, gameInfo.GameOptions.RestrictedCategories, function() end, function() end, false) + end + Tooltip.AddButtonTooltip(GUI.restrictedUnitsOrPresetsBtn, 'lob_RestrictedUnitsClient') + end + LayoutHelpers.AtVerticalCenterIn(GUI.restrictedUnitsOrPresetsBtn, launchGameButton, 7) + LayoutHelpers.AtHorizontalCenterIn(GUI.restrictedUnitsOrPresetsBtn, GUI.optionsPanel) + + --------------------------------------------------------------------------- + -- Checkbox Show changed Options + --------------------------------------------------------------------------- + cbox_ShowChangedOption:SetCheck(HideDefaultOptions, false) + + --------------------------------------------------------------------------- + -- set up : player grid + --------------------------------------------------------------------------- + + -- For disgusting reasons, we pass the label factory as a parameter. + CreateSlotsUI(makeLabel) + + -- Exit Button + GUI.exitButton = UIUtil.CreateButtonWithDropshadow(GUI.chatPanel, '/BUTTON/medium/', LOC("Exit")) + LayoutHelpers.AtLeftIn(GUI.exitButton, GUI.chatPanel, 33) + LayoutHelpers.AtVerticalCenterIn(GUI.exitButton, launchGameButton, 7) + if HasCommandLineArg("/gpgnet") then + -- Quit to desktop + GUI.exitButton.label:SetText(LOC("")) + Tooltip.AddButtonTooltip(GUI.exitButton, 'esc_exit') + else + -- Back to main menu + GUI.exitButton.label:SetText(LOC("")) + Tooltip.AddButtonTooltip(GUI.exitButton, 'esc_quit') + end + + GUI.exitButton.OnClick = GUI.exitLobbyEscapeHandler + + + -- Small buttons are 100 wide, 44 tall + + -- Default option button + GUI.defaultOptions = UIUtil.CreateButtonStd(GUI.observerPanel, '/BUTTON/defaultoption/') + -- If we're the host, position the buttons lower down (and eventually shrink the observer panel) + if not isHost then + GUI.defaultOptions:Hide() + end + LayoutHelpers.AtLeftTopIn(GUI.defaultOptions, GUI.observerPanel, 11, -94) + + Tooltip.AddButtonTooltip(GUI.defaultOptions, 'lob_click_rankedoptions') + if not isHost then + GUI.defaultOptions:Disable() + else + GUI.defaultOptions.OnClick = function() + UIUtil.QuickDialog(GUI, LOC('Are you sure you want to reset to default values?'), + "", function() + -- Return all options to their default values. + OptionUtils.SetDefaults() + lobbyComm:BroadcastData({ Type = "SetAllPlayerNotReady" }) + UpdateGame() + end, + + "", nil, + nil, nil, + true + ) + end + end + + -- RANDOM MAP BUTTON -- + GUI.randMap = UIUtil.CreateButtonStd(GUI.observerPanel, '/BUTTON/randommap/') + LayoutHelpers.RightOf(GUI.randMap, GUI.defaultOptions, -19) + Tooltip.AddButtonTooltip(GUI.randMap, 'lob_click_randmap') + if not isHost then + GUI.randMap:Hide() + else + GUI.randMap.OnClick = function() + local randomMap + local mapSelectDialog + + autoRandMap = false + + -- Load the set of all available maps, with a slight evil hack on the mapselect module. + local mapDialog = import("/lua/ui/dialogs/mapselect.lua") + local allMaps = mapDialog.LoadScenarios() -- Result will be cached. + + -- Only include maps which have enough slots for the players we have. + local filteredMaps = table.filter(allMaps, + function(scenInfo) + local supportedPlayers = table.getsize(scenInfo.Configurations.standard.teams[1].armies) + return supportedPlayers >= GetPlayerCount() + end + ) + local mapCount = table.getn(filteredMaps) + local selectedMap = filteredMaps[math.floor(math.random(1, mapCount))] + + -- Set the new map. + SetGameOption('ScenarioFile', selectedMap.file) + ClearBadMapFlags() + UpdateGame() + end + end + + local autoteamButtonStates = { + { + key = 'tvsb', + tooltip = 'lob_auto_tvsb' + }, + { + key = 'lvsr', + tooltip = 'lob_auto_lvsr' + }, + { + key = 'pvsi', + tooltip = 'lob_auto_pvsi' + }, + { + key = 'manual', + tooltip = 'lob_auto_manual' + }, + { + key = 'none', + tooltip = 'lob_auto_none' + }, + } + + local initialState = Prefs.GetFromCurrentProfile("LobbyOpt_AutoTeams") or "none" + GUI.autoTeams = ToggleButton(GUI.observerPanel, '/BUTTON/autoteam/', autoteamButtonStates, initialState) + + LayoutHelpers.RightOf(GUI.autoTeams, GUI.randMap, -19) + if not isHost then + GUI.autoTeams:Hide() + else + GUI.autoTeams.OnStateChanged = function(self, newState) + SetGameOption('AutoTeams', newState) + AssignAutoTeams() + end + end + + -- CLOSE/OPEN EMPTY SLOTS BUTTON -- + GUI.closeEmptySlots = UIUtil.CreateButtonStd(GUI.observerPanel, '/BUTTON/closeslots/') + Tooltip.AddButtonTooltip(GUI.closeEmptySlots, 'lob_close_empty_slots') + if not isHost then + GUI.closeEmptySlots:Hide() + LayoutHelpers.AtLeftTopIn(GUI.closeEmptySlots, GUI.defaultOptions, -40, 43) + else + LayoutHelpers.AtLeftTopIn(GUI.closeEmptySlots, GUI.defaultOptions, -31, 43) + GUI.closeEmptySlots.OnClick = function(self, modifiers) + if lobbyComm:IsHost() then + if modifiers.Ctrl then + for slot = 1,numOpenSlots do + HostUtils.SetSlotClosed(slot, false) + end + return + end + local openSpot = false + for slot = 1,numOpenSlots do + openSpot = openSpot or not (gameInfo.PlayerOptions[slot] or gameInfo.ClosedSlots[slot]) + end + if modifiers.Right and gameInfo.AdaptiveMap then + for slot = 1,numOpenSlots do + if openSpot then + if not (gameInfo.PlayerOptions[slot] or gameInfo.ClosedSlots[slot]) then + HostUtils.SetSlotClosedSpawnMex(slot) + end + else + if gameInfo.ClosedSlots[slot] and gameInfo.SpawnMex[slot] then + HostUtils.SetSlotClosed(slot, false) + end + end + end + else + for slot = 1,numOpenSlots do + if not gameInfo.SpawnMex[slot] then + HostUtils.SetSlotClosed(slot, openSpot) + end + end + end + end + end + end + + + -- GO OBSERVER BUTTON -- + GUI.becomeObserver = UIUtil.CreateButtonStd(GUI.observerPanel, '/BUTTON/observer/') + LayoutHelpers.RightOf(GUI.becomeObserver, GUI.closeEmptySlots, -25) + Tooltip.AddButtonTooltip(GUI.becomeObserver, 'lob_become_observer') + GUI.becomeObserver.OnClick = function() + if IsPlayer(localPlayerID) then + if isHost then + HostUtils.ConvertPlayerToObserver(FindSlotForID(localPlayerID)) + else + lobbyComm:SendData(hostID, {Type = 'RequestConvertToObserver'}) + end + elseif IsObserver(localPlayerID) then + if isHost then + HostUtils.ConvertObserverToPlayer(FindObserverSlotForID(localPlayerID)) + else + lobbyComm:SendData(hostID, {Type = 'RequestConvertToPlayer'}) + end + end + end + + -- CPU BENCH BUTTON -- + GUI.rerunBenchmark = UIUtil.CreateButtonStd(GUI.observerPanel, '/BUTTON/cputest/', '', 11) + LayoutHelpers.RightOf(GUI.rerunBenchmark, GUI.becomeObserver, -25) + Tooltip.AddButtonTooltip(GUI.rerunBenchmark,{text=LOC("Run CPU Benchmark Test"), body=LOC("Recalculates your CPU rating.")}) + GUI.rerunBenchmark.OnClick = function(self, modifiers) + ForkThread(function() UpdateBenchmark(true) end) + end + + -- Autobalance Button -- + GUI.PenguinAutoBalance = UIUtil.CreateButtonStd(GUI.observerPanel, '/BUTTON/autobalance/') + LayoutHelpers.RightOf(GUI.PenguinAutoBalance, GUI.rerunBenchmark, -25) + Tooltip.AddButtonTooltip(GUI.PenguinAutoBalance, {text=LOC("Autobalance"), body=LOC("Automatically balance players into 2 equally sized teams")}) + if not isHost then + GUI.PenguinAutoBalance:Hide() + else + -- What this does: it balances all occupied slots into two teams with equal numbers of + -- players. If teams are set manually and half of the occupied slots are set to team 1 + -- and half to team 2, then it balances the players while keeping the team-slot matches. + -- If the teams are set manually, but there is an uneven number of players on teams 1 + -- and 2, then players' teams are changed automatically to be alternating team 1 and team 2. + -- If there are an odd number of occupied slots, the last one is set to team - (no team) + -- and the others are balanced without it. Alternatively, if teams are not set manually, + -- players will be balanced into the slowest available slot numbers on their teams. + -- If there is an odd number of players in that case, the last player will be made an + -- observer if human or removed if AI. + + -- How it balances: this function checks every possible balance combination for making + -- the two teams (while keeping their player counts equal to half the number of occupied + -- slots, rounded down, and not using the last player if there is an odd number of players). + -- To do this, the function sums up all the relevant players' ratings (keeping mean and + -- deviation separate - it balances teams to have similar total ratings, and also similar + -- total uncertainties (grayness)), and then divides by two. That yields the goal values + -- for each team. Any deviation from those values is calculated to help determine a team's + -- imbalance value. Then, the various team combinations are tested, and the one with the + -- lowest imbalance value is used. + + + -- Automatically balance an even number of non-observer players into 2 teams in the lobby + GUI.PenguinAutoBalance.OnClick = function() + + -- make sure spawns are set to fixed or penguin_autobalance + if gameInfo.GameOptions.TeamSpawn ~= 'fixed' and gameInfo.GameOptions.TeamSpawn ~= 'penguin_autobalance' then + gameInfo.GameOptions.TeamSpawn = 'fixed' + -- tell everyone else to set spawns to fixed + lobbyComm:BroadcastData { + Type = 'GameOptions', + Options = {['TeamSpawn'] = 'fixed'} + } + AddChatText(LOC("Enabled fixed spawn locations")) + end + + -- a table of the target mean, target deviation, and the lowest logged imbalance value + local goalValue = {0, 0, 99999} + + local playerCount = 0 + -- a table of the highest occupied slot's slot number, that slot's player's order number + -- in the playerRatings table, and a booleon of whether or not that player is human + local lastSlot = {0, 0, false} + local playerRatings = {} + -- get rating data for each player + for i, player in gameInfo.PlayerOptions:pairs() do + playerRatings[i] = {player.MEAN, player.DEV, player.StartSpot, player.Team - 1} + playerCount = playerCount + 1 + if player.StartSpot > lastSlot[1] then + lastSlot = {player.StartSpot, i, player.Human} + end + goalValue[1] = goalValue[1] + player.MEAN + goalValue[2] = goalValue[2] + player.DEV + end + + -- if there are fewer than 2 players, there is no need to balance + if playerCount < 2 then + UpdateGame() + return + end + + -- if there is an odd number of players, remove the last one from the balancing + if math.mod(playerCount, 2) == 1 then + goalValue[1] = goalValue[1] - playerRatings[lastSlot[2]][1] + goalValue[2] = goalValue[2] - playerRatings[lastSlot[2]][2] + playerRatings[lastSlot[2]] = nil + playerCount = playerCount - 1 + -- set the player to not be on a team if teams are manual and fixed + -- otherwise make the player an observer if human or remove it if AI + if gameInfo.GameOptions.AutoTeams == 'none' and gameInfo.GameOptions.TeamSpawn == 'fixed' then + for i, player in gameInfo.PlayerOptions:pairs() do + if player.StartSpot == lastSlot[1] then + player.Team = 1 -- no team + break + end + end + else + if lastSlot[3] then + HostUtils.ConvertPlayerToObserver(lastSlot[1]) + else + HostUtils.RemoveAI(lastSlot[1]) + end + end + end + + -- the goal value is all of the remaining players' ratings divided by 2 + goalValue[1] = goalValue[1] / 2 + goalValue[2] = goalValue[2] / 2 + + local sortedPlayerRatings = {} + local sortedSlotTeams = {} + local numPlayersTeam1 = 0 + local numPlayersTeam2 = 0 + local sortingValue1 + local sortingValue2 + -- sort the players in a weighted cross between displayed and base rating + -- the order goes from greatest to lowest result of: mean - (deviation * 2.2) + for i, player in playerRatings do + local orderNum = 1 + sortingValue1 = player[1] - (player[2] * 2.2) + for i2, player2 in playerRatings do + sortingValue2 = player2[1] - (player2[2] * 2.2) + if sortingValue1 < sortingValue2 or (sortingValue1 == sortingValue2 and i > i2) then + orderNum = orderNum + 1 + end + end + -- these are sorted in parallel + sortedPlayerRatings[orderNum] = {player[1], player[2]} + sortedSlotTeams[orderNum] = {player[3], player[4]} + if player[4] == 1 then + numPlayersTeam1 = numPlayersTeam1 + 1 + elseif player[4] == 2 then + numPlayersTeam2 = numPlayersTeam2 + 1 + end + end + + + -- the number of players per team + local teamSize = playerCount / 2 + + + -- make the sorted list of slots for each team + local sortedTeam1Slots = {} + local sortedTeam2Slots = {} + local team1OrderNum = 0 + local team2OrderNum = 0 + + local manualTeams + + if gameInfo.GameOptions.AutoTeams == 'pvsi' then -- odd vs even + for i = 1, 16 do + if not gameInfo.ClosedSlots[i] then + if math.mod(i, 2) == 1 then + team1OrderNum = team1OrderNum + 1 + sortedTeam1Slots[team1OrderNum] = i + else + team2OrderNum = team2OrderNum + 1 + sortedTeam2Slots[team2OrderNum] = i + end + end + end + elseif gameInfo.GameOptions.AutoTeams == 'tvsb' then -- top vs bottom + local midLine = GUI.mapView.Top() + (GUI.mapView.Height() / 2) + for i, startPosition in GUI.mapView.startPositions do + if not gameInfo.ClosedSlots[i] then + if startPosition.Top() < midLine then + team1OrderNum = team1OrderNum + 1 + sortedTeam1Slots[team1OrderNum] = i + else + team2OrderNum = team2OrderNum + 1 + sortedTeam2Slots[team2OrderNum] = i + end + end + end + elseif gameInfo.GameOptions.AutoTeams == 'lvsr' then -- left vs right + local midLine = GUI.mapView.Left() + (GUI.mapView.Width() / 2) + for i, startPosition in GUI.mapView.startPositions do + if not gameInfo.ClosedSlots[i] then + if startPosition.Left() < midLine then + team1OrderNum = team1OrderNum + 1 + sortedTeam1Slots[team1OrderNum] = i + else + team2OrderNum = team2OrderNum + 1 + sortedTeam2Slots[team2OrderNum] = i + end + end + end + else + manualTeams = true + end + + -- If the teams were not set properly, set them properly. + -- When teams are set manually, they are not set properly if the number of + -- players on either team does not equal the team size. + -- When teams are not set manually, they are not set properly if the number + -- of slots on either team is less than the team size. + if (manualTeams and (numPlayersTeam1 != teamSize or numPlayersTeam2 != teamSize)) + or (not manualTeams and (table.getn(sortedTeam1Slots) < teamSize or table.getn(sortedTeam2Slots) < teamSize)) then + -- set AutoTeams to none (so, they can be set by slot by this function) + gameInfo.GameOptions.AutoTeams = 'none' + local counter = 0 + for i, player in gameInfo.PlayerOptions:pairs() do + for i2, slotTeam in sortedSlotTeams do + if player.StartSpot == slotTeam[1] then + counter = counter + 1 + -- set the player's team + if math.mod(counter, 2) == 1 then + player.Team = 2 -- team 1 + slotTeam[2] = 1 + else + player.Team = 3 -- team 2 + slotTeam[2] = 2 + end + -- tell everyone else the team number for that slot + lobbyComm:BroadcastData( + { + Type = 'PlayerOptions', + Options = {['Team'] = slotTeam[2] + 1}, -- make team number 1 higher for the backend + Slot = slotTeam[1], + }) + break + end + end + end + end + + -- if teams are set to manual, make the sorted list of slots for each team + if gameInfo.GameOptions.AutoTeams == 'none' then + sortedTeam1Slots = {} + sortedTeam2Slots = {} + for i, slotTeam in sortedSlotTeams do + team1OrderNum = 0 + team2OrderNum = 0 + for i2, slotTeam2 in sortedSlotTeams do + if slotTeam[1] > slotTeam2[1] or (slotTeam[1] == slotTeam2[1] and i >= i2) then + if slotTeam2[2] == 1 then + team1OrderNum = team1OrderNum + 1 + else + team2OrderNum = team2OrderNum + 1 + end + end + end + -- add the slot to its team's table + if slotTeam[2] == 1 then + sortedTeam1Slots[team1OrderNum] = slotTeam[1] + else + sortedTeam2Slots[team2OrderNum] = slotTeam[1] + end + end + end + + + + -- a table of team1's mean, deviation, and imbalance value + local teamValue + -- a table of team members + local team1 = {} + -- a table of the most balanced team + local bestTeam = {} + local choosableCount = playerCount - teamSize + + -- the number of iterations is the number of team combinations to check, which is + -- exactly half of the number of possible teams, which covers every possibility, + -- since the remaining half are just the opposite of what was already checked, + -- which means they have the exact same balance + -- ie: Player A + Player B vs Player C + Player D == Player C + Player D vs Player A + Player B + -- this works because of the order in which the combinations are tested + local numIterations + if teamSize == 2 then + numIterations = 3 + elseif teamSize == 3 then + numIterations = 10 + elseif teamSize == 4 then + numIterations = 35 + elseif teamSize == 5 then + numIterations = 126 + elseif teamSize == 6 then + numIterations = 462 + elseif teamSize == 7 then + numIterations = 1716 + else + numIterations = 6435 + end + + local currentIteration = 0 + + -- test the balance of different combinations of teams, covering balance possibility + -- intended for use with 2 teams of even player counts + -- combinations are iterated starting with the lowest-numbered players on team1 first, + -- and progressively iterating the highest-numbered player on team1 to each higher-numbered + -- possible player, and then repeating the process with the next highest-numbered player + -- increasing by 1... this process continues until every possible balacnce combination + -- of 2 equally sized teams of even player counts has been covered + local function testCombinations(team1MemberNumber, firstPlayerToCheck) + -- check if this player is the last player on the team + local lastPlayer + if team1MemberNumber < teamSize then + lastPlayer = false + else + lastPlayer = true + end + -- iterate through the possible players for this team1MemberNumber + for i = firstPlayerToCheck, choosableCount + team1MemberNumber do + -- when the number of iterations is reached, every possible balance of even player count + -- of the 2 equally sized teams has been checked, and the function ends + if currentIteration >= numIterations then + return + end + team1[team1MemberNumber] = i + if lastPlayer then + -- test this combination of team members + teamValue = {0, 0, 0} + -- add each team member's base rating and devation to the team's values + for i, player in team1 do + teamValue[1] = teamValue[1] + sortedPlayerRatings[player][1] + teamValue[2] = teamValue[2] + sortedPlayerRatings[player][2] + end + -- calculate the team's imbalance value + teamValue[3] = math.abs(teamValue[2] - goalValue[2]) * 1.2 + math.abs(teamValue[1] - goalValue[1]) + -- check if the team's imbalance value is lower than the lowest logged imbalance value + if teamValue[3] < goalValue[3] then + -- if it is lower, then this is the best balance so far, and it is logged over the previous best balance + goalValue[3] = teamValue[3] + -- deepcopy the team's player numbers + for i, player in team1 do + bestTeam[i] = player + end + end + currentIteration = currentIteration + 1 + else + -- test a subset of combinations + testCombinations(team1MemberNumber + 1, i + 1) + end + end + end + + testCombinations(1, 1) + + + -- specify the players on team 2 (aka, the ones not on team 1) + local bestTeam2 = {} + for i = 1, playerCount do + if not table.find(bestTeam, i) then + table.insert(bestTeam2, i) + end + end + + --shuffle player pairs + local random + local temp + for i, slot in bestTeam do + random = Random(1, teamSize) + + --random swap on team 1 + temp = bestTeam[random] + bestTeam[random] = bestTeam[i] + bestTeam[i] = temp + + --mirrored swap on team2 + temp = bestTeam2[random] + bestTeam2[random] = bestTeam2[i] + bestTeam2[i] = temp + end + + -- move players on team1 to the intended slots + local team1OrderNum = 0 + local slotA + local slotB + for i, player in bestTeam do + team1OrderNum = team1OrderNum + 1 + slotA = sortedSlotTeams[player][1] + slotB = sortedTeam1Slots[team1OrderNum] + HostUtils.SwapPlayers(slotA, slotB) + -- keep track of the slot changes in sortedSlotTeams + for i, slotTeam in sortedSlotTeams do + if slotTeam[1] == slotB then + slotTeam[1] = slotA + break + end + end + sortedSlotTeams[player][1] = slotB + end + + -- move players on team2 to the intended slots + local team2OrderNum = 0 + for i, player in bestTeam2 do + team2OrderNum = team2OrderNum + 1 + slotA = sortedSlotTeams[player][1] + slotB = sortedTeam2Slots[team2OrderNum] + HostUtils.SwapPlayers(slotA, slotB) + -- keep track of the slot changes in sortedSlotTeams + for i, slotTeam in sortedSlotTeams do + if slotTeam[1] == slotB then + slotTeam[1] = slotA + break + end + end + sortedSlotTeams[player][1] = slotB + end + UpdateGame() + AddChatText(LOC("Finished autobalancing")) + end + end + + -- Observer List + GUI.observerList = ItemList(GUI.observerPanel) + GUI.observerList:SetFont(UIUtil.bodyFont, 12) + GUI.observerList:SetColors(UIUtil.fontColor, "00000000", UIUtil.fontOverColor, UIUtil.highlightColor, "ffbcfffe") + LayoutHelpers.AtLeftTopIn(GUI.observerList, GUI.observerPanel, 4, 2) + LayoutHelpers.AtRightBottomIn(GUI.observerList, GUI.observerPanel, 15) + GUI.observerList.OnClick = function(self, row, event) + if isHost and event.Modifiers.Right then + -- determine the number of teams (excluding the no team (-) option that equals 1 on the backend) + local teams = {} + local numTeams = 0 + for i, player in gameInfo.PlayerOptions:pairs() do + if not teams[player.Team] and player.Team ~= 1 then + teams[player.Team] = true + numTeams = numTeams + 1 + end + end + + -- adjust index by 1 because 0-based (ItemList rows) vs 1-based (Lua array) indexing + local obsIndex = row + 1 + local maxObsIndex = self:GetItemCount() + + -- adjust index by the number of rows taken up by team ratings. + ---@see refreshObserverList + if gameInfo.GameOptions['TeamSpawn'] == 'fixed' then + if numTeams < 3 then + obsIndex = obsIndex - numTeams + else + -- 3+ teams has ratings at the end of the list, don't allow kicking when clicking those rating rows + maxObsIndex = maxObsIndex - numTeams + end + end + + -- the host can get the kick dialog brought up for observer list rows that are players (aka, they have + -- a positive observer index and thereby aren't team ratings) and that aren't the local player (the host) + -- and that aren't the rows with team ratings when there are 3 or more teams + if obsIndex > 0 and gameInfo.Observers[obsIndex].OwnerID ~= localPlayerID and obsIndex <= maxObsIndex then + UIUtil.QuickDialog(GUI, "Are you sure?", + "Kick Player", function() + SendSystemMessage("lobui_0756", gameInfo.Observers[obsIndex].PlayerName) + lobbyComm:EjectPeer(gameInfo.Observers[obsIndex].OwnerID, "KickedByHost") + end, + "", nil, + nil, nil, + true, + {worldCover = false, enterButton = 1, escapeButton = 2} + ) + end + end + end + UIUtil.CreateLobbyVertScrollbar(GUI.observerList, 0, 0, -1) + + -- Setup large pretty faction selector and set the factional background to its initial value. + local lastFaction = GetSanitisedLastFaction() + CreateUI_Faction_Selector(lastFaction) + + RefreshLobbyBackground(lastFaction) + + GUI.uiCreated = true + + if singlePlayer then + -- observers are always allowed in skirmish games. + SetGameOption("AllowObservers", true) + end + + --------------------------------------------------------------------------- + -- other logic, including lobby callbacks + --------------------------------------------------------------------------- + GUI.posGroup = false + -- get ping times + GUI.pingThread = ForkThread( + function() + while lobbyComm do + for slot, player in gameInfo.PlayerOptions:pairs() do + if player.Human and player.OwnerID ~= localPlayerID then + local peer = lobbyComm:GetPeer(player.OwnerID) + local ping = peer.ping + local connectionStatus = CalcConnectionStatus(peer) + GUI.slots[slot].pingStatus.ConnectionStatus = connectionStatus + if ping then + ping = math.floor(ping) + GUI.slots[slot].pingStatus.PingActualValue = ping + GUI.slots[slot].pingStatus:SetValue(ping) + if ping > 500 then + GUI.slots[slot].pingStatus:Show() + else + GUI.slots[slot].pingStatus:Hide() + end + -- Set the ping bar to a colour representing the status of our connection. + GUI.slots[slot].pingStatus._bar:SetTexture(UIUtil.SkinnableFile('/game/unit_bmp/bar-0' .. connectionStatus .. '_bmp.dds')) + else + GUI.slots[slot].pingStatus:Hide() + end + end + end + WaitSeconds(1) + end + end) + if false then + import("/lua/ui/events/snowflake.lua"). CreateSnowFlakes(GUI) + end +end + +function setupChatEdit(chatPanel) + GUI.chatEdit = Edit(chatPanel) + LayoutHelpers.AtLeftTopIn(GUI.chatEdit, GUI.chatBG, 4, 3) + GUI.chatEdit.Width:Set(GUI.chatBG.Width() - LayoutHelpers.ScaleNumber(4)) + LayoutHelpers.SetHeight(GUI.chatEdit, 22) + GUI.chatEdit:SetFont(UIUtil.bodyFont, 16) + GUI.chatEdit:SetForegroundColor(UIUtil.fontColor) + GUI.chatEdit:ShowBackground(false) + GUI.chatEdit:SetDropShadow(true) + GUI.chatEdit:AcquireFocus() + + GUI.chatDisplayScroll = UIUtil.CreateLobbyVertScrollbar(chatPanel, -15, 25, 0) + + GUI.chatEdit:SetMaxChars(200) + GUI.chatEdit.OnCharPressed = function(self, charcode) + if charcode == UIUtil.VK_TAB then + return true + end + + local charLim = self:GetMaxChars() + if STR_Utf8Len(self:GetText()) >= charLim then + local sound = Sound({Cue = 'UI_Menu_Error_01', Bank = 'Interface',}) + PlaySound(sound) + end + end + + -- We work extremely hard to keep keyboard focus on the chat box, otherwise users can trigger + -- in-game keybindings in the lobby. + -- That would be very bad. We should probably instead just not assign those keybindings yet... + GUI.chatEdit.OnLoseKeyboardFocus = function(self) + self:AcquireFocus() + end + + local commandQueueIndex = 0 + local commandQueue = {} + GUI.chatEdit.OnEnterPressed = function(self, text) + if text:gsub("%s+", "") == '' then -- If the text, trimmed of all space, is equal to '' + return + end + GpgNetSend('Chat', text) + table.insert(commandQueue, 1, text) + commandQueueIndex = 0 + if string.sub(text, 1, 1) == '/' then + local spaceStart = string.find(text, " ") or string.len(text) + 1 + local comKey = string.sub(text, 2, spaceStart - 1) + local params = string.sub(text, spaceStart + 1) + local commandFunc = commands[string.lower(comKey)] + if not commandFunc then + AddChatText(LOCF("Command Not Known: %s", comKey)) + return + end + commandFunc(params) + else + PublicChat(text) + end + end + + GUI.chatEdit.OnEscPressed = function(self, text) + + local changelogDialogManager = import("/lua/ui/lobby/changelog/changelogdialog.lua") + local changelogDialogIsOpen = changelogDialogManager.IsOpen() + + -- The default behaviour buggers up our escape handlers. Just delegate the escape push to + -- the escape handling mechanism. + if HasCommandLineArg("/gpgnet") or changelogDialogIsOpen then + -- Quit to desktop + EscapeHandler.HandleEsc(not changelogDialogIsOpen) + else + -- Back to main menu + GUI.exitButton.OnClick() + end + + -- Don't clear the textbox, either. + return true + end + + --- Handle up/down arrow presses for the chat box. + GUI.chatEdit.OnNonTextKeyPressed = function(self, keyCode) + if AddUnicodeCharToEditText(self, keyCode) then + return + end + if commandQueue and not table.empty(commandQueue) then + if keyCode == 38 then + if commandQueue[commandQueueIndex + 1] then + commandQueueIndex = commandQueueIndex + 1 + self:SetText(commandQueue[commandQueueIndex]) + end + end + if keyCode == 40 then + if commandQueueIndex ~= 1 then + if commandQueue[commandQueueIndex - 1] then + commandQueueIndex = commandQueueIndex - 1 + self:SetText(commandQueue[commandQueueIndex]) + end + else + commandQueueIndex = 0 + self:ClearText() + end + end + end + end + chatPanel.edit = GUI.chatEdit +end + +function RefreshOptionDisplayData(scenarioInfo) + local globalOpts = import("/lua/ui/lobby/lobbyoptions.lua").globalOpts + local teamOptions = import("/lua/ui/lobby/lobbyoptions.lua").teamOptions + local AIOpts = import("/lua/ui/lobby/lobbyoptions.lua").AIOpts + if not scenarioInfo and gameInfo.GameOptions.ScenarioFile and (gameInfo.GameOptions.ScenarioFile ~= "") then + scenarioInfo = MapUtil.LoadScenario(gameInfo.GameOptions.ScenarioFile) + end + formattedOptions = {} + nonDefaultFormattedOptions = {} + + -- Show a summary of the number of active mods. + local modStr = false + local modNum = table.getn(Mods.GetGameMods(gameInfo.GameMods)) or 0 + local modNumUI = table.getn(Mods.GetUiMods()) or 0 + if modNum > 0 and modNumUI > 0 then + modStr = modNum..' Mods (and '..modNumUI..' UI Mods)' + if modNum == 1 and modNumUI > 1 then + modStr = modNum..' Mod (and '..modNumUI..' UI Mods)' + elseif modNum > 1 and modNumUI == 1 then + modStr = modNum..' Mods (and '..modNumUI..' UI Mod)' + elseif modNum == 1 and modNumUI == 1 then + modStr = modNum..' Mod (and '..modNumUI..' UI Mod)' + else + modStr = modNum..' Mods (and '..modNumUI..' UI Mods)' + end + elseif modNum > 0 and modNumUI == 0 then + modStr = modNum..' Mods' + if modNum == 1 then + modStr = modNum..' Mod' + end + elseif modNum == 0 and modNumUI > 0 then + modStr = modNumUI..' UI Mods' + if modNum == 1 then + modStr = modNumUI..' UI Mod' + end + end + + local description = "No mods enabled." + + if modNum + modNumUI > 0 then + description = "" + + local descriptionSimMods = { "", } + if modNum > 0 then + table.insert(descriptionSimMods, LOC("The host enabled the following sim mods:")) + for k, mod in Mods.GetGameMods() do + table.insert(descriptionSimMods, "\r\n - " .. tostring(mod.name)) + end + + table.insert(descriptionSimMods, "\r\n") + end + + local descriptionUIMods = { "", } + if modNumUI > 0 then + table.insert(descriptionUIMods, LOC("You have enabled the following UI mods:")) + for k, mod in Mods.GetUiMods() do + table.insert(descriptionUIMods, "\r\n - " .. tostring(mod.name)) + end + end + + descriptionSimMods = tostring(table.concat(descriptionSimMods)) + descriptionUIMods = tostring(table.concat(descriptionUIMods)) + + if modNum > 0 and modNumUI > 0 then + description = tostring(table.concat({descriptionSimMods, "\r\n", descriptionUIMods})) + else + if modNum > 0 then + description = descriptionSimMods + end + + if modNumUI > 0 then + description = descriptionUIMods + end + end + end + + if modStr then + local option = { + text = modStr, + value = LOC('Check Mod Manager'), + mod = true, + + manualTooltipTitle = 'Enabled mods', + manualTooltipDescription = description + } + + table.insert(formattedOptions, option) + table.insert(nonDefaultFormattedOptions, option) + end + + -- Update the unit restrictions display. + if gameInfo.GameOptions.RestrictedCategories ~= nil then + local restrNum = table.getn(gameInfo.GameOptions.RestrictedCategories) + if restrNum ~= 0 then + local restrictLabel + if restrNum == 1 then -- just 1 + restrictLabel = LOC("1 Build Restriction") + else + restrictLabel = LOCF("%d Build Restrictions", restrNum) + end + + local option = { + text = restrictLabel, + value = LOC("Check Unit Manager"), + mod = true, + tooltip = 'Lobby_BuildRestrict_Option', + valueTooltip = 'Lobby_BuildRestrict_Option' + } + + table.insert(formattedOptions, option) + table.insert(nonDefaultFormattedOptions, option) + end + end + + -- Add an option to the formattedOption lists + local function addFormattedOption(optData, gameOption) + -- Don't show multiplayer-only options in single-player + if optData.mponly and singlePlayer then + return + end + + -- Don't bother for options with only one value. These are usually someone trying to do + -- something clever with a mod or such, not "real" options we care about. + if table.getn(optData.values) <= 1 then + return + end + + local option = { + text = optData.label, + tooltip = { text = optData.label, body = optData.help } + } + + -- Options are stored as keys from the values array in optData. We want to display the + -- descriptive string in the UI, so let's go dig it out. + + -- Scan the values array to find the one with the key matching our value for that option. + for k, val in optData.values do + local key = val.key or val + + if key == gameOption then + option.key = key + option.value = val.text or optData.value_text + option.valueTooltip = {text = optData.label, body = val.help or optData.value_help} + + table.insert(formattedOptions, option) + + -- Add this option to the non-default set for the UI. + if k ~= optData.default then + table.insert(nonDefaultFormattedOptions, option) + end + + break + end + end + end + + local function addOptionsFrom(optionObject) + for index, optData in optionObject do + local gameOption = gameInfo.GameOptions[optData.key] + addFormattedOption(optData, gameOption) + end + end + + -- Add the core options to the formatted option lists + addOptionsFrom(globalOpts) + addOptionsFrom(teamOptions) + addOptionsFrom(AIOpts) + + -- Add options from the scenario object, if any are provided. + if scenarioInfo.options then + if not MapUtil.ValidateScenarioOptions(scenarioInfo.options, true) then + AddChatText(LOC('The options included in this map specified invalid defaults. See moholog for details.')) + AddChatText(LOC('An arbitrary option has been selected for now: check the game options screen!')) + end + + for index, optData in scenarioInfo.options do + addFormattedOption(optData, gameInfo.GameOptions[optData.key]) + end + end + + GUI.OptionContainer:CalcVisible() +end + +function wasConnected(peer) + for _,v in pairs(connectedTo) do + if v == peer then + return true + end + end + return false +end + +--- Return a status code representing the status of our connection to a peer. +-- @param peer, native table as returned by lobbyComm:GetPeer() +-- @return A value describing the connectivity to given peer. +-- 1 means no connectivity, 2 means they haven't reported that they can talk to us, 3 means +-- +-- @todo: This function has side effects despite the naming suggesting that it shouldn't. +-- These need to go away. +function CalcConnectionStatus(peer) + if peer.status ~= 'Established' then + return 3 + else + if not wasConnected(peer.id) then + local peerSlot = FindSlotForID(peer.id) + local slot = GUI.slots[peerSlot] + local playerInfo = gameInfo.PlayerOptions[peerSlot] + + slot.name:SetTitleText(GetPlayerDisplayName(playerInfo)) + slot.name._text:SetFont('Arial Gras', 15) + if not table.find(ConnectionEstablished, peer.name) then + if playerInfo.Human and not IsLocallyOwned(peerSlot) then + table.insert(ConnectionEstablished, peer.name) + for k, v in CurrentConnection do -- Remove PlayerName in this Table + if v == peer.name then + CurrentConnection[k] = nil + break + end + end + end + end + + table.insert(connectedTo, peer.id) + end + if not table.find(peer.establishedPeers, lobbyComm:GetLocalPlayerID()) then + -- they haven't reported that they can talk to us? + return 1 + end + + local peers = lobbyComm:GetPeers() + for k,v in peers do + if v.id ~= peer.id and v.status == 'Established' then + if not table.find(peer.establishedPeers, v.id) then + -- they can't talk to someone we can talk to. + return 1 + end + end + end + return 2 + end +end + +function EveryoneHasEstablishedConnections(check_observers) + local important = {} + for slot, player in gameInfo.PlayerOptions:pairs() do + if not table.find(important, player.OwnerID) then + table.insert(important, player.OwnerID) + end + end + + if check_observers then + for slot,observer in gameInfo.Observers:pairs() do + if not table.find(important, observer.OwnerID) then + table.insert(important, observer.OwnerID) + end + end + end + + local result = true + for k, id in important do + if id ~= localPlayerID then + local peer = lobbyComm:GetPeer(id) + for k2, other in important do + if id ~= other and not table.find(peer.establishedPeers, other) then + result = false + AddChatText(LOCF("%s doesn't have an established connection to %s", + peer.name, + lobbyComm:GetPeer(other).name + )) + end + end + end + end + return result +end + +function AddChatText(text, playerID, scrollToBottom) + if not GUI.chatDisplay then + LOG("Can't add chat text -- no chat display") + LOG("text=" .. repr(text)) + return + end + + local chatPlayerColor = Prefs.GetFromCurrentProfile('ChatPlayerColor') + if chatPlayerColor == nil then + chatPlayerColor = true + end + + local scrolledToBottom = GUI.chatPanel:IsScrolledToBottom() or scrollToBottom + local nameColor = "AAAAAA" -- Displaying text in grey by default if the player is observer + local textColor = "AAAAAA" + local nameFont = "Arial Gras" + for id, player in gameInfo.PlayerOptions:pairs() do + if player.OwnerID == playerID and player.Human then + textColor = nil + nameColor = gameColors.PlayerColors[player.PlayerColor] + if not chatPlayerColor then + nameFont = UIUtil.bodyFont + if Prefs.GetOption('faction_font_color') then + nameColor = import("/lua/skins/skins.lua").skins[ FACTION_NAMES[GetLocalPlayerData():AsTable().Faction] ].fontColor + textColor = nameColor + else + nameColor = nil + end + end + break + end + end + local name = FindNameForID(playerID) + + GUI.chatDisplay:PostMessage(text, name, {fontColor = textColor}, {fontColor = nameColor, fontFamily = nameFont}) + if scrolledToBottom then + GUI.chatPanel:ScrollToBottom() + else + GUI.newMessageArrow:Enable() + end +end + +--- Update a slot display in a single map control. +function RefreshMapPosition(mapCtrl, slotIndex) + + local playerInfo = gameInfo.PlayerOptions[slotIndex] + local notFixed = gameInfo.GameOptions['TeamSpawn'] ~= 'fixed' + + -- Evil autoteams voodoo. + if gameInfo.GameOptions.AutoTeams and not gameInfo.AutoTeams[slotIndex] and lobbyComm:IsHost() then + gameInfo.AutoTeams[slotIndex] = 1 + end + + -- The ACUButton instance representing this slot, if any. + local marker = mapCtrl.startPositions[slotIndex] + if marker then + marker:SetClosed(gameInfo.ClosedSlots[slotIndex]) + if gameInfo.ClosedSlots[slotIndex] and gameInfo.SpawnMex[slotIndex] then + marker:SetClosedSpawnMex() + end + end + + mapCtrl:UpdatePlayer(slotIndex, playerInfo, notFixed) + + -- Nothing more for us to do for a closed or missing slot. + if gameInfo.ClosedSlots[slotIndex] or not marker then + return + end + + if gameInfo.GameOptions.AutoTeams then + if gameInfo.GameOptions.AutoTeams == 'lvsr' then + local midLine = mapCtrl.Left() + (mapCtrl.Width() / 2) + if notFixed then + local markerPos = marker.Left() + if markerPos < midLine then + marker:SetTeam(2) + else + marker:SetTeam(3) + end + end + elseif gameInfo.GameOptions.AutoTeams == 'tvsb' then + local midLine = mapCtrl.Top() + (mapCtrl.Height() / 2) + if notFixed then + local markerPos = marker.Top() + if markerPos < midLine then + marker:SetTeam(2) + else + marker:SetTeam(3) + end + end + elseif gameInfo.GameOptions.AutoTeams == 'pvsi' then + if notFixed then + if math.mod(slotIndex, 2) ~= 0 then + marker:SetTeam(2) + else + marker:SetTeam(3) + end + end + elseif gameInfo.GameOptions.AutoTeams == 'manual' and notFixed then + marker:SetTeam(gameInfo.AutoTeams[slotIndex] or 1) + end + end +end + +--- Update a single slot in all displayed map controls. +function RefreshMapPositionForAllControls(slot) + RefreshMapPosition(GUI.mapView, slot) + if LrgMap and not LrgMap.isHidden then + RefreshMapPosition(LrgMap.content.mapPreview, slot) + end +end + +function ShowMapPositions(mapCtrl, scenario) + local playerArmyArray = MapUtil.GetArmies(scenario) + + for inSlot, army in playerArmyArray do + RefreshMapPosition(mapCtrl, inSlot) + end +end + +function ConfigureMapListeners(mapCtrl, scenario) + local playerArmyArray = MapUtil.GetArmies(scenario) + + for inSlot, army in playerArmyArray do + local slot = inSlot -- Closure copy. + + -- The ACUButton instance representing this slot. + local marker = mapCtrl.startPositions[inSlot] + + marker.OnRollover = function(self, state) + local slotName = GUI.slots[slot].name + if state == 'enter' then + slotName:HandleEvent({Type = 'MouseEnter'}) + elseif state == 'exit' then + slotName:HandleEvent({Type = 'MouseExit'}) + end + end + + marker.OnClick = function(self) + if gameInfo.GameOptions['TeamSpawn'] == 'fixed' then + if FindSlotForID(localPlayerID) ~= slot and gameInfo.PlayerOptions[slot] == nil then + if IsPlayer(localPlayerID) then + if lobbyComm:IsHost() then + HostUtils.MovePlayerToEmptySlot(FindSlotForID(localPlayerID), slot) + else + lobbyComm:SendData(hostID, {Type = 'MovePlayer', RequestedSlot = slot}) + end + -- if first click is a not empty slot and second click is a empty slot: reset vars + if mapPreviewSlotSwap == true then + mapPreviewSlotSwap = false + mapPreviewSlotSwapFrom = 0 + end + elseif IsObserver(localPlayerID) then + if lobbyComm:IsHost() then + local requestedFaction = GetSanitisedLastFaction() + HostUtils.ConvertObserverToPlayer(FindObserverSlotForID(localPlayerID), slot) + else + lobbyComm:SendData( + hostID, + { + Type = 'RequestConvertToPlayer', + ObserverSlot = FindObserverSlotForID(localPlayerID), + PlayerSlot = slot + } + ) + end + end + else -- swap players on map preview + if lobbyComm:IsHost() and mapPreviewSlotSwap == false then + mapPreviewSlotSwapFrom = slot + mapPreviewSlotSwap = true + elseif lobbyComm:IsHost() and mapPreviewSlotSwap == true and mapPreviewSlotSwapFrom ~= slot then + mapPreviewSlotSwap = false + DoSlotBehavior(mapPreviewSlotSwapFrom, 'move_player_to_slot' .. slot, '') + mapPreviewSlotSwapFrom = 0 + end + end + else + if gameInfo.GameOptions.AutoTeams and lobbyComm:IsHost() then + -- Handle the manual-mode reassignment of slots to teams. + if gameInfo.GameOptions.AutoTeams == 'manual' then + if not gameInfo.ClosedSlots[slot] and (gameInfo.PlayerOptions[slot] or gameInfo.GameOptions['TeamSpawn'] ~= 'fixed') then + local targetTeam + if gameInfo.AutoTeams[slot] == 7 then + -- 2 here corresponds to team 1, since a team value of 1 represents + -- "no team". Apparently GPG really, really didn't like zero. + targetTeam = 2 + else + targetTeam = gameInfo.AutoTeams[slot] + 1 + end + + marker:SetTeam(targetTeam) + gameInfo.AutoTeams[slot] = targetTeam + + lobbyComm:BroadcastData( + { + Type = 'AutoTeams', + Slot = slot, + Team = gameInfo.AutoTeams[slot], + } + ) + gameInfo.PlayerOptions[slot]['Team'] = gameInfo.AutoTeams[slot] + SetSlotInfo(slot, gameInfo.PlayerOptions[slot]) + UpdateGame() + end + end + end + end + end + + if lobbyComm:IsHost() then + marker.OnRightClick = function(self) + if gameInfo.SpawnMex[slot] then + HostUtils.SetSlotClosed(slot, false) + elseif gameInfo.ClosedSlots[slot] then + if gameInfo.AdaptiveMap then + HostUtils.SetSlotClosedSpawnMex(slot) + else + HostUtils.SetSlotClosed(slot, false) + end + else + HostUtils.SetSlotClosed(slot, true) + end + end + end + end +end + +function SendCompleteGameStateToPeer(peerId) + lobbyComm:SendData(peerId, {Type = 'GameInfo', GameInfo = GameInfo.Flatten(gameInfo)}) +end + +function UpdateClientModStatus(newHostSimMods) + -- Apply the new game mods from the host, but don't touch our UI mod configuration. + selectedSimMods = newHostSimMods + + -- Make sure none of our selected UI mods are blacklisted + local bannedMods = CheckModCompatability() + if not table.empty(bannedMods) then + WarnIncompatibleMods() + + selectedUIMods = SetUtils.Subtract(selectedUIMods, bannedMods) + end + + Mods.SetSelectedMods(SetUtils.Union(selectedSimMods, selectedUIMods)) +end + +local IsFromHost = function(data) return data.SenderID == hostID end +local AmHost = function(data) return lobbyComm:IsHost() end + +local FromSubjectOrHost = function(data) + if IsFromHost(data) then + return true + end + + -- Do ridiculous things to infer the identity of the subject of the message. + -- TODO: A UNIFORM PROTOCOL MAYBE? + local subjectID = data.SenderID + if data.PlayerName then + return data.SenderID == FindIDForName(data.PlayerName) + end + + if data.Slot and gameInfo.PlayerOptions[data.Slot] then + return data.SenderID == FindIDForName(gameInfo.PlayerOptions[data.Slot].PlayerName) + end + + return false +end + +-- +local MessageHandlers = { + -- Update player options. Either the host reconfiguring, or users tweaking their own settings. + PlayerOptions = { + Accept = function(data) + for key, val in data.Options do + -- The host *is* allowed to set options on slots he doesn't own, of course. + if data.SenderID ~= hostID then + if key == 'Team' and gameInfo.GameOptions['AutoTeams'] ~= 'none' then + WARN("Attempt to set Team while Auto Teams are on.") + return false + elseif gameInfo.PlayerOptions[data.Slot].OwnerID ~= data.SenderID then + WARN("Attempt to set option on unowned slot.") + return false + end + end + end + + -- TODO: Players may not change *all* of their own options... + + return true + end, + Handle = function(data) + local options = data.Options + + for key, val in options do + gameInfo.PlayerOptions[data.Slot][key] = val + if lobbyComm:IsHost() then + local playerInfo = gameInfo.PlayerOptions[data.Slot] + if playerInfo.Human then + GpgNetSend('PlayerOption', playerInfo.OwnerID, key, val) + else + GpgNetSend('AIOption', playerInfo.PlayerName, key, val) + end + + -- TODO: This should be a global listener on PlayerData objects, but I'm in too + -- much pain to implement that listener system right now. EVIL HACK TIME + if key == "Ready" then + HostUtils.RefreshButtonEnabledness() + end + -- DONE. + end + end + + SetSlotInfo(data.Slot, gameInfo.PlayerOptions[data.Slot]) + end + }, + + -- SenderName here is inserted by lobbyComm, so validating it is just a gratuitious effort to + -- detect obnoxiousness. + PublicChat = { + Accept = function(data) + return data.SenderName == FindNameForID(data.SenderID) + end, + Handle = function(data) + AddChatText(data.Text, data.SenderID) + end + }, + + PrivateChat = { + Accept = function(data) + return data.SenderName == FindNameForID(data.SenderID) + end, + Handle = function(data) + AddChatText("<<"..LOCF("From %s", data.SenderName)..">> "..data.Text) + end + }, + + CPUBenchmark = { + Accept = FromSubjectOrHost, + Handle = function(data) + local newInfo = false + if data.PlayerName and CPU_Benchmarks[data.PlayerName] ~= data.Result then + newInfo = true + end + + local benchmarks = {} + if data.PlayerName then + benchmarks[data.PlayerName] = data.Result + else + benchmarks = data.Benchmarks + end + + for name, result in benchmarks do + CPU_Benchmarks[name] = result + local id = FindIDForName(name) + local slot = FindSlotForID(id) + if slot then + SetSlotCPUBar(slot, gameInfo.PlayerOptions[slot]) + else + refreshObserverList() + end + end + + -- Host broadcasts new CPU benchmark information to give the info to clients that are not directly connected to data.PlayerName yet. + if lobbyComm:IsHost() and newInfo then + lobbyComm:BroadcastData({Type='CPUBenchmark', Benchmarks=CPU_Benchmarks}) + end + end + }, + + SetPlayerNotReady = { + Accept = FromSubjectOrHost, + Handle = function(data) + + -- allow the user to make changes + EnableSlot(data.Slot) + + -- allow the user to become an observer again + GUI.becomeObserver:Enable() + + -- update player options + SetPlayerOption(data.Slot, 'Ready', false) + + -- update GUI + GUI.slots[data.Slot].ready:SetCheck(false) + end + }, + + AutoTeams = { + Accept = IsFromHost, + Handle = function(data) + gameInfo.AutoTeams[data.Slot] = data.Team + gameInfo.PlayerOptions[data.Slot]['Team'] = data.Team + SetSlotInfo(data.Slot, gameInfo.PlayerOptions[data.Slot]) + UpdateGame() + end + }, + + AddPlayer = { + + ---@class LobbyAddPlayerData + ---@field PlayerOptions PlayerData + ---@field SenderId number + ---@field SenderName string + ---@field Type string + + ---@param data LobbyAddPlayerData + Accept = function(data) + -- we need to do quite a bit of checks to prevent malicious values + if type(data.PlayerOptions.MEAN) != 'number' then + return false + end + + if type (data.PlayerOptions.NG) != 'number' then + return false + end + + if type(data.PlayerOptions.Faction) != 'number' then + return false + end + + if type(data.PlayerOptions.PlayerName) != 'string' then + return false + end + + local charactersInPlayerName = string.len(data.PlayerOptions.PlayerName) + if charactersInPlayerName < 2 or charactersInPlayerName > 32 then + return false + end + + if data.PlayerOptions.PlayerClan then + if type(data.PlayerOptions.PlayerClan) != 'string' then + return false + end + + -- note: there are strange clan names that use more than 1 byte per character + if string.len(data.PlayerOptions.PlayerClan) > 6 then + return false + end + end + + if not data.PlayerOptions.OwnerID then + return false + end + + if not (data.PlayerOptions.OwnerID == data.SenderID) then + return false + end + + if FindNameForID(data.SenderID) then + return false + end + + -- check game version and reject if there is a missmatch + local hostVersion, hostGametype, hostCommit = import("/lua/version.lua").GetVersionData() + local playerVersion, playerGameType, playerCommit = tostring(data.PlayerOptions.Version), tostring(data.PlayerOptions.GameType), tostring(data.PlayerOptions.Commit) + if hostVersion ~= playerVersion or hostGametype ~= playerGameType or hostCommit ~= playerCommit then + local playerName = data.PlayerOptions.PlayerName + AddChatText(LOCF("Game version missmatch detected with %s. \r\n - host: %s (@%s)\r\n - %s: %s (@%s). \r\n\r\nTo prevent desyncs, %s is ejected automatically. It is possible that a new game version is released. If this keeps happening then it is better to rehost.", playerName, hostVersion, hostCommit:sub(1, 8), playerName, playerVersion, playerCommit:sub(1, 8), playerName)) + return false + end + + return lobbyComm:IsHost() + end, + Reject = function(data) + lobbyComm:EjectPeer(data.SenderID, "Game version missmatch or invalid player data.") + end, + Handle = function(data) + -- try to reassign the same slot as in the last game if it's a rehosted game, otherwise give it an empty + -- slot or move it to observer + SendCompleteGameStateToPeer(data.SenderID) + + if argv.isRehost then + local rehostSlot = FindRehostSlotForID(data.SenderID) or 0 + if rehostSlot ~= 0 and gameInfo.PlayerOptions[rehostSlot] then + -- If the slot is occupied, the occupying player will be moved away or to observer. If it's an + -- AI, it will be removed + local occupyingPlayer = gameInfo.PlayerOptions[rehostSlot] + if not occupyingPlayer.Human then + HostUtils.RemoveAI(rehostSlot) + HostUtils.TryAddPlayer(data.SenderID, rehostSlot, PlayerData(data.PlayerOptions)) + else + HostUtils.ConvertPlayerToObserver(rehostSlot, true) + HostUtils.TryAddPlayer(data.SenderID, rehostSlot, PlayerData(data.PlayerOptions)) + HostUtils.ConvertObserverToPlayer(FindObserverSlotForID(occupyingPlayer.OwnerID)) + end + else + HostUtils.TryAddPlayer(data.SenderID, rehostSlot, PlayerData(data.PlayerOptions)) + end + else + HostUtils.TryAddPlayer(data.SenderID, 0, PlayerData(data.PlayerOptions)) + end + if HasCommandLineArg('/gpgnet') then + PlayVoice(Sound{Bank = 'XGG',Cue = 'XGG_Computer__04716'}, true) + end + end + }, + + -- Player requests move. + MovePlayer = { + Accept = AmHost, -- Forgery would require tricking lobbyComm... + Handle = function(data) + local CurrentSlot = FindSlotForID(data.SenderID) + + -- Handle ready-races. + if gameInfo.PlayerOptions[CurrentSlot].Ready then + return + end + + -- Player requests to be moved to a different empty slot. + HostUtils.MovePlayerToEmptySlot(CurrentSlot, data.RequestedSlot) + end + }, + + RequestConvertToObserver = { + Accept = AmHost, + Handle = function(data) + HostUtils.ConvertPlayerToObserver(FindSlotForID(data.SenderID)) + end + + }, + + RequestConvertToPlayer = { + Accept = AmHost, + Handle = function(data) + HostUtils.ConvertObserverToPlayer(FindObserverSlotForID(data.SenderID), data.PlayerSlot) + end + }, + + RequestColor = { + Accept = AmHost, + Handle = function(data) + local TargetSlot = FindSlotForID(data.SenderID) + if IsColorFree(data.Color) then + -- Color is available, let everyone else know + SetPlayerColor(gameInfo.PlayerOptions[TargetSlot], data.Color) + lobbyComm:BroadcastData({ Type = 'SetColor', Color = data.Color, Slot = TargetSlot }) + SetSlotInfo(TargetSlot, gameInfo.PlayerOptions[TargetSlot]) + else + -- Sorry, it's not free. Force the player back to the color we have for him. + lobbyComm:SendData(data.SenderID, { + Type = 'SetColor', + Color = gameInfo.PlayerOptions[TargetSlot].PlayerColor, Slot = TargetSlot + }) + end + end + }, + + -- Sent to the host to advise them of which mods the players have. + SetAvailableMods = { + Accept = AmHost, + Handle = function(data) + availableMods[data.SenderID] = data.Mods + HostUtils.UpdateMods(data.SenderID, data.Name) + end + }, + + -- Sent by a non-host peer when a map is selected that they don't have installed. + MissingMap = { + Accept = AmHost, + Handle = function(data) + HostUtils.PlayerMissingMapAlert(data.SenderID) + end + }, + + -- This is insane. + SystemMessage = { + Handle = function(data) + PrintSystemMessage(data.Id, data.Args) + end + }, + + -- This is very insane and somewhat insecure... + Peer_Really_Disconnected = { + Handle = function(data) + if data.Observ == false then + gameInfo.PlayerOptions[data.Slot] = nil + elseif data.Observ == true then + gameInfo.Observers[data.Slot] = nil + end + AddChatText(LOCF("Lost connection to %s.", data.Options.PlayerName), "Engine0003") + ClearSlotInfo(data.Slot) + UpdateGame() + end + }, + + SetAllPlayerNotReady = { + Accept = IsFromHost, + Handle = function(data) + if not IsPlayer(localPlayerID) then + return + end + local localSlot = FindSlotForID(localPlayerID) + EnableSlot(localSlot) + GUI.becomeObserver:Enable() + SetPlayerOption(localSlot, 'Ready', false) + end + }, + + -- Host telling us about things changing in the game configuration... + + GameOptions = { + Accept = IsFromHost, + Handle = function(data) + for key, value in data.Options do + gameInfo.GameOptions[key] = value + end + + UpdateGame() + end + }, + ClearSlot = { + Accept = IsFromHost, + Handle = function(data) + gameInfo.PlayerOptions[data.Slot] = nil + ClearSlotInfo(data.Slot) + end + }, + ModsChanged = { + Accept = IsFromHost, + Handle = function(data) + gameInfo.GameMods = data.GameMods + + UpdateClientModStatus(data.GameMods) + UpdateGame() + import("/lua/ui/lobby/modsmanager.lua").UpdateClientModStatus(gameInfo.GameMods) + end + }, + SlotClosed = { + Accept = IsFromHost, + Handle = function(data) + gameInfo.ClosedSlots[data.Slot] = data.Closed + gameInfo.SpawnMex[data.Slot] = false + ClearSlotInfo(data.Slot) + PossiblyAnnounceGameFull() + end + }, + SlotClosedSpawnMex = { + Accept = IsFromHost, + Handle = function(data) + gameInfo.ClosedSlots[data.Slot] = data.ClosedSpawnMex + gameInfo.SpawnMex[data.Slot] = data.ClosedSpawnMex + ClearSlotInfo(data.Slot) + PossiblyAnnounceGameFull() + end + }, + GameInfo = { + Accept = IsFromHost, + Handle = function(data) + -- Completely update the game state. To be used exactly once: when first connecting. + local hostFlatInfo = data.GameInfo + gameInfo = GameInfo.CreateGameInfo(LobbyComm.maxPlayerSlots, hostFlatInfo) + + UpdateClientModStatus(gameInfo.GameMods, true) + UpdateGame() + end + }, + SetColor = { + Accept = IsFromHost, + Handle = function(data) + SetPlayerColor(gameInfo.PlayerOptions[data.Slot], data.Color) + SetSlotInfo(data.Slot, gameInfo.PlayerOptions[data.Slot]) + end + }, + ConvertObserverToPlayer = { + Accept = IsFromHost, + Handle = function(data) + gameInfo.Observers[data.OldSlot] = nil + gameInfo.PlayerOptions[data.NewSlot] = PlayerData(data.Options) + SetSlotInfo(data.NewSlot, gameInfo.PlayerOptions[data.NewSlot]) + UpdateFactionSelectorForPlayer(gameInfo.PlayerOptions[data.NewSlot]) + end + }, + + ConvertPlayerToObserver = { + Accept = IsFromHost, + Handle = function(data) + gameInfo.Observers[data.NewSlot] = PlayerData(data.Options) + gameInfo.PlayerOptions[data.OldSlot] = nil + ClearSlotInfo(data.OldSlot) + UpdateFactionSelectorForPlayer(gameInfo.Observers[data.NewSlot]) + end + }, + + SlotAssigned = { + Accept = IsFromHost, + Handle = function(data) + gameInfo.PlayerOptions[data.Slot] = PlayerData(data.Options) + if HasCommandLineArg('/gpgnet') then + PlayVoice(Sound{Bank = 'XGG',Cue = 'XGG_Computer__04716'}, true) + end + SetSlotInfo(data.Slot, gameInfo.PlayerOptions[data.Slot]) + UpdateFactionSelectorForPlayer(gameInfo.PlayerOptions[data.Slot]) + PossiblyAnnounceGameFull() + end + }, + + SlotMove = { + Accept = IsFromHost, + Handle = function(data) + gameInfo.PlayerOptions[data.OldSlot] = nil + gameInfo.PlayerOptions[data.NewSlot] = PlayerData(data.Options) + gameInfo.PlayerOptions[data.NewSlot].Ready = false + ClearSlotInfo(data.OldSlot) + SetSlotInfo(data.NewSlot, gameInfo.PlayerOptions[data.NewSlot]) + UpdateFactionSelectorForPlayer(gameInfo.PlayerOptions[data.NewSlot]) + end + }, + + SwapPlayers = { + Accept = IsFromHost, + Handle = function(data) + DoSlotSwap(data.Slot1, data.Slot2) + end + }, + + ObserverAdded = { + Accept = IsFromHost, + Handle = function(data) + gameInfo.Observers[data.Slot] = PlayerData(data.Options) + refreshObserverList() + end + }, + + -- Start the game! + Launch = { + Accept = IsFromHost, + Handle = function(data) + local info = data.GameInfo + info.GameMods = Mods.GetGameMods(info.GameMods) + SetWindowedLobby(false) + + -- Evil hack to correct the skin for randomfaction players before launch. + for index, player in info.PlayerOptions do + -- Set the skin to the faction you'll be playing as, whatever that may be. (prevents + -- random-faction people from ending up with something retarded) + if player.OwnerID == localPlayerID then + UIUtil.SetCurrentSkin(FACTION_NAMES[player.Faction]) + end + end + + Presets.SaveLastGamePreset() + lobbyComm:LaunchGame(info) + end + }, +} + + +-- LobbyComm Callbacks +function InitLobbyComm(protocol, localPort, desiredPlayerName, localPlayerUID, natTraversalProvider) + lobbyComm = LobbyComm.CreateLobbyComm(protocol, localPort, desiredPlayerName, localPlayerUID, natTraversalProvider) + + if not lobbyComm then + error('Failed to create lobby using port ' .. tostring(localPort)) + end + + lobbyComm.ConnectionFailed = function(self, reason) + LOG("CONNECTION FAILED " .. reason) + GUI.connectionFailedDialog = UIUtil.ShowInfoDialog(GUI.panel, LOCF(Strings.ConnectionFailed, Strings[reason] or reason), + "", ReturnToMenu) + + lobbyComm:Destroy() + lobbyComm = nil + end + + lobbyComm.LaunchFailed = function(self,reasonKey) + AddChatText(LOC(Strings[reasonKey] or reasonKey)) + end + + lobbyComm.Ejected = function(self,reason) + LOG("EJECTED " .. reason) + + GUI.connectionFailedDialog = UIUtil.ShowInfoDialog(GUI, LOCF(Strings.Ejected, Strings[reason] or reason), "", ReturnToMenu) + lobbyComm:Destroy() + lobbyComm = nil + end + + lobbyComm.ConnectionToHostEstablished = function(self,myID,myName,theHostID) + LOG("CONNECTED TO HOST") + hostID = theHostID + localPlayerID = myID + localPlayerName = myName + + lobbyComm:SendData(hostID, { Type = 'SetAvailableMods', Mods = Mods.GetLocallyAvailableMods(), Name = localPlayerName}) + + lobbyComm:SendData(hostID, + { + Type = 'AddPlayer', + PlayerOptions = GetLocalPlayerData():AsTable() + } + ) + + -- Update, if needed, and broadcast, your CPU benchmark value. + if not singlePlayer then + ForkThread(function() UpdateBenchmark() end) + end + + local function KeepAliveThreadFunc() + local threshold = LobbyComm.quietTimeout + local active = true + local prev = 0 + while lobbyComm do + local host = lobbyComm:GetPeer(hostID) + if active and host.quiet > threshold then + active = false + local function OnRetry() + host = lobbyComm:GetPeer(hostID) + threshold = host.quiet + LobbyComm.quietTimeout + active = true + end + UIUtil.QuickDialog(GUI, "Connection to host timed out.", + "Keep Trying", OnRetry, + "Give Up", ReturnToMenu, + nil, nil, + true, + {worldCover = false, escapeButton = 2}) + elseif host.quiet < prev then + threshold = LobbyComm.quietTimeout + end + prev = host.quiet + WaitSeconds(1) + end + end -- KeepAliveThreadFunc + + GUI.keepAliveThread = ForkThread(KeepAliveThreadFunc) + CreateUI(LobbyComm.maxPlayerSlots) + 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 sent via `BroadcastData` and/or `SendData`. + ---@param self UILobbyCommunication + ---@param data UILobbyReceivedMessage + lobbyComm.DataReceived = function(self, data) + -- make it more convenient to debug malicious traffic + SPEW(string.format("Received data of type %s from %s (%s)", tostring(data.Type), tostring(data.SenderID), tostring(data.SenderName))) + + -- Decide if we should just drop the packet. Violations here are usually people using a + -- modified lobby.lua to try to do stupid shit. + if not MessageHandlers[data.Type] then + WARN("Unknown message type: " .. tostring(data.Type)) + return + end + + -- No defined validator is taken to be always-accept. + if not MessageHandlers[data.Type].Accept or MessageHandlers[data.Type].Accept(data) then + MessageHandlers[data.Type].Handle(data) + elseif MessageHandlers[data.Type].Reject then + MessageHandlers[data.Type].Reject(data) + else + WARN("Rejected message of type " .. tostring(data.Type) .. " from " .. tostring(FindNameForID(data.SenderID))) + end + end + + lobbyComm.SystemMessage = function(self, text) + AddChatText(text) + end + + lobbyComm.GameLaunched = function(self) + local player = lobbyComm:GetLocalPlayerID() + for i, v in gameInfo.PlayerOptions do + if v.Human and v.OwnerID == player then + Prefs.SetToCurrentProfile('LoadingFaction', v.Faction) + break + end + end + + GpgNetSend('GameState', 'Launching') + if GUI.pingThread then + KillThread(GUI.pingThread) + end + if GUI.keepAliveThread then + KillThread(GUI.keepAliveThread) + end + GUI:Destroy() + GUI = false + MenuCommon.MenuCleanup() + lobbyComm:Destroy() + lobbyComm = false + + -- determine if cheat keys should be mapped + if not DebugFacilitiesEnabled() then + IN_ClearKeyMap() + IN_AddKeyMapTable(import("/lua/keymap/keymapper.lua").GetKeyMappings(gameInfo.GameOptions['CheatsEnabled']=='true')) + end + end + + lobbyComm.Hosting = function(self) + InitHostUtils() + + localPlayerID = lobbyComm:GetLocalPlayerID() + hostID = localPlayerID + HostUtils.UpdateMods() + + --- Returns true if the given option has the given key as a valid setting. + local function keyIsValidForOption(option, key) + for k, v in option.values do + if v.key == key or v == key then + return true + end + end + return false + end + + -- Given an option key, find the value stored in the profile (if any) and assign either it, + -- or that option's default value, to the current game state. + local setOptionsFromPref = function(option) + local defValue = Prefs.GetFromCurrentProfile("LobbyOpt_" .. option.key) + + -- Do the slightly stupid thing to check if the option we found in the profile is + -- a valid key for this option. Some mods muck about with the possibilities, so we + -- need to make sure we use a sane default if that's happened. + if defValue == nil or not keyIsValidForOption(option, defValue) then + -- Exception to make AllowObservers work because the engine requires + -- the keys to be bool. Custom options should use 'True' or 'False' + if option.key == 'AllowObservers' then + defValue = option.values[option.default].key + else + defValue = option.values[option.default].key or option.values[option.default] + end + end + + SetGameOption(option.key, defValue, true) + end + + -- Give myself the first slot + local myPlayerData = GetLocalPlayerData() + + gameInfo.PlayerOptions[1] = myPlayerData + + -- set default lobby values + for index, option in globalOpts do + setOptionsFromPref(option) + end + + for index, option in teamOpts do + setOptionsFromPref(option) + end + + for index, option in AIOpts do + setOptionsFromPref(option) + end + + -- The key, LastScenario, is referred to from GPG code we don't hook. + if not self.desiredScenario or self.desiredScenario == "" then + self.desiredScenario = Prefs.GetFromCurrentProfile("LastScenario") + end + local scenarioInfo = MapUtil.LoadScenario(self.desiredScenario) + if not scenarioInfo or scenarioInfo.type != UIUtil.requiredType then + self.desiredScenario = UIUtil.defaultScenario + end + SetGameOption('ScenarioFile', self.desiredScenario, true) + + GUI.keepAliveThread = ForkThread( + -- Eject players who haven't sent a heartbeat in a while + function() + while true and lobbyComm do + local peers = lobbyComm:GetPeers() + for k,peer in peers do + if peer.quiet > LobbyComm.quietTimeout then + lobbyComm:EjectPeer(peer.id,'TimedOutToHost') + -- %s timed out. + SendSystemMessage("lobui_0205", peer.name) + + -- Search and Remove the peer disconnected + for k, v in CurrentConnection do + if v == peer.name then + CurrentConnection[k] = nil + break + end + end + for k, v in ConnectionEstablished do + if v == peer.name then + ConnectionEstablished[k] = nil + break + end + end + for k, v in ConnectedWithProxy do + if v == peer.id then + ConnectedWithProxy[k] = nil + break + end + end + end + end + WaitSeconds(1) + end + end + ) + + CreateUI(LobbyComm.maxPlayerSlots) + ForkThread(function() UpdateBenchmark(false) end) + + if argv.isRehost then + local settings = Presets.GetLastGameSettings() + if not settings then + ApplyGameSettings(settings) + end + + local rehostSlot = FindRehostSlotForID(localPlayerID) + if rehostSlot then + HostUtils.MovePlayerToEmptySlot(1, rehostSlot) + end + + for index, playerInfo in ipairs(rehostPlayerOptions) do + if not playerInfo.Human then + HostUtils.AddAI(playerInfo.PlayerName, playerInfo.AIPersonality, playerInfo.StartSpot) + end + end + end + + UpdateGame() + end + + lobbyComm.PeerDisconnected = function(self,peerName,peerID) + + -- Search and Remove the peer disconnected + for k, v in CurrentConnection do + if v == peerName then + CurrentConnection[k] = nil + break + end + end + for k, v in ConnectionEstablished do + if v == peerName then + ConnectionEstablished[k] = nil + break + end + end + for k, v in ConnectedWithProxy do + if v == peerID then + ConnectedWithProxy[k] = nil + break + end + end + + if IsPlayer(peerID) then + local slot = FindSlotForID(peerID) + if slot and lobbyComm:IsHost() then + if HasCommandLineArg('/gpgnet') then + PlayVoice(Sound{Bank = 'XGG',Cue = 'XGG_Computer__04717'}, true) + end + lobbyComm:BroadcastData( + { + Type = 'Peer_Really_Disconnected', + Options = gameInfo.PlayerOptions[slot]:AsTable(), + Slot = slot, + Observ = false, + } + ) + ClearSlotInfo(slot) + gameInfo.PlayerOptions[slot] = nil + UpdateGame() + end + elseif IsObserver(peerID) then + local slot2 = FindObserverSlotForID(peerID) + if slot2 and lobbyComm:IsHost() then + lobbyComm:BroadcastData( + { + Type = 'Peer_Really_Disconnected', + Options = gameInfo.Observers[slot2]:AsTable(), + Slot = slot2, + Observ = true, + } + ) + gameInfo.Observers[slot2] = nil + UpdateGame() + end + end + + availableMods[peerID] = nil + if HostUtils.UpdateMods then + HostUtils.UpdateMods() + end + end + + lobbyComm.GameConfigRequested = function(self) + return { + Options = gameInfo.GameOptions, + HostedBy = localPlayerName, + PlayerCount = GetPlayerCount(), + GameName = gameName, + ProductCode = import("/lua/productcode.lua").productCode, + } + end +end + +function SetPlayerOptions(slot, options, ignoreRefresh) + if not IsLocallyOwned(slot) and not lobbyComm:IsHost() then + WARN("Hey you can't set a player option on a slot you don't own:") + for key, val in options do + WARN("(slot:"..tostring(slot).." / key:"..tostring(key).." / val:"..tostring(val)..")") + end + return + end + + for key, val in options do + gameInfo.PlayerOptions[slot][key] = val + end + + lobbyComm:BroadcastData( + { + Type = 'PlayerOptions', + Options = options, + Slot = slot, + }) + + if not ignoreRefresh then + UpdateGame() + end +end + +function SetPlayerOption(slot, key, val, ignoreRefresh) + local options = {} + options[key] = val + SetPlayerOptions(slot, options, ignoreRefresh) + refreshObserverList() +end + +function SetGameOptions(options, ignoreRefresh) + if not lobbyComm:IsHost() then + WARN('Attempt to set game option by a non-host') + return + end + + for key, val in options do + Prefs.SetToCurrentProfile('LobbyOpt_' .. key, val) + gameInfo.GameOptions[key] = val + + -- don't want to send all restricted categories to gpgnet, so just send bool + -- note if more things need to be translated to gpgnet, a translation table would be a better implementation + -- but since there's only one, we'll call it out here + if key == 'RestrictedCategories' then + local restrictionsEnabled = false + if val ~= nil then + if not table.empty(val) then + restrictionsEnabled = true + end + end + GpgNetSend('GameOption', key, restrictionsEnabled) + elseif key == 'ScenarioFile' then + -- Special-snowflake the LastScenario key (used by GPG code). + Prefs.SetToCurrentProfile('LastScenario', val) + GpgNetSend('GameOption', key, val) + if gameInfo.GameOptions.ScenarioFile and (gameInfo.GameOptions.ScenarioFile ~= '') then + -- Warn about attempts to load nonexistent maps. + if not DiskGetFileInfo(gameInfo.GameOptions.ScenarioFile) then + AddChatText(LOC('The selected map does not exist.')) + else + local scenarioInfo = MapUtil.LoadScenario(gameInfo.GameOptions.ScenarioFile) + if scenarioInfo and scenarioInfo.map and (scenarioInfo.map ~= '') then + GpgNetSend('GameOption', 'Slots', table.getsize(scenarioInfo.Configurations.standard.teams[1].armies)) + end + end + end + else + GpgNetSend('GameOption', key, val) + end + end + + lobbyComm:BroadcastData { + Type = 'GameOptions', + Options = options + } + + if not ignoreRefresh then + UpdateGame() + end +end + +function SetGameOption(key, val, ignoreRefresh) + local options = {} + options[key] = val + SetGameOptions(options, ignoreRefresh) +end + +function DebugDump() + if lobbyComm then + lobbyComm:DebugDump() + end +end + +-- Perform one-time setup of the large map preview +function CreateBigPreview(parent) + local scenarioInfo = MapUtil.LoadScenario(gameInfo.GameOptions.ScenarioFile) + if scenarioInfo.hidePreviewMarkers then + return + end + + if LrgMap then + LrgMap.isHidden = false + RefreshLargeMap() + LrgMap:Show() + return + end + + -- Size of the map preview to generate. + local MAP_PREVIEW_SIZE = 721 + + -- The size of the mass/hydrocarbon icons + local HYDROCARBON_ICON_SIZE = 14 + local MASS_ICON_SIZE = 10 + + local dialogContent = Group(parent) + LayoutHelpers.SetDimensions(dialogContent, MAP_PREVIEW_SIZE + 10, MAP_PREVIEW_SIZE + 10) + + LrgMap = Popup(parent, dialogContent) + + -- The LrgMap shouldn't be destroyed due to issues related to texture pooling. Evil hack ensues. + local onTryMapClose = function() + LrgMap:Hide() + LrgMap.isHidden = true + end + LrgMap.OnEscapePressed = onTryMapClose + LrgMap.OnShadowClicked = onTryMapClose + + -- Create the map preview + local mapPreview = ResourceMapPreview(dialogContent, MAP_PREVIEW_SIZE, MASS_ICON_SIZE, HYDROCARBON_ICON_SIZE) + dialogContent.mapPreview = mapPreview + LayoutHelpers.AtCenterIn(mapPreview, dialogContent) + + local closeBtn = UIUtil.CreateButtonStd(dialogContent, '/dialogs/close_btn/close') + LayoutHelpers.AtRightTopIn(closeBtn, dialogContent, 1, 1) + closeBtn.OnClick = onTryMapClose + + -- Keep the close button on top of the border (which is itself on top of the map preview) + LayoutHelpers.DepthOverParent(closeBtn, mapPreview, 2) + + RefreshLargeMap() +end + +-- Refresh the large map preview (so it can update if something changes while it's open) +function RefreshLargeMap() + if not LrgMap or LrgMap.isHidden then + return + end + + local scenarioInfo = MapUtil.LoadScenario(gameInfo.GameOptions.ScenarioFile) + LrgMap.content.mapPreview:SetScenario(scenarioInfo, true) + ConfigureMapListeners(LrgMap.content.mapPreview, scenarioInfo) + ShowMapPositions(LrgMap.content.mapPreview, scenarioInfo) +end + +-------------------------------------------------- +-- Ping GUI Functions +-------------------------------------------------- + +local ConnectionStatusInfo = { + 'Player is not connected to someone', + 'Connected', + 'Not Connected', + 'No connection info available', +} + +function Ping_AddControlTooltip(control, delay, slotNumber) + --This function creates the Ping tooltip for a slot along with necessary mouseover function. + --It is called during the UI creation. + -- control: The control to which the tooltip is to be added. + -- delay: Amount of time to delay before showing tooltip. See Tooltip.CreateMouseoverDisplay for info. + -- slotNumber: The slot number associated with the control. + local pingText = function() + local pingInfo + if GUI.slots[slotNumber].pingStatus.PingActualValue then + pingInfo = GUI.slots[slotNumber].pingStatus.PingActualValue + else + pingInfo = LOC('UnKnown') + end + return LOC('Ping: ') .. pingInfo + end + local pingBody = function() + local conInfo + if GUI.slots[slotNumber].pingStatus.ConnectionStatus then + conInfo = GUI.slots[slotNumber].pingStatus.ConnectionStatus + else + conInfo = 4 + end + return LOC('Only shows when > 500') .. '\n\n' .. LOC(ConnectionStatusInfo[conInfo]) + end + Tooltip.AddAutoUpdatedControlTooltip(control, pingText, pingBody, delay) +end + +--CPU Status Bar Configuration +local greenBarMax = 300 +local yellowBarMax = 375 +local scoreSkew1 = 0 --Skews all CPU scores up or down by the amount specified (0 = no skew) +local scoreSkew2 = 1.0 --Skews all CPU scores specified coefficient (1.0 = no skew) + +--Variables for CPU Test +local BenchTime + +-------------------------------------------------- +-- CPU Benchmarking Functions +-------------------------------------------------- +function CPUBenchmark() + --This function gives the CPU some busy work to do. + --CPU score is determined by how quickly the work is completed. + local totalTime = 0 + local lastTime + local currTime + local countTime = 0 + --Make everything a local variable + --This is necessary because we don't want LUA searching through the globals as part of the benchmark + local TableInsert = table.insert + local TableRemove = table.remove + local h + local i + local j + local k + local l + local m + local n = {} + for h = 1, 24, 1 do + -- If the need for the benchmark no longer exists, abort it now. + if not lobbyComm then + return + end + + lastTime = GetSystemTimeSeconds() + for i = 1.0, 30.4, 0.0008 do + --This instruction set should cover most LUA operators + j = i + i --Addition + k = i * i --Multiplication + l = k / j --Division + m = j - i --Subtraction + j = math.pow(i, 4) --Power + l = -i --Negation + m = {'1234567890', 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', true} --Create Table + TableInsert(m, '1234567890') --Insert Table Value + k = i < j --Less Than + k = i == j --Equality + k = i <= j --Less Than or Equal to + k = not k + n[tostring(i)] = m + end + currTime = GetSystemTimeSeconds() + totalTime = totalTime + currTime - lastTime + + if totalTime > countTime then + --This is necessary in order to make this 'thread' yield so other things can be done. + countTime = totalTime + .125 + coroutine.yield(1) + end + end + BenchTime = math.ceil(totalTime * 100) +end + +-------------------------------------------------- +-- CPU GUI Functions +-------------------------------------------------- +function CPU_AddControlTooltip(control, delay, slotNumber) + --This function creates the benchmark tooltip for a slot along with necessary mouseover function. + --It is called during the UI creation. + -- control: The control to which the tooltip is to be added. + -- delay: Amount of time to delay before showing tooltip. See Tooltip.CreateMouseoverDisplay for info. + -- slotNumber: The slot number associated with the control. + local CPUText = function() + local CPUInfo + if GUI.slots[slotNumber].CPUSpeedBar.CPUActualValue then + CPUInfo = GUI.slots[slotNumber].CPUSpeedBar.CPUActualValue + else + CPUInfo = LOC('UnKnown') + end + return LOC('CPU Rating: ') .. CPUInfo + end + local CPUBody = function() + return LOC('0=Fastest, 450=Slowest') + end + Tooltip.AddAutoUpdatedControlTooltip(control, CPUText, CPUBody, delay) +end + +--- Get the CPU benchmark score for the local machine. +-- If a previously-calculated benchmark score is found in the profile, it is returned immediately. +-- Otherwise, a fresh score is calculated and stored (enjoy the lag!). +-- +-- @param force If truthy, a fresh score is always calculated. +-- @return A benchmark score between 0 and 450, or nil if StressCPU returned nil (which occurs iff +-- the lobby is exited before the calculation is complete. Callers should abort gracefully +-- in this situation). +-- @see StressCPU +function GetBenchmarkScore(force) + local wait = 10 + local benchmark + + if force then + wait = 0 + else + benchmark = GetPreference('CPUBenchmark') + end + + if not benchmark then + -- We defer the calculation by 10s here because, often, non-forced requests are occurring on + -- startup, and we want to give other tasks, such as connection negotiation, a fighting + -- chance of completing before we ruin everything. + -- + -- Benchmark scores are associated with the machine, not the profile: hence SetPreference. + + benchmark = StressCPU(wait) + SetPreference('CPUBenchmark', benchmark) + end + + return benchmark +end + +--- Updates the displayed benchmark score for the local player. +-- +-- @param force Passed as the `force` parameter to GetBenchmarkScore. +-- @see GetBenchmarkScore +function UpdateBenchmark(force) + local benchmark = GetBenchmarkScore(force) + + if benchmark then + CPU_Benchmarks[localPlayerName] = benchmark + lobbyComm:BroadcastData({ Type = 'CPUBenchmark', PlayerName = localPlayerName, Result = benchmark }) + if FindObserverSlotForID(localPlayerID) then + refreshObserverList() + else + UpdateCPUBar(localPlayerName) + end + end +end + +-- This function instructs the PC to do a CPU score benchmark. +-- It handles the necessary UI updates during the benchmark, sends +-- the benchmark result to other players when finished, and it updates the local +-- user's UI with their new result. +-- waitTime: The delay in seconds that this function should wait before starting the benchmark. +function StressCPU(waitTime) + GUI.rerunBenchmark:Disable() + for i = waitTime, 1, -1 do + GUI.rerunBenchmark.label:SetText(i..'s') + WaitSeconds(1) + + -- lobbyComm is destroyed when the lobby is exited. If the user left the lobby, we no longer + -- want to run the benchmark (it just introduces lag as the user is trying to do something + -- else). + if not lobbyComm then + return + end + end + + --Get our last benchmark (if there was one) + local currentBestBenchmark = 10000 + + GUI.rerunBenchmark.label:SetText('. . .') + + --Run three benchmarks and keep the best one + for i=1, 3, 1 do + BenchTime = 0 + CPUBenchmark() + + BenchTime = scoreSkew2 * BenchTime + scoreSkew1 + + -- The bench might have yeilded to a launcher, so we verify the lobbyComm is available when + -- we need it in a moment here (as well as aborting if we're wasting our time more than usual) + if not lobbyComm then + return + end + + --If this benchmark was better than our best so far... + if BenchTime < currentBestBenchmark then + currentBestBenchmark = BenchTime + end + end + + --Reset Button UI + GUI.rerunBenchmark:Enable() + GUI.rerunBenchmark.label:SetText('') + + return currentBestBenchmark +end + +function UpdateCPUBar(playerName) + --This function updates the UI with a CPU benchmark bar for the specified playerName. + -- playerName: The name of the player whose benchmark should be updated. + local playerId = FindIDForName(playerName) + local playerSlot = FindSlotForID(playerId) + if playerSlot ~= nil then + SetSlotCPUBar(playerSlot, gameInfo.PlayerOptions[playerSlot]) + end +end + +function SetSlotCPUBar(slot, playerInfo) + --This function updates the UI with a CPU benchmark bar for the specified slot/playerInfo. + -- slot: a numbered slot (1-however many slots there are for this map) + -- playerInfo: The corresponding playerInfo object from gameInfo.PlayerOptions[slot]. + + + if GUI.slots[slot].CPUSpeedBar then + GUI.slots[slot].CPUSpeedBar:Hide() + if playerInfo.Human then + local b = CPU_Benchmarks[playerInfo.PlayerName] + if b then + -- For display purposes, the bar has a higher minimum that the actual barMin value. + -- This is to ensure that the bar is visible for very small values + + -- Lock values to the largest possible result. + if b > GUI.slots[slot].CPUSpeedBar.barMax then + b = GUI.slots[slot].CPUSpeedBar.barMax + end + + GUI.slots[slot].CPUSpeedBar:SetValue(b) + GUI.slots[slot].CPUSpeedBar.CPUActualValue = b + + GUI.slots[slot].CPUSpeedBar:Show() + end + end + end +end + +function SetGameTitleText(title) + GUI.titleText:SetColor("B9BFB9") + if title == '' then + title = LOC("FAF Game Lobby") + end + GUI.titleText:SetText(title) +end + +function ShowTitleDialog() + CreateInputDialog(GUI, "Game Title", + function(self, text) + -- remove new lines from the text + text = text:gsub("\r", "") + text = text:gsub("\n", "") + + SetGameOption("Title", text, true) + SetGameTitleText(text) + end, gameInfo.GameOptions.Title +) +end + +-- Rule title +function SetRuleTitleText(rule) + GUI.RuleLabel:SetColors("B9BFB9") + if rule == '' then + if lobbyComm:IsHost() then + GUI.RuleLabel:SetColors("FFCC00") + rule = LOC("No Rules: Click to add rules") + else + rule = "No rules." + end + end + + GUI.RuleLabel:SetText(rule) +end + +-- Show the rule change dialog. +function ShowRuleDialog() + CreateInputDialog(GUI, "Game Rules", + function(self, text) + SetGameOption("GameRules", text, true) + SetRuleTitleText(text) + end, gameInfo.GameOptions.GameRules +) +end + +-- Faction selector +function CreateUI_Faction_Selector(lastFaction) + -- Build a list of button objects from the list of defined factions. Each faction will use the + -- faction key as its RadioButton texture path offset. + local buttons = {} + for i, faction in FactionData.Factions do + buttons[i] = { + texturePath = faction.Key + } + end + + -- Special-snowflaking for the random faction. + table.insert(buttons, { + texturePath = "random" + }) + + local factionSelector = RadioButton(GUI.panel, "/factionselector/", buttons, lastFaction, true) + GUI.factionSelector = factionSelector + LayoutHelpers.AtLeftTopIn(factionSelector, GUI.panel, 407, 20) + factionSelector.OnChoose = function(self, targetFaction, key) + local localSlot = FindSlotForID(localPlayerID) + local slotFactionIndex = GetSlotFactionIndex(targetFaction) + Prefs.SetToCurrentProfile('LastFaction', targetFaction) + GUI.slots[localSlot].faction:SetItem(slotFactionIndex) + SetPlayerOption(localSlot, 'Faction', slotFactionIndex) + gameInfo.PlayerOptions[localSlot].Faction = slotFactionIndex + + RefreshLobbyBackground(targetFaction) + UIUtil.SetCurrentSkin(FACTION_NAMES[targetFaction]) + end + + -- Only enable all buttons incase all the buttons are disabled, to avoid overriding partially disabling of the buttons + factionSelector.Enable = function(self) + for k, v in self.mButtons do + if v._controlState == "up" then + return + end + end + for k, v in self.mButtons do + v:Enable() + end + end + + factionSelector.SetCheck = function(self, index) + for i,button in self.mButtons do + if index ==i then + button:SetCheck(true) + else + button:SetCheck(false) + end + end + self.mCurSelection = index + end + + factionSelector.EnableSpecificButtons = function(self, specificButtons) + for i,button in self.mButtons do + if specificButtons[i] then + button:Enable() + else + button:Disable() + end + end + end +end + +function UpdateFactionSelectorForPlayer(playerInfo) + if playerInfo.OwnerID == localPlayerID then + UpdateFactionSelector() + end +end + +function UpdateFactionSelector() + local playerSlotID = FindSlotForID(localPlayerID) + local playerSlot = GUI.slots[playerSlotID] + if not playerSlot or not playerSlot.AvailableFactions or gameInfo.PlayerOptions[playerSlotID].Ready then + UIUtil.setEnabled(GUI.factionSelector, false) + return + end + local enabledList = {} + for index,button in GUI.factionSelector.mButtons do + enabledList[index] = false + for i,value in playerSlot.AvailableFactions do + if value == allAvailableFactionsList[index] then + if gameInfo.PlayerOptions[playerSlotID].Faction == i then + GUI.factionSelector:SetCheck(index) + end + enabledList[index] = true + break + end + end + end + GUI.factionSelector:EnableSpecificButtons(enabledList) +end + +function GetSlotFactionIndex(factionIndex) + local localSlot = GUI.slots[FindSlotForID(localPlayerID)] + local actualFaction = allAvailableFactionsList[factionIndex] + for index,value in localSlot.AvailableFactions do + if value == actualFaction then + return index + end + end +end + +function RefreshLobbyBackground(faction) + local LobbyBackground = Prefs.GetFromCurrentProfile('LobbyBackground') or 1 + if GUI.background then + GUI.background:Destroy() + end + if LobbyBackground == 1 then -- Factions + faction = faction or GetSanitisedLastFaction() + if FACTION_NAMES[faction] then + GUI.background = Bitmap(GUI, "/textures/ui/common/BACKGROUND/faction/faction-background-paint_" .. FACTION_NAMES[faction] .. "_bmp.dds") + else + return + end + elseif LobbyBackground == 2 then -- Concept art + GUI.background = Bitmap(GUI, "/textures/ui/common/BACKGROUND/art/art-background-paint0" .. math.random(1, 5) .. "_bmp.dds") + elseif LobbyBackground == 3 then -- Screenshot + GUI.background = Bitmap(GUI, "/textures/ui/common/BACKGROUND/scrn/scrn-background-paint" .. math.random(1, 14) .. "_bmp.dds") + elseif LobbyBackground == 4 then -- Map + local MapPreview = import("/lua/ui/controls/mappreview.lua").MapPreview + GUI.background = MapPreview(GUI) -- Background map + if gameInfo.GameOptions.ScenarioFile and (gameInfo.GameOptions.ScenarioFile ~= '') then + local scenarioInfo = MapUtil.LoadScenario(gameInfo.GameOptions.ScenarioFile) + if scenarioInfo and scenarioInfo.map and (scenarioInfo.map ~= '') and scenarioInfo.preview then + if not GUI.background:SetTexture(scenarioInfo.preview) then + GUI.background:SetTextureFromMap(scenarioInfo.map) + end + end + end + elseif LobbyBackground == 5 then -- None + return + end + + local LobbyBackgroundStretch = Prefs.GetFromCurrentProfile('LobbyBackgroundStretch') or 'true' + LayoutHelpers.AtCenterIn(GUI.background, GUI) + LayoutHelpers.DepthUnderParent(GUI.background, GUI.panel) + if LobbyBackgroundStretch == 'true' then + LayoutHelpers.FillParent(GUI.background, GUI) + else + LayoutHelpers.FillParentPreserveAspectRatio(GUI.background, GUI) + end +end + +function ShowLobbyOptionsDialog() + local dialogContent = Group(GUI) + LayoutHelpers.SetDimensions(dialogContent, 420, 310) + + local dialog = Popup(GUI, dialogContent) + GUI.lobbyOptionsDialog = dialog + + local buttons = { + { + label = LOC("") -- Factions + }, + { + label = LOC("") -- Concept art + }, + { + label = LOC("") -- Screenshot + }, + { + label = LOC("") -- Map + }, + { + label = LOC("") -- None + } + } + + -- Label shown above the background mode selection radiobutton. + local backgroundLabel = UIUtil.CreateText(dialogContent, LOC(" "), 16, 'Arial', true) + local selectedBackgroundState = Prefs.GetFromCurrentProfile("LobbyBackground") or 1 + local backgroundRadiobutton = RadioButton(dialogContent, '/RADIOBOX/', buttons, selectedBackgroundState, false, true) + + LayoutHelpers.AtLeftTopIn(backgroundLabel, dialogContent, 15, 15) + LayoutHelpers.AtLeftTopIn(backgroundRadiobutton, dialogContent, 15, 40) + + backgroundRadiobutton.OnChoose = function(self, index, key) + Prefs.SetToCurrentProfile("LobbyBackground", index) + RefreshLobbyBackground() + end + -- label for displaying chat font size + local currentFontSize = Prefs.GetFromCurrentProfile('LobbyChatFontSize') or 14 + local slider_Chat_SizeFont_TEXT = UIUtil.CreateText(dialogContent, LOC(" ").. currentFontSize, 14, 'Arial', true) + LayoutHelpers.AtRightTopIn(slider_Chat_SizeFont_TEXT, dialogContent, 27, 162) + + -- slider for changing chat font size + local slider_Chat_SizeFont = Slider(dialogContent, false, 9, 20, + UIUtil.SkinnableFile('/slider02/slider_btn_up.dds'), + UIUtil.SkinnableFile('/slider02/slider_btn_over.dds'), + UIUtil.SkinnableFile('/slider02/slider_btn_down.dds'), + UIUtil.SkinnableFile('/slider02/slider-back_bmp.dds')) + LayoutHelpers.AtRightTopIn(slider_Chat_SizeFont, dialogContent, 20, 182) + slider_Chat_SizeFont:SetValue(currentFontSize) + slider_Chat_SizeFont.OnValueChanged = function(self, newValue) + local isScrolledDown = GUI.chatPanel:IsScrolledToBottom() + + local sliderValue = math.floor(self._currentValue()) + slider_Chat_SizeFont_TEXT:SetText(LOC(" ").. sliderValue) + + Prefs.SetToCurrentProfile('LobbyChatFontSize', sliderValue) + -- updating chat panel with new font size + GUI.chatPanel:SetFont(nil, sliderValue) + + if isScrolledDown then + GUI.chatPanel:ScrollToBottom() + end + end + if true then + --snowflakes count + local currentSnowFlakesCount = Prefs.GetFromCurrentProfile('SnowFlakesCount') or 100 + local slider_SnowFlakes_Count_TEXT = UIUtil.CreateText(dialogContent, LOC("Snowflakes count").. currentSnowFlakesCount, 14, 'Arial', true) + LayoutHelpers.AtRightTopIn(slider_SnowFlakes_Count_TEXT, dialogContent, 27, 202) + + -- slider for changing chat font size + local slider_SnowFlakes_Count = Slider(dialogContent, false, 100, 1000, + UIUtil.SkinnableFile('/slider02/slider_btn_up.dds'), + UIUtil.SkinnableFile('/slider02/slider_btn_over.dds'), + UIUtil.SkinnableFile('/slider02/slider_btn_down.dds'), + UIUtil.SkinnableFile('/slider02/slider-back_bmp.dds')) + LayoutHelpers.AtRightTopIn(slider_SnowFlakes_Count, dialogContent, 20, 222) + slider_SnowFlakes_Count:SetValue(currentSnowFlakesCount) + slider_SnowFlakes_Count.OnValueChanged = function(self, newValue) + local sliderValue = math.floor(newValue) + slider_SnowFlakes_Count_TEXT:SetText(LOC("Snowflakes count").. sliderValue) + Prefs.SetToCurrentProfile('SnowFlakesCount', sliderValue) + import("/lua/ui/events/snowflake.lua").Clear() + import("/lua/ui/events/snowflake.lua").CreateSnowFlakes(GUI, sliderValue) + end + end + + + -- + local cbox_WindowedLobby = UIUtil.CreateCheckbox(dialogContent, '/CHECKBOX/', LOC("Windowed mode")) + LayoutHelpers.AtRightTopIn(cbox_WindowedLobby, dialogContent, 20, 42) + Tooltip.AddCheckboxTooltip(cbox_WindowedLobby, {text=LOC('Windowed mode'), body=LOC("")}) + cbox_WindowedLobby.OnCheck = function(self, checked) + local option + if checked then + option = 'true' + else + option = 'false' + end + Prefs.SetToCurrentProfile('WindowedLobby', option) + SetWindowedLobby(checked) + end + -- + local cbox_StretchBG = UIUtil.CreateCheckbox(dialogContent, '/CHECKBOX/', LOC("Stretch Background")) + LayoutHelpers.AtRightTopIn(cbox_StretchBG, dialogContent, 20, 68) + Tooltip.AddCheckboxTooltip(cbox_StretchBG, {text=LOC('Stretch Background'), body=LOC("")}) + cbox_StretchBG.OnCheck = function(self, checked) + if checked then + Prefs.SetToCurrentProfile('LobbyBackgroundStretch', 'true') + else + Prefs.SetToCurrentProfile('LobbyBackgroundStretch', 'false') + end + RefreshLobbyBackground() + end + + local cbox_FactionFontColor = UIUtil.CreateCheckbox(dialogContent, '/CHECKBOX/', LOC("Faction Font Color")) + LayoutHelpers.AtRightTopIn(cbox_FactionFontColor, dialogContent, 20, 94) + cbox_FactionFontColor.OnCheck = function(self, checked) + if checked then + Prefs.SetOption('faction_font_color', true) + else + Prefs.SetOption('faction_font_color', false) + end + UIUtil.UpdateCurrentSkin() + end + + local cbox_ChatPlayerColor = UIUtil.CreateCheckbox(dialogContent, '/CHECKBOX/', LOC("Player color in chat")) + LayoutHelpers.AtRightTopIn(cbox_ChatPlayerColor, dialogContent, 20, 120) + cbox_ChatPlayerColor.OnCheck = function(self, checked) + if checked then + Prefs.SetToCurrentProfile('ChatPlayerColor', true) + else + Prefs.SetToCurrentProfile('ChatPlayerColor', false) + end + end + + -- Quit button + local QuitButton = UIUtil.CreateButtonWithDropshadow(dialogContent, '/BUTTON/medium/', LOC("Close")) + LayoutHelpers.AtHorizontalCenterIn(QuitButton, dialogContent, 0) + LayoutHelpers.AtBottomIn(QuitButton, dialogContent, 10) + + QuitButton.OnClick = function(self) + dialog:Hide() + end + -- + local WindowedLobby = Prefs.GetFromCurrentProfile('WindowedLobby') or 'true' + cbox_WindowedLobby:SetCheck(WindowedLobby == 'true', true) + if defaultMode == 'windowed' then + -- Already set Windowed in Game + cbox_WindowedLobby:Disable() + end + -- + local lobbyBackgroundStretch = Prefs.GetFromCurrentProfile('LobbyBackgroundStretch') or 'true' + cbox_StretchBG:SetCheck(lobbyBackgroundStretch == 'true', true) + -- + local factionFontColor = Prefs.GetOption('faction_font_color') + cbox_FactionFontColor:SetCheck(factionFontColor == true, true) + + local chatPlayerColor = Prefs.GetFromCurrentProfile('ChatPlayerColor') + cbox_ChatPlayerColor:SetCheck(chatPlayerColor == true or chatPlayerColor == nil, true) +end + +---Applies new game settings, including map, mods +---@param settings WatchedGameData +function ApplyGameSettings(settings) + SetGameOptions(settings.GameOptions, true) + + rehostPlayerOptions = settings.PlayerOptions + selectedSimMods = settings.GameMods + HostUtils.UpdateMods() + + UpdateGame() +end +---Returns the current game settings +---@return WatchedGameData +function GetGameSettings() + return gameInfo +end + +--- Delegate to UIUtil's CreateInputDialog, adding the ridiculus chatEdit hack. +function CreateInputDialog(parent, title, listener, str) + UIUtil.CreateInputDialog(parent, title, listener, GUI.chatEdit, str) +end + +-- Update the combobox for the given slot so it correctly shows the set of available colours. +-- causes a new availableColours to be populated for each occupied slot +-- if a slot is specified, that slot's displayed color will be made consistent with its coded color +function Check_Availaible_Color(slot) + -- generate a table of used colors + local UsedColours = {} + for i = 1, LobbyComm.maxPlayerSlots do + -- Skips empty slots. + if gameInfo.PlayerOptions[i] then + table.insert(UsedColours, gameInfo.PlayerOptions[i].PlayerColor) + end + end + + -- generate a table of unused colors + local unusedColours = {} + for i, color in gameColors.PlayerColors do + if not table.find(UsedColours, i) then + unusedColours[i] = color + end + end + + for i = 1, LobbyComm.maxPlayerSlots do + -- Skips empty slots. + if gameInfo.PlayerOptions[i] then + local availableColours = {} + -- deepcopy the unused colors because using them by reference causes problems with the displayed color list/ChangeBitmapArray + for c, color in unusedColours do + availableColours[c] = color + end + -- add this slot's color to its availableColours (you get a nil lazyvar error if it's not included) + availableColours[ gameInfo.PlayerOptions[i].PlayerColor] = gameColors.PlayerColors[gameInfo.PlayerOptions[i].PlayerColor] + -- set the list of available colors for this slot + GUI.slots[i].color:ChangeBitmapArray(availableColours, true) + end + end + + -- if a slot was entered, set the displayed color for that slot to the coded color for that slot + if slot then + GUI.slots[slot].color:SetItem(gameInfo.PlayerOptions[slot].PlayerColor) + end +end + +function CheckModCompatability() + local blacklistedMods = {} + for modId, _ in SetUtils.Union(selectedSimMods, selectedUIMods) do + if ModBlacklist[modId] then + blacklistedMods[modId] = ModBlacklist[modId] + end + end + + return blacklistedMods +end + +function WarnIncompatibleMods() + UIUtil.QuickDialog(GUI, + "Some of your enabled mods are known to cause malfunctions with FAF, so have been disabled. See the mod manager for details - some mods may have newer versions which work.", + "") +end + +function DoSlotSwap(slot1, slot2) + + -- retrieve player info + local player1 = gameInfo.PlayerOptions[slot1] + local player2 = gameInfo.PlayerOptions[slot2] + + -- unready players in the player options + player1.Ready = false + player2.Ready = false + + -- swap teams + local team_bucket = player1.Team + player1.Team = player2.Team + player2.Team = team_bucket + + --Handle faction availability + KeepSameFactionOrRandom(slot1, slot2, player1) + KeepSameFactionOrRandom(slot2, slot1, player2) + + -- swap the slots + gameInfo.PlayerOptions[slot2] = player1 + gameInfo.PlayerOptions[slot1] = player2 + + -- update slot info + SetSlotInfo(slot2, player1) + SetSlotInfo(slot1, player2) + + -- update faction selector + UpdateFactionSelectorForPlayer(player1) + UpdateFactionSelectorForPlayer(player2) + + UpdateGame() +end + +function KeepSameFactionOrRandom(slotFrom, slotTo, player) + local playerFactionKey = GUI.slots[slotFrom].AvailableFactions[player.Faction] + --intialize to random, incase oldFaction isn't available + player.Faction = table.getn(GUI.slots[slotTo].AvailableFactions) + for index,faction in GUI.slots[slotTo].AvailableFactions do + if faction == playerFactionKey then + player.Faction = index + end + end +end + +local function SendPlayerOption(playerInfo, key, value) + if playerInfo.Human then + GpgNetSend('PlayerOption', playerInfo.OwnerID, key, value) + else + GpgNetSend('AIOption', playerInfo.PlayerName, key, value) + end +end + +--- Create the HostUtils object, containing host-only functions. By not assigning this for non-host +-- players, we ensure a hard crash should a non-host somehow end up trying to call them, simplifying +-- debugging somewhat (as well as reducing the number of toplevel definitions a fair bit). +-- This is clearly not a security feature. +function InitHostUtils() + if not lobbyComm:IsHost() then + WARN(debug.traceback(nil, "Attempt to create HostUtils by non-host.")) + return + end + + HostUtils = { + --- Cause a player's ready box to become unchecked. + -- + -- @param slot The slot number of the target player. + SetPlayerNotReady = function(slot) + local slotOptions = gameInfo.PlayerOptions[slot] + if slotOptions.Ready then + if not IsLocallyOwned(slot) then + lobbyComm:SendData(slotOptions.OwnerID, {Type = 'SetPlayerNotReady', Slot = slot}) + end + slotOptions.Ready = false + end + end, + + SetSlotClosed = function(slot, closed) + -- Don't close an occupied slot. + if gameInfo.PlayerOptions[slot] then + return + end + + lobbyComm:BroadcastData( + { + Type = 'SlotClosed', + Slot = slot, + Closed = closed + } + ) + + gameInfo.ClosedSlots[slot] = closed + gameInfo.SpawnMex[slot] = false + ClearSlotInfo(slot) + PossiblyAnnounceGameFull() + end, + + SetSlotClosedSpawnMex = function(slot) + -- Don't close an occupied slot. + if gameInfo.PlayerOptions[slot] then + return + end + + lobbyComm:BroadcastData( + { + Type = 'SlotClosedSpawnMex', + Slot = slot, + ClosedSpawnMex = true + } + ) + + gameInfo.ClosedSlots[slot] = true + gameInfo.SpawnMex[slot] = true + ClearSlotInfo(slot) + PossiblyAnnounceGameFull() + end, + + ConvertPlayerToObserver = function(playerSlot, ignoreMsg) + -- make sure player exists + if not gameInfo.PlayerOptions[playerSlot] then + WARN("HostUtils.ConvertPlayerToObserver for nonexistent player in slot " .. tostring(playerSlot)) + return + end + + -- find a free observer slot + local index = 1 + while gameInfo.Observers[index] do + index = index + 1 + end + + HostUtils.SetPlayerNotReady(playerSlot) + local ownerID = gameInfo.PlayerOptions[playerSlot].OwnerID + gameInfo.Observers[index] = gameInfo.PlayerOptions[playerSlot] + gameInfo.PlayerOptions[playerSlot] = nil + + if lobbyComm:IsHost() then + GpgNetSend('PlayerOption', ownerID, 'Team', -1) + GpgNetSend('PlayerOption', ownerID, 'Army', -1) + GpgNetSend('PlayerOption', ownerID, 'StartSpot', -index) + end + + ClearSlotInfo(playerSlot) + + -- TODO: can probably avoid transmitting the options map here. The slot number should be enough. + lobbyComm:BroadcastData( + { + Type = 'ConvertPlayerToObserver', + OldSlot = playerSlot, + NewSlot = index, + Options = gameInfo.Observers[index]:AsTable(), + } + ) + + if not ignoreMsg then + -- %s has switched from a player to an observer. + SendSystemMessage("lobui_0226", gameInfo.Observers[index].PlayerName) + end + + UpdateGame() + end, + + ConvertObserverToPlayer = function(fromObserverSlot, toPlayerSlot, ignoreMsg) + -- If no slot is specified (user clicked "go player" button), select a default. + if not toPlayerSlot or toPlayerSlot < 1 or toPlayerSlot > numOpenSlots then + toPlayerSlot = HostUtils.FindEmptySlot() + + -- If it's still -1 (No slots available) check for AIs and evict the first one + if toPlayerSlot < 1 then + for i = 1, numOpenSlots do + local slot = gameInfo.PlayerOptions[i] + if slot and not slot.Human then + HostUtils.RemoveAI(i) + toPlayerSlot = i + break + end + end + + -- There are no AIs and no slots, so break out with a message + if toPlayerSlot < 1 and gameInfo.Observers[fromObserverSlot] then + SendPersonalSystemMessage(gameInfo.Observers[fromObserverSlot].OwnerID, "lobui_0608") + return + end + end + end + + if not gameInfo.Observers[fromObserverSlot] then -- IF no Observer on the current slot : QUIT + return + elseif gameInfo.PlayerOptions[toPlayerSlot] then -- IF Player is in the target slot : QUIT + return + elseif gameInfo.ClosedSlots[toPlayerSlot] then -- IF target slot is Closed : QUIT + return + end + + local incomingPlayer = gameInfo.Observers[fromObserverSlot] + + -- Give them a default colour if the one they already have isn't free. + if not IsColorFree(incomingPlayer.PlayerColor) then + local newColour = GetAvailableColor() + SetPlayerColor(incomingPlayer, newColour) + end + + gameInfo.PlayerOptions[toPlayerSlot] = incomingPlayer + gameInfo.Observers[fromObserverSlot] = nil + + lobbyComm:BroadcastData( + { + Type = 'ConvertObserverToPlayer', + OldSlot = fromObserverSlot, + NewSlot = toPlayerSlot, + Options = gameInfo.PlayerOptions[toPlayerSlot]:AsTable(), + } + ) + + if not ignoreMsg then + -- %s has switched from an observer to player. + SendSystemMessage("lobui_0227", incomingPlayer.PlayerName) + end + + SetSlotInfo(toPlayerSlot, gameInfo.PlayerOptions[toPlayerSlot]) + + -- This is far from optimally efficient, as it will SetSlotInfo twice when autoteams is enabled. + AssignAutoTeams() + + UpdateFactionSelectorForPlayer(gameInfo.PlayerOptions[toPlayerSlot]) + end, + + RemoveAI = function(slot) + if gameInfo.PlayerOptions[slot].Human then + WARN('Use EjectPlayer to remove humans') + return + end + + gameInfo.PlayerOptions[slot] = nil + ClearSlotInfo(slot) + lobbyComm:BroadcastData( + { + Type = 'ClearSlot', + Slot = slot, + } + ) + end, + + --- Returns false if there's an obvious reason why a slot movement between the two given + -- slots will fail. + -- + -- @param moveFrom Slot number to move from + -- @param moveTo Slot number to move to. + SanityCheckSlotMovement = function(moveFrom, moveTo) + if moveTo == moveFrom then + -- no need to move a slot to its current location + return false + end + + if gameInfo.ClosedSlots[moveTo] then + LOG("HostUtils.MovePlayerToEmptySlot: requested slot " .. moveTo .. " is closed") + return false + end + + if moveTo > numOpenSlots or moveTo < 1 then + LOG("HostUtils.MovePlayerToEmptySlot: requested slot " .. moveTo .. " is out of range") + return false + end + + if moveFrom > numOpenSlots or moveFrom < 1 then + LOG("HostUtils.MovePlayerToEmptySlot: target slot " .. moveFrom .. " is out of range") + return false + end + + return true + end, + + --- Move a player from one slot to another, unoccupied one. Is a no-op if the requested slot + -- is occupied, closed, or out of range. Races over network may cause this to occur during + -- normal operation. + -- + -- @param currentSlot The slot occupied by the player to move + -- @param requestedSlot The slot to move this player to. + MovePlayerToEmptySlot = function(currentSlot, requestedSlot) + -- Bail out early for the stupid cases. + if not HostUtils.SanityCheckSlotMovement(currentSlot, requestedSlot) then + return + end + + -- This one's only specific to moving to an empty slot, naturally. + if gameInfo.PlayerOptions[requestedSlot] then + LOG("HostUtils.MovePlayerToEmptySlot: requested slot " .. requestedSlot .. " already occupied") + return false + end + + local player = gameInfo.PlayerOptions[currentSlot] + + KeepSameFactionOrRandom(currentSlot, requestedSlot, player) + + gameInfo.PlayerOptions[requestedSlot] = gameInfo.PlayerOptions[currentSlot] + gameInfo.PlayerOptions[currentSlot] = nil + ClearSlotInfo(currentSlot) + SetSlotInfo(requestedSlot, gameInfo.PlayerOptions[requestedSlot]) + + lobbyComm:BroadcastData( + { + Type = 'SlotMove', + OldSlot = currentSlot, + NewSlot = requestedSlot, + Options = gameInfo.PlayerOptions[requestedSlot]:AsTable(), + } + ) + + -- This is far from optimally efficient, as it will SetSlotInfo twice when autoteams is enabled. + AssignAutoTeams() + end, + + --- Swap the players in the two given slots. + -- + -- If the target slot is unoccupied, the player in the first slot is simply moved there. + -- If the target slot is closed, this is a no-op. + -- If a player or ai occupies both slots, they are swapped. + SwapPlayers = function(slot1, slot2) + + -- Bail out early for the stupid cases. + if not HostUtils.SanityCheckSlotMovement(slot1, slot2) then + return + end + + local player1 = gameInfo.PlayerOptions[slot1] + local player2 = gameInfo.PlayerOptions[slot2] + + -- If we're moving onto a blank, take the easy way out. + if not player2 then + HostUtils.MovePlayerToEmptySlot(slot1, slot2) + return + end + + -- Do the swap on our end + DoSlotSwap(slot1, slot2) + + -- Tell everyone else to do the swap too + lobbyComm:BroadcastData( + { + Type = 'SwapPlayers', + Slot1 = slot1, + Slot2 = slot2, + } + ) + + -- %s has switched with %s + SendSystemMessage("lobui_0417", player1.PlayerName, player2.PlayerName) + end, + + --- Add an observer + -- + -- @param observerData A PlayerData object representing this observer. + TryAddObserver = function(senderID, observerData) + local index = 1 + while gameInfo.Observers[index] do + index = index + 1 + end + + gameInfo.Observers[index] = observerData + + lobbyComm:BroadcastData( + { + Type = 'ObserverAdded', + Slot = index, + Options = observerData:AsTable(), + } + ) + + -- %s has joined as an observer. + SendSystemMessage("lobui_0202", observerData.PlayerName) + refreshObserverList() + end, + + --- Attempt to add a player to a slot. If none are available, add them as an observer. + -- + -- @param senderID The peer ID of the player we're adding. + -- @param slot The slot to insert the player to. A value of less than 1 indicates "any slot" + -- @param playerData A PlayerData object representing the player to add. + TryAddPlayer = function(senderID, slot, playerData) + -- CPU benchmark code + if playerData.Human and not singlePlayer then + lobbyComm:SendData(senderID, {Type='CPUBenchmark', Benchmarks=CPU_Benchmarks}) + end + + local newSlot = slot + + if not slot or slot < 1 or newSlot > numOpenSlots then + newSlot = HostUtils.FindEmptySlot() + end + + -- if no slot available, and human, try to make them an observer + if newSlot == -1 then + PrivateChat(senderID, LOC("No slots available, attempting to make you an observer")) + if playerData.Human then + HostUtils.TryAddObserver(senderID, playerData) + end + return + end + + -- if a color is requested, attempt to use that color if available, otherwise, assign first available + if not IsColorFree(playerData.PlayerColor, slot) then + SetPlayerColor(playerData, GetAvailableColor()) + end + + gameInfo.PlayerOptions[newSlot] = playerData + lobbyComm:BroadcastData( + { + Type = 'SlotAssigned', + Slot = newSlot, + Options = playerData:AsTable(), + } + ) + + SetSlotInfo(newSlot, gameInfo.PlayerOptions[newSlot]) + -- This is far from optimally efficient, as it will SetSlotInfo twice when autoteams is enabled. + AssignAutoTeams() + PossiblyAnnounceGameFull() + end, + + --- Add an AI to the game in the given slot. + -- + -- @param name The name to use in the player list for this AI. + -- @param personality The "personality" key, such as "adaptive" or "easy", for this AI. + -- @param slot (optional) The slot into which to put this AI. Defaults to the first empty + -- slot from the top of the list. + AddAI = function(name, personality, slot) + HostUtils.TryAddPlayer(hostID, slot, GetAIPlayerData(name, personality, slot)) + end, + + PlayerMissingMapAlert = function(id) + local slot = FindSlotForID(id) + local name + local needMessage = false + if slot then + name = gameInfo.PlayerOptions[slot].PlayerName + if not gameInfo.PlayerOptions[slot].BadMap then + needMessage = true + end + gameInfo.PlayerOptions[slot].BadMap = true + else + slot = FindObserverSlotForID(id) + if slot then + name = gameInfo.Observers[slot].PlayerName + if not gameInfo.Observers[slot].BadMap then + needMessage = true + end + gameInfo.Observers[slot].BadMap = true + end + end + + if needMessage then + -- %s is missing map %s. + SendSystemMessage("lobui_0330", name, gameInfo.GameOptions.ScenarioFile) + LOG('>> '..name..' is missing map '..gameInfo.GameOptions.ScenarioFile) + if name == localPlayerName then + LOG('>> '..gameInfo.GameOptions.ScenarioFile..' replaced with '.. UIUtil.defaultScenario) + SetGameOption('ScenarioFile', UIUtil.defaultScenario) + end + end + end, + + -- This function is needed because army numbers need to be calculated: armies are numbered incrementally in slot order. + -- Call this function once just before game starts + SendArmySettingsToServer = function() + local armyIdx = 1 + local MAXSLOT = 16 + for slotNum = 1, MAXSLOT do + local playerInfo = gameInfo.PlayerOptions[slotNum] + if playerInfo ~= nil then + LOG('>> SendArmySettingsToServer: Setting army '..armyIdx..' for slot '..slotNum) + SendPlayerOption(playerInfo, 'Army', armyIdx) + armyIdx = armyIdx + 1 + else + LOG('>> SendArmySettingsToServer: Slot '..slotNum..' empty') + end + end + end, + + --- Send player settings to the server + SendPlayerSettingsToServer = function(slotNum) + local playerInfo = gameInfo.PlayerOptions[slotNum] + SendPlayerOption(playerInfo, 'Faction', playerInfo.Faction) + SendPlayerOption(playerInfo, 'Color', playerInfo.PlayerColor) + SendPlayerOption(playerInfo, 'Team', playerInfo.Team) + SendPlayerOption(playerInfo, 'StartSpot', slotNum) + end, + + --- Called by the host when someone's readyness state changes to update the enabledness of buttons. + RefreshButtonEnabledness = function() + -- disable options when all players are marked ready + -- Is at least one person not ready? + local playerNotReady = GetPlayersNotReady() ~= false + + -- Host should be able to set game options even if he is observer if all slots are AI + local hostObserves = false + if not playerNotReady and IsObserver(localPlayerID) then + hostObserves = true + end + + local buttonState = hostObserves or playerNotReady + + UIUtil.setEnabled(GUI.gameoptionsButton, buttonState) + UIUtil.setEnabled(GUI.defaultOptions, buttonState) + UIUtil.setEnabled(GUI.randMap, buttonState) + UIUtil.setEnabled(GUI.autoTeams, buttonState) + UIUtil.setEnabled(GUI.restrictedUnitsOrPresetsBtn, buttonState) + + -- Launch button enabled if everyone is ready. + UIUtil.setEnabled(GUI.launchGameButton, singlePlayer or hostObserves or not playerNotReady) + + -- Disable the AutoBalance button if any players are on a tean greater than 2 or buttonState is false + local noOtherTeams = true + for i, player in gameInfo.PlayerOptions:pairs() do + -- Team numbers are 1 higher on the backend + if player.Team > 3 then + noOtherTeams = false + break + end + end + UIUtil.setEnabled(GUI.PenguinAutoBalance, noOtherTeams and buttonState) + end, + + -- Update our local gameInfo.GameMods from selected map name and selected mods, then + -- notify other clients about the change. + UpdateMods = function(newPlayerID, newPlayerName) + local newmods = {} + local missingmods = {} + local blacklistedMods = {} + + -- If any of our active sim mods are blacklisted, warn the user, turn them off, and + -- go through the "mods changed" handler code again with the smaller set. + local bannedMods = CheckModCompatability() + if not table.empty(bannedMods) then + WarnIncompatibleMods() + + selectedSimMods = SetUtils.Subtract(selectedSimMods, bannedMods) + selectedUIMods = SetUtils.Subtract(selectedUIMods, bannedMods) + OnModsChanged(selectedSimMods, selectedUIMods) + + return + end + + for modId, _ in selectedSimMods do + if IsModAvailable(modId) then + newmods[modId] = true + else + table.insert(missingmods, modId) + end + end + + -- We were called to update the sim mod set for the game, and have _really_ made changes + if not table.equal(gameInfo.GameMods, newmods) and not newPlayerID then + gameInfo.GameMods = newmods + lobbyComm:BroadcastData { Type = "ModsChanged", GameMods = gameInfo.GameMods } + local nummods = 0 + local uids = "" + + for k in gameInfo.GameMods do + nummods = nummods + 1 + if uids == "" then + uids = k + else + uids = string.format("%s %s", uids, k) + end + + end + GpgNetSend('GameMods', "activated", nummods) + + if nummods > 0 then + GpgNetSend('GameMods', "uids", uids) + end + elseif not table.equal(gameInfo.GameMods, newmods) and newPlayerID then + local modnames = "" + local totalMissing = table.getn(missingmods) + local totalListed = 0 + if totalMissing > 0 then + for index, id in missingmods do + for k,mod in Mods.GetGameMods() do + if mod.uid == id then + totalListed = totalListed + 1 + if totalMissing == totalListed then + modnames = modnames .. " " .. mod.name + else + modnames = modnames .. " " .. mod.name .. " + " + end + end + end + end + end + local reason = (LOCF('You were automaticly removed from the lobby because you ' .. + 'don\'t have the following mod(s):\n%s \nPlease, install the mod before you join the game lobby', modnames)) + -- TODO: Verify this functionality + if FindNameForID(newPlayerID) then + AddChatText(FindNameForID(newPlayerID)..LOC(' kicked because he does not have this mod: ')..modnames) + else + if newPlayerName then + AddChatText(newPlayerName..LOC(' kicked because he does not have this mod: ')..modnames) + else + AddChatText(LOC('The last player is kicked because he does not have this mod: ')..modnames) + end + end + lobbyComm:EjectPeer(newPlayerID, reason) + end + end, + + --- Find and return the id of an unoccupied slot. + -- + -- @return The id of an empty slot, of -1 if none is available. + FindEmptySlot = function() + for i = 1, numOpenSlots do + if not gameInfo.PlayerOptions[i] and not gameInfo.ClosedSlots[i] then + return i + end + end + + return -1 + end, + + KickObservers = function(reason) + for k,observer in gameInfo.Observers:pairs() do + lobbyComm:EjectPeer(observer.OwnerID, reason or "KickedByHost") + end + gameInfo.Observers = WatchedValueArray(LobbyComm.maxPlayerSlots) + end + } +end diff --git a/lua/ui/lobby/lobby.lua b/lua/ui/lobby/lobby.lua index a76654e3527..b876c573bfd 100644 --- a/lua/ui/lobby/lobby.lua +++ b/lua/ui/lobby/lobby.lua @@ -1,7386 +1,128 @@ --***************************************************************************** ---* File: lua/modules/ui/lobby/lobby.lua ---* Author: Chris Blackwell ---* Summary: Game selection UI +--* FAF notes: +--* This file is the engine-facing entry point for the custom-game lobby. The +--* original (organically grown) implementation now lives in `lobby-old.lua`; +--* this thin wrapper drives the reactive-MVC rebuild under `customlobby/`. --* ---* Copyright © 2005 Gas Powered Games, Inc. All rights reserved. +--* The engine and the menus call CreateLobby / HostGame / JoinGame / +--* ConnectToPeer / DisconnectFromPeer on this module, exactly as before, so no +--* caller needs to change. See `customlobby/CLAUDE.md` and +--* `TARGET_ARCHITECTURE.md`. --***************************************************************************** -local GameVersion = import("/lua/version.lua").GetVersion -local UIUtil = import("/lua/ui/uiutil.lua") -local MenuCommon = import("/lua/ui/menus/menucommon.lua") -local Prefs = import("/lua/user/prefs.lua") -local MapUtil = import("/lua/ui/maputil.lua") -local Group = import("/lua/maui/group.lua").Group -local RadioButton = import("/lua/ui/controls/radiobutton.lua").RadioButton -local ResourceMapPreview = import("/lua/ui/controls/resmappreview.lua").ResourceMapPreview -local Popup = import("/lua/ui/controls/popups/popup.lua").Popup -local Slider = import("/lua/maui/slider.lua").Slider -local PlayerData = import("/lua/ui/lobby/data/playerdata.lua").PlayerData -local GameInfo = import("/lua/ui/lobby/data/gamedata.lua") -local WatchedValueArray = import("/lua/ui/lobby/data/watchedvalue/watchedvaluearray.lua").WatchedValueArray -local LayoutHelpers = import("/lua/maui/layouthelpers.lua") -local Bitmap = import("/lua/maui/bitmap.lua").Bitmap -local Button = import("/lua/maui/button.lua").Button -local ToggleButton = import("/lua/ui/controls/togglebutton.lua").ToggleButton -local Edit = import("/lua/maui/edit.lua").Edit -local LobbyComm = import("/lua/ui/lobby/lobbycomm.lua") -local Tooltip = import("/lua/ui/game/tooltip.lua") -local Mods = import("/lua/mods.lua") -local FactionData = import("/lua/factions.lua") -local TextArea = import("/lua/ui/controls/textarea.lua").TextArea -local Presets = import("/lua/ui/lobby/presets.lua") -local utils = import("/lua/system/utils.lua") +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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 Trueskill = import("/lua/ui/lobby/trueskill.lua") -local Player = Trueskill.Player -local Rating = Trueskill.Rating -local ModBlacklist = import("/etc/faf/blacklist.lua").Blacklist -local Teams = Trueskill.Teams +local MenuCommon = import("/lua/ui/menus/menucommon.lua") local EscapeHandler = import("/lua/ui/dialogs/eschandler.lua") -local CountryTooltips = import("/lua/ui/help/tooltips-country.lua").tooltip -local SetUtils = import("/lua/system/setutils.lua") -local JSON = import("/lua/system/dkson.lua").json -local UTF = import("/lua/utf.lua") --- Uveso - aitypes inside aitypes.lua are now also available as a function. -local aitypes -local AIKeys = {} -local AIStrings = {} -local AITooltips = {} - - - -function GetAITypes() - AIKeys = {} - AIStrings = {} - AITooltips = {} - aitypes = import("/lua/ui/lobby/aitypes.lua").GetAItypes() - for _, aidata in aitypes do - table.insert(AIKeys, aidata.key) - table.insert(AIStrings, aidata.name) - table.insert(AITooltips, 'aitype_'..aidata.key) - end -end -GetAITypes() - ---This is a special table that allows us to pass data to blueprints.lua, before the rest of the game is loaded. --- do not use this for anything that doesnt do blueprint modding, use GameOptions for that instead, which will load it into sim. - -local IsSyncReplayServer = false - -local AddUnicodeCharToEditText = import("/lua/utf.lua").AddUnicodeCharToEditText - -if HasCommandLineArg("/syncreplay") and HasCommandLineArg("/gpgnet") then - IsSyncReplayServer = true -end - -local globalOpts = import("/lua/ui/lobby/lobbyoptions.lua").globalOpts -local teamOpts = import("/lua/ui/lobby/lobbyoptions.lua").teamOptions -local AIOpts = import("/lua/ui/lobby/lobbyoptions.lua").AIOpts -local gameColors = import("/lua/gamecolors.lua").GameColors - -local numOpenSlots = LobbyComm.maxPlayerSlots - --- Add lobby options from AI mods -function ImportModAIOptions() - local simMods = import("/lua/mods.lua").AllMods() - local OptionData - local alreadyStored - for Index, ModData in simMods do - if exists(ModData.location..'/lua/AI/LobbyOptions/lobbyoptions.lua') then - OptionData = import(ModData.location..'/lua/AI/LobbyOptions/lobbyoptions.lua').AIOpts - for s, t in OptionData do - -- check, if we have this option already stored - alreadyStored = false - for k, v in AIOpts do - if v.key == t.key then - alreadyStored = true - break - end - end - if not alreadyStored then - table.insert(AIOpts, t) - end - end - end - end -end -ImportModAIOptions() - --- Maps faction identifiers to their names. -local FACTION_NAMES = {[1] = "uef", [2] = "aeon", [3] = "cybran", [4] = "seraphim", [5] = "random" } - -local rehostPlayerOptions = {} -- Player options loaded from preset, used for rehosting - -local formattedOptions = {} -local nonDefaultFormattedOptions = {} -local LrgMap = false - -local HostUtils -local mapPreviewSlotSwapFrom = 0 -local mapPreviewSlotSwap = false - -teamIcons = { - '/lobby/team_icons/team_no_icon.dds', - '/lobby/team_icons/team_1_icon.dds', - '/lobby/team_icons/team_2_icon.dds', - '/lobby/team_icons/team_3_icon.dds', - '/lobby/team_icons/team_4_icon.dds', - '/lobby/team_icons/team_5_icon.dds', - '/lobby/team_icons/team_6_icon.dds', - '/lobby/team_icons/team_7_icon.dds', - '/lobby/team_icons/team_8_icon.dds', -} - -DebugEnabled = Prefs.GetFromCurrentProfile('LobbyDebug') or '' -local HideDefaultOptions = Prefs.GetFromCurrentProfile('LobbyHideDefaultOptions') == 'true' - -local connectedTo = {} -- by UID -CurrentConnection = {} -- by Name -ConnectionEstablished = {} -- by Name -ConnectedWithProxy = {} -- by UID - - -allAvailableFactionsList = {} - -local availableMods = {} -- map from peer ID to set of available mods; each set is a map from "mod id"->true -local selectedSimMods = {} -- Similar map for activated sim mods -local selectedUIMods = {} -- Similar map for activated UI mods - -local CPU_Benchmarks = {} -- Stores CPU benchmark data - -local function parseCommandlineArguments() - -- Set of all possible command line option keys. - -- The client sometimes gives us empty-string as some args, which gets interpreted as that key - -- having as value the name of the next key. This set lets us interpret that case using the - -- default option. - local CMDLINE_ARGUMENT_KEYS = { - ["/init"] = true, - ["/country"] = true, - ["/numgames"] = true, - ["/mean"] = true, - ["/clan"] = true, - ["/deviation"] = true, - ["/joincustom"] = true, - ["/gpgnet"] = true, - } - - local function GetCommandLineArgOrDefault(argname, default) - local arg = GetCommandLineArg(argname, 1) - if arg and not CMDLINE_ARGUMENT_KEYS[arg[1]] then - return arg[1] - end - return default - end - - return { - PrefLanguage = tostring(string.lower(GetCommandLineArgOrDefault("/country", "world"))), - isRehost = HasCommandLineArg("/rehost"), - initName = GetCommandLineArgOrDefault("/init", ""), - numGames = tonumber(GetCommandLineArgOrDefault("/numgames", 0)), - playerMean = tonumber(GetCommandLineArgOrDefault("/mean", 1500)), - playerClan = tostring(GetCommandLineArgOrDefault("/clan", "")), - playerDeviation = tonumber(GetCommandLineArgOrDefault("/deviation", 500)), - debugLobby = HasCommandLineArg("/debugLobby"), -- Used by LaunchFAInstances script to set players as ready by default - } -end -local argv = parseCommandlineArguments() - -local playerRating = math.floor(Trueskill.round2((argv.playerMean - 3 * argv.playerDeviation) / 100.0) * 100) - -local function ParseWhisper(params) - local delimStart = string.find(params, " ") - if delimStart then - local name = string.sub(params, 1, delimStart-1) - local targID = FindIDForName(name) - if targID then - PrivateChat(targID, string.sub(params, delimStart+1)) - else - AddChatText(LOC("Invalid whisper target.")) - end - end -end - -local commands = { - pm = ParseWhisper, - private = ParseWhisper, - w = ParseWhisper, - whisper = ParseWhisper, -} - -local Strings = LobbyComm.Strings - ----@type LobbyComm -local lobbyComm = false -local localPlayerName = "" -local gameName = "" -local hostID = false -local singlePlayer = false ----@type Group -local GUI = false -local localPlayerID = false ----@type GameData | WatchedGameData -local gameInfo = false -local lastKickMessage = UTF.UnescapeString(Prefs.GetFromCurrentProfile('lastKickMessage') or "") - -local defaultMode =(HasCommandLineArg("/windowed") and "windowed") or Prefs.GetFromCurrentProfile('options').primary_adapter -local windowedMode = defaultMode == "windowed" or (HasCommandLineArg("/windowed")) - -function SetWindowedLobby(windowed) - -- Dont change resolution if user already using windowed mode - if windowed == windowedMode or defaultMode == 'windowed' then +local CustomLobbyModel = import("/lua/ui/lobby/customlobby/customlobbymodel.lua") +local CustomLobbyInterface = import("/lua/ui/lobby/customlobby/customlobbyinterface.lua") + +local maxConnections = 16 + +---@type UICustomLobbyInstance | false +local Instance = false + +--- Called by the engine / menus to create a new (unconnected) lobby. +--- Matches the legacy signature so existing callers (uimain, gameselect, +--- gamecreate, onlineprovider) work unchanged. +---@param protocol UILobbyProtocol +---@param localPort number +---@param desiredPlayerName string +---@param localPlayerUID? UILobbyPeerId +---@param natTraversalProvider? userdata +---@param over Control +---@param exitBehavior fun() +function CreateLobby(protocol, localPort, desiredPlayerName, localPlayerUID, natTraversalProvider, over, exitBehavior) + -- The FAF client pokes GPGNet messages in through here with localPort == -1. + -- The barebones lobby doesn't handle those yet. + if localPort == -1 then + WARN("CustomLobby: GPGNet message path (localPort == -1) is not supported yet") return end - if windowed then - ConExecute('SC_PrimaryAdapter windowed') - else - ConExecute('SC_PrimaryAdapter ' .. tostring(defaultMode)) - end - - windowedMode = windowed -end - --- String from which to build the various "Move player to slot" labels. -local slotMenuStrings = { - open = "Open", - close = "Close", - closed = "Closed", - occupy = "Occupy", - pm = "Private Message", - remove_to_kik = "Kick Player", - remove_to_observer = "Move Player to Observer", - close_spawn_mex = "Close - spawn mex", - closed_spawn_mex = "Closed - spawn mex", -} -local slotMenuData = { - open = { - host = { - 'close', - 'occupy', - 'ailist', - }, - client = { - 'occupy', - }, - }, - closed = { - host = { - 'open', - }, - client = { - }, - }, - player = { - host = { - 'pm', - 'remove_to_observer', - 'remove_to_kik', - 'move' - }, - client = { - 'pm', - }, - }, - ai = { - host = { - 'remove_to_kik', - 'ailist', - }, - client = { - }, - }, -} - -function GetNumAvailStartSpots() - local numAvailStartSpots = nil - local scenarioInfo = nil - if gameInfo.GameOptions.ScenarioFile and (gameInfo.GameOptions.ScenarioFile ~= "") then - scenarioInfo = MapUtil.LoadScenario(gameInfo.GameOptions.ScenarioFile) - end - if scenarioInfo then - local armyTable = MapUtil.GetArmies(scenarioInfo) - if armyTable then - if gameInfo.GameOptions['RandomMap'] == 'Off' then - numAvailStartSpots = table.getn(armyTable) - else - numAvailStartSpots = numberOfPlayers - end - end - else - WARN("Can't assign random start spots, no scenario selected.") - end - return numAvailStartSpots -end - -local function GetSlotMenuData() - if gameInfo.AdaptiveMap then - if not slotMenuData.closed_spawn_mex then - slotMenuData.closed_spawn_mex = { - host = { - 'open', - 'close', - }, - client = { - }, - } - table.insert(slotMenuData.open.host, 2, 'close_spawn_mex') - table.insert(slotMenuData.closed.host, 2, 'close_spawn_mex') - end - else - if slotMenuData.closed_spawn_mex then - slotMenuData.closed_spawn_mex = nil - table.remove(slotMenuData.open.host, 2) - table.remove(slotMenuData.closed.host, 2) - end - end - return slotMenuData -end - -local function GetSlotMenuTables(stateKey, hostKey, slotNum) - local keys = {} - local strings = {} - local tooltips = {} - - if not GetSlotMenuData()[stateKey] then - WARN("Invalid slot menu state selected: " .. tostring(stateKey)) - return nil - end - - if not GetSlotMenuData()[stateKey][hostKey] then - WARN("Invalid slot menu host key selected: " .. tostring(hostKey)) - return nil - end - - local isPlayerReady = false - local localPlayerSlot = FindSlotForID(localPlayerID) - if localPlayerSlot then - if gameInfo.PlayerOptions[localPlayerSlot].Ready then - isPlayerReady = true - end - end - - for index, key in GetSlotMenuData()[stateKey][hostKey] do - if key == 'ailist' then - if slotNum then - for i = 1, numOpenSlots, 1 do - if i ~= slotNum then - table.insert(keys, 'move_player_to_slot' .. i) - table.insert(strings, LOCF("Move AI to slot %s", i)) - table.insert(tooltips, nil) - end - end - end - table.destructiveCat(keys, AIKeys) - table.destructiveCat(strings, AIStrings) - table.destructiveCat(tooltips, AITooltips) - elseif key == 'move' then - -- Generate the "move player to slot X" entries. - for i = 1, numOpenSlots, 1 do - if i ~= slotNum then - table.insert(keys, 'move_player_to_slot' .. i) - table.insert(strings, LOCF("Move Player to slot %s", i)) - table.insert(tooltips, nil) - end - end - else - if not (isPlayerReady and key == 'occupy') then - table.insert(keys, key) - table.insert(strings, slotMenuStrings[key]) - -- Add a tooltip key here if we ever get any interesting options. - table.insert(tooltips, nil) - end - end - end - - return keys, strings, tooltips -end - ---- Get the value of the LastColor, sanitised in case it's an unsafe value. --- In case a new patch removes a color -function GetSanitisedLastColor() - local lastColor = Prefs.GetFromCurrentProfile('LastColorFAF') or 1 - if lastColor > table.getn(gameColors.PlayerColors) or lastColor < 1 then - lastColor = 1 - end - - return lastColor -end - ---- Get the value of the LastFaction, sanitised in case it's an unsafe value. --- --- This means when some retarded mod (*cough*Nomads*cough*) writes a large number to LastFaction, we --- don't catch fire. -function GetSanitisedLastFaction() - local lastFaction = Prefs.GetFromCurrentProfile('LastFaction') or 1 - if lastFaction > table.getn(FactionData.Factions) + 1 or lastFaction < 1 then - lastFaction = 1 - end - - return lastFaction -end - ---- Get a PlayerData object for the local player, configured using data from their profile. -function GetLocalPlayerData() - - local version, gametype, commit = import("/lua/version.lua").GetVersionData() - - return PlayerData( - { - PlayerName = localPlayerName, - OwnerID = localPlayerID, - Human = true, - PlayerColor = GetSanitisedLastColor(), - Faction = GetSanitisedLastFaction(), - PlayerClan = argv.playerClan, - PL = playerRating, - NG = argv.numGames, - MEAN = argv.playerMean, - DEV = argv.playerDeviation, - Country = argv.PrefLanguage, - - Version = version, - GameType = gametype, - Commit = commit, - - Ready = argv.debugLobby, - } -) -end - ---- Compute an estimation of the rating of the given AI. The values originate from 'aitypes.lua' ----@param gameOptions table ----@param aiLobbyProperties AILobbyProperties ----@return number -function ComputeAIRating(gameOptions, aiLobbyProperties) - - if not aiLobbyProperties then - return 0 - end - - if not aiLobbyProperties.rating then - return 0 - end - - if not gameInfo.GameOptions.ScenarioFile then - return 0 - end - - -- try and take into account map - local scenarioInfo = MapUtil.LoadScenario(gameInfo.GameOptions.ScenarioFile) - if not (scenarioInfo and scenarioInfo.size and scenarioInfo.size[1] and scenarioInfo.size[2]) then - return 0 - end - - -- clamp the value - local maparea = math.max(scenarioInfo.size[1], scenarioInfo.size[2]) - if maparea < 256 then - maparea = 256 - elseif maparea > 4096 then - maparea = 4096 - end - - -- process various multipliers to determine rating - local mapMultiplier = aiLobbyProperties.ratingMapMultiplier[maparea] or 1.0 - local cheatBuildMultiplier = (tonumber(gameOptions.BuildMult) or 1.0) - 1.0 - local cheatResourceMultiplier = (tonumber(gameOptions.CheatMult) or 1.0) - 1.0 - - -- compute the rating - local cheatBuildValue = (aiLobbyProperties.ratingBuildMultiplier or 0.0) * cheatBuildMultiplier - local cheatResourceValue = (aiLobbyProperties.ratingCheatMultiplier or 0.0) * cheatResourceMultiplier - local cheatOmniValue = (gameOptions.OmniCheat == 'on' and aiLobbyProperties.ratingOmniBonus) or 0.0 - local rating = mapMultiplier * (aiLobbyProperties.rating + cheatBuildValue + cheatResourceValue + cheatOmniValue) - - -- prevent very low numbers - if rating < aiLobbyProperties.ratingNegativeThreshold then - rating = aiLobbyProperties.ratingNegativeThreshold + (rating - aiLobbyProperties.ratingNegativeThreshold) * 0.2 - end - - return math.floor(rating) -end - -function GetAIPlayerData(name, AIPersonality, slot) - local AIColor - -- gets the color of the player/AI occupying the slot directly prior if available - for i = 1, LobbyComm.maxPlayerSlots do - if gameInfo.PlayerOptions[i].StartSpot == slot then - if IsColorFree(gameInfo.PlayerOptions[i].PlayerColor, slot) then - AIColor = gameInfo.PlayerOptions[i].PlayerColor - end - break - end - end - if not AIColor then - AIColor = GetAvailableColor() - end - - -- retrieve properties from AI table - ---@type AILobbyProperties | nil - local aiLobbyProperties = nil - for k, entry in aitypes do - if entry.key == AIPersonality then - aiLobbyProperties = entry - end - end - local iRating = ComputeAIRating(gameInfo.GameOptions, aiLobbyProperties) - - return PlayerData( - { - OwnerID = hostID, - PlayerName = name, - Ready = true, - Human = false, - AIPersonality = AIPersonality, - PlayerColor = AIColor, - ArmyColor = AIColor, - - PL = iRating, - MEAN = iRating, - DEV = 0, - - -- keep track of the AI lobby properties for easier access - AILobbyProperties = aiLobbyProperties, - } -) -end - -local function DoSlotBehavior(slot, key, name) - if key == 'open' then - HostUtils.SetSlotClosed(slot, false) - elseif key == 'close' then - HostUtils.SetSlotClosed(slot, true) - elseif key == 'close_spawn_mex' then - HostUtils.SetSlotClosedSpawnMex(slot) - elseif key == 'occupy' then - if IsPlayer(localPlayerID) and not gameInfo.PlayerOptions[FindSlotForID(localPlayerID)].Ready then - if lobbyComm:IsHost() then - HostUtils.MovePlayerToEmptySlot(FindSlotForID(localPlayerID), slot) - else - lobbyComm:SendData(hostID, {Type = 'MovePlayer', RequestedSlot = slot}) - end - elseif IsObserver(localPlayerID) then - if lobbyComm:IsHost() then - local requestedFaction = GetSanitisedLastFaction() - HostUtils.ConvertObserverToPlayer(FindObserverSlotForID(localPlayerID), slot) - else - lobbyComm:SendData( - hostID, - { - Type = 'RequestConvertToPlayer', - ObserverSlot = FindObserverSlotForID(localPlayerID), - PlayerSlot = slot - } - ) - end - end - UpdateFactionSelector() - elseif key == 'pm' then - if gameInfo.PlayerOptions[slot].Human then - GUI.chatEdit:SetText(string.format("/whisper %s ", gameInfo.PlayerOptions[slot].PlayerName)) - end - -- Handle the various "Move to slot X" options. - elseif string.sub(key, 1, 19) == 'move_player_to_slot' then - HostUtils.SwapPlayers(slot, tonumber(string.sub(key, 20))) - elseif key == 'remove_to_observer' then - local playerInfo = gameInfo.PlayerOptions[slot] - if playerInfo.Human then - HostUtils.ConvertPlayerToObserver(slot) - end - elseif key == 'remove_to_kik' then - if gameInfo.PlayerOptions[slot].Human then - local kickMessage = function(self, str) - local msg - - msg = "\n Kicked by host. \n Reason: " .. str + -- Hand the front-end's escape handler back so ours can take over. + MenuCommon.MenuCleanup() - SendSystemMessage("lobui_0756", gameInfo.PlayerOptions[slot].PlayerName) - lobbyComm:EjectPeer(gameInfo.PlayerOptions[slot].OwnerID, msg) + -- Models first, then the view (so its components subscribe to a live model), + -- then the lobby object (whose callbacks write the model). + -- TODO: derive SlotCount from the chosen map instead of a fixed default. + CustomLobbyModel.SetupSingleton(8) + CustomLobbyInterface.SetupSingleton() - -- Save message for next time - Prefs.SetToCurrentProfile('lastKickMessage', UTF.EscapeString(str)) - lastKickMessage = str - end + Instance = InternalCreateLobby( + import("/lua/ui/lobby/customlobby/customlobbyinstance.lua").CustomLobbyInstance, + protocol, localPort, maxConnections, desiredPlayerName, localPlayerUID, natTraversalProvider + ) - CreateInputDialog(GUI, "Are you sure?", kickMessage, lastKickMessage) - else - HostUtils.RemoveAI(slot) + -- Minimal leave handler so the user isn't trapped. + EscapeHandler.PushEscapeHandler(function() + EscapeHandler.PopEscapeHandler() + if Instance then + Instance:Destroy() + Instance = false end - else - -- We're adding an AI of some sort. - if lobbyComm:IsHost() then - HostUtils.AddAI(name, key, slot) - end - end -end - -local function IsModAvailable(modId) - for k,v in availableMods do - if not v[modId] then - return false + CustomLobbyInterface.CloseDebug() + if exitBehavior then + exitBehavior() end - end - return true -end - - -function Reset() - lobbyComm = false - localPlayerName = "" - gameName = "" - hostID = false - singlePlayer = false - GUI = false - localPlayerID = false - availableMods = {} - selectedUIMods = Mods.GetSelectedUIMods() - selectedSimMods = Mods.GetSelectedSimMods() - numOpenSlots = LobbyComm.maxPlayerSlots - gameInfo = GameInfo.CreateGameInfo(LobbyComm.maxPlayerSlots) -end - ---- Create a new, unconnected lobby. -function ReallyCreateLobby(protocol, localPort, desiredPlayerName, localPlayerUID, natTraversalProvider, over, exitBehavior, playerHasSupcom) - Reset() - - -- Among other things, this clears uimain's override escape handler, allowing our escape - -- handler manager to work. - MenuCommon.MenuCleanup() - - if GUI then - WARN('CreateLobby called twice for UI construction (Should be unreachable)') - GUI:Destroy() - return - end - - -- Make sure we have a profile - if not GetPreference("profile.current") then - Prefs.CreateProfile("FAF_"..desiredPlayerName) - end - - GUI = UIUtil.CreateScreenGroup(over, "CreateLobby ScreenGroup") - - GUI.exitBehavior = exitBehavior - - GUI.optionControls = {} - GUI.slots = {} - - -- Set up the base escape handler first: want this one at the bottom of the stack. - GUI.exitLobbyEscapeHandler = function() - GUI.chatEdit:AbandonFocus() - local quitDialog = UIUtil.QuickDialog(GUI, - "Exit game lobby?", - "", function() - EscapeHandler.PopEscapeHandler() - if HasCommandLineArg("/gpgnet") then - -- Quit to desktop - EscapeHandler.SafeQuit() - else - -- Back to main menu - ReturnToMenu(false) - end - end, - - -- Fight to keep our focus on the chat input box, to prevent keybinding madness. - "", function() - GUI.chatEdit:AcquireFocus() - end, - nil, nil, - true, - {escapeButton = 2, enterButton = 1, worldCover = true} - ) - end - EscapeHandler.PushEscapeHandler(GUI.exitLobbyEscapeHandler) - - GUI.connectdialog = UIUtil.ShowInfoDialog(GUI, Strings.TryingToConnect, Strings.AbortConnect, ReturnToMenu) - -- Prevent the dialog from being closed due to user action. - GUI.connectdialog.OnEscapePressed = function() end - GUI.connectdialog.OnShadowClicked = function() end - - InitLobbyComm(protocol, localPort, desiredPlayerName, localPlayerUID, natTraversalProvider) - - -- Store off the validated playername - localPlayerName = lobbyComm:GetLocalPlayerName() - local Prefs = import("/lua/user/prefs.lua") - local windowed = Prefs.GetFromCurrentProfile('WindowedLobby') or 'false' - SetWindowedLobby(windowed == 'true') - -end - --- A map from message types to functions that process particular message types. -local MESSAGE_HANDLERS = { - -- TODO: Finalise signature and semantics. - ConnectivityState = function() - end -} - ---- Handle an incoming message from the FAF client via the GPGNet protocol. --- --- @param jsonBlob A JSON string containing the message to process. --- Messages are JSON strings containing two fields: --- command_id: A string identifying the type of message. This string is used as a key into --- MESSAGE_HANDLERS to find the function to use to process this message. --- arguments: An array of arguments that should be passed to the handler function. -function HandleGPGNetMessage(jsonBlob) - local jsonObj = JSON.decode(jsonBlob) - table.print(jsonObj) - local handler = MESSAGE_HANDLERS[jsonObj.command_id] - if not handler then - WARN("Incomprehensible JSON message: \n" .. jsonBlob) - return - end - - handler(unpack(jsonObj.arguments)) -end - ---- Start a synchronous replay session --- --- @param replayID The ID of the replay to download and play. -function StartSyncReplaySession(replayID) - SetFrontEndData('syncreplayid', replayID) - local dl = UIUtil.QuickDialog(GetFrame(0), "Downloading the replay file...") - LaunchReplaySession('gpgnet://' .. GetCommandLineArg('/gpgnet',1)[1] .. '/' .. import("/lua/user/prefs.lua").GetFromCurrentProfile('Name')) - dl:Destroy() - UIUtil.QuickDialog(GetFrame(0), "You dont have this map.", "Exit", function() ExitApplication() end) + end) end ---- Create a new unconnected lobby/Entry point for processing messages sent from the FAF lobby. --- --- This function is called exactly once by the game when a new lobby should be created. --- @see ReallyCreateLobby --- --- This function is called whenever the FAF lobby sends a message into the game, with the message --- in the desiredPlayerName parameter as a JSON string with a length no greater than 4061 bytes. --- This madness is justified by this being one of the smallish number of functions we can have --- called from outside. --- @see HandleGPGNetMessage --- --- This function is also called by the sync replay server when a session should be started. (this --- should probably be refactored to use the JSON messenger protocol) --- @see StartSyncReplaySession -function CreateLobby(protocol, localPort, desiredPlayerName, localPlayerUID, natTraversalProvider, over, exitBehavior, playerHasSupcom) - -- Is this an incoming GPGNet message? - if localPort == -1 then - HandleGPGNetMessage(desiredPlayerName) - return - end - - -- Special-casing for sync-replay. - -- TODO: Consider replacing this with a gpgnet message type. - if IsSyncReplayServer then - StartSyncReplaySession(localPlayerUID) +--- Hosts the lobby created by `CreateLobby`. +---@param desiredGameName string +---@param scenarioFileName FileName +---@param inSinglePlayer boolean +function HostGame(desiredGameName, scenarioFileName, inSinglePlayer) + if not Instance then return end - - -- Okay, so we actually are creating a lobby, instead of doing some ridiculous hack. - ReallyCreateLobby(protocol, localPort, desiredPlayerName, localPlayerUID, natTraversalProvider, over, exitBehavior, playerHasSupcom) -end - --- create the lobby as a host -function HostGame(desiredGameName, scenarioFileName, inSinglePlayer) - singlePlayer = inSinglePlayer - gameName = lobbyComm:MakeValidGameName(desiredGameName) - lobbyComm.desiredScenario = string.gsub(scenarioFileName, ".v%d%d%d%d_scenario.lua", "_scenario.lua") - lobbyComm:HostGame() + local scenario = string.gsub(scenarioFileName, ".v%d%d%d%d_scenario.lua", "_scenario.lua") --[[@as FileName]] + CustomLobbyModel.SetScenario(CustomLobbyModel.GetSingleton(), scenario) + Instance:HostGame() end --- join an already existing lobby +--- Joins the lobby created by `CreateLobby`. +---@param address GPGNetAddress +---@param asObserver boolean +---@param playerName? string +---@param uid? UILobbyPeerId function JoinGame(address, asObserver, playerName, uid) - 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) - LOG("ConnectToPeer (name=" .. name .. ", uid=" .. uid .. ", address=" .. addressAndPort ..", USE PROXY)") - table.insert(ConnectedWithProxy, uid) - end - lobbyComm:ConnectToPeer(addressAndPort,name,uid) -end - -function DisconnectFromPeer(uid) - LOG("DisconnectFromPeer (uid=" .. uid ..")") - if wasConnected(uid) then - table.remove(connectedTo, uid) - end - GpgNetSend('Disconnected', string.format("%d", uid)) - lobbyComm:DisconnectFromPeer(uid) -end - -function SetHasSupcom(cmd) - -- TODO: Refactor SyncReplayServer gubbins to use generalised JSON protocol. - if IsSyncReplayServer then - if cmd == 0 then - SessionResume() - elseif cmd == 1 then - SessionRequestPause() - end - end -end - -function SetHasForgedAlliance(speed) - if IsSyncReplayServer then - if GetGameSpeed() ~= speed then - SetGameSpeed(speed) - end - end -end - --- TODO: These functions are dumb. We have these things called "hashmaps". -function FindSlotForID(id) - for k, player in gameInfo.PlayerOptions:pairs() do - if player.OwnerID == id and player.Human then - return k - end - end - return nil -end - -function FindRehostSlotForID(id) - for index, player in ipairs(rehostPlayerOptions) do - if player.OwnerID == id and player.Human then - return player.StartSpot - end - end - return nil -end - -function FindNameForID(id) - if (IsObserver(id)) then - return (FindObserverNameForID(id)) - end - - for k, player in gameInfo.PlayerOptions:pairs() do - if player.OwnerID == id and player.Human then - return player.PlayerName - end - end - return nil -end - -function FindIDForName(name) - for k, player in gameInfo.PlayerOptions:pairs() do - if player.PlayerName == name and player.Human then - return player.OwnerID - end - end - return nil -end - -function FindObserverSlotForID(id) - for k, observer in gameInfo.Observers:pairs() do - if observer.OwnerID == id then - return k - end - end - - return nil -end - -function FindObserverNameForID(id) - for k, observer in gameInfo.Observers:pairs() do - if observer.OwnerID == id then - return observer.PlayerName - end - end - return nil -end - -function IsLocallyOwned(slot) - return gameInfo.PlayerOptions[slot].OwnerID == localPlayerID -end - -function IsPlayer(id) - return FindSlotForID(id) ~= nil -end - -function IsObserver(id) - return FindObserverSlotForID(id) ~= nil -end - -function UpdateSlotBackground(slotIndex) - if gameInfo.ClosedSlots[slotIndex] then - GUI.slots[slotIndex].SlotBackground:SetTexture(UIUtil.UIFile('/SLOT/slot-dis.dds')) - else - if gameInfo.PlayerOptions[slotIndex] then - GUI.slots[slotIndex].SlotBackground:SetTexture(UIUtil.UIFile('/SLOT/slot-player.dds')) - else - GUI.slots[slotIndex].SlotBackground:SetTexture(UIUtil.UIFile('/SLOT/slot-player_other.dds')) - end - end -end - -function GetPlayerDisplayName(playerInfo) - local playerName = playerInfo.PlayerName - local displayName = "" - if playerInfo.PlayerClan ~= "" then - return string.format("[%s] %s", playerInfo.PlayerClan, playerInfo.PlayerName) - else - return playerInfo.PlayerName + if Instance then + Instance:JoinGame(address, playerName, uid) end end --- Refresh (with a sledgehammer) all the items in the observer list. -local function refreshObserverList() - GUI.observerList:DeleteAllItems() - - -- create the table that will hold the data for displaying team rating information - local teamRatings = {} - local numTeams = 0 - -- calculate/display team ratings if spawns are fixed - if gameInfo.GameOptions['TeamSpawn'] == 'fixed' then - - -- cycle through each player - for i, player in gameInfo.PlayerOptions:pairs() do - - -- get the team number (which is 1 higher on the backend) - local team = player.Team - 1 - -- add the player's rating information if the player is on a team - if team > 0 then - -- make sure the team is included in the teamRatings table - if teamRatings[team] == nil then - -- initialize the team's rating in this table as having 0 mean and 0 deviation, respectively - teamRatings[team] = {0, 0} - end - -- add the player's rating information (mean and deviation) to the its team's totals - teamRatings[team] = {teamRatings[team][1] + player.MEAN, teamRatings[team][2] + player.DEV} - end - end - - for i, team in teamRatings do - numTeams = numTeams + 1 - end - - -- if there are 1 or 2 teams, list them before observers - if numTeams == 1 or numTeams == 2 then - if not lobbyComm:IsHost() then - GUI.observerList:AddItem(LOC('Team Ratings:')) - end - for i, rating in teamRatings do - GUI.observerList:AddItem( - LOCF('Team %d: %d (%d+/-%d)', i, math.round(rating[1] - rating[2] * 3), math.round(rating[1]), math.round(rating[2] * 3)) - ) - end - if not lobbyComm:IsHost() then - GUI.observerList:AddItem('') - end - end - end - - - local observers = false - - for slot, observer in gameInfo.Observers:pairs() do - - if not observers then - observers = true - if not lobbyComm:IsHost() then - GUI.observerList:AddItem(LOC('Observers')..':') - end - end - - observer.ObserverListIndex = GUI.observerList:GetItemCount() -- Pin-head William made this zero-based - - -- Create a label for this observer of the form: - -- PlayerName (R:xxx, P:xxx, C:xxx) - -- Such conciseness is necessary as the field in the UI is rather narrow... - local observer_label = observer.PlayerName .. " (R:" .. observer.PL - - -- Add the ping only if this entry refers to a different client. - if observer and (observer.OwnerID ~= localPlayerID) and observer.ObserverListIndex then - local peer = lobbyComm:GetPeer(observer.OwnerID) - - local ping = 0 - if peer.ping ~= nil then - ping = math.floor(peer.ping) - end - - observer_label = observer_label .. ", P:" .. ping - end - - -- Add the CPU score if one is available. - local score_CPU = CPU_Benchmarks[observer.PlayerName] - if score_CPU then - observer_label = observer_label .. ", C:" .. score_CPU - end - observer_label = observer_label .. ")" - - GUI.observerList:AddItem(observer_label) - end - - -- if there are more than 2 teams (and slots are fixed), list them after observers - if numTeams > 2 then - if not lobbyComm:IsHost() then - GUI.observerList:AddItem('') - GUI.observerList:AddItem(LOC('Team Ratings:')) - end - for i, rating in teamRatings do - GUI.observerList:AddItem( - LOCF('Team %d: %d (%d+/-%d)', i, math.round(rating[1] - rating[2] * 3), math.round(rating[1]), math.round(rating[2] * 3)) - ) - end +--- Called by the engine. +function ConnectToPeer(addressAndPort, name, uid) + if Instance then + Instance:ConnectToPeer(addressAndPort, name, uid) end end -local WVT = import("/lua/ui/lobby/data/watchedvalue/watchedvaluetable.lua") - --- update the data in a player slot --- TODO: With lazyvars, this function should be eliminated. Lazy-value-callbacks should be used --- instead to incrementaly update things. -function SetSlotInfo(slotNum, playerInfo) - -- Remove the ConnectDialog. It probably makes more sense to do this when we get the game state. - if GUI.connectdialog then - GUI.connectdialog:Close() - GUI.connectdialog = nil - - -- ChangelogDialog, if necessary. - local changelogDialogManager = import("/lua/ui/lobby/changelog/changelogdialog.lua") - if changelogDialogManager.ShouldOpenChangelog() then - changelogDialogManager.CreateChangelogDialog(GetFrame(0)) - end +--- Called by the engine. +function DisconnectFromPeer(uid) + if Instance then + Instance:DisconnectFromPeer(uid) end - - playerInfo.StartSpot = slotNum - - local slot = GUI.slots[slotNum] - local isHost = lobbyComm:IsHost() - local isLocallyOwned = IsLocallyOwned(slotNum) - - -- Set enabledness of controls according to host privelage etc. - -- Yeah, we set it twice. No, it's not brilliant. Blurgh. - local facColEnabled = isLocallyOwned or (isHost and not playerInfo.Human) - UIUtil.setEnabled(slot.faction, facColEnabled) - UIUtil.setEnabled(slot.color, facColEnabled) - - -- Possibly override it due to the ready box. - if isLocallyOwned then - if playerInfo.Ready and playerInfo.Human then - DisableSlot(slotNum, true) - else - EnableSlot(slotNum) - end - else - DisableSlot(slotNum) - end - - --- Returns true if the team selector for this slot should be enabled. - -- - -- The predicate was getting unpleasantly long to read. - local function teamSelectionEnabled(autoTeams, ready, locallyOwned, isHost) - -- If autoteams has control, no selector for you. - if autoTeams ~= 'none' then - return false - end - - if isHost and not playerInfo.Human then - return true - end - - -- You can control your own one when you're not ready. - if locallyOwned then - return not ready - end - - if isHost then - -- The host can control the team of others, provided he's not ready himself. - local slot = FindSlotForID(localPlayerID) - local is_ready = slot and gameInfo.PlayerOptions[slot].Ready -- could be observer - - return not is_ready - end - end - - -- Disable team selection if "auto teams" is controlling it. Moderatelty ick. - local autoTeams = gameInfo.GameOptions.AutoTeams - UIUtil.setEnabled(slot.team, teamSelectionEnabled(autoTeams, playerInfo.Ready, isLocallyOwned, isHost)) - - local hostKey - if isHost then - hostKey = 'host' - else - hostKey = 'client' - end - - -- These states are used to select the appropriate strings with GetSlotMenuTables. - local slotState - if not playerInfo.Human then - slot.ratingText:Hide() - slotState = 'ai' - elseif not isLocallyOwned then - slotState = 'player' - else - slotState = nil - end - - slot.name:ClearItems() - - if slotState then - slot.name:Enable() - local slotKeys, slotStrings, slotTooltips = GetSlotMenuTables(slotState, hostKey, slotNum) - slot.name.slotKeys = slotKeys - - if not table.empty(slotKeys) then - slot.name:AddItems(slotStrings) - slot.name:Enable() - Tooltip.AddComboTooltip(slot.name, slotTooltips) - else - slot.name.slotKeys = nil - slot.name:Disable() - Tooltip.RemoveComboTooltip(slot.name) - end - else - -- no slotState indicate this must be ourself, and you can't do anything to yourself - slot.name.slotKeys = nil - slot.name:Disable() - end - - slot.ratingText:Show() - slot.ratingText:SetText(playerInfo.PL) - slot.ratingText:SetColor("ffffffff") - - -- dynamic tooltip to show rating and deviation for each player - local tooltipText = {} - tooltipText['text'] = LOC("Rating") - tooltipText['body'] = LOCF("%s's TrueSkill Rating is %s +/- %s", playerInfo.PlayerName, math.round(playerInfo.MEAN), math.ceil(playerInfo.DEV * 3)) - slot.tooltiprating = Tooltip.AddControlTooltip(slot.ratingText, tooltipText) - - slot.numGamesText:Show() - slot.numGamesText:SetText(playerInfo.NG) - - slot.name:Show() - -- Change name colour according to the state of the slot. - if slotState == 'ai' then - slot.name:SetTitleTextColor("dbdbb9") -- Beige Color for AI - slot.name._text:SetFont('Arial Gras', 12) - elseif FindSlotForID(hostID) == slotNum then - slot.name:SetTitleTextColor("ffc726") -- Orange Color for Host - slot.name._text:SetFont('Arial Gras', 15) - elseif slotState == 'player' then - slot.name:SetTitleTextColor("64d264") -- Green Color for Players - slot.name._text:SetFont('Arial Gras', 15) - elseif isLocallyOwned then - slot.name:SetTitleTextColor("6363d2") -- Blue Color for You - slot.name._text:SetFont('Arial Gras', 15) - else - slot.name:SetTitleTextColor(UIUtil.fontColor) -- Normal Color for Other - slot.name._text:SetFont('Arial Gras', 12) - end - - local playerName = playerInfo.PlayerName - if wasConnected(playerInfo.OwnerID) or isLocallyOwned or not playerInfo.Human then - slot.name:SetTitleText(GetPlayerDisplayName(playerInfo)) - slot.name._text:SetFont('Arial Gras', 15) - if not table.find(ConnectionEstablished, playerName) then - if playerInfo.Human and not isLocallyOwned then - AddChatText(LOCF("Connection to %s established.", playerName)) - - table.insert(ConnectionEstablished, playerName) - for k, v in CurrentConnection do - if v == playerName then - CurrentConnection[k] = nil - break - end - end - end - end - else - slot.name:SetTitleText(LOCF('Connecting to %s...', playerName)) - slot.name._text:SetFont('Arial Gras', 11) - end - - slot.faction:Show() - - -- Check if faction is possible for that slot, if not set to random - -- For example: AIs always start with faction 5, so that needs to be adjusted to fit in slot.Faction - if table.getn(slot.AvailableFactions) < playerInfo.Faction then - playerInfo.Faction = table.getn(slot.AvailableFactions) - end - slot.faction:SetItem(playerInfo.Faction) - - slot.color:Show() - Check_Availaible_Color(slotNum) - - slot.team:Show() - slot.team:SetItem(playerInfo.Team) - - -- Send team data to the server - if isHost then - HostUtils.SendPlayerSettingsToServer(slotNum) - end - - UIUtil.setVisible(slot.ready, playerInfo.Human and not singlePlayer) - slot.ready:SetCheck(playerInfo.Ready, true) - - if isLocallyOwned and playerInfo.Human then - Prefs.SetToCurrentProfile('LastColorFAF', playerInfo.PlayerColor) - Prefs.SetToCurrentProfile('LastFaction', playerInfo.Faction) - end - - -- Show the player's nationality - if not playerInfo.Country then - slot.KinderCountry:Hide() - else - slot.KinderCountry:Show() - slot.KinderCountry:SetTexture(UIUtil.UIFile('/countries/'..playerInfo.Country..'.dds')) - - Tooltip.AddControlTooltip(slot.KinderCountry, {text=LOC("Country"), body=LOC(CountryTooltips[playerInfo.Country])}) - end - - UpdateSlotBackground(slotNum) - - -- Set the CPU bar - SetSlotCPUBar(slotNum, playerInfo) - - ShowGameQuality() - RefreshMapPositionForAllControls(slotNum) - - if isHost then - HostUtils.RefreshButtonEnabledness() - end - refreshObserverList() -end - -function ClearSlotInfo(slotIndex) - local slot = GUI.slots[slotIndex] - - local hostKey - if lobbyComm:IsHost() then - GpgNetSend('ClearSlot', slotIndex) - hostKey = 'host' - else - hostKey = 'client' - end - - local stateKey - local stateText - if gameInfo.ClosedSlots[slotIndex] and gameInfo.SpawnMex[slotIndex] and gameInfo.AdaptiveMap then - stateKey = 'closed_spawn_mex' - stateText = slotMenuStrings.closed_spawn_mex - elseif gameInfo.ClosedSlots[slotIndex] then - gameInfo.SpawnMex[slotIndex] = false - stateKey = 'closed' - stateText = slotMenuStrings.closed - else - stateKey = 'open' - stateText = slotMenuStrings.open - end - - local slotKeys, slotStrings, slotTooltips = GetSlotMenuTables(stateKey, hostKey) - - -- set the text appropriately - slot.name:ClearItems() - slot.name:SetTitleText(LOC(stateText)) - if not table.empty(slotKeys) then - slot.name.slotKeys = slotKeys - slot.name:AddItems(slotStrings) - Tooltip.AddComboTooltip(slot.name, slotTooltips) - slot.name:Enable() - else - slot.name.slotKeys = nil - slot.name:Disable() - Tooltip.RemoveComboTooltip(slot.name) - end - - slot.name._text:SetFont('Arial Gras', 12) - if stateKey == 'closed' then - slot.name:SetTitleTextColor("Crimson") - elseif stateKey == 'closed_spawn_mex' then - slot.name:SetTitleTextColor("2c7f33") - else - slot.name:SetTitleTextColor('B9BFB9') - end - - slot:HideControls() - - UpdateSlotBackground(slotIndex) - ShowGameQuality() - RefreshMapPositionForAllControls(slotIndex) - Check_Availaible_Color() - refreshObserverList() -end - -function IsColorFree(colorIndex, currentSlotNumber) - for id, player in gameInfo.PlayerOptions:pairs() do - if player.PlayerColor == colorIndex then - if currentSlotNumber then - if player.StartSpot != currentSlotNumber then - return false - end - else - return false - end - end - end - - return true -end - -function GetPlayerCount() - local numPlayers = 0 - for k,player in gameInfo.PlayerOptions:pairs() do - if player then - numPlayers = numPlayers + 1 - end - end - return numPlayers -end - -local function GetPlayersNotReady() - local notReady = false - for k,v in gameInfo.PlayerOptions:pairs() do - if v.Human and not v.Ready then - if not notReady then - notReady = {} - end - table.insert(notReady, v.PlayerName) - end - end - - return notReady -end - -local function GetRandomFactionIndex(slotNumber) - local randomfaction = nil - local counter = 50 - while counter > 0 do - counter = (counter - 1) - randomfaction = math.random(1, table.getn(GUI.slots[slotNumber].AvailableFactions) - 1) - end - return randomfaction -end - -local function AssignRandomFactions() - for index, player in gameInfo.PlayerOptions do - -- No random if there is only 1 option - if table.getn(GUI.slots[index].AvailableFactions) >= 2 then - local randomFactionID = table.getn(GUI.slots[index].AvailableFactions) - -- note that this doesn't need to be aware if player has supcom or not since they would only be able to select - -- the random faction ID if they have supcom - if player.Faction >= randomFactionID then - player.Faction = GetRandomFactionIndex(index) - end - end - end -end - --- Convert the local (slot dependend) faction indexes to the global faction indexes -local function FixFactionIndexes() - for index, player in gameInfo.PlayerOptions do - local playerFaction = GUI.slots[index].AvailableFactions[player.Faction] - for i,v in allAvailableFactionsList do - if v == playerFaction then - player.Faction = i - continue - end - end - end - -end - ---------------------------- --- autobalance functions -- ---------------------------- -local function team_sort_by_sum(t1, t2) - return t1['sum'] < t2['sum'] -end - -local function autobalance_bestworst(players, teams_arg) - local players = table.deepcopy(players) - local result = {} - local best = true - local teams = {} - - for t, slots in teams_arg do - table.insert(teams, {team=t, slots=table.deepcopy(slots), sum=0}) - end - - -- teams first picks best player and then worst player, repeat - while not table.empty(players) do - for i, t in teams do - local team = t['team'] - local slots = t['slots'] - local slot = table.remove(slots, 1) - if not slot then continue end - local player - - if best then - player = table.remove(players, 1) - else - player = table.remove(players) - end - - if not player then break end - - teams[i]['sum'] = teams[i]['sum'] + player['rating'] - table.insert(result, {player=player['pos'], rating=player['rating'], team=team, slot=slot}) - end - - best = not best - if best then - table.sort(teams, team_sort_by_sum) - end - end - - return result -end - -local function autobalance_avg(players, teams_arg) - local players = table.deepcopy(players) - local result = {} - local teams = {} - local max_sum = 0 - - for t, slots in teams_arg do - table.insert(teams, {team=t, slots=table.deepcopy(slots), sum=0}) - end - - while not table.empty(players) do - local first_team = true - for i, t in teams do - local team = t['team'] - local slots = t['slots'] - local slot = table.remove(slots, 1) - if not slot then continue end - local player - local player_key - - for j, p in players do - player_key = j - if first_team or t['sum'] + p['rating'] <= max_sum then - break - end - end - - player = table.remove(players, player_key) - if not player then break end - - teams[i]['sum'] = teams[i]['sum'] + player['rating'] - max_sum = math.max(max_sum, teams[i]['sum']) - table.insert(result, {player=player['pos'], rating=player['rating'], team=team, slot=slot}) - first_team = false - end - - table.sort(teams, team_sort_by_sum) - end - - return result -end - -local function autobalance_rr(players, teams) - local players = table.deepcopy(players) - local teams = table.deepcopy(teams) - local result = {} - - local team_picks = {} - local i = 1 - for team, slots in teams do - table.insert(team_picks, {team=team, sum=i}) - i = i + 1 - end - - while not table.empty(players) do - for i, pick in team_picks do - local slot = table.remove(teams[pick.team], 1) - if not slot then continue end - local player = table.remove(players, 1) - if not player then break end - pick.sum = pick.sum + i - - table.insert(result, {player=player.pos, rating=player.rating, team=pick.team, slot=slot}) - end - - table.sort(team_picks, function(a, b) return a.sum > b.sum end) - end - - return result -end - -local function autobalance_random(players, teams_arg) - local players = table.deepcopy(players) - local result = {} - local teams = {} - - players = table.shuffle(players) - - for t, slots in teams_arg do - table.insert(teams, {team=t, slots=table.deepcopy(slots)}) - end - - while not table.empty(players) do - for _, t in teams do - local team = t['team'] - local slot = table.remove(t['slots'], 1) - if not slot then continue end - local player = table.remove(players, 1) - - if not player then break end - - table.insert(result, {player=player['pos'], rating=player['rating'], team=team, slot=slot}) - end - end - - return result -end - -function autobalance_quality(players) - local teams = nil - local quality = 0 - - for _, p in players do - local i = p['player'] - local team = p['team'] - local playerInfo = gameInfo.PlayerOptions[i] - local player = Player.create(playerInfo.PlayerName, - Rating.create(playerInfo.MEAN or 1500, playerInfo.DEV or 500)) - - if not teams then - teams = Teams.create() - end - - teams:addPlayer(team, player) - end - - if teams and table.getn(teams:getTeams()) > 1 then - quality = Trueskill.computeQuality(teams) - end - - return quality -end - ---- If the game is full, GPGNetSend about it so the client can do a fancy popup if it has focus. -function PossiblyAnnounceGameFull() - -- Search for an empty non-closed slot. - for i = 1, numOpenSlots do - if not gameInfo.ClosedSlots[i] then - if not gameInfo.PlayerOptions[i] then - return - end - end - end - - -- Game is full, let's tell the client. - GpgNetSend("GameFull") -end - -local function AssignRandomStartSpots() - local teamSpawn = gameInfo.GameOptions['TeamSpawn'] - - if teamSpawn == 'fixed' or teamSpawn == 'penguin_autobalance' then - return - end - - function teamsAddSpot(teams, team, spot) - if not teams[team] then - teams[team] = {} - end - table.insert(teams[team], spot) - end - - -- rearrange players according to the provided setup - function rearrangePlayers(data) - gameInfo.GameOptions['Quality'] = data.quality - - -- Copy a reference to each of the PlayerData objects indexed by their original slots. - local orgPlayerOptions = {} - for k, p in gameInfo.PlayerOptions do - orgPlayerOptions[k] = p - end - - local mirrored = string.find(teamSpawn, 'mirrored') - if mirrored then - local rating_cmp = function(a,b) return a.rating > b.rating end - local slot_cmp = function(a,b) return a.slot < b.slot end - - function getMasterOrder(sortedSlots) - local masterOrder = {} - - local slot2nr = {} - for k, p in sortedSlots.byNr do - slot2nr[p.slot] = k - end - - for k, p in sortedSlots.byRating do - table.insert(masterOrder, slot2nr[p.slot]) - end - - return masterOrder - end - - function teamsSameSize(slots) - local size - - for t, sorted in slots do - local s = table.getn(sorted.byNr) - if not size then size = s end - - if size ~= s then return false end - end - - return true - end - - function reorderSlots(sortedSlots, masterOrder) - local newSlots = {} - for i, j in masterOrder do - table.insert(newSlots, sortedSlots.byNr[j].slot) - end - - for i, s in newSlots do - sortedSlots.byRating[i].slot = s - end - end - - local slots = {} - local masterTeam - - for _, p in data.setup do - if not slots[p.team] then slots[p.team] = {} end - if not slots[p.team].byNr then slots[p.team].byNr = {} end - if not slots[p.team].byRating then slots[p.team].byRating = {} end - if not masterTeam then masterTeam = p.team end - - table.binsert(slots[p.team].byNr, p, slot_cmp) - table.binsert(slots[p.team].byRating, p, rating_cmp) - end - - -- abort mirroring if team sizes differ - if not teamsSameSize(slots) then - WARN("Mirroring disabled due to teams not having the same number of players") - else - local masterOrder = getMasterOrder(slots[masterTeam]) - for t, sorted in slots do - reorderSlots(sorted, masterOrder) - end - end - end - - -- Rearrange the players in the slots to match the chosen configuration. The result object - -- maps old slots to new slots, and we use orgPlayerOptions to avoid losing a reference to - -- an object (and because swapping is too much like hard work). - gameInfo.PlayerOptions = {} - for _, r in data.setup do - local playerOptions = orgPlayerOptions[r.player] - playerOptions.Team = r.team + 1 - playerOptions.StartSpot = r.slot - gameInfo.PlayerOptions[r.slot] = playerOptions - - -- Send team data to the server - local playerInfo = gameInfo.PlayerOptions[r.slot] - HostUtils.SendPlayerSettingsToServer(r.slot) - end - end - - local numAvailStartSpots = GetNumAvailStartSpots() - - local AutoTeams = gameInfo.GameOptions.AutoTeams - local positionGroups = {} - local teams = {} - - -- Used to actualise the virtual teams produced by the "Team -" no-team team. - local synthesizedTeamCounter = 9 - for i = 1, numAvailStartSpots do - if not gameInfo.ClosedSlots[i] then - local team = nil - local group = nil - - if AutoTeams == 'lvsr' then - local midLine = GUI.mapView.Left() + (GUI.mapView.Width() / 2) - local markerPos = GUI.mapView.startPositions[i].Left() - - if markerPos < midLine then - team = 2 - else - team = 3 - end - elseif AutoTeams == 'tvsb' then - local midLine = GUI.mapView.Top() + (GUI.mapView.Height() / 2) - local markerPos = GUI.mapView.startPositions[i].Top() - - if markerPos < midLine then - team = 2 - else - team = 3 - end - elseif AutoTeams == 'pvsi' then - if math.mod(i, 2) ~= 0 then - team = 2 - else - team = 3 - end - elseif AutoTeams == 'manual' then - team = gameInfo.AutoTeams[i] - else -- none - team = gameInfo.PlayerOptions[i].Team - group = 1 - end - - group = group or team - if not positionGroups[group] then - positionGroups[group] = {} - end - table.insert(positionGroups[group], i) - - if team ~= nil then - -- Team 1 secretly represents "No team", so give them a real team (but one that - -- nobody else can possibly have) - if team == 1 then - team = synthesizedTeamCounter - synthesizedTeamCounter = synthesizedTeamCounter + 1 - end - teamsAddSpot(teams, team, i) - end - end - end - gameInfo.GameOptions.RandomPositionGroups = positionGroups - - -- shuffle the array for randomness. - for i, team in teams do - teams[i] = table.shuffle(team) - end - teams = table.shuffle(teams) - - local ratingTable = {} - for i = 1, numAvailStartSpots do - local playerInfo = gameInfo.PlayerOptions[i] - if playerInfo then - table.insert(ratingTable, { pos=i, rating = playerInfo.MEAN - playerInfo.DEV * 3 }) - end - end - - if teamSpawn == 'random' or teamSpawn == 'random_reveal' then - s = autobalance_random(ratingTable, teams) - q = autobalance_quality(s) - rearrangePlayers{setup=s, quality=q} - return - end - - ratingTable = table.shuffle(ratingTable) -- random order for people with same rating - table.sort(ratingTable, function(a, b) return a['rating'] > b['rating'] end) - - local setups = {} - local functions = { - rr=autobalance_rr, - bestworst=autobalance_bestworst, - avg=autobalance_avg, - } - - local cmp = function(a, b) return a.quality > b.quality end - local s, q - for fname, f in functions do - s = f(ratingTable, teams) - if s then - q = autobalance_quality(s) - table.binsert(setups, {setup=s, quality=q}, cmp) - end - end - - local n_random = 0 - local frac = (teamSpawn == 'balanced_flex' or teamSpawn == 'balanced_flex_reveal') and 0.95 or 1 - -- add 100 random compositions and keep 3 with at least of best quality - for i=1, 100 do - s = autobalance_random(ratingTable, teams) - q = autobalance_quality(s) - - if q > setups[1].quality * frac then - table.binsert(setups, {setup=s, quality=q}, cmp) - n_random = n_random + 1 - if n_random > 2 then break end - end - end - - if teamSpawn == 'balanced_flex' or teamSpawn == 'balanced_flex_reveal' then - setups = table.shuffle(setups) - end - - best = table.remove(setups, 1) - rearrangePlayers(best) -end - - -local function AssignAutoTeams() - -- A function to take a player index and return the team they should be on. - local getTeam - if gameInfo.GameOptions.AutoTeams == 'lvsr' then - local midLine = GUI.mapView.Left() + (GUI.mapView.Width() / 2) - local startPositions = GUI.mapView.startPositions - - getTeam = function(playerIndex) - local markerPos = startPositions[playerIndex].Left() - if markerPos < midLine then - return 2 - else - return 3 - end - end - elseif gameInfo.GameOptions.AutoTeams == 'tvsb' then - local midLine = GUI.mapView.Top() + (GUI.mapView.Height() / 2) - local startPositions = GUI.mapView.startPositions - - getTeam = function(playerIndex) - local markerPos = startPositions[playerIndex].Top() - if markerPos < midLine then - return 2 - else - return 3 - end - end - elseif gameInfo.GameOptions.AutoTeams == 'pvsi' or gameInfo.GameOptions['RandomMap'] ~= 'Off' then - getTeam = function(playerIndex) - if math.mod(playerIndex, 2) ~= 0 then - return 2 - else - return 3 - end - end - elseif gameInfo.GameOptions.AutoTeams == 'manual' then - getTeam = function(playerIndex) - return gameInfo.AutoTeams[playerIndex] or 1 - end - else - return - end - - for i = 1, LobbyComm.maxPlayerSlots do - if not gameInfo.ClosedSlots[i] and gameInfo.PlayerOptions[i] then - local correctTeam = getTeam(i) - if gameInfo.PlayerOptions[i].Team ~= correctTeam then - SetPlayerOption(i, "Team", correctTeam, true) - SetSlotInfo(i, gameInfo.PlayerOptions[i]) - end - end - end -end - -local function AssignAINames() - local aiNames = import("/lua/ui/lobby/ainames.lua").ainames - local nameSlotsTaken = {} - for index, faction in FactionData.Factions do - nameSlotsTaken[index] = {} - end - for index, player in gameInfo.PlayerOptions do - if not player.Human then - local playerFaction = player.Faction - local factionNames = aiNames[FactionData.Factions[playerFaction].Key] - local ranNum - repeat - ranNum = math.random(1, table.getn(factionNames)) - until nameSlotsTaken[playerFaction][ranNum] == nil - nameSlotsTaken[playerFaction][ranNum] = true - player.PlayerName = factionNames[ranNum] .. " (" .. player.PlayerName .. ")" - end - end -end - - --- call this whenever the lobby needs to exit and not go in to the game -function ReturnToMenu(reconnect) - if lobbyComm then - lobbyComm:Destroy() - lobbyComm = false - end - - local exitfn = GUI.exitBehavior - - GUI:Destroy() - GUI = false - - if not reconnect then - exitfn() - else - local ipnumber = GetCommandLineArg("/joincustom", 1)[1] - import("/lua/ui/uimain.lua").StartJoinLobbyUI("UDP", ipnumber, localPlayerName) - end -end - -function PrintSystemMessage(id, parameters) - AddChatText(LOCF("Unknown system message. Check localisation file", unpack(parameters))) -end - -function SendSystemMessage(id, ...) - local data = { - Type = "SystemMessage", - Id = id, - Args = arg - } - - lobbyComm:BroadcastData(data) - PrintSystemMessage(id, arg) -end - -function SendPersonalSystemMessage(targetID, id, ...) - if targetID ~= localPlayerID then - local data = { - Type = "SystemMessage", - Id = id, - Args = arg - } - - lobbyComm:SendData(targetID, data) - end -end - -function PublicChat(text) - lobbyComm:BroadcastData( - { - Type = "PublicChat", - Text = text, - } - ) - AddChatText(text, localPlayerID, true) -end - -function PrivateChat(targetID,text) - if targetID ~= localPlayerID then - lobbyComm:SendData( - targetID, - { - Type = 'PrivateChat', - Text = text, - } - ) - end - local targetName = FindNameForID(targetID) - if targetName then - AddChatText("<<"..LOCF("To %s", targetName)..">> " .. text) - end -end - -function UpdateAvailableSlots(numAvailStartSpots, scenario) - if numAvailStartSpots > LobbyComm.maxPlayerSlots then - WARN("Lobby requests " .. numAvailStartSpots .. " but there are only " .. LobbyComm.maxPlayerSlots .. " available") - end - - for i = 1, numAvailStartSpots do - local availableFactionsForSpotI = FACTION_NAMES - if scenario.Configurations.standard.factions then - availableFactionsForSpotI = scenario.Configurations.standard.factions[i] - end - - local factionBmps = {} - local factionTooltips = {} - local factionList = {} - for index, factionKey in availableFactionsForSpotI do - for _, tbl in FactionData.Factions do - if factionKey == tbl.Key then - factionBmps[index] = tbl.SmallIcon - factionTooltips[index] = tbl.TooltipID - factionList[index] = tbl.Key - break - end - end - end - if table.getn(factionBmps) > 1 then - table.insert(factionBmps, "/faction_icon-sm/random_ico.dds") - table.insert(factionTooltips, 'lob_random') - table.insert(factionList, 'random') - end - - local oldAvailableFactions = GUI.slots[i].AvailableFactions - GUI.slots[i].AvailableFactions = factionList - - local diff = table.getn(factionList) ~= table.getn(oldAvailableFactions) - for k = 1,table.getn(factionList) do - if oldAvailableFactions[k] ~= factionList[k] then - diff = true - break - end - end - if not diff then - continue - end - - GUI.slots[i].faction:ChangeBitmapArray(factionBmps) - Tooltip.AddComboTooltip(GUI.slots[i].faction, factionTooltips) - - if gameInfo.PlayerOptions[i] then - local playerFactionIndex = table.getn(factionList) - for index,key in factionList do - if key == oldAvailableFactions[gameInfo.PlayerOptions[i].Faction] then - playerFactionIndex = index - break - end - end - if FindSlotForID(localPlayerID) == i then - local fact = factionList[playerFactionIndex] - for index,value in allAvailableFactionsList do - if fact == value then - GUI.factionSelector:SetSelected(index) - break - end - end - UpdateFactionSelector() - else - GUI.slots[i].faction:SetItem(playerFactionIndex) - gameInfo.PlayerOptions[i].Faction = playerFactionIndex - end - end - end - - -- if number of available slots has changed, update it - if gameInfo.firstUpdateAvailableSlotsDone and numOpenSlots == numAvailStartSpots then - -- Remove closed_spawn_mex if necessary - if not gameInfo.AdaptiveMap then - for i = 1, numAvailStartSpots do - if gameInfo.ClosedSlots[i] and gameInfo.SpawnMex[i] then - ClearSlotInfo(i) - gameInfo.SpawnMex[i] = nil - end - end - end - return - end - - -- reopen slots in case the new map has more startpositions then the previous map. - if numOpenSlots < numAvailStartSpots then - for i = numOpenSlots + 1, numAvailStartSpots do - gameInfo.ClosedSlots[i] = nil - gameInfo.SpawnMex[i] = nil - GUI.slots[i]:Show() - ClearSlotInfo(i) - DisableSlot(i) - end - end - numOpenSlots = numAvailStartSpots - - for i = 1, numAvailStartSpots do - if gameInfo.ClosedSlots[i] then - GUI.slots[i]:Show() - if not gameInfo.PlayerOptions[i] then - ClearSlotInfo(i) - end - if not gameInfo.PlayerOptions[i].Ready then - EnableSlot(i) - end - end - end - - for i = numAvailStartSpots + 1, LobbyComm.maxPlayerSlots do - if lobbyComm:IsHost() and gameInfo.PlayerOptions[i] then - local info = gameInfo.PlayerOptions[i] - if info.Human then - HostUtils.ConvertPlayerToObserver(i) - else - HostUtils.RemoveAI(i) - end - end - DisableSlot(i) - GUI.slots[i]:Hide() - gameInfo.ClosedSlots[i] = true - gameInfo.SpawnMex[i] = nil - end - - gameInfo.firstUpdateAvailableSlotsDone = true -end - -local function TryLaunch(skipNoObserversCheck) - if not singlePlayer then - local notReady = GetPlayersNotReady() - if notReady then - for k,v in notReady do - AddChatText(LOCF("%s isn't ready.",v)) - end - return - end - end - - local teamsPresent = {} - - -- make sure there are some players (could all be observers?) - -- Also count teams. There needs to be at least 2 teams (or all FFA) represented - local numPlayers = 0 - local numHumanPlayers = 0 - local numTeams = 0 - for slot, player in gameInfo.PlayerOptions:pairs() do - if player then - numPlayers = numPlayers + 1 - - if player.Human then - numHumanPlayers = numHumanPlayers + 1 - end - - -- Make sure to increment numTeams for people in the special "-" team, represented by 1. - if not teamsPresent[player.Team] or player.Team == 1 then - teamsPresent[player.Team] = true - numTeams = numTeams + 1 - end - end - end - - -- Ensure, for a non-sandbox game, there are some teams to fight. - if gameInfo.GameOptions['Victory'] ~= 'sandbox' and numTeams < 2 then - --AddChatText(LOC("There must be more than one player or team or the Victory Condition must be set to Sandbox.")) - -- In case we start a game as single player we set the game temporarily to Sandbox mode. This will not change the lobby option itself! - SPEW('GameOptions[\'Victory\'] changed temporarily from "'..gameInfo.GameOptions['Victory']..'" to "sandbox"') - gameInfo.GameOptions['Victory'] = 'sandbox' - end - - if numPlayers == 0 then - AddChatText(LOC("There are no players assigned to player slots, can not continue")) - return - end - - if not gameInfo.GameOptions.AllowObservers then - - -- if observers are not allowed, and team spawn is set to penguin_autobalance, and there are - -- an odd number of players, then make the last player an observer now if human - -- (before the check(s)/prompt(s) for having observer(s) when they're not allowed) - if gameInfo.GameOptions.TeamSpawn == 'penguin_autobalance' then - if math.mod(numPlayers, 2) == 1 then - for i = 16, 1, -1 do - -- this gets the last occupied slot - if gameInfo.PlayerOptions[i] then - LOG(gameInfo.PlayerOptions[i].Human) - if gameInfo.PlayerOptions[i].Human then - HostUtils.ConvertPlayerToObserver(i) - end - break - end - end - end - end - - local hostIsObserver = false - local anyOtherObservers = false - for k, observer in gameInfo.Observers:pairs() do - if observer.OwnerID == localPlayerID then - hostIsObserver = true - else - anyOtherObservers = true - end - end - - if hostIsObserver then - AddChatText(LOC("Cannot launch if the host isn't assigned a slot and observers are not allowed.")) - return - end - - if anyOtherObservers and not skipNoObserversCheck then - UIUtil.QuickDialog(GUI, "Launching will kick observers because \"allow observers\" is disabled. Continue?", - "", function() TryLaunch(true) end, - "", nil, nil, nil, true, - {worldCover = false, enterButton = 1, escapeButton = 2}) - return - end - - HostUtils.KickObservers("GameLaunched") - end - - if not EveryoneHasEstablishedConnections(gameInfo.GameOptions.AllowObservers) then - return - end - - numberOfPlayers = numPlayers - local function LaunchGame() - - if gameInfo.GameOptions.TeamSpawn == 'penguin_autobalance' then - GUI.PenguinAutoBalance.OnClick() - end - - -- These two things must happen before the flattening step, mostly for terrible reasons. - -- This isn't ideal, as it leads to redundant UI repaints :/ - AssignAutoTeams() - - -- Force observers to start with the UEF skin to prevent them from launching as "random". - if IsObserver(localPlayerID) then - UIUtil.SetCurrentSkin("uef") - end - - -- Eliminate the WatchedValue structures. - gameInfo = GameInfo.Flatten(gameInfo) - - if gameInfo.GameOptions['RandomMap'] ~= 'Off' then - autoRandMap = true - autoMap() - end - - SetFrontEndData('NextOpBriefing', nil) - -- assign random factions just as game is launched - AssignRandomFactions() - -- fix faction indexes - FixFactionIndexes() - AssignRandomStartSpots() - AssignAINames() - local allRatings = {} - local clanTags = {} - for k, player in gameInfo.PlayerOptions do - if player.PL then - allRatings[player.PlayerName] = player.PL - clanTags[player.PlayerName] = player.PlayerClan - - if not player.Human then - allRatings[player.PlayerName] = ComputeAIRating(gameInfo.GameOptions, player.AILobbyProperties) - end - end - - if player.OwnerID == localPlayerID then - UIUtil.SetCurrentSkin(FACTION_NAMES[player.Faction]) - end - end - gameInfo.GameOptions['Ratings'] = allRatings - gameInfo.GameOptions['ClanTags'] = clanTags - - scenarioInfo = MapUtil.LoadScenario(gameInfo.GameOptions.ScenarioFile) - - -- Load in the default map options if they are not set manually - - -- Not all maps have options - if scenarioInfo.options then - - -- If we don't validate them first then the people using the default - -- as a value instead of the index of the value will mess us up - MapUtil.ValidateScenarioOptions(scenarioInfo.options) - - -- For every option, if it's not set yet then add its default value - for _, option in scenarioInfo.options do - if not gameInfo.GameOptions[option.key] then - -- When the value data of the option is formatted as: - -- values = { - -- { text = "Easy", help = "We'll have sufficient time to start building up our defense strategy.", key = 1, }, - -- { text = "Normal", help = "There's sufficient time - but we'll need to hurry up.", key = 2, }, - -- { text = "Heroic", help = "There's little time - no space for errors.", key = 3, }, - -- { text = "Legendary", help = "We're being dropped in the middle of it - we knew it was a suicide mission when we signed up for it.", key = 4, }, - -- }, - local keyVersion = option.values[option.default].key - - -- When the value data of the option is formatted as: - -- values = { - -- '1','2','3','4','5','6','7','8','9','10','11','12','13','14','15','16','17','18','19','20' - -- } - local valueVersion = option.values[option.default] - - -- Expect a key version, fall back on a value version - gameInfo.GameOptions[option.key] = keyVersion or valueVersion - - -- Can be removed once this code leaves the develop branch - SPEW("Loading default map option: " .. tostring (option.key) .. " = " .. tostring (gameInfo.GameOptions[option.key])) - end - end - end - - if scenarioInfo.AdaptiveMap then - gameInfo.GameOptions["SpawnMex"] = gameInfo.SpawnMex - end - if gameInfo.GameOptions["CheatsEnabled"] == "true" and singlePlayer then - gameInfo.GameOptions["GameSpeed"] = "adjustable" - end - - HostUtils.SendArmySettingsToServer() - - -- Tell everyone else to launch and then launch ourselves. - -- TODO: Sending gamedata here isn't necessary unless lobbyComm is fucking stupid and allows - -- out-of-order message delivery. - -- Downlord: I use this in clients now to store the rehost preset. So if you're going to remove this, please - -- check if rehosting still works for non-host players. - lobbyComm:BroadcastData({ Type = 'Launch', GameInfo = gameInfo }) - - -- set the mods - gameInfo.GameMods = Mods.GetGameMods(gameInfo.GameMods) - - SetWindowedLobby(false) - - Presets.SaveLastGamePreset() - - -- launch the game - lobbyComm:LaunchGame(gameInfo) - - - end - - LaunchGame() -end - -local function AlertHostMapMissing() - if lobbyComm:IsHost() then - HostUtils.PlayerMissingMapAlert(localPlayerID) - else - lobbyComm:SendData(hostID, {Type = 'MissingMap'}) - end -end - -local function UpdateGame() - -- This allows us to assume the existence of UI elements throughout. - if not GUI.uiCreated then - WARN(debug.traceback(nil, "UpdateGame() pointlessly called before UI creation!")) - return - end - - local scenarioInfo - - if gameInfo.GameOptions.ScenarioFile and (gameInfo.GameOptions.ScenarioFile ~= "") then - scenarioInfo = MapUtil.LoadScenario(gameInfo.GameOptions.ScenarioFile) - - -- update AI rating as game settings change - for k = 1, 16 do - local playerOptions = gameInfo.PlayerOptions[k] - if playerOptions then - if not playerOptions.Human then - playerOptions.PL = ComputeAIRating(gameInfo.GameOptions, playerOptions.AILobbyProperties); - playerOptions.MEAN = playerOptions.PL - playerOptions.DEV = 0 - end - end - end - - if scenarioInfo and scenarioInfo.map and scenarioInfo.map ~= '' then - GUI.mapView:SetScenario(scenarioInfo) - ShowMapPositions(GUI.mapView, scenarioInfo) - ConfigureMapListeners(GUI.mapView, scenarioInfo) - - -- Briefing button takes priority over the patch notes if the map has a briefing - if scenarioInfo.hasBriefing then - GUI.briefingButton:Show() - GUI.patchnotesButton:Hide() - else - GUI.briefingButton:Hide() - GUI.patchnotesButton:Show() - end - - -- contains information that is available during blueprint loading - local preGameData = {} - - -- MAP ASSETS LOADING -- - - -- store the (selected) map directory so that we can load individual blueprints from it - preGameData.CurrentMapDir = Dirname(gameInfo.GameOptions.ScenarioFile) - - -- STRATEGIC ICON REPLACEMENT -- - - -- icon replacements - local iconReplacements = { } - - -- retrieve all (selected) mods - local allMods = Mods.AllMods() - local selectedMods = Mods.GetSelectedMods() - - -- loop over selected mods identifiers - for uid, _ in selectedMods do - - -- get the mod, determine path to icon configuration file - local mod = allMods[uid] - - -- check for mod integrity - if not (mod.name and mod.author) then - WARN("Unable to load icons from mod '" .. uid .. "', the mod_info.lua file is not properly defined. It needs a name and author field.") - end - - -- path to configuration file - local iconConfigurationPath = mod.location .. "/mod_icons.lua" - - -- see if it exists - if DiskGetFileInfo(iconConfigurationPath) then - - -- see if we can import it - local ok, msg = pcall( - function() - - -- attempt to load the file - local env = { } - doscript(iconConfigurationPath, env) - - -- syntax errors are caught internally and instead it just returns the table untouched - if not (env.UnitIconAssignments or env.ScriptedIconAssignments) then - error("Lobby.lua - can not import the icon configuration file at '" .. iconConfigurationPath .. "'. This could be due to missing functionality functionality or a parsing error.") - end - end - ) - - -- if it passes this basic check, then continue - if ok then - local info = { } - info.Name = mod.name - info.Author = mod.author - info.Location = mod.location - info.Identifier = string.lower(utils.StringSplit(mod.location, '/')[2]) - info.UID = uid - table.insert(iconReplacements, info) - -- tell us (and then spam the author, not the dev) if it failed - else - WARN("Unable to load icons from mod '" .. tostring(mod.name) .. "' with uid '" .. tostring(uid) .. "'. Please inform the author: " .. tostring(mod.author)) - WARN(msg) - end - end - end - - preGameData.IconReplacements = iconReplacements - - -- try and set the preferences - it may crash when running multiple instances on a single machine that all try and start at the same time. - local ok, msg = pcall( - function() - -- store in preferences so that we can retrieve it during blueprint loading - SetPreference('PreGameData', preGameData) - end - ) - - if not ok then - WARN("Unable to update preferences. Are you running multiple instances on the same machine?" ) - WARN(msg) - end - - -- PREFETCHING -- - - -- Note that the PreGameData is not properly updated, - -- hence we can not rely on mod and / or lobby option - -- changes to be present. - - -- local mods = Mods.GetGameMods(gameInfo.GameMods) - -- PrefetchSession(scenarioInfo.map, mods, true) - - else - AlertHostMapMissing() - GUI.mapView:Clear() - end - end - - local isHost = lobbyComm:IsHost() - - local localPlayerSlot = FindSlotForID(localPlayerID) - if localPlayerSlot then - local playerOptions = gameInfo.PlayerOptions[localPlayerSlot] - - -- Disable some controls if the user is ready. - local notReady = not playerOptions.Ready - - UIUtil.setEnabled(GUI.becomeObserver, notReady) - UIUtil.setEnabled(GUI.briefingButton, notReady) - -- This button is enabled for all non-host players to view the configuration, and for the - -- host to select presets (rather confusingly, one object represents both potential buttons) - UIUtil.setEnabled(GUI.restrictedUnitsOrPresetsBtn, not isHost or notReady) - - UIUtil.setEnabled(GUI.factionSelector, notReady) - if notReady then - UpdateFactionSelector() - end - else - UIUtil.setEnabled(GUI.factionSelector, false) - end - - gameInfo.AdaptiveMap = scenarioInfo.AdaptiveMap - - local numPlayers = GetPlayerCount() - - local numAvailStartSpots = LobbyComm.maxPlayerSlots - if scenarioInfo then - local armyTable = MapUtil.GetArmies(scenarioInfo) - if armyTable then - numAvailStartSpots = table.getn(armyTable) - end - end - - UpdateAvailableSlots(numAvailStartSpots, scenarioInfo) - - -- Update all slots. - for i = 1, LobbyComm.maxPlayerSlots do - if gameInfo.ClosedSlots[i] then - UpdateSlotBackground(i) - else - if gameInfo.PlayerOptions[i] then - SetSlotInfo(i, gameInfo.PlayerOptions[i]) - else - ClearSlotInfo(i) - end - end - end - - if isHost then - HostUtils.RefreshButtonEnabledness() - end - RefreshOptionDisplayData(scenarioInfo) - - -- Update the map background to reflect the possibly-changed map. - if Prefs.GetFromCurrentProfile('LobbyBackground') == 4 then - RefreshLobbyBackground() - end - - -- Set the map name at the top right corner in lobby - if scenarioInfo.name then - GUI.MapNameLabel:StreamText(scenarioInfo.name, 20) - end - - -- Add Tooltip info on Map Name Label - if scenarioInfo then - local TTips_map_version = scenarioInfo.map_version or "1" - local TTips_army = table.getsize(scenarioInfo.Configurations.standard.teams[1].armies) - local TTips_sizeX = scenarioInfo.size[1] / 51.2 - local TTips_sizeY = scenarioInfo.size[2] / 51.2 - - local mapTooltip = { - text = scenarioInfo.name, - body = '- '..LOC("Map version")..' : '..TTips_map_version..'\n '.. - '- '..LOC("Max Players")..' : '..TTips_army..' max'..'\n '.. - '- '..LOC("Map Size")..' : '..TTips_sizeX..'km x '..TTips_sizeY..'km' - } - - Tooltip.AddControlTooltip(GUI.MapNameLabel, mapTooltip) - Tooltip.AddControlTooltip(GUI.GameQualityLabel, mapTooltip) - end - - -- If the large map is shown, update it. - RefreshLargeMap() - - SetRuleTitleText(gameInfo.GameOptions.GameRules or "") - SetGameTitleText(gameInfo.GameOptions.Title or LOC("FAF Game Lobby")) - - if isHost and GUI.autoTeams then - GUI.autoTeams:SetState(gameInfo.GameOptions.AutoTeams,true) - Tooltip.DestroyMouseoverDisplay() - end -end - ---- Update the game quality display -function ShowGameQuality() - GUI.GameQualityLabel:SetText("") - - -- Can't compute a game quality for random spawns! - if gameInfo.GameOptions.TeamSpawn ~= 'fixed' then - return - end - - local teams = Teams.create() - - -- Everything catches fire if the teams aren't numbered sequentially from 1. - -- I hope it is not the case that everything catches fire when there are >2 teams, but in - -- principle that should work... - - -- Start by creating a map from each *used* team to an element from an ascending set of integers. - local tsTeam = 1 - local teamMap = {} - for i = 1, LobbyComm.maxPlayerSlots do - local playerOptions = gameInfo.PlayerOptions[i] - -- Team 1 represents "No team", so these people are all singleton teams. - if playerOptions and (teamMap[playerOptions.Team] == nil or playerOptions.Team == 1) then - teamMap[playerOptions.Team] = tsTeam - tsTeam = tsTeam + 1 - end - end - - -- Now we just use the map to relate real teams to trueSkill teams. - for i = 1, LobbyComm.maxPlayerSlots do - local playerOptions = gameInfo.PlayerOptions[i] - if playerOptions then - -- Can't do it for AI, either, not sensibly. - if not playerOptions.Human and (playerOptions.MEAN or 0) == 0 then - return - end - - local player = Player.create( - playerOptions.PlayerName, - Rating.create(playerOptions.MEAN, playerOptions.DEV) - ) - - teams:addPlayer(teamMap[playerOptions.Team], player) - end - end - - -- Rating only meaningful in games with 2 teams - if table.getsize(teams:getTeams()) ~= 2 then - return - end - - local quality = Trueskill.computeQuality(teams) - - if quality > 0 then - gameInfo.GameOptions.Quality = quality - GUI.GameQualityLabel:StreamText(LOCF("Game quality: %s%%", string.format("%.2f",quality)), 20) - end -end - --- Holds some utility functions to do with game option management. -local OptionUtils = { - -- Set all game options to their default values. - SetDefaults = function() - local options = {} - for index, option in teamOpts do - options[option.key] = option.values[option.default].key or option.values[option.default] - end - for index, option in globalOpts do - -- Exception to make AllowObservers work because the engine requires - -- the keys to be bool. Custom options should use 'True' or 'False' - if option.key == 'AllowObservers' then - options[option.key] = option.values[option.default].key - else - options[option.key] = option.values[option.default].key or option.values[option.default] - end - end - - for index, option in AIOpts do - options[option.key] = option.values[option.default].key or option.values[option.default] - end - - options.RestrictedCategories = {} - - SetGameOptions(options) - end -} - --- callback when Mod Manager dialog finishes (modlist==nil on cancel) --- FIXME: The mod manager should be given a list of game mods set by the host, which --- clients can look at but not changed, and which don't get saved in our local prefs. -function OnModsChanged(simMods, UIMods, ignoreRefresh) - -- We depend upon ModsManager to not allow the user to change mods they shouldn't be able to - selectedSimMods = simMods - selectedUIMods = UIMods - - Mods.SetSelectedMods(SetUtils.Union(selectedSimMods, selectedUIMods)) - if lobbyComm:IsHost() then - HostUtils.UpdateMods() - end - - if not ignoreRefresh then - -- reload AI types in case we have enable or disable an AI mod. - GetAITypes() - GUI.AIFillCombo:ClearItems() - GUI.AIFillCombo:AddItems(AIStrings) - GUI.AIFillCombo:SetTitleText(LOC('Choose AI for autofilling')) - UpdateGame() - end -end - -function GetAvailableColor() - for i = 1, LobbyComm.maxPlayerSlots do - if IsColorFree(gameColors.LobbyColorOrder[i]) then - return gameColors.LobbyColorOrder[i] - end - end - WARN('Error: No available colors found.') -end - ---- This function is retarded. --- Unfortunately, we're stuck with it. --- The game requires both ArmyColor and PlayerColor be set. We don't want to have to write two fields --- all the time, and the magic that makes PlayerData work precludes adding member functions to it. --- So, we have this. Tough shit. :P -function SetPlayerColor(playerData, newColor) - playerData.ArmyColor = newColor - playerData.PlayerColor = newColor -end - -function autoMap() - local randomAutoMap - if gameInfo.GameOptions['RandomMap'] == 'Official' then - randomAutoMap = import("/lua/ui/dialogs/mapselect.lua").randomAutoMap(true) - else - randomAutoMap = import("/lua/ui/dialogs/mapselect.lua").randomAutoMap(false) - end -end - -function ClientsMissingMap() - local ret = nil - - for index, player in gameInfo.PlayerOptions:pairs() do - if player.BadMap then - if not ret then ret = {} end - table.insert(ret, player.PlayerName) - end - end - - for index, observer in gameInfo.Observers:pairs() do - if observer.BadMap then - if not ret then ret = {} end - table.insert(ret, observer.PlayerName) - end - end - - return ret -end - -function ClearBadMapFlags() - for index, player in gameInfo.PlayerOptions:pairs() do - player.BadMap = false - end - - for index, observer in gameInfo.Observers:pairs() do - observer.BadMap = false - end -end - -function EnableSlot(slot) - GUI.slots[slot].team:Enable() - GUI.slots[slot].color:Enable() - GUI.slots[slot].faction:Enable() - GUI.slots[slot].ready:Enable() -end - -function DisableSlot(slot, exceptReady) - GUI.slots[slot].team:Disable() - GUI.slots[slot].color:Disable() - GUI.slots[slot].faction:Disable() - if not exceptReady then - GUI.slots[slot].ready:Disable() - end -end - --- Used for the quick-swap feature -local playersToSwap = false - --- set up player "slots" which is the line representing a player and player specific options -function CreateSlotsUI(makeLabel) - local Combo = import("/lua/ui/controls/combo.lua").Combo - local BitmapCombo = import("/lua/ui/controls/combo.lua").BitmapCombo - local StatusBar = import("/lua/maui/statusbar.lua").StatusBar - local ColumnLayout = import("/lua/ui/controls/columnlayout.lua").ColumnLayout - - -- The dimensions of the columns used for slot UI controls. - local COLUMN_POSITIONS = {1, 21, 47, 91, 133, 395, 465, 535, 605, 677, 749} - local COLUMN_WIDTHS = {20, 20, 45, 45, 257, 59, 59, 59, 62, 62, 51} - - local labelGroup = ColumnLayout(GUI.playerPanel, COLUMN_POSITIONS, COLUMN_WIDTHS) - - GUI.labelGroup = labelGroup - LayoutHelpers.SetDimensions(labelGroup, 791, 21) - LayoutHelpers.AtLeftTopIn(labelGroup, GUI.playerPanel, 5, 5) - - local slotLabel = makeLabel("--", 14) - labelGroup:AddChild(slotLabel) - - -- No label required for the second column (flag), so skip it. (Even eviler hack) - labelGroup.numChildren = labelGroup.numChildren + 1 - - local ratingLabel = makeLabel("R", 14) - labelGroup:AddChild(ratingLabel) - - local numGamesLabel = makeLabel("G", 14) - labelGroup:AddChild(numGamesLabel) - - local nameLabel = makeLabel(LOC("Nickname"), 14) - labelGroup:AddChild(nameLabel) - - local colorLabel = makeLabel(LOC("Color"), 14) - labelGroup:AddChild(colorLabel) - - local factionLabel = makeLabel(LOC("Faction"), 14) - labelGroup:AddChild(factionLabel) - - local teamLabel = makeLabel(LOC("Team"), 14) - labelGroup:AddChild(teamLabel) - - labelGroup:AddChild(makeLabel(LOC("CPU"), 14)) - - if not singlePlayer then - labelGroup:AddChild(makeLabel(LOC("Ping"), 14)) - labelGroup:AddChild(makeLabel(LOC("Ready"), 14)) - end - - for i= 1, LobbyComm.maxPlayerSlots do - -- Capture the index in the current closure so it's accessible on callbacks - local curRow = i - - -- The background is parented on the GUI so it doesn't vanish when we hide the slot. - local slotBackground = Bitmap(GUI, UIUtil.SkinnableFile("/SLOT/slot-dis.dds")) - - -- Inherit dimensions of the slot control from the background image. - local newSlot = ColumnLayout(GUI.playerPanel, COLUMN_POSITIONS, COLUMN_WIDTHS) - newSlot.Width:Set(slotBackground.Width) - newSlot.Height:Set(slotBackground.Height) - - LayoutHelpers.AtLeftTopIn(slotBackground, newSlot) - newSlot.SlotBackground = slotBackground - - -- Default mouse behaviours for the slot. - local defaultHandler = function(self, event) - if curRow > numOpenSlots then - return - end - - local associatedMarker = GUI.mapView.startPositions[curRow] - if event.Type == 'MouseEnter' then - if gameInfo.GameOptions['TeamSpawn'] == 'fixed' then - associatedMarker.indicator:Play() - end - elseif event.Type == 'MouseExit' then - associatedMarker.indicator:Stop() - elseif event.Type == 'ButtonDClick' then - DoSlotBehavior(curRow, 'occupy', '') - end - - return Group.HandleEvent(self, event) - end - newSlot.HandleEvent = defaultHandler - - -- Slot number - local slotNumber = UIUtil.CreateText(newSlot, tostring(i), 14, 'Arial') - newSlot.slotNumber = slotNumber - LayoutHelpers.SetWidth(slotNumber, COLUMN_WIDTHS[1]) - slotNumber.Height:Set(newSlot.Height) - newSlot:AddChild(slotNumber) - newSlot.tooltipnumber = Tooltip.AddControlTooltip(slotNumber, 'slot_number') - slotNumber.id = i - slotNumber.HandleEvent = function(self,event) - if lobbyComm:IsHost() then - if event.Type == 'ButtonPress' then - if playersToSwap then - --same number clicked - if self.id == playersToSwap then - playersToSwap = false - self:SetColor(UIUtil.fontColor) - elseif gameInfo.PlayerOptions[playersToSwap] then - HostUtils.SwapPlayers(playersToSwap, self.id) - GUI.slots[playersToSwap].slotNumber:SetColor(UIUtil.fontColor) - playersToSwap = false - elseif gameInfo.PlayerOptions[self.id] then - HostUtils.SwapPlayers(self.id, playersToSwap) - GUI.slots[playersToSwap].slotNumber:SetColor(UIUtil.fontColor) - playersToSwap = false - end - else - self:SetColor('ff00ffff') - playersToSwap = self.id - end - end - end - end - -- COUNTRY - -- Added a bitmap on the left of Rating, the bitmap is a Flag of Country - local flag = Bitmap(newSlot, UIUtil.SkinnableFile("/countries/world.dds")) - newSlot.KinderCountry = flag - LayoutHelpers.SetWidth(flag, COLUMN_WIDTHS[2]) - newSlot:AddChild(flag) - - -- TODO: Factorise this boilerplate. - -- Rating - local ratingText = UIUtil.CreateText(newSlot, "", 14, 'Arial') - newSlot.ratingText = ratingText - ratingText:SetColor('B9BFB9') - ratingText:SetDropShadow(true) - newSlot:AddChild(ratingText) - - -- NumGame - local numGamesText = UIUtil.CreateText(newSlot, "", 14, 'Arial') - newSlot.numGamesText = numGamesText - numGamesText:SetColor('B9BFB9') - numGamesText:SetDropShadow(true) - Tooltip.AddControlTooltip(numGamesText, 'num_games') - newSlot:AddChild(numGamesText) - - -- Name - local nameLabel = Combo(newSlot, 14, 16, true, nil, "UI_Tab_Rollover_01", "UI_Tab_Click_01") - newSlot.name = nameLabel - nameLabel._text:SetFont('Arial Gras', 15) - newSlot:AddChild(nameLabel) - LayoutHelpers.SetWidth(nameLabel, COLUMN_WIDTHS[5]) - -- left deal with name clicks - nameLabel.OnEvent = defaultHandler - nameLabel.OnClick = function(self, index, text) - DoSlotBehavior(curRow, self.slotKeys[index], text) - end - - -- Hide the marker when the dropdown is hidden - nameLabel.OnHide = function() - local associatedMarker = GUI.mapView.startPositions[curRow] - if associatedMarker then - associatedMarker.indicator:Stop() - end - end - - -- Color - local colorSelector = BitmapCombo(newSlot, gameColors.PlayerColors, 1, true, nil, "UI_Tab_Rollover_01", "UI_Tab_Click_01") - newSlot.color = colorSelector - - newSlot:AddChild(colorSelector) - LayoutHelpers.SetWidth(colorSelector, COLUMN_WIDTHS[6]) - colorSelector.OnClick = function(self, index) - if not lobbyComm:IsHost() then - lobbyComm:SendData(hostID, { Type = 'RequestColor', Color = index }) - SetPlayerColor(gameInfo.PlayerOptions[curRow], index) - UpdateGame() - else - if IsColorFree(index) then - lobbyComm:BroadcastData({ Type = 'SetColor', Color = index, Slot = curRow }) - SetPlayerColor(gameInfo.PlayerOptions[curRow], index) - UpdateGame() - else - self:SetItem(gameInfo.PlayerOptions[curRow].PlayerColor) - end - end - end - colorSelector.OnEvent = defaultHandler - Tooltip.AddControlTooltip(colorSelector, 'lob_color') - - -- Faction - -- builds the faction tables, and then adds random faction icon to the end - local factionBmps = {} - local factionTooltips = {} - local factionList = {} - for index, tbl in FactionData.Factions do - factionBmps[index] = tbl.SmallIcon - factionTooltips[index] = tbl.TooltipID - factionList[index] = tbl.Key - end - table.insert(factionBmps, "/faction_icon-sm/random_ico.dds") - table.insert(factionTooltips, 'lob_random') - table.insert(factionList, 'random') - allAvailableFactionsList = factionList - - local factionSelector = BitmapCombo(newSlot, factionBmps, table.getn(factionBmps), nil, nil, "UI_Tab_Rollover_01", "UI_Tab_Click_01") - newSlot.faction = factionSelector - newSlot.AvailableFactions = factionList - newSlot:AddChild(factionSelector) - LayoutHelpers.SetWidth(factionSelector, COLUMN_WIDTHS[7]) - factionSelector.OnClick = function(self, index) - SetPlayerOption(curRow, 'Faction', index) - if curRow == FindSlotForID(FindIDForName(localPlayerName)) then - local fact = GUI.slots[FindSlotForID(localPlayerID)].AvailableFactions[index] - for ind,value in allAvailableFactionsList do - if fact == value then - GUI.factionSelector:SetSelected(ind) - break - end - end - end - - Tooltip.DestroyMouseoverDisplay() - end - Tooltip.AddControlTooltip(factionSelector, 'lob_faction') - Tooltip.AddComboTooltip(factionSelector, factionTooltips) - factionSelector.OnEvent = defaultHandler - - -- Team - local teamSelector = Combo(newSlot, 17, 9, nil, nil, "UI_Tab_Rollover_01", "UI_Tab_Click_01") - teamSelector:AddItems({' - ', ' 1', ' 2', ' 3', ' 4', ' 5', ' 6', ' 7', ' 8'}) - teamSelector._text:SetFont('Arial', 14) - teamSelector._titleColor = 'White' - newSlot.team = teamSelector - newSlot:AddChild(teamSelector) - LayoutHelpers.SetWidth(teamSelector, COLUMN_WIDTHS[8]) - teamSelector.OnClick = function(self, index, text) - Tooltip.DestroyMouseoverDisplay() - SetPlayerOption(curRow, 'Team', index) - end - Tooltip.AddControlTooltip(teamSelector, 'lob_team') - teamSelector.OnEvent = defaultHandler - - -- CPU - local barMax = 450 - local barMin = 0 - local CPUGroup = Group(newSlot) - newSlot.CPUGroup = CPUGroup - LayoutHelpers.SetWidth(CPUGroup, COLUMN_WIDTHS[9]) - CPUGroup.Height:Set(newSlot.Height) - newSlot:AddChild(CPUGroup) - local CPUSpeedBar = StatusBar(CPUGroup, barMin, barMax, false, false, - UIUtil.UIFile('/game/unit_bmp/bar_black_bmp.dds'), - UIUtil.UIFile('/game/unit_bmp/bar_purple_bmp.dds'), - true) - newSlot.CPUSpeedBar = CPUSpeedBar - LayoutHelpers.AtTopIn(CPUSpeedBar, CPUGroup, 7) - LayoutHelpers.AtLeftIn(CPUSpeedBar, CPUGroup, 0) - LayoutHelpers.AtRightIn(CPUSpeedBar, CPUGroup, 0) - CPU_AddControlTooltip(CPUSpeedBar, 0, curRow) - CPUSpeedBar.CPUActualValue = 450 - CPUSpeedBar.barMax = barMax - - -- Ping - barMax = 1000 - barMin = 0 - local pingGroup = Group(newSlot) - newSlot.pingGroup = pingGroup - LayoutHelpers.SetWidth(pingGroup, COLUMN_WIDTHS[10]) - pingGroup.Height:Set(newSlot.Height) - newSlot:AddChild(pingGroup) - local pingStatus = StatusBar(pingGroup, barMin, barMax, false, false, - UIUtil.SkinnableFile('/game/unit_bmp/bar-back_bmp.dds'), - UIUtil.SkinnableFile('/game/unit_bmp/bar-01_bmp.dds'), - true) - newSlot.pingStatus = pingStatus - LayoutHelpers.AtTopIn(pingStatus, pingGroup, 7) - LayoutHelpers.AtLeftIn(pingStatus, pingGroup, 0) - LayoutHelpers.AtRightIn(pingStatus, pingGroup, 0) - Ping_AddControlTooltip(pingStatus, 0, curRow) - - -- Ready Checkbox - local readyBox = UIUtil.CreateCheckbox(newSlot, '/CHECKBOX/') - newSlot.ready = readyBox - newSlot:AddChild(readyBox) - readyBox.OnCheck = function(self, checked) - UIUtil.setEnabled(GUI.becomeObserver, not checked) - if checked then - DisableSlot(curRow, true) - else - EnableSlot(curRow) - end - SetPlayerOption(curRow, 'Ready', checked) - end - - newSlot.HideControls = function() - -- hide these to clear slot of visible data - flag:Hide() - ratingText:Hide() - numGamesText:Hide() - factionSelector:Hide() - colorSelector:Hide() - teamSelector:Hide() - CPUSpeedBar:Hide() - pingStatus:Hide() - readyBox:Hide() - end - newSlot.HideControls() - - if singlePlayer then - -- TODO: Use of groups may allow this to be simplified... - readyBox:Hide() - pingStatus:Hide() - end - - if i == 1 then - LayoutHelpers.Below(newSlot, GUI.labelGroup) - else - LayoutHelpers.Below(newSlot, GUI.slots[i - 1], 3) - end - - GUI.slots[i] = newSlot - end -end - --- create UI won't typically be called directly by another module -function CreateUI(maxPlayers) - local ResourceMapPreview = import("/lua/ui/controls/resmappreview.lua").ResourceMapPreview - local ItemList = import("/lua/maui/itemlist.lua").ItemList - local Prefs = import("/lua/user/prefs.lua") - local Tooltip = import("/lua/ui/game/tooltip.lua") - local Combo = import("/lua/ui/controls/combo.lua") - - local isHost = lobbyComm:IsHost() - local lastFaction = GetSanitisedLastFaction() - UIUtil.SetCurrentSkin(FACTION_NAMES[lastFaction]) - - --------------------------------------------------------------------------- - -- Set up main control panels - --------------------------------------------------------------------------- - GUI.panel = Bitmap(GUI, UIUtil.SkinnableFile("/scx_menu/lan-game-lobby/lobby.dds")) - LayoutHelpers.AtCenterIn(GUI.panel, GUI) - GUI.panelWideLeft = Bitmap(GUI, UIUtil.SkinnableFile('/scx_menu/lan-game-lobby/wide.dds')) - LayoutHelpers.CenteredLeftOf(GUI.panelWideLeft, GUI.panel) - GUI.panelWideLeft.Left:Set(function() return GUI.Left() end) - GUI.panelWideRight = Bitmap(GUI, UIUtil.SkinnableFile('/scx_menu/lan-game-lobby/wide.dds')) - LayoutHelpers.CenteredRightOf(GUI.panelWideRight, GUI.panel) - GUI.panelWideRight.Right:Set(function() return GUI.Right() end) - - -- Create a label with a given size and initial text - local function makeLabel(text, size) - return UIUtil.CreateText(GUI.panel, text, size, 'Arial Gras', true) - end - - -- Map name label - GUI.MapNameLabel = makeLabel(LOC("Loading..."), 17) - LayoutHelpers.AtRightTopIn(GUI.MapNameLabel, GUI.panel, 5, 45) - - -- Game Quality Label - GUI.GameQualityLabel = makeLabel("", 11) - LayoutHelpers.AtRightTopIn(GUI.GameQualityLabel, GUI.panel, 5, 64) - - -- Title Label - GUI.titleText = makeLabel(LOC("FAF Game Lobby"), 17) - LayoutHelpers.AtLeftTopIn(GUI.titleText, GUI.panel, 5, 20) - - if isHost then - GUI.titleText.HandleEvent = function(self, event) - if event.Type == 'ButtonPress' then - ShowTitleDialog() - end - end - end - - -- Rule Label - local RuleLabel = TextArea(GUI.panel, 350, 34) - GUI.RuleLabel = RuleLabel - RuleLabel:SetFont('Arial Gras', 11) - RuleLabel:SetColors("B9BFB9", "00000000", "B9BFB9", "00000000") - LayoutHelpers.AtLeftTopIn(RuleLabel, GUI.panel, 5, 44) - RuleLabel:DeleteAllItems() - local tmptext - if isHost then - tmptext = LOC("No Rules: Click to add rules") - RuleLabel:SetColors("FFCC00") - else - tmptext = LOC("No rules") - end - - RuleLabel:SetText(tmptext) - if isHost then - RuleLabel.OnClick = function(self) - ShowRuleDialog() - end - end - - -- Mod Label - GUI.ModFeaturedLabel = makeLabel("", 13) - LayoutHelpers.AtLeftTopIn(GUI.ModFeaturedLabel, GUI.panel, 50, 61) - - -- Set the mod name to a value appropriate for the mod in use. - local modLabels = { - ["init_faf.lua"] = "FA Forever", - ["init_blackops.lua"] = "BlackOps", - ["init_coop.lua"] = "COOP", - ["init_balancetesting.lua"] = "Balance Testing", - ["init_gw.lua"] = "Galactic War", - ["init_labwars.lua"] = "Labwars", - ["init_ladder1v1.lua"] = "Ladder 1v1", - ["init_nomads.lua"] = "Nomads Mod", - ["init_phantomx.lua"] = "PhantomX", - ["init_supremedestruction.lua"] = "SupremeDestruction", - ["init_xtremewars.lua"] = "XtremeWars", - - } - GUI.ModFeaturedLabel:StreamText(modLabels[argv.initName] or "", 20) - - -- Lobby options panel - GUI.LobbyOptions = UIUtil.CreateButtonWithDropshadow(GUI.panel, '/BUTTON/medium/', LOC("Settings")) - LayoutHelpers.AtRightTopIn(GUI.LobbyOptions, GUI.panel, 44, 3) - GUI.LobbyOptions.OnClick = function() - ShowLobbyOptionsDialog() - end - Tooltip.AddButtonTooltip(GUI.LobbyOptions, 'lobby_click_Settings') - - -- Logo - GUI.logo = Bitmap(GUI, '/textures/ui/common/scx_menu/lan-game-lobby/logo.dds') - LayoutHelpers.AtLeftTopIn(GUI.logo, GUI, 1, 1) - - local version, gametype, commit = import("/lua/version.lua").GetVersionData() - GUI.gameVersionText = UIUtil.CreateText(GUI.panel, LOC('Game version ') .. version, 9, UIUtil.bodyFont) - GUI.gameVersionText:SetColor('677983') - GUI.gameVersionText:SetDropShadow(true) - - Tooltip.AddControlTooltipManual(GUI.gameVersionText, 'Version control', string.format( - LOC('Game version: %s\nGame type: %s\nCommit hash: %s'), version, gametype, commit:sub(1, 8) - )) - - LayoutHelpers.AtLeftTopIn(GUI.gameVersionText, GUI.panel, 70, 3) - - -- Player Slots - GUI.playerPanel = Group(GUI.panel, "playerPanel") - LayoutHelpers.AtLeftTopIn(GUI.playerPanel, GUI.panel, 6, 70) - LayoutHelpers.SetDimensions(GUI.playerPanel, 706, 307) - - -- Observer section - GUI.observerPanel = Group(GUI.panel, "observerPanel") - - -- Scale the observer panel according to the buttons we are showing. - local obsOffset - local obsHeight - if isHost then - obsHeight = 84--159 - obsOffset = 620--545 - else - obsHeight = 206 - obsOffset = 498 - end - LayoutHelpers.AtLeftTopIn(GUI.observerPanel, GUI.panel, 512, obsOffset) - LayoutHelpers.SetDimensions(GUI.observerPanel, 278, obsHeight) - UIUtil.SurroundWithBorder(GUI.observerPanel, '/scx_menu/lan-game-lobby/frame/') - - -- Chat - GUI.chatPanel = Group(GUI.panel, "chatPanel") - LayoutHelpers.AtLeftTopIn(GUI.chatPanel, GUI.panel, 11, 459) - LayoutHelpers.SetWidth(GUI.chatPanel, 478) - LayoutHelpers.SetHeight(GUI.chatPanel, 245) - UIUtil.SurroundWithBorder(GUI.chatPanel, '/scx_menu/lan-game-lobby/frame/') - - if isHost then - GUI.AIFillPanel = Group(GUI.panel) - GUI.AIFillPanel.Left:Set(GUI.observerPanel.Left) - GUI.AIFillPanel.Top:Set(GUI.chatPanel.Top) - LayoutHelpers.SetHeight(GUI.AIFillPanel, 60) - LayoutHelpers.SetWidth(GUI.AIFillPanel, 278) - UIUtil.SurroundWithBorder(GUI.AIFillPanel, '/scx_menu/lan-game-lobby/frame/') - GUI.AIFillCombo = Combo.Combo(GUI.AIFillPanel, 14, 12, false, nil) - LayoutHelpers.AtHorizontalCenterIn(GUI.AIFillCombo, GUI.AIFillPanel) - LayoutHelpers.AtTopIn(GUI.AIFillCombo, GUI.AIFillPanel, 5) - GUI.AIFillCombo.Width:Set(function() return GUI.AIFillPanel.Width() - LayoutHelpers.ScaleNumber(15) end) - GUI.AIFillCombo:AddItems(AIStrings) - GUI.AIFillCombo:SetTitleText(LOC('Choose AI for autofilling')) - Tooltip.AddComboTooltip(GUI.AIFillCombo, AITooltips) - GUI.AIFillButton = UIUtil.CreateButtonStd(GUI.AIFillCombo, '/BUTTON/medium/', LOC('Fill Slots'), 12) - LayoutHelpers.SetWidth(GUI.AIFillButton, 129) - LayoutHelpers.SetHeight(GUI.AIFillButton, 30) - LayoutHelpers.AtLeftTopIn(GUI.AIFillButton, GUI.AIFillCombo, -10, 20) - GUI.AIClearButton = UIUtil.CreateButtonStd(GUI.AIFillButton, '/BUTTON/medium/', LOC('Clear Slots'), 12) - GUI.AIClearButton.Width:Set(GUI.AIFillButton.Width) - GUI.AIClearButton.Height:Set(GUI.AIFillButton.Height) - LayoutHelpers.RightOf(GUI.AIClearButton, GUI.AIFillButton, -19) - GUI.TeamCountSelector = Combo.BitmapCombo(GUI.AIClearButton, teamIcons, 1, false, nil, "UI_Tab_Rollover_01", "UI_Tab_Click_01") - LayoutHelpers.SetWidth(GUI.TeamCountSelector, 44) - LayoutHelpers.AtTopIn(GUI.TeamCountSelector, GUI.AIClearButton, 5) - LayoutHelpers.AtRightIn(GUI.TeamCountSelector, GUI.AIFillPanel, 8) - local tooltipText = {} - tooltipText['text'] = 'Teams Count' - tooltipText['body'] = 'On how many teams share players?' - Tooltip.AddControlTooltip(GUI.TeamCountSelector, tooltipText, 0) - local ChangedSlots = {} - GUI.AIFillButton.OnClick = function() - local AIKeyIndex, AIName = GUI.AIFillCombo:GetItem() - if ChangedSlots[1] ~= nil then - for i = 1, table.getn(ChangedSlots) do - HostUtils.AddAI(AIName, AIKeys[AIKeyIndex], ChangedSlots[i]) - end - else - for Slot = 1, GetNumAvailStartSpots() do - if not (gameInfo.PlayerOptions[Slot] or gameInfo.ClosedSlots[Slot]) then - HostUtils.AddAI(AIName, AIKeys[AIKeyIndex], Slot) - table.insert(ChangedSlots, Slot) - end - end - end - if gameInfo.GameOptions.AutoTeams == 'none' then - GUI.TeamCountSelector.OnClick(nil, GUI.TeamCountSelector:GetItem(), nil) - else - AssignAutoTeams() - end - end - GUI.AIClearButton.OnClick = function() - for i = 1, table.getn(ChangedSlots) do - HostUtils.RemoveAI(ChangedSlots[i]) - end - ChangedSlots = {} - end - GUI.TeamCountSelector.OnClick = function(Self, Index, Text) - local OccupiedSlots = 0 - local AvailStartSpots = GetNumAvailStartSpots() - for Slot = 1, AvailStartSpots do - if gameInfo.PlayerOptions[Slot] ~= nil then - OccupiedSlots = OccupiedSlots + 1 - end - end - local PlayersPerTeam = 0 - if Index > 1 then - PlayersPerTeam = math.floor(OccupiedSlots / (Index - 1)) - end - local AssignedTeam = 2 - local Counter = 0 - for Slot = 1, AvailStartSpots do - if gameInfo.PlayerOptions[Slot] then - if AssignedTeam > Index then - SetPlayerOption(Slot, 'Team', 1, true) - else - SetPlayerOption(Slot, 'Team', AssignedTeam, true) - Counter = Counter + 1 - if Counter >= PlayersPerTeam then - AssignedTeam = AssignedTeam + 1 - Counter = 0 - end - end - SetSlotInfo(Slot, gameInfo.PlayerOptions[Slot]) - end - end - end - end - - -- Map Preview - GUI.mapPanel = Group(GUI.panel, "mapPanel") - LayoutHelpers.AtLeftTopIn(GUI.mapPanel, GUI.panel, 813, 88) - LayoutHelpers.SetDimensions(GUI.mapPanel, 198, 198) - LayoutHelpers.DepthOverParent(GUI.mapPanel, GUI.panel, 2) - UIUtil.SurroundWithBorder(GUI.mapPanel, '/scx_menu/lan-game-lobby/frame/') - - -- Map Preview Info Labels - local tooltipText = {} - tooltipText['text'] = LOC("Map Preview") - -- Map Preview Info Labels - if isHost then - tooltipText['body'] = LOCF("%s\n%s", "Left click ACU icon to move yourself or swap players.", "Right click ACU icon to close or open the slot.") - else - tooltipText['body'] = LOC("Left click ACU icon to move yourself.") - end - Tooltip.AddControlTooltip(GUI.mapPanel, tooltipText) - - GUI.optionsPanel = Group(GUI.panel, "optionsPanel") -- ORANGE Square in Screenshoot - LayoutHelpers.AtLeftTopIn(GUI.optionsPanel, GUI.panel, 813, 325) - LayoutHelpers.SetDimensions(GUI.optionsPanel, 198, 337) - LayoutHelpers.DepthOverParent(GUI.optionsPanel, GUI.panel, 2) - UIUtil.SurroundWithBorder(GUI.optionsPanel, '/scx_menu/lan-game-lobby/frame/') - - --------------------------------------------------------------------------- - -- set up map panel - --------------------------------------------------------------------------- - GUI.mapView = ResourceMapPreview(GUI.mapPanel, 200, 3, 5) - LayoutHelpers.AtLeftTopIn(GUI.mapView, GUI.mapPanel, -1, -1) - LayoutHelpers.DepthOverParent(GUI.mapView, GUI.mapPanel, -1) - - GUI.LargeMapPreview = UIUtil.CreateButtonWithDropshadow(GUI.mapPanel, '/BUTTON/zoom/', "") - LayoutHelpers.SetDimensions(GUI.LargeMapPreview, 30, 30) - LayoutHelpers.AtRightIn(GUI.LargeMapPreview, GUI.mapPanel, -1) - LayoutHelpers.AtBottomIn(GUI.LargeMapPreview, GUI.mapPanel, -1) - LayoutHelpers.DepthOverParent(GUI.LargeMapPreview, GUI.mapPanel, 2) - Tooltip.AddButtonTooltip(GUI.LargeMapPreview, 'lob_click_LargeMapPreview') - GUI.LargeMapPreview.OnClick = function() - CreateBigPreview(GUI) - end - - -- Checkbox Show changed Options - local cbox_ShowChangedOption = UIUtil.CreateCheckbox(GUI.optionsPanel, '/CHECKBOX/', LOC("Hide default options"), true, 11) - LayoutHelpers.AtLeftTopIn(cbox_ShowChangedOption, GUI.optionsPanel, 0, -32) - - Tooltip.AddCheckboxTooltip(cbox_ShowChangedOption, {text=LOC("Hide default options"), body=LOC("Show only changed Options and Advanced Map Options")}) - cbox_ShowChangedOption.OnCheck = function(self, checked) - HideDefaultOptions = checked - RefreshOptionDisplayData() - GUI.OptionContainer:ScrollSetTop('Vert', 0) - Prefs.SetToCurrentProfile('LobbyHideDefaultOptions', tostring(checked)) - end - - -- Patchnotes Button - GUI.patchnotesButton = UIUtil.CreateButtonWithDropshadow(GUI.panel, '/Button/medium/', "Patchnotes") - Tooltip.AddButtonTooltip(GUI.patchnotesButton, 'Lobby_patchnotes') - LayoutHelpers.AtBottomIn(GUI.patchnotesButton, GUI.optionsPanel, -51) - LayoutHelpers.AtHorizontalCenterIn(GUI.patchnotesButton, GUI.optionsPanel, -55) - GUI.patchnotesButton.OnClick = function(self, event) - import("/lua/ui/lobby/changelog/changelogdialog.lua").CreateChangelogDialog(GUI) - end - - -- Create mission briefing button - local briefingButton = UIUtil.CreateButtonWithDropshadow(GUI.optionsPanel, '/BUTTON/medium/', "Briefing") - GUI.briefingButton = briefingButton - LayoutHelpers.AtBottomIn(GUI.briefingButton, GUI.optionsPanel, -51) - LayoutHelpers.AtHorizontalCenterIn(GUI.briefingButton, GUI.optionsPanel, -55) - briefingButton.OnClick = function(self, modifiers) - GUI.briefing = Group(GUI) - GUI.briefing.Depth:Set(function() return GUI.Depth() + 20 end) - LayoutHelpers.FillParent(GUI.briefing, GUI) - import('/lua/ui/campaign/operationbriefing.lua').CreateUI(GUI.briefing, gameInfo.GameOptions.ScenarioFile) - end - - -- A buton that, for the host, is "game options", but for everyone else shows a ready-only mod - -- manager. - if isHost then - GUI.gameoptionsButton = UIUtil.CreateButtonWithDropshadow(GUI.optionsPanel, '/BUTTON/medium/', "") - Tooltip.AddButtonTooltip(GUI.gameoptionsButton, 'lob_select_map') - GUI.gameoptionsButton.OnClick = function(self) - local mapSelectDialog - - autoRandMap = false - local function selectBehavior(selectedScenario, changedOptions, restrictedCategories) - local options = {} - if autoRandMap then - options['ScenarioFile'] = selectedScenario.file - else - mapSelectDialog:Destroy() - GUI.chatEdit:AcquireFocus() - - -- remove old 'Advanced options incase of new map - if gameInfo.GameOptions.ScenarioFile and string.lower(selectedScenario.file) ~= string.lower(gameInfo.GameOptions.ScenarioFile) then - local scenario = MapUtil.LoadScenario(gameInfo.GameOptions.ScenarioFile) - if scenario.options then - for _,value in scenario.options do - gameInfo.GameOptions[value.key] = nil - end - end - end - - for optionKey, data in changedOptions do - options[optionKey] = data.value - end - options['ScenarioFile'] = selectedScenario.file - options['RestrictedCategories'] = restrictedCategories - - -- every new map, clear the flags, and clients will report if a new map is bad - ClearBadMapFlags() - HostUtils.UpdateMods() - SetGameOptions(options) - end - for optionKey, data in changedOptions do - if optionKey == 'AutoTeams' then - AssignAutoTeams() - end - end - end - - local function exitBehavior() - mapSelectDialog:Close() - GUI.chatEdit:AcquireFocus() - UpdateGame() - end - - GUI.chatEdit:AbandonFocus() - - mapSelectDialog = import("/lua/ui/dialogs/mapselect.lua").CreateDialog( - selectBehavior, - exitBehavior, - GUI, - singlePlayer, - gameInfo.GameOptions.ScenarioFile, - gameInfo.GameOptions, - availableMods, - OnModsChanged - ) - end - else - local modsManagerCallback = function(active_sim_mods, active_ui_mods) - import("/lua/mods.lua").SetSelectedMods(SetUtils.Union(active_sim_mods, active_ui_mods)) - RefreshOptionDisplayData() - GUI.chatEdit:AcquireFocus() - end - GUI.gameoptionsButton = UIUtil.CreateButtonWithDropshadow(GUI.optionsPanel, '/BUTTON/medium/', LOC("")) - GUI.gameoptionsButton.OnClick = function(self, modifiers) - import("/lua/ui/lobby/modsmanager.lua").CreateDialog(GUI, false, nil, modsManagerCallback) - end - Tooltip.AddButtonTooltip(GUI.gameoptionsButton, 'Lobby_Mods') - end - - LayoutHelpers.AtBottomIn(GUI.gameoptionsButton, GUI.optionsPanel, -51) - LayoutHelpers.AtHorizontalCenterIn(GUI.gameoptionsButton, GUI.optionsPanel, 53) - - --------------------------------------------------------------------------- - -- set up chat display - --------------------------------------------------------------------------- - - GUI.chatDisplay = import("/lua/ui/lobby/chatarea.lua").ChatArea( - GUI.chatPanel, - function() return GUI.chatPanel.Width() - 20 end, - function() return GUI.chatPanel.Height() - GUI.chatBG.Height() end - ) - LayoutHelpers.AtLeftTopIn(GUI.chatDisplay, GUI.chatPanel, 2) - LayoutHelpers.DepthUnderParent(GUI.chatDisplay, GUI.chatPanel) - - --------------------------------------------------------------------------- - -- set up all .*Scroll* functions for the chat panel - --------------------------------------------------------------------------- - GUI.chatPanel.top = 1 -- using 1-based index scrolling - - -- this function get index of 1st line on the last scroll page (when scroll all the way down) - GUI.chatPanel.GetScrollLastPage = function(self) - return table.getn(GUI.chatDisplay.ChatLines) - self.linesPerScrollPage - end - -- this function gets scrolling max range and current range - GUI.chatPanel.GetScrollValues = function(self, axis) - local max = table.getsize(GUI.chatDisplay.ChatLines) - local bottom = math.min(self.top + self.linesPerScrollPage, max) - return 1, max, self.top, bottom - end - -- this function controls how many lines to scroll when clicking on up/down arrows of the scrollbar - GUI.chatPanel.ScrollLines = function(self, axis, delta) - self:ScrollSetTop(axis, self.top + math.floor(delta)) - end - -- this function controls how many pages to scroll when clicking above/below thumb of the scrollbar - GUI.chatPanel.ScrollPages = function(self, axis, delta) - self:ScrollSetTop(axis, self.top + math.floor(delta) * self.linesPerScrollPage) - end - -- this function controls how to scroll to an item from top index - GUI.chatPanel.ScrollSetTop = function(self, axis, top) - top = math.floor(top) - if top == self.top then return end - local delta = self:GetScrollLastPage() - self.top = math.max(math.min(delta + 1, top), 1) - self.bottom = self.top + self.linesPerScrollPage - GUI.chatDisplay:ShowLines(self.top, self.bottom) - if self.top >= delta + 1 then - GUI.newMessageArrow:Disable() - end - end - -- this function triggers scrolling on mouse wheel event - GUI.chatPanel.HandleEvent = function(self, event) - if event.Type == 'WheelRotation' then - -- scroll chat panel by 1 line in up/down direction - local lines = event.WheelRotation > 0 and -1 or 1 - self:ScrollLines(nil, lines) - end - end - -- this function informs vertical scrollbar that the chat panel can be scrolled - GUI.chatPanel.IsScrollable = function(self, axis) - return true - end - GUI.chatPanel.ScrollToBottom = function(self) - self:ScrollSetTop(nil, self:GetScrollLastPage() + 1) - end - GUI.chatPanel.IsScrolledToBottom = function(self) - return self.top >= self:GetScrollLastPage() - end - -- this function set how many chat lines can fit per scroll page (chatPanel) - GUI.chatPanel.LinesOnPage = import("/lua/lazyvar.lua").Create() - GUI.chatPanel.LinesOnPage.OnDirty = function(var) - GUI.chatPanel.linesPerScrollPage = var() - end - -- --------- Chat Scrolling Functions ----------------------- - - -- this function sets font for all chat lines and re-creates them - GUI.chatPanel.SetFont = function(self, fontFamily, fontSize) - GUI.chatDisplay:SetFont(fontFamily, fontSize) - GUI.chatDisplay:ShowLines(self.top, self.bottom) - end - -- set initial scrolling based on chat font size - local fontSize = tonumber(Prefs.GetFromCurrentProfile('LobbyChatFontSize')) or 14 - - local newMessageArrow = Button(GUI.chatPanel, '/textures/ui/common/lobby/chat_arrow/arrow_up.dds', '/textures/ui/common/lobby/chat_arrow/arrow_down.dds', '/textures/ui/common/lobby/chat_arrow/arrow_down.dds','/textures/ui/common/lobby/chat_arrow/arrow_dis.dds', "UI_Arrow_Click") - GUI.newMessageArrow = newMessageArrow - -- newMessageArrow:SetTexture('/textures/ui/common/FACTIONSELECTOR/aeon/d_up.dds') - LayoutHelpers.AtBottomIn(newMessageArrow, GUI.chatDisplay, 5) - LayoutHelpers.AtRightIn(newMessageArrow, GUI.chatDisplay, 5) - LayoutHelpers.DepthOverParent(newMessageArrow, GUI.chatDisplay, 5) - newMessageArrow.Width:Set(25) - newMessageArrow.Height:Set(25) - GUI.newMessageArrow.OnClick = function(this, modifiers) - GUI.chatPanel:ScrollToBottom() - end - GUI.newMessageArrow:Disable() - - -- Annoying evil extra Bitmap to make chat box have padding inside its background. - local chatBG = Bitmap(GUI.chatPanel) - GUI.chatBG = chatBG - chatBG:SetSolidColor('FF212123') - LayoutHelpers.Below(chatBG, GUI.chatDisplay, 0) - LayoutHelpers.AtLeftIn(chatBG, GUI.chatDisplay, -2) - chatBG.Width:Set(GUI.chatPanel.Width) - LayoutHelpers.SetHeight(chatBG, 24) - - -- Set up the chat edit buttons and functions - setupChatEdit(GUI.chatPanel) - -- finally create chat lines - GUI.chatDisplay:CreateLines() - --------------------------------------------------------------------------- - -- Option display - --------------------------------------------------------------------------- - GUI.OptionContainer = Group(GUI.optionsPanel) - GUI.OptionContainer.Bottom:Set(function() return GUI.optionsPanel.Bottom() end) - - -- Leave space for the scrollbar. - GUI.OptionContainer.Width:Set(function() return GUI.optionsPanel.Width() - LayoutHelpers.ScaleNumber(18) end) - GUI.OptionContainer.top = 0 - LayoutHelpers.AtLeftTopIn(GUI.OptionContainer, GUI.optionsPanel, 1, 1) - LayoutHelpers.DepthOverParent(GUI.OptionContainer, GUI.optionsPanel, -1) - - GUI.OptionDisplay = {} - - function CreateOptionElements() - local function CreateElement(index) - local element = Group(GUI.OptionContainer) - - element.bg = Bitmap(element) - element.bg:SetSolidColor('ff333333') - element.bg.Left:Set(element.Left) - element.bg.Right:Set(element.Right) - element.bg.Bottom:Set(function() return element.value.Bottom() + 2 end) - element.bg.Top:Set(element.Top) - - element.bg2 = Bitmap(element) - element.bg2:SetSolidColor('ff000000') - element.bg2.Left:Set(function() return element.bg.Left() + 1 end) - element.bg2.Right:Set(function() return element.bg.Right() - 1 end) - element.bg2.Bottom:Set(function() return element.bg.Bottom() - 1 end) - element.bg2.Top:Set(function() return element.value.Top() + 0 end) - - LayoutHelpers.SetHeight(element, 36) - element.Width:Set(GUI.OptionContainer.Width) - element:DisableHitTest() - - element.text = UIUtil.CreateText(element, '', 14, "Arial") - element.text:SetColor(UIUtil.fontColor) - element.text:DisableHitTest() - LayoutHelpers.AtLeftTopIn(element.text, element, 5) - - element.value = UIUtil.CreateText(element, '', 14, "Arial") - element.value:SetColor(UIUtil.fontOverColor) - element.value:DisableHitTest() - LayoutHelpers.AtRightTopIn(element.value, element, 5, 16) - - GUI.OptionDisplay[index] = element - end - - CreateElement(1) - LayoutHelpers.AtLeftTopIn(GUI.OptionDisplay[1], GUI.OptionContainer) - - local index = 2 - while index ~= 10 do - CreateElement(index) - LayoutHelpers.Below(GUI.OptionDisplay[index], GUI.OptionDisplay[index-1]) - index = index + 1 - end - end - CreateOptionElements() - - local numLines = function() return table.getsize(GUI.OptionDisplay) end - - local function DataSize() - if HideDefaultOptions then - return table.getn(nonDefaultFormattedOptions) - else - return table.getn(formattedOptions) - end - end - - -- called when the scrollbar for the control requires data to size itself - -- GetScrollValues must return 4 values in this order: - -- rangeMin, rangeMax, visibleMin, visibleMax - -- aixs can be "Vert" or "Horz" - GUI.OptionContainer.GetScrollValues = function(self, axis) - local size = DataSize() - --LOG(size, ":", self.top, ":", math.min(self.top + numLines, size)) - return 0, size, self.top, math.min(self.top + numLines(), size) - end - - -- called when the scrollbar wants to scroll a specific number of lines (negative indicates scroll up) - GUI.OptionContainer.ScrollLines = function(self, axis, delta) - self:ScrollSetTop(axis, self.top + math.floor(delta)) - end - - -- called when the scrollbar wants to scroll a specific number of pages (negative indicates scroll up) - GUI.OptionContainer.ScrollPages = function(self, axis, delta) - self:ScrollSetTop(axis, self.top + math.floor(delta) * numLines()) - end - - -- called when the scrollbar wants to set a new visible top line - GUI.OptionContainer.ScrollSetTop = function(self, axis, top) - top = math.floor(top) - if top == self.top then return end - local size = DataSize() - self.top = math.max(math.min(size - numLines() , top), 0) - self:CalcVisible() - end - - -- called to determine if the control is scrollable on a particular access. Must return true or false. - GUI.OptionContainer.IsScrollable = function(self, axis) - return true - end - -- determines what controls should be visible or not - GUI.OptionContainer.CalcVisible = function(self) - local function SetTextLine(line, data, lineID) - if data.mod then - -- The special label at the top stating the number of mods. - line.text:SetColor('ffff7777') - LayoutHelpers.AtHorizontalCenterIn(line.text, line, 5) - LayoutHelpers.AtHorizontalCenterIn(line.value, line, 5, 16) - LayoutHelpers.ResetRight(line.value) - else - -- Game options. - line.text:SetColor(UIUtil.fontColor) - LayoutHelpers.AtLeftTopIn(line.text, line, 5) - LayoutHelpers.AtRightTopIn(line.value, line, 5, 16) - LayoutHelpers.ResetLeft(line.value) - end - line.text:SetText(LOCF(data.text, data.key)) - line.bg:Show() - line.value:SetText(LOCF(data.value, data.key)) - line.bg2:Show() - line.bg.HandleEvent = Group.HandleEvent - line.bg2.HandleEvent = Bitmap.HandleEvent - if data.tooltip then - Tooltip.AddControlTooltip(line.bg, data.tooltip) - Tooltip.AddControlTooltip(line.bg2, data.valueTooltip) - end - - if data.manualTooltipTitle then - Tooltip.AddControlTooltipManual(line.bg, data.manualTooltipTitle, data.manualTooltipDescription ) - Tooltip.AddControlTooltipManual(line.bg2, data.manualTooltipTitle, data.manualTooltipDescription ) - end - - end - - local optionsToUse - if HideDefaultOptions then - optionsToUse = nonDefaultFormattedOptions - else - optionsToUse = formattedOptions - end - - for i, v in GUI.OptionDisplay do - if optionsToUse[i + self.top] then - SetTextLine(v, optionsToUse[i + self.top], i + self.top) - else - v.text:SetText('') - v.value:SetText('') - v.bg:Hide() - v.bg2:Hide() - end - end - end - - GUI.OptionContainer.HandleEvent = function(self, event) - if event.Type == 'WheelRotation' then - local lines = 1 - if event.WheelRotation > 0 then - lines = -1 - end - self:ScrollLines(nil, lines) - end - end - - RefreshOptionDisplayData() - - GUI.OptionContainerScroll = UIUtil.CreateLobbyVertScrollbar(GUI.OptionContainer, 2) - LayoutHelpers.DepthOverParent(GUI.OptionContainerScroll, GUI.OptionContainer, 2) - - -- Launch Button - local launchGameButton = UIUtil.CreateButtonWithDropshadow(GUI.chatPanel, '/BUTTON/large/', LOC("Launch Game")) - GUI.launchGameButton = launchGameButton - LayoutHelpers.AtHorizontalCenterIn(launchGameButton, GUI) - LayoutHelpers.AtBottomIn(launchGameButton, GUI.panel, -8) - Tooltip.AddButtonTooltip(launchGameButton, 'Lobby_Launch') - UIUtil.setVisible(launchGameButton, isHost) - launchGameButton.OnClick = function(self) - TryLaunch(false) - end - - -- Create skirmish mode's "load game" button. - local loadButton = UIUtil.CreateButtonWithDropshadow(GUI.optionsPanel, '/BUTTON/medium/',"Load") - GUI.loadButton = loadButton - UIUtil.setVisible(loadButton, singlePlayer) - LayoutHelpers.AtVerticalCenterIn(GUI.loadButton, launchGameButton, 7) - LayoutHelpers.AtHorizontalCenterIn(GUI.loadButton, GUI.optionsPanel) - loadButton.OnClick = function(self, modifiers) - import("/lua/ui/dialogs/saveload.lua").CreateLoadDialog(GUI) - end - Tooltip.AddButtonTooltip(loadButton, 'Lobby_Load') - - -- Create the "Lobby presets" button for the host. If not the host, the same field is occupied - -- instead by the read-only "Unit Manager" button. - GUI.restrictedUnitsOrPresetsBtn = UIUtil.CreateButtonWithDropshadow(GUI.optionsPanel, '/BUTTON/medium/', "") - - if singlePlayer then - GUI.restrictedUnitsOrPresetsBtn:Hide() - elseif isHost then - GUI.restrictedUnitsOrPresetsBtn.label:SetText(LOC("Presets")) - GUI.restrictedUnitsOrPresetsBtn.OnClick = function(self, modifiers) - Presets.CreateUI(GUI) - end - Tooltip.AddButtonTooltip(GUI.restrictedUnitsOrPresetsBtn, 'Lobby_presetDescription') - else - GUI.restrictedUnitsOrPresetsBtn.label:SetText(LOC("Unit Manager")) - GUI.restrictedUnitsOrPresetsBtn.OnClick = function(self, modifiers) - import("/lua/ui/lobby/unitsmanager.lua").CreateDialog(GUI.panel, gameInfo.GameOptions.RestrictedCategories, function() end, function() end, false) - end - Tooltip.AddButtonTooltip(GUI.restrictedUnitsOrPresetsBtn, 'lob_RestrictedUnitsClient') - end - LayoutHelpers.AtVerticalCenterIn(GUI.restrictedUnitsOrPresetsBtn, launchGameButton, 7) - LayoutHelpers.AtHorizontalCenterIn(GUI.restrictedUnitsOrPresetsBtn, GUI.optionsPanel) - - --------------------------------------------------------------------------- - -- Checkbox Show changed Options - --------------------------------------------------------------------------- - cbox_ShowChangedOption:SetCheck(HideDefaultOptions, false) - - --------------------------------------------------------------------------- - -- set up : player grid - --------------------------------------------------------------------------- - - -- For disgusting reasons, we pass the label factory as a parameter. - CreateSlotsUI(makeLabel) - - -- Exit Button - GUI.exitButton = UIUtil.CreateButtonWithDropshadow(GUI.chatPanel, '/BUTTON/medium/', LOC("Exit")) - LayoutHelpers.AtLeftIn(GUI.exitButton, GUI.chatPanel, 33) - LayoutHelpers.AtVerticalCenterIn(GUI.exitButton, launchGameButton, 7) - if HasCommandLineArg("/gpgnet") then - -- Quit to desktop - GUI.exitButton.label:SetText(LOC("")) - Tooltip.AddButtonTooltip(GUI.exitButton, 'esc_exit') - else - -- Back to main menu - GUI.exitButton.label:SetText(LOC("")) - Tooltip.AddButtonTooltip(GUI.exitButton, 'esc_quit') - end - - GUI.exitButton.OnClick = GUI.exitLobbyEscapeHandler - - - -- Small buttons are 100 wide, 44 tall - - -- Default option button - GUI.defaultOptions = UIUtil.CreateButtonStd(GUI.observerPanel, '/BUTTON/defaultoption/') - -- If we're the host, position the buttons lower down (and eventually shrink the observer panel) - if not isHost then - GUI.defaultOptions:Hide() - end - LayoutHelpers.AtLeftTopIn(GUI.defaultOptions, GUI.observerPanel, 11, -94) - - Tooltip.AddButtonTooltip(GUI.defaultOptions, 'lob_click_rankedoptions') - if not isHost then - GUI.defaultOptions:Disable() - else - GUI.defaultOptions.OnClick = function() - UIUtil.QuickDialog(GUI, LOC('Are you sure you want to reset to default values?'), - "", function() - -- Return all options to their default values. - OptionUtils.SetDefaults() - lobbyComm:BroadcastData({ Type = "SetAllPlayerNotReady" }) - UpdateGame() - end, - - "", nil, - nil, nil, - true - ) - end - end - - -- RANDOM MAP BUTTON -- - GUI.randMap = UIUtil.CreateButtonStd(GUI.observerPanel, '/BUTTON/randommap/') - LayoutHelpers.RightOf(GUI.randMap, GUI.defaultOptions, -19) - Tooltip.AddButtonTooltip(GUI.randMap, 'lob_click_randmap') - if not isHost then - GUI.randMap:Hide() - else - GUI.randMap.OnClick = function() - local randomMap - local mapSelectDialog - - autoRandMap = false - - -- Load the set of all available maps, with a slight evil hack on the mapselect module. - local mapDialog = import("/lua/ui/dialogs/mapselect.lua") - local allMaps = mapDialog.LoadScenarios() -- Result will be cached. - - -- Only include maps which have enough slots for the players we have. - local filteredMaps = table.filter(allMaps, - function(scenInfo) - local supportedPlayers = table.getsize(scenInfo.Configurations.standard.teams[1].armies) - return supportedPlayers >= GetPlayerCount() - end - ) - local mapCount = table.getn(filteredMaps) - local selectedMap = filteredMaps[math.floor(math.random(1, mapCount))] - - -- Set the new map. - SetGameOption('ScenarioFile', selectedMap.file) - ClearBadMapFlags() - UpdateGame() - end - end - - local autoteamButtonStates = { - { - key = 'tvsb', - tooltip = 'lob_auto_tvsb' - }, - { - key = 'lvsr', - tooltip = 'lob_auto_lvsr' - }, - { - key = 'pvsi', - tooltip = 'lob_auto_pvsi' - }, - { - key = 'manual', - tooltip = 'lob_auto_manual' - }, - { - key = 'none', - tooltip = 'lob_auto_none' - }, - } - - local initialState = Prefs.GetFromCurrentProfile("LobbyOpt_AutoTeams") or "none" - GUI.autoTeams = ToggleButton(GUI.observerPanel, '/BUTTON/autoteam/', autoteamButtonStates, initialState) - - LayoutHelpers.RightOf(GUI.autoTeams, GUI.randMap, -19) - if not isHost then - GUI.autoTeams:Hide() - else - GUI.autoTeams.OnStateChanged = function(self, newState) - SetGameOption('AutoTeams', newState) - AssignAutoTeams() - end - end - - -- CLOSE/OPEN EMPTY SLOTS BUTTON -- - GUI.closeEmptySlots = UIUtil.CreateButtonStd(GUI.observerPanel, '/BUTTON/closeslots/') - Tooltip.AddButtonTooltip(GUI.closeEmptySlots, 'lob_close_empty_slots') - if not isHost then - GUI.closeEmptySlots:Hide() - LayoutHelpers.AtLeftTopIn(GUI.closeEmptySlots, GUI.defaultOptions, -40, 43) - else - LayoutHelpers.AtLeftTopIn(GUI.closeEmptySlots, GUI.defaultOptions, -31, 43) - GUI.closeEmptySlots.OnClick = function(self, modifiers) - if lobbyComm:IsHost() then - if modifiers.Ctrl then - for slot = 1,numOpenSlots do - HostUtils.SetSlotClosed(slot, false) - end - return - end - local openSpot = false - for slot = 1,numOpenSlots do - openSpot = openSpot or not (gameInfo.PlayerOptions[slot] or gameInfo.ClosedSlots[slot]) - end - if modifiers.Right and gameInfo.AdaptiveMap then - for slot = 1,numOpenSlots do - if openSpot then - if not (gameInfo.PlayerOptions[slot] or gameInfo.ClosedSlots[slot]) then - HostUtils.SetSlotClosedSpawnMex(slot) - end - else - if gameInfo.ClosedSlots[slot] and gameInfo.SpawnMex[slot] then - HostUtils.SetSlotClosed(slot, false) - end - end - end - else - for slot = 1,numOpenSlots do - if not gameInfo.SpawnMex[slot] then - HostUtils.SetSlotClosed(slot, openSpot) - end - end - end - end - end - end - - - -- GO OBSERVER BUTTON -- - GUI.becomeObserver = UIUtil.CreateButtonStd(GUI.observerPanel, '/BUTTON/observer/') - LayoutHelpers.RightOf(GUI.becomeObserver, GUI.closeEmptySlots, -25) - Tooltip.AddButtonTooltip(GUI.becomeObserver, 'lob_become_observer') - GUI.becomeObserver.OnClick = function() - if IsPlayer(localPlayerID) then - if isHost then - HostUtils.ConvertPlayerToObserver(FindSlotForID(localPlayerID)) - else - lobbyComm:SendData(hostID, {Type = 'RequestConvertToObserver'}) - end - elseif IsObserver(localPlayerID) then - if isHost then - HostUtils.ConvertObserverToPlayer(FindObserverSlotForID(localPlayerID)) - else - lobbyComm:SendData(hostID, {Type = 'RequestConvertToPlayer'}) - end - end - end - - -- CPU BENCH BUTTON -- - GUI.rerunBenchmark = UIUtil.CreateButtonStd(GUI.observerPanel, '/BUTTON/cputest/', '', 11) - LayoutHelpers.RightOf(GUI.rerunBenchmark, GUI.becomeObserver, -25) - Tooltip.AddButtonTooltip(GUI.rerunBenchmark,{text=LOC("Run CPU Benchmark Test"), body=LOC("Recalculates your CPU rating.")}) - GUI.rerunBenchmark.OnClick = function(self, modifiers) - ForkThread(function() UpdateBenchmark(true) end) - end - - -- Autobalance Button -- - GUI.PenguinAutoBalance = UIUtil.CreateButtonStd(GUI.observerPanel, '/BUTTON/autobalance/') - LayoutHelpers.RightOf(GUI.PenguinAutoBalance, GUI.rerunBenchmark, -25) - Tooltip.AddButtonTooltip(GUI.PenguinAutoBalance, {text=LOC("Autobalance"), body=LOC("Automatically balance players into 2 equally sized teams")}) - if not isHost then - GUI.PenguinAutoBalance:Hide() - else - -- What this does: it balances all occupied slots into two teams with equal numbers of - -- players. If teams are set manually and half of the occupied slots are set to team 1 - -- and half to team 2, then it balances the players while keeping the team-slot matches. - -- If the teams are set manually, but there is an uneven number of players on teams 1 - -- and 2, then players' teams are changed automatically to be alternating team 1 and team 2. - -- If there are an odd number of occupied slots, the last one is set to team - (no team) - -- and the others are balanced without it. Alternatively, if teams are not set manually, - -- players will be balanced into the slowest available slot numbers on their teams. - -- If there is an odd number of players in that case, the last player will be made an - -- observer if human or removed if AI. - - -- How it balances: this function checks every possible balance combination for making - -- the two teams (while keeping their player counts equal to half the number of occupied - -- slots, rounded down, and not using the last player if there is an odd number of players). - -- To do this, the function sums up all the relevant players' ratings (keeping mean and - -- deviation separate - it balances teams to have similar total ratings, and also similar - -- total uncertainties (grayness)), and then divides by two. That yields the goal values - -- for each team. Any deviation from those values is calculated to help determine a team's - -- imbalance value. Then, the various team combinations are tested, and the one with the - -- lowest imbalance value is used. - - - -- Automatically balance an even number of non-observer players into 2 teams in the lobby - GUI.PenguinAutoBalance.OnClick = function() - - -- make sure spawns are set to fixed or penguin_autobalance - if gameInfo.GameOptions.TeamSpawn ~= 'fixed' and gameInfo.GameOptions.TeamSpawn ~= 'penguin_autobalance' then - gameInfo.GameOptions.TeamSpawn = 'fixed' - -- tell everyone else to set spawns to fixed - lobbyComm:BroadcastData { - Type = 'GameOptions', - Options = {['TeamSpawn'] = 'fixed'} - } - AddChatText(LOC("Enabled fixed spawn locations")) - end - - -- a table of the target mean, target deviation, and the lowest logged imbalance value - local goalValue = {0, 0, 99999} - - local playerCount = 0 - -- a table of the highest occupied slot's slot number, that slot's player's order number - -- in the playerRatings table, and a booleon of whether or not that player is human - local lastSlot = {0, 0, false} - local playerRatings = {} - -- get rating data for each player - for i, player in gameInfo.PlayerOptions:pairs() do - playerRatings[i] = {player.MEAN, player.DEV, player.StartSpot, player.Team - 1} - playerCount = playerCount + 1 - if player.StartSpot > lastSlot[1] then - lastSlot = {player.StartSpot, i, player.Human} - end - goalValue[1] = goalValue[1] + player.MEAN - goalValue[2] = goalValue[2] + player.DEV - end - - -- if there are fewer than 2 players, there is no need to balance - if playerCount < 2 then - UpdateGame() - return - end - - -- if there is an odd number of players, remove the last one from the balancing - if math.mod(playerCount, 2) == 1 then - goalValue[1] = goalValue[1] - playerRatings[lastSlot[2]][1] - goalValue[2] = goalValue[2] - playerRatings[lastSlot[2]][2] - playerRatings[lastSlot[2]] = nil - playerCount = playerCount - 1 - -- set the player to not be on a team if teams are manual and fixed - -- otherwise make the player an observer if human or remove it if AI - if gameInfo.GameOptions.AutoTeams == 'none' and gameInfo.GameOptions.TeamSpawn == 'fixed' then - for i, player in gameInfo.PlayerOptions:pairs() do - if player.StartSpot == lastSlot[1] then - player.Team = 1 -- no team - break - end - end - else - if lastSlot[3] then - HostUtils.ConvertPlayerToObserver(lastSlot[1]) - else - HostUtils.RemoveAI(lastSlot[1]) - end - end - end - - -- the goal value is all of the remaining players' ratings divided by 2 - goalValue[1] = goalValue[1] / 2 - goalValue[2] = goalValue[2] / 2 - - local sortedPlayerRatings = {} - local sortedSlotTeams = {} - local numPlayersTeam1 = 0 - local numPlayersTeam2 = 0 - local sortingValue1 - local sortingValue2 - -- sort the players in a weighted cross between displayed and base rating - -- the order goes from greatest to lowest result of: mean - (deviation * 2.2) - for i, player in playerRatings do - local orderNum = 1 - sortingValue1 = player[1] - (player[2] * 2.2) - for i2, player2 in playerRatings do - sortingValue2 = player2[1] - (player2[2] * 2.2) - if sortingValue1 < sortingValue2 or (sortingValue1 == sortingValue2 and i > i2) then - orderNum = orderNum + 1 - end - end - -- these are sorted in parallel - sortedPlayerRatings[orderNum] = {player[1], player[2]} - sortedSlotTeams[orderNum] = {player[3], player[4]} - if player[4] == 1 then - numPlayersTeam1 = numPlayersTeam1 + 1 - elseif player[4] == 2 then - numPlayersTeam2 = numPlayersTeam2 + 1 - end - end - - - -- the number of players per team - local teamSize = playerCount / 2 - - - -- make the sorted list of slots for each team - local sortedTeam1Slots = {} - local sortedTeam2Slots = {} - local team1OrderNum = 0 - local team2OrderNum = 0 - - local manualTeams - - if gameInfo.GameOptions.AutoTeams == 'pvsi' then -- odd vs even - for i = 1, 16 do - if not gameInfo.ClosedSlots[i] then - if math.mod(i, 2) == 1 then - team1OrderNum = team1OrderNum + 1 - sortedTeam1Slots[team1OrderNum] = i - else - team2OrderNum = team2OrderNum + 1 - sortedTeam2Slots[team2OrderNum] = i - end - end - end - elseif gameInfo.GameOptions.AutoTeams == 'tvsb' then -- top vs bottom - local midLine = GUI.mapView.Top() + (GUI.mapView.Height() / 2) - for i, startPosition in GUI.mapView.startPositions do - if not gameInfo.ClosedSlots[i] then - if startPosition.Top() < midLine then - team1OrderNum = team1OrderNum + 1 - sortedTeam1Slots[team1OrderNum] = i - else - team2OrderNum = team2OrderNum + 1 - sortedTeam2Slots[team2OrderNum] = i - end - end - end - elseif gameInfo.GameOptions.AutoTeams == 'lvsr' then -- left vs right - local midLine = GUI.mapView.Left() + (GUI.mapView.Width() / 2) - for i, startPosition in GUI.mapView.startPositions do - if not gameInfo.ClosedSlots[i] then - if startPosition.Left() < midLine then - team1OrderNum = team1OrderNum + 1 - sortedTeam1Slots[team1OrderNum] = i - else - team2OrderNum = team2OrderNum + 1 - sortedTeam2Slots[team2OrderNum] = i - end - end - end - else - manualTeams = true - end - - -- If the teams were not set properly, set them properly. - -- When teams are set manually, they are not set properly if the number of - -- players on either team does not equal the team size. - -- When teams are not set manually, they are not set properly if the number - -- of slots on either team is less than the team size. - if (manualTeams and (numPlayersTeam1 != teamSize or numPlayersTeam2 != teamSize)) - or (not manualTeams and (table.getn(sortedTeam1Slots) < teamSize or table.getn(sortedTeam2Slots) < teamSize)) then - -- set AutoTeams to none (so, they can be set by slot by this function) - gameInfo.GameOptions.AutoTeams = 'none' - local counter = 0 - for i, player in gameInfo.PlayerOptions:pairs() do - for i2, slotTeam in sortedSlotTeams do - if player.StartSpot == slotTeam[1] then - counter = counter + 1 - -- set the player's team - if math.mod(counter, 2) == 1 then - player.Team = 2 -- team 1 - slotTeam[2] = 1 - else - player.Team = 3 -- team 2 - slotTeam[2] = 2 - end - -- tell everyone else the team number for that slot - lobbyComm:BroadcastData( - { - Type = 'PlayerOptions', - Options = {['Team'] = slotTeam[2] + 1}, -- make team number 1 higher for the backend - Slot = slotTeam[1], - }) - break - end - end - end - end - - -- if teams are set to manual, make the sorted list of slots for each team - if gameInfo.GameOptions.AutoTeams == 'none' then - sortedTeam1Slots = {} - sortedTeam2Slots = {} - for i, slotTeam in sortedSlotTeams do - team1OrderNum = 0 - team2OrderNum = 0 - for i2, slotTeam2 in sortedSlotTeams do - if slotTeam[1] > slotTeam2[1] or (slotTeam[1] == slotTeam2[1] and i >= i2) then - if slotTeam2[2] == 1 then - team1OrderNum = team1OrderNum + 1 - else - team2OrderNum = team2OrderNum + 1 - end - end - end - -- add the slot to its team's table - if slotTeam[2] == 1 then - sortedTeam1Slots[team1OrderNum] = slotTeam[1] - else - sortedTeam2Slots[team2OrderNum] = slotTeam[1] - end - end - end - - - - -- a table of team1's mean, deviation, and imbalance value - local teamValue - -- a table of team members - local team1 = {} - -- a table of the most balanced team - local bestTeam = {} - local choosableCount = playerCount - teamSize - - -- the number of iterations is the number of team combinations to check, which is - -- exactly half of the number of possible teams, which covers every possibility, - -- since the remaining half are just the opposite of what was already checked, - -- which means they have the exact same balance - -- ie: Player A + Player B vs Player C + Player D == Player C + Player D vs Player A + Player B - -- this works because of the order in which the combinations are tested - local numIterations - if teamSize == 2 then - numIterations = 3 - elseif teamSize == 3 then - numIterations = 10 - elseif teamSize == 4 then - numIterations = 35 - elseif teamSize == 5 then - numIterations = 126 - elseif teamSize == 6 then - numIterations = 462 - elseif teamSize == 7 then - numIterations = 1716 - else - numIterations = 6435 - end - - local currentIteration = 0 - - -- test the balance of different combinations of teams, covering balance possibility - -- intended for use with 2 teams of even player counts - -- combinations are iterated starting with the lowest-numbered players on team1 first, - -- and progressively iterating the highest-numbered player on team1 to each higher-numbered - -- possible player, and then repeating the process with the next highest-numbered player - -- increasing by 1... this process continues until every possible balacnce combination - -- of 2 equally sized teams of even player counts has been covered - local function testCombinations(team1MemberNumber, firstPlayerToCheck) - -- check if this player is the last player on the team - local lastPlayer - if team1MemberNumber < teamSize then - lastPlayer = false - else - lastPlayer = true - end - -- iterate through the possible players for this team1MemberNumber - for i = firstPlayerToCheck, choosableCount + team1MemberNumber do - -- when the number of iterations is reached, every possible balance of even player count - -- of the 2 equally sized teams has been checked, and the function ends - if currentIteration >= numIterations then - return - end - team1[team1MemberNumber] = i - if lastPlayer then - -- test this combination of team members - teamValue = {0, 0, 0} - -- add each team member's base rating and devation to the team's values - for i, player in team1 do - teamValue[1] = teamValue[1] + sortedPlayerRatings[player][1] - teamValue[2] = teamValue[2] + sortedPlayerRatings[player][2] - end - -- calculate the team's imbalance value - teamValue[3] = math.abs(teamValue[2] - goalValue[2]) * 1.2 + math.abs(teamValue[1] - goalValue[1]) - -- check if the team's imbalance value is lower than the lowest logged imbalance value - if teamValue[3] < goalValue[3] then - -- if it is lower, then this is the best balance so far, and it is logged over the previous best balance - goalValue[3] = teamValue[3] - -- deepcopy the team's player numbers - for i, player in team1 do - bestTeam[i] = player - end - end - currentIteration = currentIteration + 1 - else - -- test a subset of combinations - testCombinations(team1MemberNumber + 1, i + 1) - end - end - end - - testCombinations(1, 1) - - - -- specify the players on team 2 (aka, the ones not on team 1) - local bestTeam2 = {} - for i = 1, playerCount do - if not table.find(bestTeam, i) then - table.insert(bestTeam2, i) - end - end - - --shuffle player pairs - local random - local temp - for i, slot in bestTeam do - random = Random(1, teamSize) - - --random swap on team 1 - temp = bestTeam[random] - bestTeam[random] = bestTeam[i] - bestTeam[i] = temp - - --mirrored swap on team2 - temp = bestTeam2[random] - bestTeam2[random] = bestTeam2[i] - bestTeam2[i] = temp - end - - -- move players on team1 to the intended slots - local team1OrderNum = 0 - local slotA - local slotB - for i, player in bestTeam do - team1OrderNum = team1OrderNum + 1 - slotA = sortedSlotTeams[player][1] - slotB = sortedTeam1Slots[team1OrderNum] - HostUtils.SwapPlayers(slotA, slotB) - -- keep track of the slot changes in sortedSlotTeams - for i, slotTeam in sortedSlotTeams do - if slotTeam[1] == slotB then - slotTeam[1] = slotA - break - end - end - sortedSlotTeams[player][1] = slotB - end - - -- move players on team2 to the intended slots - local team2OrderNum = 0 - for i, player in bestTeam2 do - team2OrderNum = team2OrderNum + 1 - slotA = sortedSlotTeams[player][1] - slotB = sortedTeam2Slots[team2OrderNum] - HostUtils.SwapPlayers(slotA, slotB) - -- keep track of the slot changes in sortedSlotTeams - for i, slotTeam in sortedSlotTeams do - if slotTeam[1] == slotB then - slotTeam[1] = slotA - break - end - end - sortedSlotTeams[player][1] = slotB - end - UpdateGame() - AddChatText(LOC("Finished autobalancing")) - end - end - - -- Observer List - GUI.observerList = ItemList(GUI.observerPanel) - GUI.observerList:SetFont(UIUtil.bodyFont, 12) - GUI.observerList:SetColors(UIUtil.fontColor, "00000000", UIUtil.fontOverColor, UIUtil.highlightColor, "ffbcfffe") - LayoutHelpers.AtLeftTopIn(GUI.observerList, GUI.observerPanel, 4, 2) - LayoutHelpers.AtRightBottomIn(GUI.observerList, GUI.observerPanel, 15) - GUI.observerList.OnClick = function(self, row, event) - if isHost and event.Modifiers.Right then - -- determine the number of teams (excluding the no team (-) option that equals 1 on the backend) - local teams = {} - local numTeams = 0 - for i, player in gameInfo.PlayerOptions:pairs() do - if not teams[player.Team] and player.Team ~= 1 then - teams[player.Team] = true - numTeams = numTeams + 1 - end - end - - -- adjust index by 1 because 0-based (ItemList rows) vs 1-based (Lua array) indexing - local obsIndex = row + 1 - local maxObsIndex = self:GetItemCount() - - -- adjust index by the number of rows taken up by team ratings. - ---@see refreshObserverList - if gameInfo.GameOptions['TeamSpawn'] == 'fixed' then - if numTeams < 3 then - obsIndex = obsIndex - numTeams - else - -- 3+ teams has ratings at the end of the list, don't allow kicking when clicking those rating rows - maxObsIndex = maxObsIndex - numTeams - end - end - - -- the host can get the kick dialog brought up for observer list rows that are players (aka, they have - -- a positive observer index and thereby aren't team ratings) and that aren't the local player (the host) - -- and that aren't the rows with team ratings when there are 3 or more teams - if obsIndex > 0 and gameInfo.Observers[obsIndex].OwnerID ~= localPlayerID and obsIndex <= maxObsIndex then - UIUtil.QuickDialog(GUI, "Are you sure?", - "Kick Player", function() - SendSystemMessage("lobui_0756", gameInfo.Observers[obsIndex].PlayerName) - lobbyComm:EjectPeer(gameInfo.Observers[obsIndex].OwnerID, "KickedByHost") - end, - "", nil, - nil, nil, - true, - {worldCover = false, enterButton = 1, escapeButton = 2} - ) - end - end - end - UIUtil.CreateLobbyVertScrollbar(GUI.observerList, 0, 0, -1) - - -- Setup large pretty faction selector and set the factional background to its initial value. - local lastFaction = GetSanitisedLastFaction() - CreateUI_Faction_Selector(lastFaction) - - RefreshLobbyBackground(lastFaction) - - GUI.uiCreated = true - - if singlePlayer then - -- observers are always allowed in skirmish games. - SetGameOption("AllowObservers", true) - end - - --------------------------------------------------------------------------- - -- other logic, including lobby callbacks - --------------------------------------------------------------------------- - GUI.posGroup = false - -- get ping times - GUI.pingThread = ForkThread( - function() - while lobbyComm do - for slot, player in gameInfo.PlayerOptions:pairs() do - if player.Human and player.OwnerID ~= localPlayerID then - local peer = lobbyComm:GetPeer(player.OwnerID) - local ping = peer.ping - local connectionStatus = CalcConnectionStatus(peer) - GUI.slots[slot].pingStatus.ConnectionStatus = connectionStatus - if ping then - ping = math.floor(ping) - GUI.slots[slot].pingStatus.PingActualValue = ping - GUI.slots[slot].pingStatus:SetValue(ping) - if ping > 500 then - GUI.slots[slot].pingStatus:Show() - else - GUI.slots[slot].pingStatus:Hide() - end - -- Set the ping bar to a colour representing the status of our connection. - GUI.slots[slot].pingStatus._bar:SetTexture(UIUtil.SkinnableFile('/game/unit_bmp/bar-0' .. connectionStatus .. '_bmp.dds')) - else - GUI.slots[slot].pingStatus:Hide() - end - end - end - WaitSeconds(1) - end - end) - if false then - import("/lua/ui/events/snowflake.lua"). CreateSnowFlakes(GUI) - end -end - -function setupChatEdit(chatPanel) - GUI.chatEdit = Edit(chatPanel) - LayoutHelpers.AtLeftTopIn(GUI.chatEdit, GUI.chatBG, 4, 3) - GUI.chatEdit.Width:Set(GUI.chatBG.Width() - LayoutHelpers.ScaleNumber(4)) - LayoutHelpers.SetHeight(GUI.chatEdit, 22) - GUI.chatEdit:SetFont(UIUtil.bodyFont, 16) - GUI.chatEdit:SetForegroundColor(UIUtil.fontColor) - GUI.chatEdit:ShowBackground(false) - GUI.chatEdit:SetDropShadow(true) - GUI.chatEdit:AcquireFocus() - - GUI.chatDisplayScroll = UIUtil.CreateLobbyVertScrollbar(chatPanel, -15, 25, 0) - - GUI.chatEdit:SetMaxChars(200) - GUI.chatEdit.OnCharPressed = function(self, charcode) - if charcode == UIUtil.VK_TAB then - return true - end - - local charLim = self:GetMaxChars() - if STR_Utf8Len(self:GetText()) >= charLim then - local sound = Sound({Cue = 'UI_Menu_Error_01', Bank = 'Interface',}) - PlaySound(sound) - end - end - - -- We work extremely hard to keep keyboard focus on the chat box, otherwise users can trigger - -- in-game keybindings in the lobby. - -- That would be very bad. We should probably instead just not assign those keybindings yet... - GUI.chatEdit.OnLoseKeyboardFocus = function(self) - self:AcquireFocus() - end - - local commandQueueIndex = 0 - local commandQueue = {} - GUI.chatEdit.OnEnterPressed = function(self, text) - if text:gsub("%s+", "") == '' then -- If the text, trimmed of all space, is equal to '' - return - end - GpgNetSend('Chat', text) - table.insert(commandQueue, 1, text) - commandQueueIndex = 0 - if string.sub(text, 1, 1) == '/' then - local spaceStart = string.find(text, " ") or string.len(text) + 1 - local comKey = string.sub(text, 2, spaceStart - 1) - local params = string.sub(text, spaceStart + 1) - local commandFunc = commands[string.lower(comKey)] - if not commandFunc then - AddChatText(LOCF("Command Not Known: %s", comKey)) - return - end - commandFunc(params) - else - PublicChat(text) - end - end - - GUI.chatEdit.OnEscPressed = function(self, text) - - local changelogDialogManager = import("/lua/ui/lobby/changelog/changelogdialog.lua") - local changelogDialogIsOpen = changelogDialogManager.IsOpen() - - -- The default behaviour buggers up our escape handlers. Just delegate the escape push to - -- the escape handling mechanism. - if HasCommandLineArg("/gpgnet") or changelogDialogIsOpen then - -- Quit to desktop - EscapeHandler.HandleEsc(not changelogDialogIsOpen) - else - -- Back to main menu - GUI.exitButton.OnClick() - end - - -- Don't clear the textbox, either. - return true - end - - --- Handle up/down arrow presses for the chat box. - GUI.chatEdit.OnNonTextKeyPressed = function(self, keyCode) - if AddUnicodeCharToEditText(self, keyCode) then - return - end - if commandQueue and not table.empty(commandQueue) then - if keyCode == 38 then - if commandQueue[commandQueueIndex + 1] then - commandQueueIndex = commandQueueIndex + 1 - self:SetText(commandQueue[commandQueueIndex]) - end - end - if keyCode == 40 then - if commandQueueIndex ~= 1 then - if commandQueue[commandQueueIndex - 1] then - commandQueueIndex = commandQueueIndex - 1 - self:SetText(commandQueue[commandQueueIndex]) - end - else - commandQueueIndex = 0 - self:ClearText() - end - end - end - end - chatPanel.edit = GUI.chatEdit -end - -function RefreshOptionDisplayData(scenarioInfo) - local globalOpts = import("/lua/ui/lobby/lobbyoptions.lua").globalOpts - local teamOptions = import("/lua/ui/lobby/lobbyoptions.lua").teamOptions - local AIOpts = import("/lua/ui/lobby/lobbyoptions.lua").AIOpts - if not scenarioInfo and gameInfo.GameOptions.ScenarioFile and (gameInfo.GameOptions.ScenarioFile ~= "") then - scenarioInfo = MapUtil.LoadScenario(gameInfo.GameOptions.ScenarioFile) - end - formattedOptions = {} - nonDefaultFormattedOptions = {} - - -- Show a summary of the number of active mods. - local modStr = false - local modNum = table.getn(Mods.GetGameMods(gameInfo.GameMods)) or 0 - local modNumUI = table.getn(Mods.GetUiMods()) or 0 - if modNum > 0 and modNumUI > 0 then - modStr = modNum..' Mods (and '..modNumUI..' UI Mods)' - if modNum == 1 and modNumUI > 1 then - modStr = modNum..' Mod (and '..modNumUI..' UI Mods)' - elseif modNum > 1 and modNumUI == 1 then - modStr = modNum..' Mods (and '..modNumUI..' UI Mod)' - elseif modNum == 1 and modNumUI == 1 then - modStr = modNum..' Mod (and '..modNumUI..' UI Mod)' - else - modStr = modNum..' Mods (and '..modNumUI..' UI Mods)' - end - elseif modNum > 0 and modNumUI == 0 then - modStr = modNum..' Mods' - if modNum == 1 then - modStr = modNum..' Mod' - end - elseif modNum == 0 and modNumUI > 0 then - modStr = modNumUI..' UI Mods' - if modNum == 1 then - modStr = modNumUI..' UI Mod' - end - end - - local description = "No mods enabled." - - if modNum + modNumUI > 0 then - description = "" - - local descriptionSimMods = { "", } - if modNum > 0 then - table.insert(descriptionSimMods, LOC("The host enabled the following sim mods:")) - for k, mod in Mods.GetGameMods() do - table.insert(descriptionSimMods, "\r\n - " .. tostring(mod.name)) - end - - table.insert(descriptionSimMods, "\r\n") - end - - local descriptionUIMods = { "", } - if modNumUI > 0 then - table.insert(descriptionUIMods, LOC("You have enabled the following UI mods:")) - for k, mod in Mods.GetUiMods() do - table.insert(descriptionUIMods, "\r\n - " .. tostring(mod.name)) - end - end - - descriptionSimMods = tostring(table.concat(descriptionSimMods)) - descriptionUIMods = tostring(table.concat(descriptionUIMods)) - - if modNum > 0 and modNumUI > 0 then - description = tostring(table.concat({descriptionSimMods, "\r\n", descriptionUIMods})) - else - if modNum > 0 then - description = descriptionSimMods - end - - if modNumUI > 0 then - description = descriptionUIMods - end - end - end - - if modStr then - local option = { - text = modStr, - value = LOC('Check Mod Manager'), - mod = true, - - manualTooltipTitle = 'Enabled mods', - manualTooltipDescription = description - } - - table.insert(formattedOptions, option) - table.insert(nonDefaultFormattedOptions, option) - end - - -- Update the unit restrictions display. - if gameInfo.GameOptions.RestrictedCategories ~= nil then - local restrNum = table.getn(gameInfo.GameOptions.RestrictedCategories) - if restrNum ~= 0 then - local restrictLabel - if restrNum == 1 then -- just 1 - restrictLabel = LOC("1 Build Restriction") - else - restrictLabel = LOCF("%d Build Restrictions", restrNum) - end - - local option = { - text = restrictLabel, - value = LOC("Check Unit Manager"), - mod = true, - tooltip = 'Lobby_BuildRestrict_Option', - valueTooltip = 'Lobby_BuildRestrict_Option' - } - - table.insert(formattedOptions, option) - table.insert(nonDefaultFormattedOptions, option) - end - end - - -- Add an option to the formattedOption lists - local function addFormattedOption(optData, gameOption) - -- Don't show multiplayer-only options in single-player - if optData.mponly and singlePlayer then - return - end - - -- Don't bother for options with only one value. These are usually someone trying to do - -- something clever with a mod or such, not "real" options we care about. - if table.getn(optData.values) <= 1 then - return - end - - local option = { - text = optData.label, - tooltip = { text = optData.label, body = optData.help } - } - - -- Options are stored as keys from the values array in optData. We want to display the - -- descriptive string in the UI, so let's go dig it out. - - -- Scan the values array to find the one with the key matching our value for that option. - for k, val in optData.values do - local key = val.key or val - - if key == gameOption then - option.key = key - option.value = val.text or optData.value_text - option.valueTooltip = {text = optData.label, body = val.help or optData.value_help} - - table.insert(formattedOptions, option) - - -- Add this option to the non-default set for the UI. - if k ~= optData.default then - table.insert(nonDefaultFormattedOptions, option) - end - - break - end - end - end - - local function addOptionsFrom(optionObject) - for index, optData in optionObject do - local gameOption = gameInfo.GameOptions[optData.key] - addFormattedOption(optData, gameOption) - end - end - - -- Add the core options to the formatted option lists - addOptionsFrom(globalOpts) - addOptionsFrom(teamOptions) - addOptionsFrom(AIOpts) - - -- Add options from the scenario object, if any are provided. - if scenarioInfo.options then - if not MapUtil.ValidateScenarioOptions(scenarioInfo.options, true) then - AddChatText(LOC('The options included in this map specified invalid defaults. See moholog for details.')) - AddChatText(LOC('An arbitrary option has been selected for now: check the game options screen!')) - end - - for index, optData in scenarioInfo.options do - addFormattedOption(optData, gameInfo.GameOptions[optData.key]) - end - end - - GUI.OptionContainer:CalcVisible() -end - -function wasConnected(peer) - for _,v in pairs(connectedTo) do - if v == peer then - return true - end - end - return false -end - ---- Return a status code representing the status of our connection to a peer. --- @param peer, native table as returned by lobbyComm:GetPeer() --- @return A value describing the connectivity to given peer. --- 1 means no connectivity, 2 means they haven't reported that they can talk to us, 3 means --- --- @todo: This function has side effects despite the naming suggesting that it shouldn't. --- These need to go away. -function CalcConnectionStatus(peer) - if peer.status ~= 'Established' then - return 3 - else - if not wasConnected(peer.id) then - local peerSlot = FindSlotForID(peer.id) - local slot = GUI.slots[peerSlot] - local playerInfo = gameInfo.PlayerOptions[peerSlot] - - slot.name:SetTitleText(GetPlayerDisplayName(playerInfo)) - slot.name._text:SetFont('Arial Gras', 15) - if not table.find(ConnectionEstablished, peer.name) then - if playerInfo.Human and not IsLocallyOwned(peerSlot) then - table.insert(ConnectionEstablished, peer.name) - for k, v in CurrentConnection do -- Remove PlayerName in this Table - if v == peer.name then - CurrentConnection[k] = nil - break - end - end - end - end - - table.insert(connectedTo, peer.id) - end - if not table.find(peer.establishedPeers, lobbyComm:GetLocalPlayerID()) then - -- they haven't reported that they can talk to us? - return 1 - end - - local peers = lobbyComm:GetPeers() - for k,v in peers do - if v.id ~= peer.id and v.status == 'Established' then - if not table.find(peer.establishedPeers, v.id) then - -- they can't talk to someone we can talk to. - return 1 - end - end - end - return 2 - end -end - -function EveryoneHasEstablishedConnections(check_observers) - local important = {} - for slot, player in gameInfo.PlayerOptions:pairs() do - if not table.find(important, player.OwnerID) then - table.insert(important, player.OwnerID) - end - end - - if check_observers then - for slot,observer in gameInfo.Observers:pairs() do - if not table.find(important, observer.OwnerID) then - table.insert(important, observer.OwnerID) - end - end - end - - local result = true - for k, id in important do - if id ~= localPlayerID then - local peer = lobbyComm:GetPeer(id) - for k2, other in important do - if id ~= other and not table.find(peer.establishedPeers, other) then - result = false - AddChatText(LOCF("%s doesn't have an established connection to %s", - peer.name, - lobbyComm:GetPeer(other).name - )) - end - end - end - end - return result -end - -function AddChatText(text, playerID, scrollToBottom) - if not GUI.chatDisplay then - LOG("Can't add chat text -- no chat display") - LOG("text=" .. repr(text)) - return - end - - local chatPlayerColor = Prefs.GetFromCurrentProfile('ChatPlayerColor') - if chatPlayerColor == nil then - chatPlayerColor = true - end - - local scrolledToBottom = GUI.chatPanel:IsScrolledToBottom() or scrollToBottom - local nameColor = "AAAAAA" -- Displaying text in grey by default if the player is observer - local textColor = "AAAAAA" - local nameFont = "Arial Gras" - for id, player in gameInfo.PlayerOptions:pairs() do - if player.OwnerID == playerID and player.Human then - textColor = nil - nameColor = gameColors.PlayerColors[player.PlayerColor] - if not chatPlayerColor then - nameFont = UIUtil.bodyFont - if Prefs.GetOption('faction_font_color') then - nameColor = import("/lua/skins/skins.lua").skins[ FACTION_NAMES[GetLocalPlayerData():AsTable().Faction] ].fontColor - textColor = nameColor - else - nameColor = nil - end - end - break - end - end - local name = FindNameForID(playerID) - - GUI.chatDisplay:PostMessage(text, name, {fontColor = textColor}, {fontColor = nameColor, fontFamily = nameFont}) - if scrolledToBottom then - GUI.chatPanel:ScrollToBottom() - else - GUI.newMessageArrow:Enable() - end -end - ---- Update a slot display in a single map control. -function RefreshMapPosition(mapCtrl, slotIndex) - - local playerInfo = gameInfo.PlayerOptions[slotIndex] - local notFixed = gameInfo.GameOptions['TeamSpawn'] ~= 'fixed' - - -- Evil autoteams voodoo. - if gameInfo.GameOptions.AutoTeams and not gameInfo.AutoTeams[slotIndex] and lobbyComm:IsHost() then - gameInfo.AutoTeams[slotIndex] = 1 - end - - -- The ACUButton instance representing this slot, if any. - local marker = mapCtrl.startPositions[slotIndex] - if marker then - marker:SetClosed(gameInfo.ClosedSlots[slotIndex]) - if gameInfo.ClosedSlots[slotIndex] and gameInfo.SpawnMex[slotIndex] then - marker:SetClosedSpawnMex() - end - end - - mapCtrl:UpdatePlayer(slotIndex, playerInfo, notFixed) - - -- Nothing more for us to do for a closed or missing slot. - if gameInfo.ClosedSlots[slotIndex] or not marker then - return - end - - if gameInfo.GameOptions.AutoTeams then - if gameInfo.GameOptions.AutoTeams == 'lvsr' then - local midLine = mapCtrl.Left() + (mapCtrl.Width() / 2) - if notFixed then - local markerPos = marker.Left() - if markerPos < midLine then - marker:SetTeam(2) - else - marker:SetTeam(3) - end - end - elseif gameInfo.GameOptions.AutoTeams == 'tvsb' then - local midLine = mapCtrl.Top() + (mapCtrl.Height() / 2) - if notFixed then - local markerPos = marker.Top() - if markerPos < midLine then - marker:SetTeam(2) - else - marker:SetTeam(3) - end - end - elseif gameInfo.GameOptions.AutoTeams == 'pvsi' then - if notFixed then - if math.mod(slotIndex, 2) ~= 0 then - marker:SetTeam(2) - else - marker:SetTeam(3) - end - end - elseif gameInfo.GameOptions.AutoTeams == 'manual' and notFixed then - marker:SetTeam(gameInfo.AutoTeams[slotIndex] or 1) - end - end -end - ---- Update a single slot in all displayed map controls. -function RefreshMapPositionForAllControls(slot) - RefreshMapPosition(GUI.mapView, slot) - if LrgMap and not LrgMap.isHidden then - RefreshMapPosition(LrgMap.content.mapPreview, slot) - end -end - -function ShowMapPositions(mapCtrl, scenario) - local playerArmyArray = MapUtil.GetArmies(scenario) - - for inSlot, army in playerArmyArray do - RefreshMapPosition(mapCtrl, inSlot) - end -end - -function ConfigureMapListeners(mapCtrl, scenario) - local playerArmyArray = MapUtil.GetArmies(scenario) - - for inSlot, army in playerArmyArray do - local slot = inSlot -- Closure copy. - - -- The ACUButton instance representing this slot. - local marker = mapCtrl.startPositions[inSlot] - - marker.OnRollover = function(self, state) - local slotName = GUI.slots[slot].name - if state == 'enter' then - slotName:HandleEvent({Type = 'MouseEnter'}) - elseif state == 'exit' then - slotName:HandleEvent({Type = 'MouseExit'}) - end - end - - marker.OnClick = function(self) - if gameInfo.GameOptions['TeamSpawn'] == 'fixed' then - if FindSlotForID(localPlayerID) ~= slot and gameInfo.PlayerOptions[slot] == nil then - if IsPlayer(localPlayerID) then - if lobbyComm:IsHost() then - HostUtils.MovePlayerToEmptySlot(FindSlotForID(localPlayerID), slot) - else - lobbyComm:SendData(hostID, {Type = 'MovePlayer', RequestedSlot = slot}) - end - -- if first click is a not empty slot and second click is a empty slot: reset vars - if mapPreviewSlotSwap == true then - mapPreviewSlotSwap = false - mapPreviewSlotSwapFrom = 0 - end - elseif IsObserver(localPlayerID) then - if lobbyComm:IsHost() then - local requestedFaction = GetSanitisedLastFaction() - HostUtils.ConvertObserverToPlayer(FindObserverSlotForID(localPlayerID), slot) - else - lobbyComm:SendData( - hostID, - { - Type = 'RequestConvertToPlayer', - ObserverSlot = FindObserverSlotForID(localPlayerID), - PlayerSlot = slot - } - ) - end - end - else -- swap players on map preview - if lobbyComm:IsHost() and mapPreviewSlotSwap == false then - mapPreviewSlotSwapFrom = slot - mapPreviewSlotSwap = true - elseif lobbyComm:IsHost() and mapPreviewSlotSwap == true and mapPreviewSlotSwapFrom ~= slot then - mapPreviewSlotSwap = false - DoSlotBehavior(mapPreviewSlotSwapFrom, 'move_player_to_slot' .. slot, '') - mapPreviewSlotSwapFrom = 0 - end - end - else - if gameInfo.GameOptions.AutoTeams and lobbyComm:IsHost() then - -- Handle the manual-mode reassignment of slots to teams. - if gameInfo.GameOptions.AutoTeams == 'manual' then - if not gameInfo.ClosedSlots[slot] and (gameInfo.PlayerOptions[slot] or gameInfo.GameOptions['TeamSpawn'] ~= 'fixed') then - local targetTeam - if gameInfo.AutoTeams[slot] == 7 then - -- 2 here corresponds to team 1, since a team value of 1 represents - -- "no team". Apparently GPG really, really didn't like zero. - targetTeam = 2 - else - targetTeam = gameInfo.AutoTeams[slot] + 1 - end - - marker:SetTeam(targetTeam) - gameInfo.AutoTeams[slot] = targetTeam - - lobbyComm:BroadcastData( - { - Type = 'AutoTeams', - Slot = slot, - Team = gameInfo.AutoTeams[slot], - } - ) - gameInfo.PlayerOptions[slot]['Team'] = gameInfo.AutoTeams[slot] - SetSlotInfo(slot, gameInfo.PlayerOptions[slot]) - UpdateGame() - end - end - end - end - end - - if lobbyComm:IsHost() then - marker.OnRightClick = function(self) - if gameInfo.SpawnMex[slot] then - HostUtils.SetSlotClosed(slot, false) - elseif gameInfo.ClosedSlots[slot] then - if gameInfo.AdaptiveMap then - HostUtils.SetSlotClosedSpawnMex(slot) - else - HostUtils.SetSlotClosed(slot, false) - end - else - HostUtils.SetSlotClosed(slot, true) - end - end - end - end -end - -function SendCompleteGameStateToPeer(peerId) - lobbyComm:SendData(peerId, {Type = 'GameInfo', GameInfo = GameInfo.Flatten(gameInfo)}) -end - -function UpdateClientModStatus(newHostSimMods) - -- Apply the new game mods from the host, but don't touch our UI mod configuration. - selectedSimMods = newHostSimMods - - -- Make sure none of our selected UI mods are blacklisted - local bannedMods = CheckModCompatability() - if not table.empty(bannedMods) then - WarnIncompatibleMods() - - selectedUIMods = SetUtils.Subtract(selectedUIMods, bannedMods) - end - - Mods.SetSelectedMods(SetUtils.Union(selectedSimMods, selectedUIMods)) -end - -local IsFromHost = function(data) return data.SenderID == hostID end -local AmHost = function(data) return lobbyComm:IsHost() end - -local FromSubjectOrHost = function(data) - if IsFromHost(data) then - return true - end - - -- Do ridiculous things to infer the identity of the subject of the message. - -- TODO: A UNIFORM PROTOCOL MAYBE? - local subjectID = data.SenderID - if data.PlayerName then - return data.SenderID == FindIDForName(data.PlayerName) - end - - if data.Slot and gameInfo.PlayerOptions[data.Slot] then - return data.SenderID == FindIDForName(gameInfo.PlayerOptions[data.Slot].PlayerName) - end - - return false -end - --- -local MessageHandlers = { - -- Update player options. Either the host reconfiguring, or users tweaking their own settings. - PlayerOptions = { - Accept = function(data) - for key, val in data.Options do - -- The host *is* allowed to set options on slots he doesn't own, of course. - if data.SenderID ~= hostID then - if key == 'Team' and gameInfo.GameOptions['AutoTeams'] ~= 'none' then - WARN("Attempt to set Team while Auto Teams are on.") - return false - elseif gameInfo.PlayerOptions[data.Slot].OwnerID ~= data.SenderID then - WARN("Attempt to set option on unowned slot.") - return false - end - end - end - - -- TODO: Players may not change *all* of their own options... - - return true - end, - Handle = function(data) - local options = data.Options - - for key, val in options do - gameInfo.PlayerOptions[data.Slot][key] = val - if lobbyComm:IsHost() then - local playerInfo = gameInfo.PlayerOptions[data.Slot] - if playerInfo.Human then - GpgNetSend('PlayerOption', playerInfo.OwnerID, key, val) - else - GpgNetSend('AIOption', playerInfo.PlayerName, key, val) - end - - -- TODO: This should be a global listener on PlayerData objects, but I'm in too - -- much pain to implement that listener system right now. EVIL HACK TIME - if key == "Ready" then - HostUtils.RefreshButtonEnabledness() - end - -- DONE. - end - end - - SetSlotInfo(data.Slot, gameInfo.PlayerOptions[data.Slot]) - end - }, - - -- SenderName here is inserted by lobbyComm, so validating it is just a gratuitious effort to - -- detect obnoxiousness. - PublicChat = { - Accept = function(data) - return data.SenderName == FindNameForID(data.SenderID) - end, - Handle = function(data) - AddChatText(data.Text, data.SenderID) - end - }, - - PrivateChat = { - Accept = function(data) - return data.SenderName == FindNameForID(data.SenderID) - end, - Handle = function(data) - AddChatText("<<"..LOCF("From %s", data.SenderName)..">> "..data.Text) - end - }, - - CPUBenchmark = { - Accept = FromSubjectOrHost, - Handle = function(data) - local newInfo = false - if data.PlayerName and CPU_Benchmarks[data.PlayerName] ~= data.Result then - newInfo = true - end - - local benchmarks = {} - if data.PlayerName then - benchmarks[data.PlayerName] = data.Result - else - benchmarks = data.Benchmarks - end - - for name, result in benchmarks do - CPU_Benchmarks[name] = result - local id = FindIDForName(name) - local slot = FindSlotForID(id) - if slot then - SetSlotCPUBar(slot, gameInfo.PlayerOptions[slot]) - else - refreshObserverList() - end - end - - -- Host broadcasts new CPU benchmark information to give the info to clients that are not directly connected to data.PlayerName yet. - if lobbyComm:IsHost() and newInfo then - lobbyComm:BroadcastData({Type='CPUBenchmark', Benchmarks=CPU_Benchmarks}) - end - end - }, - - SetPlayerNotReady = { - Accept = FromSubjectOrHost, - Handle = function(data) - - -- allow the user to make changes - EnableSlot(data.Slot) - - -- allow the user to become an observer again - GUI.becomeObserver:Enable() - - -- update player options - SetPlayerOption(data.Slot, 'Ready', false) - - -- update GUI - GUI.slots[data.Slot].ready:SetCheck(false) - end - }, - - AutoTeams = { - Accept = IsFromHost, - Handle = function(data) - gameInfo.AutoTeams[data.Slot] = data.Team - gameInfo.PlayerOptions[data.Slot]['Team'] = data.Team - SetSlotInfo(data.Slot, gameInfo.PlayerOptions[data.Slot]) - UpdateGame() - end - }, - - AddPlayer = { - - ---@class LobbyAddPlayerData - ---@field PlayerOptions PlayerData - ---@field SenderId number - ---@field SenderName string - ---@field Type string - - ---@param data LobbyAddPlayerData - Accept = function(data) - -- we need to do quite a bit of checks to prevent malicious values - if type(data.PlayerOptions.MEAN) != 'number' then - return false - end - - if type (data.PlayerOptions.NG) != 'number' then - return false - end - - if type(data.PlayerOptions.Faction) != 'number' then - return false - end - - if type(data.PlayerOptions.PlayerName) != 'string' then - return false - end - - local charactersInPlayerName = string.len(data.PlayerOptions.PlayerName) - if charactersInPlayerName < 2 or charactersInPlayerName > 32 then - return false - end - - if data.PlayerOptions.PlayerClan then - if type(data.PlayerOptions.PlayerClan) != 'string' then - return false - end - - -- note: there are strange clan names that use more than 1 byte per character - if string.len(data.PlayerOptions.PlayerClan) > 6 then - return false - end - end - - if not data.PlayerOptions.OwnerID then - return false - end - - if not (data.PlayerOptions.OwnerID == data.SenderID) then - return false - end - - if FindNameForID(data.SenderID) then - return false - end - - -- check game version and reject if there is a missmatch - local hostVersion, hostGametype, hostCommit = import("/lua/version.lua").GetVersionData() - local playerVersion, playerGameType, playerCommit = tostring(data.PlayerOptions.Version), tostring(data.PlayerOptions.GameType), tostring(data.PlayerOptions.Commit) - if hostVersion ~= playerVersion or hostGametype ~= playerGameType or hostCommit ~= playerCommit then - local playerName = data.PlayerOptions.PlayerName - AddChatText(LOCF("Game version missmatch detected with %s. \r\n - host: %s (@%s)\r\n - %s: %s (@%s). \r\n\r\nTo prevent desyncs, %s is ejected automatically. It is possible that a new game version is released. If this keeps happening then it is better to rehost.", playerName, hostVersion, hostCommit:sub(1, 8), playerName, playerVersion, playerCommit:sub(1, 8), playerName)) - return false - end - - return lobbyComm:IsHost() - end, - Reject = function(data) - lobbyComm:EjectPeer(data.SenderID, "Game version missmatch or invalid player data.") - end, - Handle = function(data) - -- try to reassign the same slot as in the last game if it's a rehosted game, otherwise give it an empty - -- slot or move it to observer - SendCompleteGameStateToPeer(data.SenderID) - - if argv.isRehost then - local rehostSlot = FindRehostSlotForID(data.SenderID) or 0 - if rehostSlot ~= 0 and gameInfo.PlayerOptions[rehostSlot] then - -- If the slot is occupied, the occupying player will be moved away or to observer. If it's an - -- AI, it will be removed - local occupyingPlayer = gameInfo.PlayerOptions[rehostSlot] - if not occupyingPlayer.Human then - HostUtils.RemoveAI(rehostSlot) - HostUtils.TryAddPlayer(data.SenderID, rehostSlot, PlayerData(data.PlayerOptions)) - else - HostUtils.ConvertPlayerToObserver(rehostSlot, true) - HostUtils.TryAddPlayer(data.SenderID, rehostSlot, PlayerData(data.PlayerOptions)) - HostUtils.ConvertObserverToPlayer(FindObserverSlotForID(occupyingPlayer.OwnerID)) - end - else - HostUtils.TryAddPlayer(data.SenderID, rehostSlot, PlayerData(data.PlayerOptions)) - end - else - HostUtils.TryAddPlayer(data.SenderID, 0, PlayerData(data.PlayerOptions)) - end - if HasCommandLineArg('/gpgnet') then - PlayVoice(Sound{Bank = 'XGG',Cue = 'XGG_Computer__04716'}, true) - end - end - }, - - -- Player requests move. - MovePlayer = { - Accept = AmHost, -- Forgery would require tricking lobbyComm... - Handle = function(data) - local CurrentSlot = FindSlotForID(data.SenderID) - - -- Handle ready-races. - if gameInfo.PlayerOptions[CurrentSlot].Ready then - return - end - - -- Player requests to be moved to a different empty slot. - HostUtils.MovePlayerToEmptySlot(CurrentSlot, data.RequestedSlot) - end - }, - - RequestConvertToObserver = { - Accept = AmHost, - Handle = function(data) - HostUtils.ConvertPlayerToObserver(FindSlotForID(data.SenderID)) - end - - }, - - RequestConvertToPlayer = { - Accept = AmHost, - Handle = function(data) - HostUtils.ConvertObserverToPlayer(FindObserverSlotForID(data.SenderID), data.PlayerSlot) - end - }, - - RequestColor = { - Accept = AmHost, - Handle = function(data) - local TargetSlot = FindSlotForID(data.SenderID) - if IsColorFree(data.Color) then - -- Color is available, let everyone else know - SetPlayerColor(gameInfo.PlayerOptions[TargetSlot], data.Color) - lobbyComm:BroadcastData({ Type = 'SetColor', Color = data.Color, Slot = TargetSlot }) - SetSlotInfo(TargetSlot, gameInfo.PlayerOptions[TargetSlot]) - else - -- Sorry, it's not free. Force the player back to the color we have for him. - lobbyComm:SendData(data.SenderID, { - Type = 'SetColor', - Color = gameInfo.PlayerOptions[TargetSlot].PlayerColor, Slot = TargetSlot - }) - end - end - }, - - -- Sent to the host to advise them of which mods the players have. - SetAvailableMods = { - Accept = AmHost, - Handle = function(data) - availableMods[data.SenderID] = data.Mods - HostUtils.UpdateMods(data.SenderID, data.Name) - end - }, - - -- Sent by a non-host peer when a map is selected that they don't have installed. - MissingMap = { - Accept = AmHost, - Handle = function(data) - HostUtils.PlayerMissingMapAlert(data.SenderID) - end - }, - - -- This is insane. - SystemMessage = { - Handle = function(data) - PrintSystemMessage(data.Id, data.Args) - end - }, - - -- This is very insane and somewhat insecure... - Peer_Really_Disconnected = { - Handle = function(data) - if data.Observ == false then - gameInfo.PlayerOptions[data.Slot] = nil - elseif data.Observ == true then - gameInfo.Observers[data.Slot] = nil - end - AddChatText(LOCF("Lost connection to %s.", data.Options.PlayerName), "Engine0003") - ClearSlotInfo(data.Slot) - UpdateGame() - end - }, - - SetAllPlayerNotReady = { - Accept = IsFromHost, - Handle = function(data) - if not IsPlayer(localPlayerID) then - return - end - local localSlot = FindSlotForID(localPlayerID) - EnableSlot(localSlot) - GUI.becomeObserver:Enable() - SetPlayerOption(localSlot, 'Ready', false) - end - }, - - -- Host telling us about things changing in the game configuration... - - GameOptions = { - Accept = IsFromHost, - Handle = function(data) - for key, value in data.Options do - gameInfo.GameOptions[key] = value - end - - UpdateGame() - end - }, - ClearSlot = { - Accept = IsFromHost, - Handle = function(data) - gameInfo.PlayerOptions[data.Slot] = nil - ClearSlotInfo(data.Slot) - end - }, - ModsChanged = { - Accept = IsFromHost, - Handle = function(data) - gameInfo.GameMods = data.GameMods - - UpdateClientModStatus(data.GameMods) - UpdateGame() - import("/lua/ui/lobby/modsmanager.lua").UpdateClientModStatus(gameInfo.GameMods) - end - }, - SlotClosed = { - Accept = IsFromHost, - Handle = function(data) - gameInfo.ClosedSlots[data.Slot] = data.Closed - gameInfo.SpawnMex[data.Slot] = false - ClearSlotInfo(data.Slot) - PossiblyAnnounceGameFull() - end - }, - SlotClosedSpawnMex = { - Accept = IsFromHost, - Handle = function(data) - gameInfo.ClosedSlots[data.Slot] = data.ClosedSpawnMex - gameInfo.SpawnMex[data.Slot] = data.ClosedSpawnMex - ClearSlotInfo(data.Slot) - PossiblyAnnounceGameFull() - end - }, - GameInfo = { - Accept = IsFromHost, - Handle = function(data) - -- Completely update the game state. To be used exactly once: when first connecting. - local hostFlatInfo = data.GameInfo - gameInfo = GameInfo.CreateGameInfo(LobbyComm.maxPlayerSlots, hostFlatInfo) - - UpdateClientModStatus(gameInfo.GameMods, true) - UpdateGame() - end - }, - SetColor = { - Accept = IsFromHost, - Handle = function(data) - SetPlayerColor(gameInfo.PlayerOptions[data.Slot], data.Color) - SetSlotInfo(data.Slot, gameInfo.PlayerOptions[data.Slot]) - end - }, - ConvertObserverToPlayer = { - Accept = IsFromHost, - Handle = function(data) - gameInfo.Observers[data.OldSlot] = nil - gameInfo.PlayerOptions[data.NewSlot] = PlayerData(data.Options) - SetSlotInfo(data.NewSlot, gameInfo.PlayerOptions[data.NewSlot]) - UpdateFactionSelectorForPlayer(gameInfo.PlayerOptions[data.NewSlot]) - end - }, - - ConvertPlayerToObserver = { - Accept = IsFromHost, - Handle = function(data) - gameInfo.Observers[data.NewSlot] = PlayerData(data.Options) - gameInfo.PlayerOptions[data.OldSlot] = nil - ClearSlotInfo(data.OldSlot) - UpdateFactionSelectorForPlayer(gameInfo.Observers[data.NewSlot]) - end - }, - - SlotAssigned = { - Accept = IsFromHost, - Handle = function(data) - gameInfo.PlayerOptions[data.Slot] = PlayerData(data.Options) - if HasCommandLineArg('/gpgnet') then - PlayVoice(Sound{Bank = 'XGG',Cue = 'XGG_Computer__04716'}, true) - end - SetSlotInfo(data.Slot, gameInfo.PlayerOptions[data.Slot]) - UpdateFactionSelectorForPlayer(gameInfo.PlayerOptions[data.Slot]) - PossiblyAnnounceGameFull() - end - }, - - SlotMove = { - Accept = IsFromHost, - Handle = function(data) - gameInfo.PlayerOptions[data.OldSlot] = nil - gameInfo.PlayerOptions[data.NewSlot] = PlayerData(data.Options) - gameInfo.PlayerOptions[data.NewSlot].Ready = false - ClearSlotInfo(data.OldSlot) - SetSlotInfo(data.NewSlot, gameInfo.PlayerOptions[data.NewSlot]) - UpdateFactionSelectorForPlayer(gameInfo.PlayerOptions[data.NewSlot]) - end - }, - - SwapPlayers = { - Accept = IsFromHost, - Handle = function(data) - DoSlotSwap(data.Slot1, data.Slot2) - end - }, - - ObserverAdded = { - Accept = IsFromHost, - Handle = function(data) - gameInfo.Observers[data.Slot] = PlayerData(data.Options) - refreshObserverList() - end - }, - - -- Start the game! - Launch = { - Accept = IsFromHost, - Handle = function(data) - local info = data.GameInfo - info.GameMods = Mods.GetGameMods(info.GameMods) - SetWindowedLobby(false) - - -- Evil hack to correct the skin for randomfaction players before launch. - for index, player in info.PlayerOptions do - -- Set the skin to the faction you'll be playing as, whatever that may be. (prevents - -- random-faction people from ending up with something retarded) - if player.OwnerID == localPlayerID then - UIUtil.SetCurrentSkin(FACTION_NAMES[player.Faction]) - end - end - - Presets.SaveLastGamePreset() - lobbyComm:LaunchGame(info) - end - }, -} - - --- LobbyComm Callbacks -function InitLobbyComm(protocol, localPort, desiredPlayerName, localPlayerUID, natTraversalProvider) - lobbyComm = LobbyComm.CreateLobbyComm(protocol, localPort, desiredPlayerName, localPlayerUID, natTraversalProvider) - - if not lobbyComm then - error('Failed to create lobby using port ' .. tostring(localPort)) - end - - lobbyComm.ConnectionFailed = function(self, reason) - LOG("CONNECTION FAILED " .. reason) - GUI.connectionFailedDialog = UIUtil.ShowInfoDialog(GUI.panel, LOCF(Strings.ConnectionFailed, Strings[reason] or reason), - "", ReturnToMenu) - - lobbyComm:Destroy() - lobbyComm = nil - end - - lobbyComm.LaunchFailed = function(self,reasonKey) - AddChatText(LOC(Strings[reasonKey] or reasonKey)) - end - - lobbyComm.Ejected = function(self,reason) - LOG("EJECTED " .. reason) - - GUI.connectionFailedDialog = UIUtil.ShowInfoDialog(GUI, LOCF(Strings.Ejected, Strings[reason] or reason), "", ReturnToMenu) - lobbyComm:Destroy() - lobbyComm = nil - end - - lobbyComm.ConnectionToHostEstablished = function(self,myID,myName,theHostID) - LOG("CONNECTED TO HOST") - hostID = theHostID - localPlayerID = myID - localPlayerName = myName - - lobbyComm:SendData(hostID, { Type = 'SetAvailableMods', Mods = Mods.GetLocallyAvailableMods(), Name = localPlayerName}) - - lobbyComm:SendData(hostID, - { - Type = 'AddPlayer', - PlayerOptions = GetLocalPlayerData():AsTable() - } - ) - - -- Update, if needed, and broadcast, your CPU benchmark value. - if not singlePlayer then - ForkThread(function() UpdateBenchmark() end) - end - - local function KeepAliveThreadFunc() - local threshold = LobbyComm.quietTimeout - local active = true - local prev = 0 - while lobbyComm do - local host = lobbyComm:GetPeer(hostID) - if active and host.quiet > threshold then - active = false - local function OnRetry() - host = lobbyComm:GetPeer(hostID) - threshold = host.quiet + LobbyComm.quietTimeout - active = true - end - UIUtil.QuickDialog(GUI, "Connection to host timed out.", - "Keep Trying", OnRetry, - "Give Up", ReturnToMenu, - nil, nil, - true, - {worldCover = false, escapeButton = 2}) - elseif host.quiet < prev then - threshold = LobbyComm.quietTimeout - end - prev = host.quiet - WaitSeconds(1) - end - end -- KeepAliveThreadFunc - - GUI.keepAliveThread = ForkThread(KeepAliveThreadFunc) - CreateUI(LobbyComm.maxPlayerSlots) - 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 sent via `BroadcastData` and/or `SendData`. - ---@param self UILobbyCommunication - ---@param data UILobbyReceivedMessage - lobbyComm.DataReceived = function(self, data) - -- make it more convenient to debug malicious traffic - SPEW(string.format("Received data of type %s from %s (%s)", tostring(data.Type), tostring(data.SenderID), tostring(data.SenderName))) - - -- Decide if we should just drop the packet. Violations here are usually people using a - -- modified lobby.lua to try to do stupid shit. - if not MessageHandlers[data.Type] then - WARN("Unknown message type: " .. tostring(data.Type)) - return - end - - -- No defined validator is taken to be always-accept. - if not MessageHandlers[data.Type].Accept or MessageHandlers[data.Type].Accept(data) then - MessageHandlers[data.Type].Handle(data) - elseif MessageHandlers[data.Type].Reject then - MessageHandlers[data.Type].Reject(data) - else - WARN("Rejected message of type " .. tostring(data.Type) .. " from " .. tostring(FindNameForID(data.SenderID))) - end - end - - lobbyComm.SystemMessage = function(self, text) - AddChatText(text) - end - - lobbyComm.GameLaunched = function(self) - local player = lobbyComm:GetLocalPlayerID() - for i, v in gameInfo.PlayerOptions do - if v.Human and v.OwnerID == player then - Prefs.SetToCurrentProfile('LoadingFaction', v.Faction) - break - end - end - - GpgNetSend('GameState', 'Launching') - if GUI.pingThread then - KillThread(GUI.pingThread) - end - if GUI.keepAliveThread then - KillThread(GUI.keepAliveThread) - end - GUI:Destroy() - GUI = false - MenuCommon.MenuCleanup() - lobbyComm:Destroy() - lobbyComm = false - - -- determine if cheat keys should be mapped - if not DebugFacilitiesEnabled() then - IN_ClearKeyMap() - IN_AddKeyMapTable(import("/lua/keymap/keymapper.lua").GetKeyMappings(gameInfo.GameOptions['CheatsEnabled']=='true')) - end - end - - lobbyComm.Hosting = function(self) - InitHostUtils() - - localPlayerID = lobbyComm:GetLocalPlayerID() - hostID = localPlayerID - HostUtils.UpdateMods() - - --- Returns true if the given option has the given key as a valid setting. - local function keyIsValidForOption(option, key) - for k, v in option.values do - if v.key == key or v == key then - return true - end - end - return false - end - - -- Given an option key, find the value stored in the profile (if any) and assign either it, - -- or that option's default value, to the current game state. - local setOptionsFromPref = function(option) - local defValue = Prefs.GetFromCurrentProfile("LobbyOpt_" .. option.key) - - -- Do the slightly stupid thing to check if the option we found in the profile is - -- a valid key for this option. Some mods muck about with the possibilities, so we - -- need to make sure we use a sane default if that's happened. - if defValue == nil or not keyIsValidForOption(option, defValue) then - -- Exception to make AllowObservers work because the engine requires - -- the keys to be bool. Custom options should use 'True' or 'False' - if option.key == 'AllowObservers' then - defValue = option.values[option.default].key - else - defValue = option.values[option.default].key or option.values[option.default] - end - end - - SetGameOption(option.key, defValue, true) - end - - -- Give myself the first slot - local myPlayerData = GetLocalPlayerData() - - gameInfo.PlayerOptions[1] = myPlayerData - - -- set default lobby values - for index, option in globalOpts do - setOptionsFromPref(option) - end - - for index, option in teamOpts do - setOptionsFromPref(option) - end - - for index, option in AIOpts do - setOptionsFromPref(option) - end - - -- The key, LastScenario, is referred to from GPG code we don't hook. - if not self.desiredScenario or self.desiredScenario == "" then - self.desiredScenario = Prefs.GetFromCurrentProfile("LastScenario") - end - local scenarioInfo = MapUtil.LoadScenario(self.desiredScenario) - if not scenarioInfo or scenarioInfo.type != UIUtil.requiredType then - self.desiredScenario = UIUtil.defaultScenario - end - SetGameOption('ScenarioFile', self.desiredScenario, true) - - GUI.keepAliveThread = ForkThread( - -- Eject players who haven't sent a heartbeat in a while - function() - while true and lobbyComm do - local peers = lobbyComm:GetPeers() - for k,peer in peers do - if peer.quiet > LobbyComm.quietTimeout then - lobbyComm:EjectPeer(peer.id,'TimedOutToHost') - -- %s timed out. - SendSystemMessage("lobui_0205", peer.name) - - -- Search and Remove the peer disconnected - for k, v in CurrentConnection do - if v == peer.name then - CurrentConnection[k] = nil - break - end - end - for k, v in ConnectionEstablished do - if v == peer.name then - ConnectionEstablished[k] = nil - break - end - end - for k, v in ConnectedWithProxy do - if v == peer.id then - ConnectedWithProxy[k] = nil - break - end - end - end - end - WaitSeconds(1) - end - end - ) - - CreateUI(LobbyComm.maxPlayerSlots) - ForkThread(function() UpdateBenchmark(false) end) - - if argv.isRehost then - local settings = Presets.GetLastGameSettings() - if not settings then - ApplyGameSettings(settings) - end - - local rehostSlot = FindRehostSlotForID(localPlayerID) - if rehostSlot then - HostUtils.MovePlayerToEmptySlot(1, rehostSlot) - end - - for index, playerInfo in ipairs(rehostPlayerOptions) do - if not playerInfo.Human then - HostUtils.AddAI(playerInfo.PlayerName, playerInfo.AIPersonality, playerInfo.StartSpot) - end - end - end - - UpdateGame() - end - - lobbyComm.PeerDisconnected = function(self,peerName,peerID) - - -- Search and Remove the peer disconnected - for k, v in CurrentConnection do - if v == peerName then - CurrentConnection[k] = nil - break - end - end - for k, v in ConnectionEstablished do - if v == peerName then - ConnectionEstablished[k] = nil - break - end - end - for k, v in ConnectedWithProxy do - if v == peerID then - ConnectedWithProxy[k] = nil - break - end - end - - if IsPlayer(peerID) then - local slot = FindSlotForID(peerID) - if slot and lobbyComm:IsHost() then - if HasCommandLineArg('/gpgnet') then - PlayVoice(Sound{Bank = 'XGG',Cue = 'XGG_Computer__04717'}, true) - end - lobbyComm:BroadcastData( - { - Type = 'Peer_Really_Disconnected', - Options = gameInfo.PlayerOptions[slot]:AsTable(), - Slot = slot, - Observ = false, - } - ) - ClearSlotInfo(slot) - gameInfo.PlayerOptions[slot] = nil - UpdateGame() - end - elseif IsObserver(peerID) then - local slot2 = FindObserverSlotForID(peerID) - if slot2 and lobbyComm:IsHost() then - lobbyComm:BroadcastData( - { - Type = 'Peer_Really_Disconnected', - Options = gameInfo.Observers[slot2]:AsTable(), - Slot = slot2, - Observ = true, - } - ) - gameInfo.Observers[slot2] = nil - UpdateGame() - end - end - - availableMods[peerID] = nil - if HostUtils.UpdateMods then - HostUtils.UpdateMods() - end - end - - lobbyComm.GameConfigRequested = function(self) - return { - Options = gameInfo.GameOptions, - HostedBy = localPlayerName, - PlayerCount = GetPlayerCount(), - GameName = gameName, - ProductCode = import("/lua/productcode.lua").productCode, - } - end -end - -function SetPlayerOptions(slot, options, ignoreRefresh) - if not IsLocallyOwned(slot) and not lobbyComm:IsHost() then - WARN("Hey you can't set a player option on a slot you don't own:") - for key, val in options do - WARN("(slot:"..tostring(slot).." / key:"..tostring(key).." / val:"..tostring(val)..")") - end - return - end - - for key, val in options do - gameInfo.PlayerOptions[slot][key] = val - end - - lobbyComm:BroadcastData( - { - Type = 'PlayerOptions', - Options = options, - Slot = slot, - }) - - if not ignoreRefresh then - UpdateGame() - end -end - -function SetPlayerOption(slot, key, val, ignoreRefresh) - local options = {} - options[key] = val - SetPlayerOptions(slot, options, ignoreRefresh) - refreshObserverList() -end - -function SetGameOptions(options, ignoreRefresh) - if not lobbyComm:IsHost() then - WARN('Attempt to set game option by a non-host') - return - end - - for key, val in options do - Prefs.SetToCurrentProfile('LobbyOpt_' .. key, val) - gameInfo.GameOptions[key] = val - - -- don't want to send all restricted categories to gpgnet, so just send bool - -- note if more things need to be translated to gpgnet, a translation table would be a better implementation - -- but since there's only one, we'll call it out here - if key == 'RestrictedCategories' then - local restrictionsEnabled = false - if val ~= nil then - if not table.empty(val) then - restrictionsEnabled = true - end - end - GpgNetSend('GameOption', key, restrictionsEnabled) - elseif key == 'ScenarioFile' then - -- Special-snowflake the LastScenario key (used by GPG code). - Prefs.SetToCurrentProfile('LastScenario', val) - GpgNetSend('GameOption', key, val) - if gameInfo.GameOptions.ScenarioFile and (gameInfo.GameOptions.ScenarioFile ~= '') then - -- Warn about attempts to load nonexistent maps. - if not DiskGetFileInfo(gameInfo.GameOptions.ScenarioFile) then - AddChatText(LOC('The selected map does not exist.')) - else - local scenarioInfo = MapUtil.LoadScenario(gameInfo.GameOptions.ScenarioFile) - if scenarioInfo and scenarioInfo.map and (scenarioInfo.map ~= '') then - GpgNetSend('GameOption', 'Slots', table.getsize(scenarioInfo.Configurations.standard.teams[1].armies)) - end - end - end - else - GpgNetSend('GameOption', key, val) - end - end - - lobbyComm:BroadcastData { - Type = 'GameOptions', - Options = options - } - - if not ignoreRefresh then - UpdateGame() - end -end - -function SetGameOption(key, val, ignoreRefresh) - local options = {} - options[key] = val - SetGameOptions(options, ignoreRefresh) -end - -function DebugDump() - if lobbyComm then - lobbyComm:DebugDump() - end -end - --- Perform one-time setup of the large map preview -function CreateBigPreview(parent) - local scenarioInfo = MapUtil.LoadScenario(gameInfo.GameOptions.ScenarioFile) - if scenarioInfo.hidePreviewMarkers then - return - end - - if LrgMap then - LrgMap.isHidden = false - RefreshLargeMap() - LrgMap:Show() - return - end - - -- Size of the map preview to generate. - local MAP_PREVIEW_SIZE = 721 - - -- The size of the mass/hydrocarbon icons - local HYDROCARBON_ICON_SIZE = 14 - local MASS_ICON_SIZE = 10 - - local dialogContent = Group(parent) - LayoutHelpers.SetDimensions(dialogContent, MAP_PREVIEW_SIZE + 10, MAP_PREVIEW_SIZE + 10) - - LrgMap = Popup(parent, dialogContent) - - -- The LrgMap shouldn't be destroyed due to issues related to texture pooling. Evil hack ensues. - local onTryMapClose = function() - LrgMap:Hide() - LrgMap.isHidden = true - end - LrgMap.OnEscapePressed = onTryMapClose - LrgMap.OnShadowClicked = onTryMapClose - - -- Create the map preview - local mapPreview = ResourceMapPreview(dialogContent, MAP_PREVIEW_SIZE, MASS_ICON_SIZE, HYDROCARBON_ICON_SIZE) - dialogContent.mapPreview = mapPreview - LayoutHelpers.AtCenterIn(mapPreview, dialogContent) - - local closeBtn = UIUtil.CreateButtonStd(dialogContent, '/dialogs/close_btn/close') - LayoutHelpers.AtRightTopIn(closeBtn, dialogContent, 1, 1) - closeBtn.OnClick = onTryMapClose - - -- Keep the close button on top of the border (which is itself on top of the map preview) - LayoutHelpers.DepthOverParent(closeBtn, mapPreview, 2) - - RefreshLargeMap() -end - --- Refresh the large map preview (so it can update if something changes while it's open) -function RefreshLargeMap() - if not LrgMap or LrgMap.isHidden then - return - end - - local scenarioInfo = MapUtil.LoadScenario(gameInfo.GameOptions.ScenarioFile) - LrgMap.content.mapPreview:SetScenario(scenarioInfo, true) - ConfigureMapListeners(LrgMap.content.mapPreview, scenarioInfo) - ShowMapPositions(LrgMap.content.mapPreview, scenarioInfo) -end - --------------------------------------------------- --- Ping GUI Functions --------------------------------------------------- - -local ConnectionStatusInfo = { - 'Player is not connected to someone', - 'Connected', - 'Not Connected', - 'No connection info available', -} - -function Ping_AddControlTooltip(control, delay, slotNumber) - --This function creates the Ping tooltip for a slot along with necessary mouseover function. - --It is called during the UI creation. - -- control: The control to which the tooltip is to be added. - -- delay: Amount of time to delay before showing tooltip. See Tooltip.CreateMouseoverDisplay for info. - -- slotNumber: The slot number associated with the control. - local pingText = function() - local pingInfo - if GUI.slots[slotNumber].pingStatus.PingActualValue then - pingInfo = GUI.slots[slotNumber].pingStatus.PingActualValue - else - pingInfo = LOC('UnKnown') - end - return LOC('Ping: ') .. pingInfo - end - local pingBody = function() - local conInfo - if GUI.slots[slotNumber].pingStatus.ConnectionStatus then - conInfo = GUI.slots[slotNumber].pingStatus.ConnectionStatus - else - conInfo = 4 - end - return LOC('Only shows when > 500') .. '\n\n' .. LOC(ConnectionStatusInfo[conInfo]) - end - Tooltip.AddAutoUpdatedControlTooltip(control, pingText, pingBody, delay) -end - ---CPU Status Bar Configuration -local greenBarMax = 300 -local yellowBarMax = 375 -local scoreSkew1 = 0 --Skews all CPU scores up or down by the amount specified (0 = no skew) -local scoreSkew2 = 1.0 --Skews all CPU scores specified coefficient (1.0 = no skew) - ---Variables for CPU Test -local BenchTime - --------------------------------------------------- --- CPU Benchmarking Functions --------------------------------------------------- -function CPUBenchmark() - --This function gives the CPU some busy work to do. - --CPU score is determined by how quickly the work is completed. - local totalTime = 0 - local lastTime - local currTime - local countTime = 0 - --Make everything a local variable - --This is necessary because we don't want LUA searching through the globals as part of the benchmark - local TableInsert = table.insert - local TableRemove = table.remove - local h - local i - local j - local k - local l - local m - local n = {} - for h = 1, 24, 1 do - -- If the need for the benchmark no longer exists, abort it now. - if not lobbyComm then - return - end - - lastTime = GetSystemTimeSeconds() - for i = 1.0, 30.4, 0.0008 do - --This instruction set should cover most LUA operators - j = i + i --Addition - k = i * i --Multiplication - l = k / j --Division - m = j - i --Subtraction - j = math.pow(i, 4) --Power - l = -i --Negation - m = {'1234567890', 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', true} --Create Table - TableInsert(m, '1234567890') --Insert Table Value - k = i < j --Less Than - k = i == j --Equality - k = i <= j --Less Than or Equal to - k = not k - n[tostring(i)] = m - end - currTime = GetSystemTimeSeconds() - totalTime = totalTime + currTime - lastTime - - if totalTime > countTime then - --This is necessary in order to make this 'thread' yield so other things can be done. - countTime = totalTime + .125 - coroutine.yield(1) - end - end - BenchTime = math.ceil(totalTime * 100) -end - --------------------------------------------------- --- CPU GUI Functions --------------------------------------------------- -function CPU_AddControlTooltip(control, delay, slotNumber) - --This function creates the benchmark tooltip for a slot along with necessary mouseover function. - --It is called during the UI creation. - -- control: The control to which the tooltip is to be added. - -- delay: Amount of time to delay before showing tooltip. See Tooltip.CreateMouseoverDisplay for info. - -- slotNumber: The slot number associated with the control. - local CPUText = function() - local CPUInfo - if GUI.slots[slotNumber].CPUSpeedBar.CPUActualValue then - CPUInfo = GUI.slots[slotNumber].CPUSpeedBar.CPUActualValue - else - CPUInfo = LOC('UnKnown') - end - return LOC('CPU Rating: ') .. CPUInfo - end - local CPUBody = function() - return LOC('0=Fastest, 450=Slowest') - end - Tooltip.AddAutoUpdatedControlTooltip(control, CPUText, CPUBody, delay) -end - ---- Get the CPU benchmark score for the local machine. --- If a previously-calculated benchmark score is found in the profile, it is returned immediately. --- Otherwise, a fresh score is calculated and stored (enjoy the lag!). --- --- @param force If truthy, a fresh score is always calculated. --- @return A benchmark score between 0 and 450, or nil if StressCPU returned nil (which occurs iff --- the lobby is exited before the calculation is complete. Callers should abort gracefully --- in this situation). --- @see StressCPU -function GetBenchmarkScore(force) - local wait = 10 - local benchmark - - if force then - wait = 0 - else - benchmark = GetPreference('CPUBenchmark') - end - - if not benchmark then - -- We defer the calculation by 10s here because, often, non-forced requests are occurring on - -- startup, and we want to give other tasks, such as connection negotiation, a fighting - -- chance of completing before we ruin everything. - -- - -- Benchmark scores are associated with the machine, not the profile: hence SetPreference. - - benchmark = StressCPU(wait) - SetPreference('CPUBenchmark', benchmark) - end - - return benchmark -end - ---- Updates the displayed benchmark score for the local player. --- --- @param force Passed as the `force` parameter to GetBenchmarkScore. --- @see GetBenchmarkScore -function UpdateBenchmark(force) - local benchmark = GetBenchmarkScore(force) - - if benchmark then - CPU_Benchmarks[localPlayerName] = benchmark - lobbyComm:BroadcastData({ Type = 'CPUBenchmark', PlayerName = localPlayerName, Result = benchmark }) - if FindObserverSlotForID(localPlayerID) then - refreshObserverList() - else - UpdateCPUBar(localPlayerName) - end - end -end - --- This function instructs the PC to do a CPU score benchmark. --- It handles the necessary UI updates during the benchmark, sends --- the benchmark result to other players when finished, and it updates the local --- user's UI with their new result. --- waitTime: The delay in seconds that this function should wait before starting the benchmark. -function StressCPU(waitTime) - GUI.rerunBenchmark:Disable() - for i = waitTime, 1, -1 do - GUI.rerunBenchmark.label:SetText(i..'s') - WaitSeconds(1) - - -- lobbyComm is destroyed when the lobby is exited. If the user left the lobby, we no longer - -- want to run the benchmark (it just introduces lag as the user is trying to do something - -- else). - if not lobbyComm then - return - end - end - - --Get our last benchmark (if there was one) - local currentBestBenchmark = 10000 - - GUI.rerunBenchmark.label:SetText('. . .') - - --Run three benchmarks and keep the best one - for i=1, 3, 1 do - BenchTime = 0 - CPUBenchmark() - - BenchTime = scoreSkew2 * BenchTime + scoreSkew1 - - -- The bench might have yeilded to a launcher, so we verify the lobbyComm is available when - -- we need it in a moment here (as well as aborting if we're wasting our time more than usual) - if not lobbyComm then - return - end - - --If this benchmark was better than our best so far... - if BenchTime < currentBestBenchmark then - currentBestBenchmark = BenchTime - end - end - - --Reset Button UI - GUI.rerunBenchmark:Enable() - GUI.rerunBenchmark.label:SetText('') - - return currentBestBenchmark -end - -function UpdateCPUBar(playerName) - --This function updates the UI with a CPU benchmark bar for the specified playerName. - -- playerName: The name of the player whose benchmark should be updated. - local playerId = FindIDForName(playerName) - local playerSlot = FindSlotForID(playerId) - if playerSlot ~= nil then - SetSlotCPUBar(playerSlot, gameInfo.PlayerOptions[playerSlot]) - end -end - -function SetSlotCPUBar(slot, playerInfo) - --This function updates the UI with a CPU benchmark bar for the specified slot/playerInfo. - -- slot: a numbered slot (1-however many slots there are for this map) - -- playerInfo: The corresponding playerInfo object from gameInfo.PlayerOptions[slot]. - - - if GUI.slots[slot].CPUSpeedBar then - GUI.slots[slot].CPUSpeedBar:Hide() - if playerInfo.Human then - local b = CPU_Benchmarks[playerInfo.PlayerName] - if b then - -- For display purposes, the bar has a higher minimum that the actual barMin value. - -- This is to ensure that the bar is visible for very small values - - -- Lock values to the largest possible result. - if b > GUI.slots[slot].CPUSpeedBar.barMax then - b = GUI.slots[slot].CPUSpeedBar.barMax - end - - GUI.slots[slot].CPUSpeedBar:SetValue(b) - GUI.slots[slot].CPUSpeedBar.CPUActualValue = b - - GUI.slots[slot].CPUSpeedBar:Show() - end - end - end -end - -function SetGameTitleText(title) - GUI.titleText:SetColor("B9BFB9") - if title == '' then - title = LOC("FAF Game Lobby") - end - GUI.titleText:SetText(title) -end - -function ShowTitleDialog() - CreateInputDialog(GUI, "Game Title", - function(self, text) - -- remove new lines from the text - text = text:gsub("\r", "") - text = text:gsub("\n", "") - - SetGameOption("Title", text, true) - SetGameTitleText(text) - end, gameInfo.GameOptions.Title -) -end - --- Rule title -function SetRuleTitleText(rule) - GUI.RuleLabel:SetColors("B9BFB9") - if rule == '' then - if lobbyComm:IsHost() then - GUI.RuleLabel:SetColors("FFCC00") - rule = LOC("No Rules: Click to add rules") - else - rule = "No rules." - end - end - - GUI.RuleLabel:SetText(rule) -end - --- Show the rule change dialog. -function ShowRuleDialog() - CreateInputDialog(GUI, "Game Rules", - function(self, text) - SetGameOption("GameRules", text, true) - SetRuleTitleText(text) - end, gameInfo.GameOptions.GameRules -) -end - --- Faction selector -function CreateUI_Faction_Selector(lastFaction) - -- Build a list of button objects from the list of defined factions. Each faction will use the - -- faction key as its RadioButton texture path offset. - local buttons = {} - for i, faction in FactionData.Factions do - buttons[i] = { - texturePath = faction.Key - } - end - - -- Special-snowflaking for the random faction. - table.insert(buttons, { - texturePath = "random" - }) - - local factionSelector = RadioButton(GUI.panel, "/factionselector/", buttons, lastFaction, true) - GUI.factionSelector = factionSelector - LayoutHelpers.AtLeftTopIn(factionSelector, GUI.panel, 407, 20) - factionSelector.OnChoose = function(self, targetFaction, key) - local localSlot = FindSlotForID(localPlayerID) - local slotFactionIndex = GetSlotFactionIndex(targetFaction) - Prefs.SetToCurrentProfile('LastFaction', targetFaction) - GUI.slots[localSlot].faction:SetItem(slotFactionIndex) - SetPlayerOption(localSlot, 'Faction', slotFactionIndex) - gameInfo.PlayerOptions[localSlot].Faction = slotFactionIndex - - RefreshLobbyBackground(targetFaction) - UIUtil.SetCurrentSkin(FACTION_NAMES[targetFaction]) - end - - -- Only enable all buttons incase all the buttons are disabled, to avoid overriding partially disabling of the buttons - factionSelector.Enable = function(self) - for k, v in self.mButtons do - if v._controlState == "up" then - return - end - end - for k, v in self.mButtons do - v:Enable() - end - end - - factionSelector.SetCheck = function(self, index) - for i,button in self.mButtons do - if index ==i then - button:SetCheck(true) - else - button:SetCheck(false) - end - end - self.mCurSelection = index - end - - factionSelector.EnableSpecificButtons = function(self, specificButtons) - for i,button in self.mButtons do - if specificButtons[i] then - button:Enable() - else - button:Disable() - end - end - end -end - -function UpdateFactionSelectorForPlayer(playerInfo) - if playerInfo.OwnerID == localPlayerID then - UpdateFactionSelector() - end -end - -function UpdateFactionSelector() - local playerSlotID = FindSlotForID(localPlayerID) - local playerSlot = GUI.slots[playerSlotID] - if not playerSlot or not playerSlot.AvailableFactions or gameInfo.PlayerOptions[playerSlotID].Ready then - UIUtil.setEnabled(GUI.factionSelector, false) - return - end - local enabledList = {} - for index,button in GUI.factionSelector.mButtons do - enabledList[index] = false - for i,value in playerSlot.AvailableFactions do - if value == allAvailableFactionsList[index] then - if gameInfo.PlayerOptions[playerSlotID].Faction == i then - GUI.factionSelector:SetCheck(index) - end - enabledList[index] = true - break - end - end - end - GUI.factionSelector:EnableSpecificButtons(enabledList) -end - -function GetSlotFactionIndex(factionIndex) - local localSlot = GUI.slots[FindSlotForID(localPlayerID)] - local actualFaction = allAvailableFactionsList[factionIndex] - for index,value in localSlot.AvailableFactions do - if value == actualFaction then - return index - end - end -end - -function RefreshLobbyBackground(faction) - local LobbyBackground = Prefs.GetFromCurrentProfile('LobbyBackground') or 1 - if GUI.background then - GUI.background:Destroy() - end - if LobbyBackground == 1 then -- Factions - faction = faction or GetSanitisedLastFaction() - if FACTION_NAMES[faction] then - GUI.background = Bitmap(GUI, "/textures/ui/common/BACKGROUND/faction/faction-background-paint_" .. FACTION_NAMES[faction] .. "_bmp.dds") - else - return - end - elseif LobbyBackground == 2 then -- Concept art - GUI.background = Bitmap(GUI, "/textures/ui/common/BACKGROUND/art/art-background-paint0" .. math.random(1, 5) .. "_bmp.dds") - elseif LobbyBackground == 3 then -- Screenshot - GUI.background = Bitmap(GUI, "/textures/ui/common/BACKGROUND/scrn/scrn-background-paint" .. math.random(1, 14) .. "_bmp.dds") - elseif LobbyBackground == 4 then -- Map - local MapPreview = import("/lua/ui/controls/mappreview.lua").MapPreview - GUI.background = MapPreview(GUI) -- Background map - if gameInfo.GameOptions.ScenarioFile and (gameInfo.GameOptions.ScenarioFile ~= '') then - local scenarioInfo = MapUtil.LoadScenario(gameInfo.GameOptions.ScenarioFile) - if scenarioInfo and scenarioInfo.map and (scenarioInfo.map ~= '') and scenarioInfo.preview then - if not GUI.background:SetTexture(scenarioInfo.preview) then - GUI.background:SetTextureFromMap(scenarioInfo.map) - end - end - end - elseif LobbyBackground == 5 then -- None - return - end - - local LobbyBackgroundStretch = Prefs.GetFromCurrentProfile('LobbyBackgroundStretch') or 'true' - LayoutHelpers.AtCenterIn(GUI.background, GUI) - LayoutHelpers.DepthUnderParent(GUI.background, GUI.panel) - if LobbyBackgroundStretch == 'true' then - LayoutHelpers.FillParent(GUI.background, GUI) - else - LayoutHelpers.FillParentPreserveAspectRatio(GUI.background, GUI) - end -end - -function ShowLobbyOptionsDialog() - local dialogContent = Group(GUI) - LayoutHelpers.SetDimensions(dialogContent, 420, 310) - - local dialog = Popup(GUI, dialogContent) - GUI.lobbyOptionsDialog = dialog - - local buttons = { - { - label = LOC("") -- Factions - }, - { - label = LOC("") -- Concept art - }, - { - label = LOC("") -- Screenshot - }, - { - label = LOC("") -- Map - }, - { - label = LOC("") -- None - } - } - - -- Label shown above the background mode selection radiobutton. - local backgroundLabel = UIUtil.CreateText(dialogContent, LOC(" "), 16, 'Arial', true) - local selectedBackgroundState = Prefs.GetFromCurrentProfile("LobbyBackground") or 1 - local backgroundRadiobutton = RadioButton(dialogContent, '/RADIOBOX/', buttons, selectedBackgroundState, false, true) - - LayoutHelpers.AtLeftTopIn(backgroundLabel, dialogContent, 15, 15) - LayoutHelpers.AtLeftTopIn(backgroundRadiobutton, dialogContent, 15, 40) - - backgroundRadiobutton.OnChoose = function(self, index, key) - Prefs.SetToCurrentProfile("LobbyBackground", index) - RefreshLobbyBackground() - end - -- label for displaying chat font size - local currentFontSize = Prefs.GetFromCurrentProfile('LobbyChatFontSize') or 14 - local slider_Chat_SizeFont_TEXT = UIUtil.CreateText(dialogContent, LOC(" ").. currentFontSize, 14, 'Arial', true) - LayoutHelpers.AtRightTopIn(slider_Chat_SizeFont_TEXT, dialogContent, 27, 162) - - -- slider for changing chat font size - local slider_Chat_SizeFont = Slider(dialogContent, false, 9, 20, - UIUtil.SkinnableFile('/slider02/slider_btn_up.dds'), - UIUtil.SkinnableFile('/slider02/slider_btn_over.dds'), - UIUtil.SkinnableFile('/slider02/slider_btn_down.dds'), - UIUtil.SkinnableFile('/slider02/slider-back_bmp.dds')) - LayoutHelpers.AtRightTopIn(slider_Chat_SizeFont, dialogContent, 20, 182) - slider_Chat_SizeFont:SetValue(currentFontSize) - slider_Chat_SizeFont.OnValueChanged = function(self, newValue) - local isScrolledDown = GUI.chatPanel:IsScrolledToBottom() - - local sliderValue = math.floor(self._currentValue()) - slider_Chat_SizeFont_TEXT:SetText(LOC(" ").. sliderValue) - - Prefs.SetToCurrentProfile('LobbyChatFontSize', sliderValue) - -- updating chat panel with new font size - GUI.chatPanel:SetFont(nil, sliderValue) - - if isScrolledDown then - GUI.chatPanel:ScrollToBottom() - end - end - if true then - --snowflakes count - local currentSnowFlakesCount = Prefs.GetFromCurrentProfile('SnowFlakesCount') or 100 - local slider_SnowFlakes_Count_TEXT = UIUtil.CreateText(dialogContent, LOC("Snowflakes count").. currentSnowFlakesCount, 14, 'Arial', true) - LayoutHelpers.AtRightTopIn(slider_SnowFlakes_Count_TEXT, dialogContent, 27, 202) - - -- slider for changing chat font size - local slider_SnowFlakes_Count = Slider(dialogContent, false, 100, 1000, - UIUtil.SkinnableFile('/slider02/slider_btn_up.dds'), - UIUtil.SkinnableFile('/slider02/slider_btn_over.dds'), - UIUtil.SkinnableFile('/slider02/slider_btn_down.dds'), - UIUtil.SkinnableFile('/slider02/slider-back_bmp.dds')) - LayoutHelpers.AtRightTopIn(slider_SnowFlakes_Count, dialogContent, 20, 222) - slider_SnowFlakes_Count:SetValue(currentSnowFlakesCount) - slider_SnowFlakes_Count.OnValueChanged = function(self, newValue) - local sliderValue = math.floor(newValue) - slider_SnowFlakes_Count_TEXT:SetText(LOC("Snowflakes count").. sliderValue) - Prefs.SetToCurrentProfile('SnowFlakesCount', sliderValue) - import("/lua/ui/events/snowflake.lua").Clear() - import("/lua/ui/events/snowflake.lua").CreateSnowFlakes(GUI, sliderValue) - end - end - - - -- - local cbox_WindowedLobby = UIUtil.CreateCheckbox(dialogContent, '/CHECKBOX/', LOC("Windowed mode")) - LayoutHelpers.AtRightTopIn(cbox_WindowedLobby, dialogContent, 20, 42) - Tooltip.AddCheckboxTooltip(cbox_WindowedLobby, {text=LOC('Windowed mode'), body=LOC("")}) - cbox_WindowedLobby.OnCheck = function(self, checked) - local option - if checked then - option = 'true' - else - option = 'false' - end - Prefs.SetToCurrentProfile('WindowedLobby', option) - SetWindowedLobby(checked) - end - -- - local cbox_StretchBG = UIUtil.CreateCheckbox(dialogContent, '/CHECKBOX/', LOC("Stretch Background")) - LayoutHelpers.AtRightTopIn(cbox_StretchBG, dialogContent, 20, 68) - Tooltip.AddCheckboxTooltip(cbox_StretchBG, {text=LOC('Stretch Background'), body=LOC("")}) - cbox_StretchBG.OnCheck = function(self, checked) - if checked then - Prefs.SetToCurrentProfile('LobbyBackgroundStretch', 'true') - else - Prefs.SetToCurrentProfile('LobbyBackgroundStretch', 'false') - end - RefreshLobbyBackground() - end - - local cbox_FactionFontColor = UIUtil.CreateCheckbox(dialogContent, '/CHECKBOX/', LOC("Faction Font Color")) - LayoutHelpers.AtRightTopIn(cbox_FactionFontColor, dialogContent, 20, 94) - cbox_FactionFontColor.OnCheck = function(self, checked) - if checked then - Prefs.SetOption('faction_font_color', true) - else - Prefs.SetOption('faction_font_color', false) - end - UIUtil.UpdateCurrentSkin() - end - - local cbox_ChatPlayerColor = UIUtil.CreateCheckbox(dialogContent, '/CHECKBOX/', LOC("Player color in chat")) - LayoutHelpers.AtRightTopIn(cbox_ChatPlayerColor, dialogContent, 20, 120) - cbox_ChatPlayerColor.OnCheck = function(self, checked) - if checked then - Prefs.SetToCurrentProfile('ChatPlayerColor', true) - else - Prefs.SetToCurrentProfile('ChatPlayerColor', false) - end - end - - -- Quit button - local QuitButton = UIUtil.CreateButtonWithDropshadow(dialogContent, '/BUTTON/medium/', LOC("Close")) - LayoutHelpers.AtHorizontalCenterIn(QuitButton, dialogContent, 0) - LayoutHelpers.AtBottomIn(QuitButton, dialogContent, 10) - - QuitButton.OnClick = function(self) - dialog:Hide() - end - -- - local WindowedLobby = Prefs.GetFromCurrentProfile('WindowedLobby') or 'true' - cbox_WindowedLobby:SetCheck(WindowedLobby == 'true', true) - if defaultMode == 'windowed' then - -- Already set Windowed in Game - cbox_WindowedLobby:Disable() - end - -- - local lobbyBackgroundStretch = Prefs.GetFromCurrentProfile('LobbyBackgroundStretch') or 'true' - cbox_StretchBG:SetCheck(lobbyBackgroundStretch == 'true', true) - -- - local factionFontColor = Prefs.GetOption('faction_font_color') - cbox_FactionFontColor:SetCheck(factionFontColor == true, true) - - local chatPlayerColor = Prefs.GetFromCurrentProfile('ChatPlayerColor') - cbox_ChatPlayerColor:SetCheck(chatPlayerColor == true or chatPlayerColor == nil, true) -end - ----Applies new game settings, including map, mods ----@param settings WatchedGameData -function ApplyGameSettings(settings) - SetGameOptions(settings.GameOptions, true) - - rehostPlayerOptions = settings.PlayerOptions - selectedSimMods = settings.GameMods - HostUtils.UpdateMods() - - UpdateGame() -end ----Returns the current game settings ----@return WatchedGameData -function GetGameSettings() - return gameInfo -end - ---- Delegate to UIUtil's CreateInputDialog, adding the ridiculus chatEdit hack. -function CreateInputDialog(parent, title, listener, str) - UIUtil.CreateInputDialog(parent, title, listener, GUI.chatEdit, str) -end - --- Update the combobox for the given slot so it correctly shows the set of available colours. --- causes a new availableColours to be populated for each occupied slot --- if a slot is specified, that slot's displayed color will be made consistent with its coded color -function Check_Availaible_Color(slot) - -- generate a table of used colors - local UsedColours = {} - for i = 1, LobbyComm.maxPlayerSlots do - -- Skips empty slots. - if gameInfo.PlayerOptions[i] then - table.insert(UsedColours, gameInfo.PlayerOptions[i].PlayerColor) - end - end - - -- generate a table of unused colors - local unusedColours = {} - for i, color in gameColors.PlayerColors do - if not table.find(UsedColours, i) then - unusedColours[i] = color - end - end - - for i = 1, LobbyComm.maxPlayerSlots do - -- Skips empty slots. - if gameInfo.PlayerOptions[i] then - local availableColours = {} - -- deepcopy the unused colors because using them by reference causes problems with the displayed color list/ChangeBitmapArray - for c, color in unusedColours do - availableColours[c] = color - end - -- add this slot's color to its availableColours (you get a nil lazyvar error if it's not included) - availableColours[ gameInfo.PlayerOptions[i].PlayerColor] = gameColors.PlayerColors[gameInfo.PlayerOptions[i].PlayerColor] - -- set the list of available colors for this slot - GUI.slots[i].color:ChangeBitmapArray(availableColours, true) - end - end - - -- if a slot was entered, set the displayed color for that slot to the coded color for that slot - if slot then - GUI.slots[slot].color:SetItem(gameInfo.PlayerOptions[slot].PlayerColor) - end -end - -function CheckModCompatability() - local blacklistedMods = {} - for modId, _ in SetUtils.Union(selectedSimMods, selectedUIMods) do - if ModBlacklist[modId] then - blacklistedMods[modId] = ModBlacklist[modId] - end - end - - return blacklistedMods -end - -function WarnIncompatibleMods() - UIUtil.QuickDialog(GUI, - "Some of your enabled mods are known to cause malfunctions with FAF, so have been disabled. See the mod manager for details - some mods may have newer versions which work.", - "") -end - -function DoSlotSwap(slot1, slot2) - - -- retrieve player info - local player1 = gameInfo.PlayerOptions[slot1] - local player2 = gameInfo.PlayerOptions[slot2] - - -- unready players in the player options - player1.Ready = false - player2.Ready = false - - -- swap teams - local team_bucket = player1.Team - player1.Team = player2.Team - player2.Team = team_bucket - - --Handle faction availability - KeepSameFactionOrRandom(slot1, slot2, player1) - KeepSameFactionOrRandom(slot2, slot1, player2) - - -- swap the slots - gameInfo.PlayerOptions[slot2] = player1 - gameInfo.PlayerOptions[slot1] = player2 - - -- update slot info - SetSlotInfo(slot2, player1) - SetSlotInfo(slot1, player2) - - -- update faction selector - UpdateFactionSelectorForPlayer(player1) - UpdateFactionSelectorForPlayer(player2) - - UpdateGame() -end - -function KeepSameFactionOrRandom(slotFrom, slotTo, player) - local playerFactionKey = GUI.slots[slotFrom].AvailableFactions[player.Faction] - --intialize to random, incase oldFaction isn't available - player.Faction = table.getn(GUI.slots[slotTo].AvailableFactions) - for index,faction in GUI.slots[slotTo].AvailableFactions do - if faction == playerFactionKey then - player.Faction = index - end - end -end - -local function SendPlayerOption(playerInfo, key, value) - if playerInfo.Human then - GpgNetSend('PlayerOption', playerInfo.OwnerID, key, value) - else - GpgNetSend('AIOption', playerInfo.PlayerName, key, value) - end -end - ---- Create the HostUtils object, containing host-only functions. By not assigning this for non-host --- players, we ensure a hard crash should a non-host somehow end up trying to call them, simplifying --- debugging somewhat (as well as reducing the number of toplevel definitions a fair bit). --- This is clearly not a security feature. -function InitHostUtils() - if not lobbyComm:IsHost() then - WARN(debug.traceback(nil, "Attempt to create HostUtils by non-host.")) - return - end - - HostUtils = { - --- Cause a player's ready box to become unchecked. - -- - -- @param slot The slot number of the target player. - SetPlayerNotReady = function(slot) - local slotOptions = gameInfo.PlayerOptions[slot] - if slotOptions.Ready then - if not IsLocallyOwned(slot) then - lobbyComm:SendData(slotOptions.OwnerID, {Type = 'SetPlayerNotReady', Slot = slot}) - end - slotOptions.Ready = false - end - end, - - SetSlotClosed = function(slot, closed) - -- Don't close an occupied slot. - if gameInfo.PlayerOptions[slot] then - return - end - - lobbyComm:BroadcastData( - { - Type = 'SlotClosed', - Slot = slot, - Closed = closed - } - ) - - gameInfo.ClosedSlots[slot] = closed - gameInfo.SpawnMex[slot] = false - ClearSlotInfo(slot) - PossiblyAnnounceGameFull() - end, - - SetSlotClosedSpawnMex = function(slot) - -- Don't close an occupied slot. - if gameInfo.PlayerOptions[slot] then - return - end - - lobbyComm:BroadcastData( - { - Type = 'SlotClosedSpawnMex', - Slot = slot, - ClosedSpawnMex = true - } - ) - - gameInfo.ClosedSlots[slot] = true - gameInfo.SpawnMex[slot] = true - ClearSlotInfo(slot) - PossiblyAnnounceGameFull() - end, - - ConvertPlayerToObserver = function(playerSlot, ignoreMsg) - -- make sure player exists - if not gameInfo.PlayerOptions[playerSlot] then - WARN("HostUtils.ConvertPlayerToObserver for nonexistent player in slot " .. tostring(playerSlot)) - return - end - - -- find a free observer slot - local index = 1 - while gameInfo.Observers[index] do - index = index + 1 - end - - HostUtils.SetPlayerNotReady(playerSlot) - local ownerID = gameInfo.PlayerOptions[playerSlot].OwnerID - gameInfo.Observers[index] = gameInfo.PlayerOptions[playerSlot] - gameInfo.PlayerOptions[playerSlot] = nil - - if lobbyComm:IsHost() then - GpgNetSend('PlayerOption', ownerID, 'Team', -1) - GpgNetSend('PlayerOption', ownerID, 'Army', -1) - GpgNetSend('PlayerOption', ownerID, 'StartSpot', -index) - end - - ClearSlotInfo(playerSlot) - - -- TODO: can probably avoid transmitting the options map here. The slot number should be enough. - lobbyComm:BroadcastData( - { - Type = 'ConvertPlayerToObserver', - OldSlot = playerSlot, - NewSlot = index, - Options = gameInfo.Observers[index]:AsTable(), - } - ) - - if not ignoreMsg then - -- %s has switched from a player to an observer. - SendSystemMessage("lobui_0226", gameInfo.Observers[index].PlayerName) - end - - UpdateGame() - end, - - ConvertObserverToPlayer = function(fromObserverSlot, toPlayerSlot, ignoreMsg) - -- If no slot is specified (user clicked "go player" button), select a default. - if not toPlayerSlot or toPlayerSlot < 1 or toPlayerSlot > numOpenSlots then - toPlayerSlot = HostUtils.FindEmptySlot() - - -- If it's still -1 (No slots available) check for AIs and evict the first one - if toPlayerSlot < 1 then - for i = 1, numOpenSlots do - local slot = gameInfo.PlayerOptions[i] - if slot and not slot.Human then - HostUtils.RemoveAI(i) - toPlayerSlot = i - break - end - end - - -- There are no AIs and no slots, so break out with a message - if toPlayerSlot < 1 and gameInfo.Observers[fromObserverSlot] then - SendPersonalSystemMessage(gameInfo.Observers[fromObserverSlot].OwnerID, "lobui_0608") - return - end - end - end - - if not gameInfo.Observers[fromObserverSlot] then -- IF no Observer on the current slot : QUIT - return - elseif gameInfo.PlayerOptions[toPlayerSlot] then -- IF Player is in the target slot : QUIT - return - elseif gameInfo.ClosedSlots[toPlayerSlot] then -- IF target slot is Closed : QUIT - return - end - - local incomingPlayer = gameInfo.Observers[fromObserverSlot] - - -- Give them a default colour if the one they already have isn't free. - if not IsColorFree(incomingPlayer.PlayerColor) then - local newColour = GetAvailableColor() - SetPlayerColor(incomingPlayer, newColour) - end - - gameInfo.PlayerOptions[toPlayerSlot] = incomingPlayer - gameInfo.Observers[fromObserverSlot] = nil - - lobbyComm:BroadcastData( - { - Type = 'ConvertObserverToPlayer', - OldSlot = fromObserverSlot, - NewSlot = toPlayerSlot, - Options = gameInfo.PlayerOptions[toPlayerSlot]:AsTable(), - } - ) - - if not ignoreMsg then - -- %s has switched from an observer to player. - SendSystemMessage("lobui_0227", incomingPlayer.PlayerName) - end - - SetSlotInfo(toPlayerSlot, gameInfo.PlayerOptions[toPlayerSlot]) - - -- This is far from optimally efficient, as it will SetSlotInfo twice when autoteams is enabled. - AssignAutoTeams() - - UpdateFactionSelectorForPlayer(gameInfo.PlayerOptions[toPlayerSlot]) - end, - - RemoveAI = function(slot) - if gameInfo.PlayerOptions[slot].Human then - WARN('Use EjectPlayer to remove humans') - return - end - - gameInfo.PlayerOptions[slot] = nil - ClearSlotInfo(slot) - lobbyComm:BroadcastData( - { - Type = 'ClearSlot', - Slot = slot, - } - ) - end, - - --- Returns false if there's an obvious reason why a slot movement between the two given - -- slots will fail. - -- - -- @param moveFrom Slot number to move from - -- @param moveTo Slot number to move to. - SanityCheckSlotMovement = function(moveFrom, moveTo) - if moveTo == moveFrom then - -- no need to move a slot to its current location - return false - end - - if gameInfo.ClosedSlots[moveTo] then - LOG("HostUtils.MovePlayerToEmptySlot: requested slot " .. moveTo .. " is closed") - return false - end - - if moveTo > numOpenSlots or moveTo < 1 then - LOG("HostUtils.MovePlayerToEmptySlot: requested slot " .. moveTo .. " is out of range") - return false - end - - if moveFrom > numOpenSlots or moveFrom < 1 then - LOG("HostUtils.MovePlayerToEmptySlot: target slot " .. moveFrom .. " is out of range") - return false - end - - return true - end, - - --- Move a player from one slot to another, unoccupied one. Is a no-op if the requested slot - -- is occupied, closed, or out of range. Races over network may cause this to occur during - -- normal operation. - -- - -- @param currentSlot The slot occupied by the player to move - -- @param requestedSlot The slot to move this player to. - MovePlayerToEmptySlot = function(currentSlot, requestedSlot) - -- Bail out early for the stupid cases. - if not HostUtils.SanityCheckSlotMovement(currentSlot, requestedSlot) then - return - end - - -- This one's only specific to moving to an empty slot, naturally. - if gameInfo.PlayerOptions[requestedSlot] then - LOG("HostUtils.MovePlayerToEmptySlot: requested slot " .. requestedSlot .. " already occupied") - return false - end - - local player = gameInfo.PlayerOptions[currentSlot] - - KeepSameFactionOrRandom(currentSlot, requestedSlot, player) - - gameInfo.PlayerOptions[requestedSlot] = gameInfo.PlayerOptions[currentSlot] - gameInfo.PlayerOptions[currentSlot] = nil - ClearSlotInfo(currentSlot) - SetSlotInfo(requestedSlot, gameInfo.PlayerOptions[requestedSlot]) - - lobbyComm:BroadcastData( - { - Type = 'SlotMove', - OldSlot = currentSlot, - NewSlot = requestedSlot, - Options = gameInfo.PlayerOptions[requestedSlot]:AsTable(), - } - ) - - -- This is far from optimally efficient, as it will SetSlotInfo twice when autoteams is enabled. - AssignAutoTeams() - end, - - --- Swap the players in the two given slots. - -- - -- If the target slot is unoccupied, the player in the first slot is simply moved there. - -- If the target slot is closed, this is a no-op. - -- If a player or ai occupies both slots, they are swapped. - SwapPlayers = function(slot1, slot2) - - -- Bail out early for the stupid cases. - if not HostUtils.SanityCheckSlotMovement(slot1, slot2) then - return - end - - local player1 = gameInfo.PlayerOptions[slot1] - local player2 = gameInfo.PlayerOptions[slot2] - - -- If we're moving onto a blank, take the easy way out. - if not player2 then - HostUtils.MovePlayerToEmptySlot(slot1, slot2) - return - end - - -- Do the swap on our end - DoSlotSwap(slot1, slot2) - - -- Tell everyone else to do the swap too - lobbyComm:BroadcastData( - { - Type = 'SwapPlayers', - Slot1 = slot1, - Slot2 = slot2, - } - ) - - -- %s has switched with %s - SendSystemMessage("lobui_0417", player1.PlayerName, player2.PlayerName) - end, - - --- Add an observer - -- - -- @param observerData A PlayerData object representing this observer. - TryAddObserver = function(senderID, observerData) - local index = 1 - while gameInfo.Observers[index] do - index = index + 1 - end - - gameInfo.Observers[index] = observerData - - lobbyComm:BroadcastData( - { - Type = 'ObserverAdded', - Slot = index, - Options = observerData:AsTable(), - } - ) - - -- %s has joined as an observer. - SendSystemMessage("lobui_0202", observerData.PlayerName) - refreshObserverList() - end, - - --- Attempt to add a player to a slot. If none are available, add them as an observer. - -- - -- @param senderID The peer ID of the player we're adding. - -- @param slot The slot to insert the player to. A value of less than 1 indicates "any slot" - -- @param playerData A PlayerData object representing the player to add. - TryAddPlayer = function(senderID, slot, playerData) - -- CPU benchmark code - if playerData.Human and not singlePlayer then - lobbyComm:SendData(senderID, {Type='CPUBenchmark', Benchmarks=CPU_Benchmarks}) - end - - local newSlot = slot - - if not slot or slot < 1 or newSlot > numOpenSlots then - newSlot = HostUtils.FindEmptySlot() - end - - -- if no slot available, and human, try to make them an observer - if newSlot == -1 then - PrivateChat(senderID, LOC("No slots available, attempting to make you an observer")) - if playerData.Human then - HostUtils.TryAddObserver(senderID, playerData) - end - return - end - - -- if a color is requested, attempt to use that color if available, otherwise, assign first available - if not IsColorFree(playerData.PlayerColor, slot) then - SetPlayerColor(playerData, GetAvailableColor()) - end - - gameInfo.PlayerOptions[newSlot] = playerData - lobbyComm:BroadcastData( - { - Type = 'SlotAssigned', - Slot = newSlot, - Options = playerData:AsTable(), - } - ) - - SetSlotInfo(newSlot, gameInfo.PlayerOptions[newSlot]) - -- This is far from optimally efficient, as it will SetSlotInfo twice when autoteams is enabled. - AssignAutoTeams() - PossiblyAnnounceGameFull() - end, - - --- Add an AI to the game in the given slot. - -- - -- @param name The name to use in the player list for this AI. - -- @param personality The "personality" key, such as "adaptive" or "easy", for this AI. - -- @param slot (optional) The slot into which to put this AI. Defaults to the first empty - -- slot from the top of the list. - AddAI = function(name, personality, slot) - HostUtils.TryAddPlayer(hostID, slot, GetAIPlayerData(name, personality, slot)) - end, - - PlayerMissingMapAlert = function(id) - local slot = FindSlotForID(id) - local name - local needMessage = false - if slot then - name = gameInfo.PlayerOptions[slot].PlayerName - if not gameInfo.PlayerOptions[slot].BadMap then - needMessage = true - end - gameInfo.PlayerOptions[slot].BadMap = true - else - slot = FindObserverSlotForID(id) - if slot then - name = gameInfo.Observers[slot].PlayerName - if not gameInfo.Observers[slot].BadMap then - needMessage = true - end - gameInfo.Observers[slot].BadMap = true - end - end - - if needMessage then - -- %s is missing map %s. - SendSystemMessage("lobui_0330", name, gameInfo.GameOptions.ScenarioFile) - LOG('>> '..name..' is missing map '..gameInfo.GameOptions.ScenarioFile) - if name == localPlayerName then - LOG('>> '..gameInfo.GameOptions.ScenarioFile..' replaced with '.. UIUtil.defaultScenario) - SetGameOption('ScenarioFile', UIUtil.defaultScenario) - end - end - end, - - -- This function is needed because army numbers need to be calculated: armies are numbered incrementally in slot order. - -- Call this function once just before game starts - SendArmySettingsToServer = function() - local armyIdx = 1 - local MAXSLOT = 16 - for slotNum = 1, MAXSLOT do - local playerInfo = gameInfo.PlayerOptions[slotNum] - if playerInfo ~= nil then - LOG('>> SendArmySettingsToServer: Setting army '..armyIdx..' for slot '..slotNum) - SendPlayerOption(playerInfo, 'Army', armyIdx) - armyIdx = armyIdx + 1 - else - LOG('>> SendArmySettingsToServer: Slot '..slotNum..' empty') - end - end - end, - - --- Send player settings to the server - SendPlayerSettingsToServer = function(slotNum) - local playerInfo = gameInfo.PlayerOptions[slotNum] - SendPlayerOption(playerInfo, 'Faction', playerInfo.Faction) - SendPlayerOption(playerInfo, 'Color', playerInfo.PlayerColor) - SendPlayerOption(playerInfo, 'Team', playerInfo.Team) - SendPlayerOption(playerInfo, 'StartSpot', slotNum) - end, - - --- Called by the host when someone's readyness state changes to update the enabledness of buttons. - RefreshButtonEnabledness = function() - -- disable options when all players are marked ready - -- Is at least one person not ready? - local playerNotReady = GetPlayersNotReady() ~= false - - -- Host should be able to set game options even if he is observer if all slots are AI - local hostObserves = false - if not playerNotReady and IsObserver(localPlayerID) then - hostObserves = true - end - - local buttonState = hostObserves or playerNotReady - - UIUtil.setEnabled(GUI.gameoptionsButton, buttonState) - UIUtil.setEnabled(GUI.defaultOptions, buttonState) - UIUtil.setEnabled(GUI.randMap, buttonState) - UIUtil.setEnabled(GUI.autoTeams, buttonState) - UIUtil.setEnabled(GUI.restrictedUnitsOrPresetsBtn, buttonState) - - -- Launch button enabled if everyone is ready. - UIUtil.setEnabled(GUI.launchGameButton, singlePlayer or hostObserves or not playerNotReady) - - -- Disable the AutoBalance button if any players are on a tean greater than 2 or buttonState is false - local noOtherTeams = true - for i, player in gameInfo.PlayerOptions:pairs() do - -- Team numbers are 1 higher on the backend - if player.Team > 3 then - noOtherTeams = false - break - end - end - UIUtil.setEnabled(GUI.PenguinAutoBalance, noOtherTeams and buttonState) - end, - - -- Update our local gameInfo.GameMods from selected map name and selected mods, then - -- notify other clients about the change. - UpdateMods = function(newPlayerID, newPlayerName) - local newmods = {} - local missingmods = {} - local blacklistedMods = {} - - -- If any of our active sim mods are blacklisted, warn the user, turn them off, and - -- go through the "mods changed" handler code again with the smaller set. - local bannedMods = CheckModCompatability() - if not table.empty(bannedMods) then - WarnIncompatibleMods() - - selectedSimMods = SetUtils.Subtract(selectedSimMods, bannedMods) - selectedUIMods = SetUtils.Subtract(selectedUIMods, bannedMods) - OnModsChanged(selectedSimMods, selectedUIMods) - - return - end - - for modId, _ in selectedSimMods do - if IsModAvailable(modId) then - newmods[modId] = true - else - table.insert(missingmods, modId) - end - end - - -- We were called to update the sim mod set for the game, and have _really_ made changes - if not table.equal(gameInfo.GameMods, newmods) and not newPlayerID then - gameInfo.GameMods = newmods - lobbyComm:BroadcastData { Type = "ModsChanged", GameMods = gameInfo.GameMods } - local nummods = 0 - local uids = "" - - for k in gameInfo.GameMods do - nummods = nummods + 1 - if uids == "" then - uids = k - else - uids = string.format("%s %s", uids, k) - end - - end - GpgNetSend('GameMods', "activated", nummods) - - if nummods > 0 then - GpgNetSend('GameMods', "uids", uids) - end - elseif not table.equal(gameInfo.GameMods, newmods) and newPlayerID then - local modnames = "" - local totalMissing = table.getn(missingmods) - local totalListed = 0 - if totalMissing > 0 then - for index, id in missingmods do - for k,mod in Mods.GetGameMods() do - if mod.uid == id then - totalListed = totalListed + 1 - if totalMissing == totalListed then - modnames = modnames .. " " .. mod.name - else - modnames = modnames .. " " .. mod.name .. " + " - end - end - end - end - end - local reason = (LOCF('You were automaticly removed from the lobby because you ' .. - 'don\'t have the following mod(s):\n%s \nPlease, install the mod before you join the game lobby', modnames)) - -- TODO: Verify this functionality - if FindNameForID(newPlayerID) then - AddChatText(FindNameForID(newPlayerID)..LOC(' kicked because he does not have this mod: ')..modnames) - else - if newPlayerName then - AddChatText(newPlayerName..LOC(' kicked because he does not have this mod: ')..modnames) - else - AddChatText(LOC('The last player is kicked because he does not have this mod: ')..modnames) - end - end - lobbyComm:EjectPeer(newPlayerID, reason) - end - end, - - --- Find and return the id of an unoccupied slot. - -- - -- @return The id of an empty slot, of -1 if none is available. - FindEmptySlot = function() - for i = 1, numOpenSlots do - if not gameInfo.PlayerOptions[i] and not gameInfo.ClosedSlots[i] then - return i - end - end - - return -1 - end, - - KickObservers = function(reason) - for k,observer in gameInfo.Observers:pairs() do - lobbyComm:EjectPeer(observer.OwnerID, reason or "KickedByHost") - end - gameInfo.Observers = WatchedValueArray(LobbyComm.maxPlayerSlots) - end - } end diff --git a/lua/ui/uimain.lua b/lua/ui/uimain.lua index 361f55581cc..52827727185 100644 --- a/lua/ui/uimain.lua +++ b/lua/ui/uimain.lua @@ -76,7 +76,7 @@ function StartHostLobbyUI(protocol, port, playerName, gameName, mapFile, natTrav LOG("Hosting lobby from the command line") local lobby -- auto lobby only works with 2+ players - local autoStart = GetCommandLineArg("/players", 1)[1] >= 2 + local autoStart = (GetCommandLineArg("/players", 1)[1] or 0) >= 2 if autoStart then lobby = import("/lua/ui/lobby/autolobby.lua") else @@ -97,7 +97,7 @@ function StartJoinLobbyUI(protocol, address, playerName, natTraversalProvider) LOG("Joining lobby from the command line") -- can also be from lobby.lua ReturnToMenu(true), but that never gets called local lobby -- auto lobby only works with 2+ players - local autoStart = GetCommandLineArg("/players", 1)[1] >= 2 + local autoStart = (GetCommandLineArg("/players", 1)[1] or 0) >= 2 if autoStart then lobby = import("/lua/ui/lobby/autolobby.lua") else From ba57fb60ef2efba35601a1b0e3abe85332cae94d Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 21 Jun 2026 22:06:03 +0200 Subject: [PATCH 06/98] Delen van de CPU score --- lua/ui/lobby/customlobby/CLAUDE.md | 47 ++-- .../CustomLobbyAuthoritativeModel.lua | 230 ++++++++++++++++++ .../customlobby/CustomLobbyController.lua | 152 ++++++++++-- .../customlobby/CustomLobbyInterface.lua | 16 +- .../lobby/customlobby/CustomLobbyMessages.lua | 30 ++- lua/ui/lobby/customlobby/CustomLobbyModel.lua | 183 ++------------ .../customlobby/CustomLobbySlotInterface.lua | 35 ++- lua/ui/lobby/lobby.lua | 6 +- 8 files changed, 481 insertions(+), 218 deletions(-) create mode 100644 lua/ui/lobby/customlobby/CustomLobbyAuthoritativeModel.lua diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index eb3cb9a286a..fa0ea42a9ba 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -16,32 +16,41 @@ Folder `customlobby/`, class/module prefix `CustomLobby` — pairs with `autolob `Autolobby*`. "Custom" is FAF's own term for non-matchmaker games (the autolobby is the automated/matchmaker path). -## Status — first vertical slice +## Two models -Built so far (no networking yet — driven by the model + a debug entry point): +- **[CustomLobbyAuthoritativeModel.lua](CustomLobbyAuthoritativeModel.lua)** — the + state the **host dictates** and that becomes part of the launched scenario: players, + game options, mods, scenario, slot flags, identity. `Players` is an **array of + per-slot LazyVars** so one slot's change re-fires only that row. Write helpers + (`SetPlayer`, `SetPlayerField`, …) keep the copy-then-`Set` discipline. +- **[CustomLobbyModel.lua](CustomLobbyModel.lua)** — general, local, high-frequency + state that is **not** part of the scenario (CPU benchmarks now; ping / connection + status later). Kept separate so its churn doesn't dirty the authoritative snapshot. + +## Status | File | Role | |------|------| -| [CustomLobbyModel.lua](CustomLobbyModel.lua) | reactive model singleton. `Players` is an **array of per-slot LazyVars** so one slot's change re-fires only that row. Write helpers (`SetPlayer`, `SetPlayerField`, …) keep the copy-then-`Set` discipline. | -| [CustomLobbySlotInterface.lua](CustomLobbySlotInterface.lua) | one slot row; subscribes to `model.Players[slot]`, renders it (read-only for now). | -| [CustomLobbyInterface.lua](CustomLobbyInterface.lua) | composition root (no subscriptions of its own); lays out the slot rows; `OpenDebug()` / `SetupSingleton` / hot-reload. | - -Inspect it without networking: - -``` -UI_Lua import("/lua/ui/lobby/customlobby/customlobbyinterface.lua").OpenDebug() -``` - -`CloseDebug()` tears it down. For the real flow, `scripts/LaunchCustomLobby.ps1` -launches host + clients into the regular lobby. +| [CustomLobbyAuthoritativeModel.lua](CustomLobbyAuthoritativeModel.lua) | host-dictated / scenario state (see above). | +| [CustomLobbyModel.lua](CustomLobbyModel.lua) | local connectivity state (CPU benchmarks). | +| [CustomLobbyInstance.lua](CustomLobbyInstance.lua) | thin `moho.lobby_methods` shell; validates/dispatches traffic, forwards callbacks to the controller. | +| [CustomLobbyController.lua](CustomLobbyController.lua) | host-authority logic (free functions): seating, `Process*` handlers, intents (`RequestSetReady`), CPU benchmark. | +| [CustomLobbyMessages.lua](CustomLobbyMessages.lua) | message registry: `AddPlayer`, `SetPlayers`, `SetReady`, `CPUBenchmark`, `SetCpuBenchmarks`. | +| [CustomLobbySlotInterface.lua](CustomLobbySlotInterface.lua) | one slot row; subscribes to its slot + CPU benchmarks; click toggles own ready. | +| [CustomLobbyInterface.lua](CustomLobbyInterface.lua) | composition root (no subscriptions of its own); lays out slot rows; `OpenDebug()` / hot-reload. | +| [/lua/ui/lobby/lobby.lua](../lobby.lua) | engine entry wrapper (`CreateLobby`/`HostGame`/`JoinGame`) → CustomLobby. Old lobby preserved at `lobby-old.lua`. | + +Working today: host + clients see each other (host-authoritative player sync), +ready toggles round-trip, and CPU benchmarks are shared. Launched via +`scripts/LaunchCustomLobby.ps1`, or inspect UI only with +`UI_Lua import("/lua/ui/lobby/customlobby/customlobbyinterface.lua").OpenDebug()`. ## Next slices (per TARGET_ARCHITECTURE.md) -1. `CustomLobbyController` (free functions) + `CustomLobbyInstance` (thin `moho` shell) + - `CustomLobbyMessages` registry — the host-authority request→validate→broadcast flow. -2. Make slot controls interactive (faction/colour/team/ready → controller **intents**). -3. Options panel, map preview, observer list, footer, chat — each its own subscribing component. -4. Sub-dialogs (map select, mods, units, presets, prefs) as mini-MVC. +1. Make slot controls interactive (faction/colour/team → controller **intents**). +2. Options panel, map preview, observer list, footer, chat — each its own subscribing component. +3. Sub-dialogs (map select, mods, units, presets, prefs) as mini-MVC. +4. Map-derived `SlotCount`; the GPGNet (`localPort == -1`) FAF-client path. ## Rules (same as autolobby) diff --git a/lua/ui/lobby/customlobby/CustomLobbyAuthoritativeModel.lua b/lua/ui/lobby/customlobby/CustomLobbyAuthoritativeModel.lua new file mode 100644 index 00000000000..e04c4c362d2 --- /dev/null +++ b/lua/ui/lobby/customlobby/CustomLobbyAuthoritativeModel.lua @@ -0,0 +1,230 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- Reactive state singleton for the (custom-games) lobby — the single source of truth +-- that the controller writes to and the components observe via `Derive`. It holds no +-- UI references and no networking. +-- +-- This is the gameInfo replacement from TARGET_ARCHITECTURE.md. Players live as an +-- array of per-slot LazyVars so a change to one slot only re-fires that slot's row +-- (replacing the legacy `SetSlotInfo` whole-row sledgehammer). +-- +-- Patterns mirror /lua/ui/lobby/autolobby/AutolobbyModel.lua and +-- /lua/ui/game/chat/ChatModel.lua. See /lua/ui/CLAUDE.md for the reactivity rules +-- (notably: never mutate a held table in place — copy then `:Set`). + +local Create = import("/lua/lazyvar.lua").Create + +--- Maximum number of player slots the engine supports. +MaxSlots = 16 + +------------------------------------------------------------------------------- +--#region Shapes + +--- A player (human or AI) occupying a slot. Mirrors the fields the legacy lobby's +--- PlayerData carries; trimmed to what the UI reads. +---@class UICustomLobbyPlayer +---@field PlayerName string +---@field OwnerID UILobbyPeerId +---@field Human boolean +---@field Faction number # 1=UEF 2=Aeon 3=Cybran 4=Seraphim 5=Random +---@field PlayerColor number +---@field ArmyColor number +---@field Team number # 1 = no team (FFA), 2..9 = teams 1..8 +---@field StartSpot number +---@field Ready boolean +---@field PL? number # rating +---@field MEAN? number +---@field DEV? number +---@field NG? number # number of games +---@field PlayerClan? string +---@field Country? string +---@field AIPersonality? string + +--#endregion + +------------------------------------------------------------------------------- +--#region Reactive model + +--- Reactive lobby-state singleton. +---@class UICustomLobbyAuthoritativeModel +---@field SlotCount LazyVar # player slots the current map supports +---@field Players LazyVar[] # one LazyVar per slot (1..MaxSlots); false = empty +---@field Observers LazyVar # observer list +---@field ClosedSlots LazyVar> +---@field SpawnMex LazyVar> +---@field AutoTeams LazyVar> +---@field GameOptions LazyVar
+---@field GameMods LazyVar
+---@field ScenarioFile LazyVar +---@field LocalPeerId LazyVar +---@field HostID LazyVar +---@field IsHost LazyVar + +---@type UICustomLobbyAuthoritativeModel | nil +local ModelInstance = nil + +--- Allocates a fresh model singleton, replacing any existing instance. +---@param slotCount? number +---@return UICustomLobbyAuthoritativeModel +function SetupSingleton(slotCount) + slotCount = slotCount or 8 + + local players = {} + for slot = 1, MaxSlots do + players[slot] = Create(false) + end + + ---@type UICustomLobbyAuthoritativeModel + local model = { + SlotCount = Create(slotCount), + Players = players, + Observers = Create({}), + ClosedSlots = Create({}), + SpawnMex = Create({}), + AutoTeams = Create({}), + GameOptions = Create({}), + GameMods = Create({}), + ScenarioFile = Create(false), + LocalPeerId = Create("-1"), + HostID = Create("-1"), + IsHost = Create(false), + } + + ModelInstance = model + return model +end + +--- Returns the model singleton, creating it on first access. +---@return UICustomLobbyAuthoritativeModel +function GetSingleton() + if not ModelInstance then + SetupSingleton() + end + return ModelInstance --[[@as UICustomLobbyAuthoritativeModel]] +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Write helpers +-- +-- The synced tables are LazyVar values, so a write must build a NEW table/value and +-- `:Set` it — mutating in place never marks dependents dirty (see /lua/ui/CLAUDE.md +-- § 2). These helpers keep that discipline in one place so the controller can't get +-- it wrong. + +--- Places (or replaces) a player at a slot. +---@param model UICustomLobbyAuthoritativeModel +---@param slot number +---@param player UICustomLobbyPlayer +function SetPlayer(model, slot, player) + model.Players[slot]:Set(player) +end + +--- Empties a slot. +---@param model UICustomLobbyAuthoritativeModel +---@param slot number +function ClearPlayer(model, slot) + model.Players[slot]:Set(false) +end + +--- Sets a single field on the player in a slot (copy-then-Set on that slot only). +---@param model UICustomLobbyAuthoritativeModel +---@param slot number +---@param key string +---@param value any +function SetPlayerField(model, slot, key, value) + local current = model.Players[slot]() + if not current then + return + end + local player = table.copy(current) + player[key] = value + model.Players[slot]:Set(player) +end + +--- Sets a single game option (copy-then-Set). +---@param model UICustomLobbyAuthoritativeModel +---@param key string +---@param value any +function SetGameOption(model, key, value) + local options = table.copy(model.GameOptions()) + options[key] = value + model.GameOptions:Set(options) +end + +--- Sets the closed flag for a slot (copy-then-Set). +---@param model UICustomLobbyAuthoritativeModel +---@param slot number +---@param closed boolean +function SetClosed(model, slot, closed) + local closedSlots = table.copy(model.ClosedSlots()) + closedSlots[slot] = closed + model.ClosedSlots:Set(closedSlots) +end + +--- Sets the scenario file. +---@param model UICustomLobbyAuthoritativeModel +---@param scenarioFile FileName | false +function SetScenario(model, scenarioFile) + model.ScenarioFile:Set(scenarioFile) +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Debugging + +--- Hot-reload hook: rebuilds the singleton on the new module and copies the current +--- raw LazyVar values across so observers don't see a state reset. +---@param newModule any +function __moduleinfo.OnReload(newModule) + if ModelInstance then + local handle = newModule.SetupSingleton(ModelInstance.SlotCount()) + for slot = 1, MaxSlots do + handle.Players[slot]:Set(ModelInstance.Players[slot]()) + end + handle.Observers:Set(ModelInstance.Observers()) + handle.ClosedSlots:Set(ModelInstance.ClosedSlots()) + handle.SpawnMex:Set(ModelInstance.SpawnMex()) + handle.AutoTeams:Set(ModelInstance.AutoTeams()) + handle.GameOptions:Set(ModelInstance.GameOptions()) + handle.GameMods:Set(ModelInstance.GameMods()) + handle.ScenarioFile:Set(ModelInstance.ScenarioFile()) + handle.LocalPeerId:Set(ModelInstance.LocalPeerId()) + handle.HostID:Set(ModelInstance.HostID()) + handle.IsHost:Set(ModelInstance.IsHost()) + end +end + +--- Hot-reload hook: re-imports this module after a couple of frames. +function __moduleinfo.OnDirty() + ForkThread( + function() + WaitFrames(2) + import(__moduleinfo.name) + end + ) +end + +--#endregion diff --git a/lua/ui/lobby/customlobby/CustomLobbyController.lua b/lua/ui/lobby/customlobby/CustomLobbyController.lua index 31c79f2fd40..90d67550880 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyController.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyController.lua @@ -22,7 +22,7 @@ -- Lobby logic for the custom lobby, as free functions (the autolobby pattern). The -- engine instantiates the thin CustomLobbyInstance and forwards its callbacks here, --- passing itself as `instance`. All state goes to CustomLobbyModel via its write +-- passing itself as `instance`. All state goes to CustomLobbyAuthoritativeModel via its write -- helpers; the host is authoritative. -- -- Barebones host-authority flow: @@ -34,6 +34,7 @@ -- -- See /lua/ui/lobby/TARGET_ARCHITECTURE.md § 5. +local CustomLobbyAuthoritativeModel = import("/lua/ui/lobby/customlobby/customlobbyauthoritativemodel.lua") local CustomLobbyModel = import("/lua/ui/lobby/customlobby/customlobbymodel.lua") --- The live lobby object, set by the first engine callback. UI-triggered intents @@ -45,7 +46,7 @@ local LobbyInstance = false --#region Helpers --- First empty player slot within the active slot count, or nil. ----@param model UICustomLobbyModel +---@param model UICustomLobbyAuthoritativeModel ---@return number | nil local function FindFreeSlot(model) for slot = 1, model.SlotCount() do @@ -57,11 +58,11 @@ local function FindFreeSlot(model) end --- The slot a peer occupies, or nil. ----@param model UICustomLobbyModel +---@param model UICustomLobbyAuthoritativeModel ---@param ownerId UILobbyPeerId ---@return number | nil local function FindSlotForOwner(model, ownerId) - for slot = 1, CustomLobbyModel.MaxSlots do + for slot = 1, CustomLobbyAuthoritativeModel.MaxSlots do local player = model.Players[slot]() if player and player.OwnerID == ownerId then return slot @@ -71,11 +72,11 @@ local function FindSlotForOwner(model, ownerId) end --- A plain per-slot snapshot of all players (false for empty), for the wire. ----@param model UICustomLobbyModel +---@param model UICustomLobbyAuthoritativeModel ---@return table local function GatherPlayers(model) local players = {} - for slot = 1, CustomLobbyModel.MaxSlots do + for slot = 1, CustomLobbyAuthoritativeModel.MaxSlots do players[slot] = model.Players[slot]() or false end return players @@ -83,7 +84,7 @@ end --- Host broadcasts the authoritative player snapshot to everyone. ---@param instance UICustomLobbyInstance ----@param model UICustomLobbyModel +---@param model UICustomLobbyAuthoritativeModel local function BroadcastPlayers(instance, model) instance:BroadcastData({ Type = 'SetPlayers', Players = GatherPlayers(model) }) end @@ -106,7 +107,7 @@ end function OnHosting(instance) LobbyInstance = instance - local model = CustomLobbyModel.GetSingleton() + local model = CustomLobbyAuthoritativeModel.GetSingleton() local id = instance:GetLocalPlayerID() model.LocalPeerId:Set(id) model.HostID:Set(id) @@ -117,7 +118,9 @@ function OnHosting(instance) player.StartSpot = 1 player.PlayerColor = 1 player.ArmyColor = 1 - CustomLobbyModel.SetPlayer(model, 1, player) + CustomLobbyAuthoritativeModel.SetPlayer(model, 1, player) + + instance.Trash:Add(ForkThread(RunBenchmarkThread, instance)) end --- Called when our connection to the host succeeds. @@ -128,7 +131,7 @@ end function OnConnectionToHostEstablished(instance, localId, localName, hostId) LobbyInstance = instance - local model = CustomLobbyModel.GetSingleton() + local model = CustomLobbyAuthoritativeModel.GetSingleton() model.LocalPeerId:Set(localId) model.HostID:Set(hostId) model.IsHost:Set(false) @@ -138,6 +141,8 @@ function OnConnectionToHostEstablished(instance, localId, localName, hostId) player.PlayerName = localName or player.PlayerName instance:SendData(hostId, { Type = 'AddPlayer', PlayerOptions = player }) + + instance.Trash:Add(ForkThread(RunBenchmarkThread, instance)) end --- Called when the connection fails. @@ -152,13 +157,13 @@ end ---@param peerName string ---@param uid UILobbyPeerId function OnPeerDisconnected(instance, peerName, uid) - local model = CustomLobbyModel.GetSingleton() + local model = CustomLobbyAuthoritativeModel.GetSingleton() if not model.IsHost() then return end local slot = FindSlotForOwner(model, uid) if slot then - CustomLobbyModel.ClearPlayer(model, slot) + CustomLobbyAuthoritativeModel.ClearPlayer(model, slot) BroadcastPlayers(instance, model) end end @@ -178,7 +183,7 @@ end ---@param instance UICustomLobbyInstance ---@param data table function ProcessAddPlayer(instance, data) - local model = CustomLobbyModel.GetSingleton() + local model = CustomLobbyAuthoritativeModel.GetSingleton() local slot = FindFreeSlot(model) if not slot then WARN("CustomLobby: no free slot for joining peer " .. tostring(data.SenderID)) @@ -191,7 +196,7 @@ function ProcessAddPlayer(instance, data) player.StartSpot = slot player.PlayerColor = slot player.ArmyColor = slot - CustomLobbyModel.SetPlayer(model, slot, player) + CustomLobbyAuthoritativeModel.SetPlayer(model, slot, player) BroadcastPlayers(instance, model) end @@ -200,8 +205,8 @@ end ---@param instance UICustomLobbyInstance ---@param data table function ProcessSetPlayers(instance, data) - local model = CustomLobbyModel.GetSingleton() - for slot = 1, CustomLobbyModel.MaxSlots do + local model = CustomLobbyAuthoritativeModel.GetSingleton() + for slot = 1, CustomLobbyAuthoritativeModel.MaxSlots do model.Players[slot]:Set(data.Players[slot] or false) end end @@ -210,14 +215,121 @@ end ---@param instance UICustomLobbyInstance ---@param data table function ProcessSetReady(instance, data) - local model = CustomLobbyModel.GetSingleton() + local model = CustomLobbyAuthoritativeModel.GetSingleton() local slot = FindSlotForOwner(model, data.SenderID) if slot then - CustomLobbyModel.SetPlayerField(model, slot, 'Ready', data.Ready and true or false) + CustomLobbyAuthoritativeModel.SetPlayerField(model, slot, 'Ready', data.Ready and true or false) BroadcastPlayers(instance, model) end end +--- Host records a peer's CPU score and re-broadcasts the authoritative table. +---@param instance UICustomLobbyInstance +---@param data table +function ProcessCpuBenchmark(instance, data) + CustomLobbyModel.SetCpuBenchmark( + CustomLobbyModel.GetSingleton(), data.SenderID, data.Benchmark) + BroadcastCpuBenchmarks(instance) +end + +--- Everyone applies the host's authoritative CPU-benchmark snapshot. +---@param instance UICustomLobbyInstance +---@param data table +function ProcessSetCpuBenchmarks(instance, data) + CustomLobbyModel.GetSingleton().CpuBenchmarks:Set(data.Benchmarks) +end + +--#endregion + +------------------------------------------------------------------------------- +--#region CPU benchmark +-- +-- A faithful port of the legacy lobby's CPU stress test (lobby-old.lua). Each peer +-- runs it shortly after connecting; the host owns the table and broadcasts it. +-- The score is a busy-loop duration (lower = faster CPU); skews were no-ops, so +-- they're dropped. + +--- Host broadcasts the authoritative CPU-benchmark snapshot to everyone. +---@param instance UICustomLobbyInstance +function BroadcastCpuBenchmarks(instance) + instance:BroadcastData({ + Type = 'SetCpuBenchmarks', + Benchmarks = CustomLobbyModel.GetSingleton().CpuBenchmarks(), + }) +end + +--- Runs the busy-loop once and returns its `ceil(seconds * 100)` cost, or nil if +--- the lobby was torn down mid-run. Must run inside a forked thread (it yields). +---@param instance UICustomLobbyInstance +---@return number | nil +local function RunOnce(instance) + local TableInsert = table.insert + local totalTime = 0 + local countTime = 0 + local lastTime, currTime, j, k, l, m + local n = {} + for h = 1, 24 do + if IsDestroyed(instance) then + return nil + end + lastTime = GetSystemTimeSeconds() + for i = 1.0, 30.4, 0.0008 do + j = i + i + k = i * i + l = k / j + m = j - i + j = math.pow(i, 4) + l = -i + m = { '1234567890', 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', true } + TableInsert(m, '1234567890') + k = i < j + k = i == j + k = i <= j + k = not k + n[tostring(i)] = m + end + currTime = GetSystemTimeSeconds() + totalTime = totalTime + currTime - lastTime + if totalTime > countTime then + -- yield so the UI keeps ticking during the benchmark + countTime = totalTime + 0.125 + coroutine.yield(1) + end + end + return math.ceil(totalTime * 100) +end + +--- Benchmarks the local CPU (best of three runs) and reports the score: the host +--- records + broadcasts it; a client sends it to the host. +---@param instance UICustomLobbyInstance +function RunBenchmarkThread(instance) + -- defer briefly so connection negotiation isn't starved + WaitSeconds(2.0) + + local best + for run = 1, 3 do + local score = RunOnce(instance) + if not score or IsDestroyed(instance) then + return + end + if not best or score < best then + best = score + end + end + + local model = CustomLobbyAuthoritativeModel.GetSingleton() + local connModel = CustomLobbyModel.GetSingleton() + + -- show our own score immediately; the host's snapshot reconciles everyone + CustomLobbyModel.SetCpuBenchmark(connModel, model.LocalPeerId(), best) + + if model.IsHost() then + BroadcastCpuBenchmarks(instance) + else + instance:SendData(model.HostID(), { Type = 'CPUBenchmark', Benchmark = best }) + end +end + --#endregion ------------------------------------------------------------------------------- @@ -232,11 +344,11 @@ function RequestSetReady(ready) return end - local model = CustomLobbyModel.GetSingleton() + local model = CustomLobbyAuthoritativeModel.GetSingleton() if model.IsHost() then local slot = FindSlotForOwner(model, model.LocalPeerId()) if slot then - CustomLobbyModel.SetPlayerField(model, slot, 'Ready', ready and true or false) + CustomLobbyAuthoritativeModel.SetPlayerField(model, slot, 'Ready', ready and true or false) BroadcastPlayers(instance, model) end else diff --git a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua index ea65e78be9d..3015fb09c10 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua @@ -31,7 +31,7 @@ local LayoutHelpers = import("/lua/maui/layouthelpers.lua") local Group = import("/lua/maui/group.lua").Group local Bitmap = import("/lua/maui/bitmap.lua").Bitmap -local CustomLobbyModel = import("/lua/ui/lobby/customlobby/customlobbymodel.lua") +local CustomLobbyAuthoritativeModel = import("/lua/ui/lobby/customlobby/customlobbyauthoritativemodel.lua") local CustomLobbySlotInterface = import("/lua/ui/lobby/customlobby/customlobbyslotinterface.lua") local LazyVarDerive = import("/lua/lazyvar.lua").Derive @@ -67,11 +67,11 @@ local CustomLobbyInterface = Class(Group) { -- One row per possible slot; the SlotCount observer reveals the active ones. self.Slots = {} - for slot = 1, CustomLobbyModel.MaxSlots do + for slot = 1, CustomLobbyAuthoritativeModel.MaxSlots do self.Slots[slot] = CustomLobbySlotInterface.Create(self.SlotsPanel, slot) end - local model = CustomLobbyModel.GetSingleton() + local model = CustomLobbyAuthoritativeModel.GetSingleton() self.SlotCountObserver = self.Trash:Add( LazyVarDerive(model.SlotCount, function(slotCountLazy) self:OnSlotCountChanged(slotCountLazy()) @@ -89,11 +89,11 @@ local CustomLobbyInterface = Class(Group) { Layouter(self.SlotsPanel) :AtLeftTopIn(self, 40, 80) :Width(PanelWidth) - :Height(CustomLobbyModel.MaxSlots * SlotHeight) + :Height(CustomLobbyAuthoritativeModel.MaxSlots * SlotHeight) :End() -- stack the rows top-to-bottom inside the panel via sibling anchoring - for slot = 1, CustomLobbyModel.MaxSlots do + for slot = 1, CustomLobbyAuthoritativeModel.MaxSlots do local row = self.Slots[slot] local builder = Layouter(row) :AtLeftIn(self.SlotsPanel) @@ -112,7 +112,7 @@ local CustomLobbyInterface = Class(Group) { ---@param self UICustomLobbyInterface ---@param count number OnSlotCountChanged = function(self, count) - for slot = 1, CustomLobbyModel.MaxSlots do + for slot = 1, CustomLobbyAuthoritativeModel.MaxSlots do if slot <= count then self.Slots[slot]:Show() else @@ -168,13 +168,13 @@ end --- `UI_Lua import("/lua/ui/lobby/customlobby/customlobbyinterface.lua").OpenDebug()` function OpenDebug() local slotCount = 6 - local model = CustomLobbyModel.SetupSingleton(slotCount) + local model = CustomLobbyAuthoritativeModel.SetupSingleton(slotCount) model.LocalPeerId:Set("1") model.IsHost:Set(true) -- four players in the first four slots, last two left open for slot = 1, 4 do - CustomLobbyModel.SetPlayer(model, slot, { + CustomLobbyAuthoritativeModel.SetPlayer(model, slot, { PlayerName = "Player " .. slot, OwnerID = tostring(slot), Human = slot ~= 4, -- slot 4 is an AI, to show the AI colour diff --git a/lua/ui/lobby/customlobby/CustomLobbyMessages.lua b/lua/ui/lobby/customlobby/CustomLobbyMessages.lua index e4a24ac1c8b..019ef110d40 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyMessages.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyMessages.lua @@ -28,7 +28,7 @@ -- This is intentionally a SMALL set for the barebones lobby: enough to see players -- join and to round-trip one interactive change (ready). -local CustomLobbyModel = import("/lua/ui/lobby/customlobby/customlobbymodel.lua") +local CustomLobbyAuthoritativeModel = import("/lua/ui/lobby/customlobby/customlobbyauthoritativemodel.lua") local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") ---@param lobby UICustomLobbyInstance @@ -41,7 +41,7 @@ end ---@param data table ---@return boolean local function IsFromHost(lobby, data) - return data.SenderID == CustomLobbyModel.GetSingleton().HostID() + return data.SenderID == CustomLobbyAuthoritativeModel.GetSingleton().HostID() end ---@class UICustomLobbyMessageHandler @@ -90,4 +90,30 @@ CustomLobbyMessages = { CustomLobbyController.ProcessSetReady(lobby, data) end, }, + + -- A client reports its CPU benchmark to the host. The host owns the table. + CPUBenchmark = { + Validate = function(lobby, data) + return type(data.Benchmark) == 'number' + end, + Accept = function(lobby, data) + return AmHost(lobby) + end, + Handler = function(lobby, data) + CustomLobbyController.ProcessCpuBenchmark(lobby, data) + end, + }, + + -- The host's authoritative snapshot of everyone's CPU benchmarks. + SetCpuBenchmarks = { + Validate = function(lobby, data) + return type(data.Benchmarks) == 'table' + end, + Accept = function(lobby, data) + return IsFromHost(lobby, data) + end, + Handler = function(lobby, data) + CustomLobbyController.ProcessSetCpuBenchmarks(lobby, data) + end, + }, } diff --git a/lua/ui/lobby/customlobby/CustomLobbyModel.lua b/lua/ui/lobby/customlobby/CustomLobbyModel.lua index ebbf7caf790..927c01ea917 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyModel.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyModel.lua @@ -20,101 +20,30 @@ --** SOFTWARE. --****************************************************************************************************** --- Reactive state singleton for the (custom-games) lobby — the single source of truth --- that the controller writes to and the components observe via `Derive`. It holds no --- UI references and no networking. --- --- This is the gameInfo replacement from TARGET_ARCHITECTURE.md. Players live as an --- array of per-slot LazyVars so a change to one slot only re-fires that slot's row --- (replacing the legacy `SetSlotInfo` whole-row sledgehammer). --- --- Patterns mirror /lua/ui/lobby/autolobby/AutolobbyModel.lua and --- /lua/ui/game/chat/ChatModel.lua. See /lua/ui/CLAUDE.md for the reactivity rules --- (notably: never mutate a held table in place — copy then `:Set`). +-- Local, high-frequency lobby state that is NOT part of the host's authoritative +-- game snapshot: CPU benchmarks (and later ping / connection status). Kept separate +-- from CustomLobbyAuthoritativeModel so its churn doesn't dirty the synced game state. See +-- /lua/ui/lobby/TARGET_ARCHITECTURE.md § 3 (LobbyConnectivityModel). local Create = import("/lua/lazyvar.lua").Create ---- Maximum number of player slots the engine supports. -MaxSlots = 16 - -------------------------------------------------------------------------------- ---#region Shapes - ---- A player (human or AI) occupying a slot. Mirrors the fields the legacy lobby's ---- PlayerData carries; trimmed to what the UI reads. ----@class UICustomLobbyPlayer ----@field PlayerName string ----@field OwnerID UILobbyPeerId ----@field Human boolean ----@field Faction number # 1=UEF 2=Aeon 3=Cybran 4=Seraphim 5=Random ----@field PlayerColor number ----@field ArmyColor number ----@field Team number # 1 = no team (FFA), 2..9 = teams 1..8 ----@field StartSpot number ----@field Ready boolean ----@field PL? number # rating ----@field MEAN? number ----@field DEV? number ----@field NG? number # number of games ----@field PlayerClan? string ----@field Country? string ----@field AIPersonality? string - ---#endregion - -------------------------------------------------------------------------------- ---#region Reactive model - ---- Reactive lobby-state singleton. +--- Reactive connectivity-state singleton. ---@class UICustomLobbyModel ----@field SlotCount LazyVar # player slots the current map supports ----@field Players LazyVar[] # one LazyVar per slot (1..MaxSlots); false = empty ----@field Observers LazyVar # observer list ----@field ClosedSlots LazyVar> ----@field SpawnMex LazyVar> ----@field AutoTeams LazyVar> ----@field GameOptions LazyVar
----@field GameMods LazyVar
----@field ScenarioFile LazyVar ----@field LocalPeerId LazyVar ----@field HostID LazyVar ----@field IsHost LazyVar - ----@type UICustomLobbyModel | nil +---@field CpuBenchmarks LazyVar> # peer id -> CPU score (lower is faster) local ModelInstance = nil ---- Allocates a fresh model singleton, replacing any existing instance. ----@param slotCount? number +--- Allocates a fresh connectivity-model singleton, replacing any existing one. ---@return UICustomLobbyModel -function SetupSingleton(slotCount) - slotCount = slotCount or 8 - - local players = {} - for slot = 1, MaxSlots do - players[slot] = Create(false) - end - +function SetupSingleton() ---@type UICustomLobbyModel local model = { - SlotCount = Create(slotCount), - Players = players, - Observers = Create({}), - ClosedSlots = Create({}), - SpawnMex = Create({}), - AutoTeams = Create({}), - GameOptions = Create({}), - GameMods = Create({}), - ScenarioFile = Create(false), - LocalPeerId = Create("-1"), - HostID = Create("-1"), - IsHost = Create(false), + CpuBenchmarks = Create({}), } - ModelInstance = model return model end ---- Returns the model singleton, creating it on first access. +--- Returns the connectivity-model singleton, creating it on first access. ---@return UICustomLobbyModel function GetSingleton() if not ModelInstance then @@ -123,97 +52,25 @@ function GetSingleton() return ModelInstance --[[@as UICustomLobbyModel]] end ---#endregion - -------------------------------------------------------------------------------- ---#region Write helpers --- --- The synced tables are LazyVar values, so a write must build a NEW table/value and --- `:Set` it — mutating in place never marks dependents dirty (see /lua/ui/CLAUDE.md --- § 2). These helpers keep that discipline in one place so the controller can't get --- it wrong. - ---- Places (or replaces) a player at a slot. ----@param model UICustomLobbyModel ----@param slot number ----@param player UICustomLobbyPlayer -function SetPlayer(model, slot, player) - model.Players[slot]:Set(player) -end - ---- Empties a slot. ----@param model UICustomLobbyModel ----@param slot number -function ClearPlayer(model, slot) - model.Players[slot]:Set(false) -end - ---- Sets a single field on the player in a slot (copy-then-Set on that slot only). ----@param model UICustomLobbyModel ----@param slot number ----@param key string ----@param value any -function SetPlayerField(model, slot, key, value) - local current = model.Players[slot]() - if not current then - return - end - local player = table.copy(current) - player[key] = value - model.Players[slot]:Set(player) -end - ---- Sets a single game option (copy-then-Set). +--- Records (or clears) a peer's CPU benchmark (copy-then-Set). ---@param model UICustomLobbyModel ----@param key string ----@param value any -function SetGameOption(model, key, value) - local options = table.copy(model.GameOptions()) - options[key] = value - model.GameOptions:Set(options) +---@param ownerId UILobbyPeerId +---@param score number +function SetCpuBenchmark(model, ownerId, score) + local benchmarks = table.copy(model.CpuBenchmarks()) + benchmarks[ownerId] = score + model.CpuBenchmarks:Set(benchmarks) end ---- Sets the closed flag for a slot (copy-then-Set). ----@param model UICustomLobbyModel ----@param slot number ----@param closed boolean -function SetClosed(model, slot, closed) - local closedSlots = table.copy(model.ClosedSlots()) - closedSlots[slot] = closed - model.ClosedSlots:Set(closedSlots) -end - ---- Sets the scenario file. ----@param model UICustomLobbyModel ----@param scenarioFile FileName | false -function SetScenario(model, scenarioFile) - model.ScenarioFile:Set(scenarioFile) -end - ---#endregion - ------------------------------------------------------------------------------- --#region Debugging ---- Hot-reload hook: rebuilds the singleton on the new module and copies the current ---- raw LazyVar values across so observers don't see a state reset. +--- Hot-reload hook: rebuilds the singleton and copies the values across. ---@param newModule any function __moduleinfo.OnReload(newModule) if ModelInstance then - local handle = newModule.SetupSingleton(ModelInstance.SlotCount()) - for slot = 1, MaxSlots do - handle.Players[slot]:Set(ModelInstance.Players[slot]()) - end - handle.Observers:Set(ModelInstance.Observers()) - handle.ClosedSlots:Set(ModelInstance.ClosedSlots()) - handle.SpawnMex:Set(ModelInstance.SpawnMex()) - handle.AutoTeams:Set(ModelInstance.AutoTeams()) - handle.GameOptions:Set(ModelInstance.GameOptions()) - handle.GameMods:Set(ModelInstance.GameMods()) - handle.ScenarioFile:Set(ModelInstance.ScenarioFile()) - handle.LocalPeerId:Set(ModelInstance.LocalPeerId()) - handle.HostID:Set(ModelInstance.HostID()) - handle.IsHost:Set(ModelInstance.IsHost()) + local handle = newModule.SetupSingleton() + handle.CpuBenchmarks:Set(ModelInstance.CpuBenchmarks()) end end diff --git a/lua/ui/lobby/customlobby/CustomLobbySlotInterface.lua b/lua/ui/lobby/customlobby/CustomLobbySlotInterface.lua index bdbfe19d6ac..99bae9b73b8 100644 --- a/lua/ui/lobby/customlobby/CustomLobbySlotInterface.lua +++ b/lua/ui/lobby/customlobby/CustomLobbySlotInterface.lua @@ -31,8 +31,9 @@ local GameColors = import("/lua/gamecolors.lua").GameColors local Group = import("/lua/maui/group.lua").Group local Bitmap = import("/lua/maui/bitmap.lua").Bitmap -local CustomLobbyModel = import("/lua/ui/lobby/customlobby/customlobbymodel.lua") +local CustomLobbyAuthoritativeModel = import("/lua/ui/lobby/customlobby/customlobbyauthoritativemodel.lua") local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") +local CustomLobbyModel = import("/lua/ui/lobby/customlobby/customlobbymodel.lua") local LazyVarDerive = import("/lua/lazyvar.lua").Derive @@ -59,9 +60,11 @@ end ---@field ColorSwatch Bitmap ---@field Name Text ---@field Faction Text +---@field Cpu Text ---@field Team Text ---@field Ready Text ---@field PlayerObserver LazyVar +---@field CpuObserver LazyVar ---@field CurrentPlayer UICustomLobbyPlayer | false local CustomLobbySlotInterface = Class(Group) { @@ -84,6 +87,7 @@ local CustomLobbySlotInterface = Class(Group) { self.ColorSwatch:SetSolidColor('00000000') self.Name = UIUtil.CreateText(self, "", 14, UIUtil.bodyFont) self.Faction = UIUtil.CreateText(self, "", 14, UIUtil.bodyFont) + self.Cpu = UIUtil.CreateText(self, "", 14, UIUtil.bodyFont) self.Team = UIUtil.CreateText(self, "", 14, UIUtil.bodyFont) self.Ready = UIUtil.CreateText(self, "", 14, UIUtil.bodyFont) @@ -99,11 +103,19 @@ local CustomLobbySlotInterface = Class(Group) { return false end - local model = CustomLobbyModel.GetSingleton() + local model = CustomLobbyAuthoritativeModel.GetSingleton() self.PlayerObserver = self.Trash:Add( LazyVarDerive(model.Players[slotIndex], function(playerLazy) self:OnPlayerChanged(playerLazy()) end)) + + self.CpuObserver = self.Trash:Add( + LazyVarDerive(CustomLobbyModel.GetSingleton().CpuBenchmarks, function(benchmarksLazy) + -- read the lazy so the dependency edge (re)forms; the value itself + -- is read from the model inside RefreshCpu + benchmarksLazy() + self:RefreshCpu() + end)) end, ---@param self UICustomLobbySlotInterface @@ -117,7 +129,8 @@ local CustomLobbySlotInterface = Class(Group) { Layouter(self.Name):AnchorToRight(self.ColorSwatch, 8):AtVerticalCenterIn(self):End() Layouter(self.Ready):AtRightIn(self, 8):AtVerticalCenterIn(self):End() Layouter(self.Team):AnchorToLeft(self.Ready, 12):AtVerticalCenterIn(self):End() - Layouter(self.Faction):AnchorToLeft(self.Team, 12):AtVerticalCenterIn(self):End() + Layouter(self.Cpu):AnchorToLeft(self.Team, 12):AtVerticalCenterIn(self):End() + Layouter(self.Faction):AnchorToLeft(self.Cpu, 12):AtVerticalCenterIn(self):End() end, --- Renders the slot from its player (or the empty state). @@ -125,6 +138,7 @@ local CustomLobbySlotInterface = Class(Group) { ---@param player UICustomLobbyPlayer | false OnPlayerChanged = function(self, player) self.CurrentPlayer = player + self:RefreshCpu() if not player then self.ColorSwatch:SetSolidColor('00000000') @@ -148,6 +162,19 @@ local CustomLobbySlotInterface = Class(Group) { self.Ready:SetColor(player.Ready and 'ff7ad97a' or 'ff888888') end, + --- Updates the CPU-score text for this slot's player from the connectivity model. + ---@param self UICustomLobbySlotInterface + RefreshCpu = function(self) + local player = self.CurrentPlayer + if not player then + self.Cpu:SetText("") + return + end + local score = CustomLobbyModel.GetSingleton().CpuBenchmarks()[player.OwnerID] + self.Cpu:SetText(score and ("CPU " .. tostring(score)) or "CPU ...") + self.Cpu:SetColor('ff9aa0a8') + end, + --- Click on the row. For now, clicking your own slot toggles your ready flag --- (a controller intent — the host applies and broadcasts it). ---@param self UICustomLobbySlotInterface @@ -156,7 +183,7 @@ local CustomLobbySlotInterface = Class(Group) { if not player then return end - if player.OwnerID == CustomLobbyModel.GetSingleton().LocalPeerId() then + if player.OwnerID == CustomLobbyAuthoritativeModel.GetSingleton().LocalPeerId() then CustomLobbyController.RequestSetReady(not player.Ready) end end, diff --git a/lua/ui/lobby/lobby.lua b/lua/ui/lobby/lobby.lua index b876c573bfd..5021ba6e8e8 100644 --- a/lua/ui/lobby/lobby.lua +++ b/lua/ui/lobby/lobby.lua @@ -35,6 +35,7 @@ local MenuCommon = import("/lua/ui/menus/menucommon.lua") local EscapeHandler = import("/lua/ui/dialogs/eschandler.lua") +local CustomLobbyAuthoritativeModel = import("/lua/ui/lobby/customlobby/customlobbyauthoritativemodel.lua") local CustomLobbyModel = import("/lua/ui/lobby/customlobby/customlobbymodel.lua") local CustomLobbyInterface = import("/lua/ui/lobby/customlobby/customlobbyinterface.lua") @@ -67,7 +68,8 @@ function CreateLobby(protocol, localPort, desiredPlayerName, localPlayerUID, nat -- Models first, then the view (so its components subscribe to a live model), -- then the lobby object (whose callbacks write the model). -- TODO: derive SlotCount from the chosen map instead of a fixed default. - CustomLobbyModel.SetupSingleton(8) + CustomLobbyAuthoritativeModel.SetupSingleton(8) + CustomLobbyModel.SetupSingleton() CustomLobbyInterface.SetupSingleton() Instance = InternalCreateLobby( @@ -98,7 +100,7 @@ function HostGame(desiredGameName, scenarioFileName, inSinglePlayer) return end local scenario = string.gsub(scenarioFileName, ".v%d%d%d%d_scenario.lua", "_scenario.lua") --[[@as FileName]] - CustomLobbyModel.SetScenario(CustomLobbyModel.GetSingleton(), scenario) + CustomLobbyAuthoritativeModel.SetScenario(CustomLobbyAuthoritativeModel.GetSingleton(), scenario) Instance:HostGame() end From fe26307dfbd9e94703dfbadda4d7e362bb7713d8 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Mon, 22 Jun 2026 05:58:16 +0200 Subject: [PATCH 07/98] Add support for new cpu benchmark --- lua/ui/lobby/customlobby/CLAUDE.md | 12 +- .../customlobby/CustomLobbyController.lua | 103 ++--- .../lobby/customlobby/CustomLobbyMessages.lua | 49 ++- lua/ui/lobby/customlobby/CustomLobbyModel.lua | 14 +- .../CustomLobbyPerformancePopover.lua | 394 ++++++++++++++++++ .../customlobby/CustomLobbySlotInterface.lua | 177 +++++++- 6 files changed, 648 insertions(+), 101 deletions(-) create mode 100644 lua/ui/lobby/customlobby/CustomLobbyPerformancePopover.lua diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index fa0ea42a9ba..aee2d361c8d 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -32,16 +32,18 @@ the automated/matchmaker path). | File | Role | |------|------| | [CustomLobbyAuthoritativeModel.lua](CustomLobbyAuthoritativeModel.lua) | host-dictated / scenario state (see above). | -| [CustomLobbyModel.lua](CustomLobbyModel.lua) | local connectivity state (CPU benchmarks). | +| [CustomLobbyModel.lua](CustomLobbyModel.lua) | local connectivity state (CPU benchmarks + per-peer sim-performance history). | +| [CustomLobbyPerformancePopover.lua](CustomLobbyPerformancePopover.lua) | hover popover over the CPU column; hand-built bitmap bar chart of a peer's `PerformanceTrackingV2` history, with a yellow recommended-unit-cap line. | | [CustomLobbyInstance.lua](CustomLobbyInstance.lua) | thin `moho.lobby_methods` shell; validates/dispatches traffic, forwards callbacks to the controller. | -| [CustomLobbyController.lua](CustomLobbyController.lua) | host-authority logic (free functions): seating, `Process*` handlers, intents (`RequestSetReady`), CPU benchmark. | -| [CustomLobbyMessages.lua](CustomLobbyMessages.lua) | message registry: `AddPlayer`, `SetPlayers`, `SetReady`, `CPUBenchmark`, `SetCpuBenchmarks`. | -| [CustomLobbySlotInterface.lua](CustomLobbySlotInterface.lua) | one slot row; subscribes to its slot + CPU benchmarks; click toggles own ready. | +| [CustomLobbyController.lua](CustomLobbyController.lua) | host-authority logic (free functions): seating, `Process*` handlers, intents (`RequestSetReady`), sharing the stored CPU benchmark. | +| [CustomLobbyMessages.lua](CustomLobbyMessages.lua) | message registry: `AddPlayer`, `SetPlayers`, `SetReady`, `ReportCpuBenchmark`, `SetCpuBenchmarks`. | +| [CustomLobbySlotInterface.lua](CustomLobbySlotInterface.lua) | one slot row; subscribes to its slot + CPU benchmarks; CPU column shows max units at +0 with a green→red cap-headroom square; click toggles own ready. | | [CustomLobbyInterface.lua](CustomLobbyInterface.lua) | composition root (no subscriptions of its own); lays out slot rows; `OpenDebug()` / hot-reload. | | [/lua/ui/lobby/lobby.lua](../lobby.lua) | engine entry wrapper (`CreateLobby`/`HostGame`/`JoinGame`) → CustomLobby. Old lobby preserved at `lobby-old.lua`. | Working today: host + clients see each other (host-authoritative player sync), -ready toggles round-trip, and CPU benchmarks are shared. Launched via +ready toggles round-trip, and each peer's stored sim-performance benchmark is shared +(no live stress test). Launched via `scripts/LaunchCustomLobby.ps1`, or inspect UI only with `UI_Lua import("/lua/ui/lobby/customlobby/customlobbyinterface.lua").OpenDebug()`. diff --git a/lua/ui/lobby/customlobby/CustomLobbyController.lua b/lua/ui/lobby/customlobby/CustomLobbyController.lua index 90d67550880..69296669008 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyController.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyController.lua @@ -120,7 +120,7 @@ function OnHosting(instance) player.ArmyColor = 1 CustomLobbyAuthoritativeModel.SetPlayer(model, 1, player) - instance.Trash:Add(ForkThread(RunBenchmarkThread, instance)) + instance.Trash:Add(ForkThread(ShareCpuBenchmarkThread, instance)) end --- Called when our connection to the host succeeds. @@ -142,7 +142,7 @@ function OnConnectionToHostEstablished(instance, localId, localName, hostId) instance:SendData(hostId, { Type = 'AddPlayer', PlayerOptions = player }) - instance.Trash:Add(ForkThread(RunBenchmarkThread, instance)) + instance.Trash:Add(ForkThread(ShareCpuBenchmarkThread, instance)) end --- Called when the connection fails. @@ -223,20 +223,19 @@ function ProcessSetReady(instance, data) end end ---- Host records a peer's CPU score and re-broadcasts the authoritative table. +--- Host records a peer's benchmark (sim-performance history) and re-broadcasts. ---@param instance UICustomLobbyInstance ----@param data table -function ProcessCpuBenchmark(instance, data) - CustomLobbyModel.SetCpuBenchmark( - CustomLobbyModel.GetSingleton(), data.SenderID, data.Benchmark) +---@param data UICustomLobbyReportCpuBenchmarkMessage +function ProcessReportCpuBenchmark(instance, data) + CustomLobbyModel.SetCpuBenchmark(CustomLobbyModel.GetSingleton(), data.SenderID, data.CpuBenchmark) BroadcastCpuBenchmarks(instance) end ---- Everyone applies the host's authoritative CPU-benchmark snapshot. +--- Everyone applies the host's authoritative benchmark snapshot. ---@param instance UICustomLobbyInstance ----@param data table +---@param data UICustomLobbySetCpuBenchmarksMessage function ProcessSetCpuBenchmarks(instance, data) - CustomLobbyModel.GetSingleton().CpuBenchmarks:Set(data.Benchmarks) + CustomLobbyModel.GetSingleton().CpuBenchmarks:Set(data.CpuBenchmarks) end --#endregion @@ -244,89 +243,47 @@ end ------------------------------------------------------------------------------- --#region CPU benchmark -- --- A faithful port of the legacy lobby's CPU stress test (lobby-old.lua). Each peer --- runs it shortly after connecting; the host owns the table and broadcasts it. --- The score is a busy-loop duration (lower = faster CPU); skews were no-ops, so --- they're dropped. +-- We share each peer's stored in-game sim-performance history (the engine's +-- `PerformanceTrackingV2` preference, accumulated over past games) rather than +-- running a live stress test. The host owns the table and broadcasts it; a client +-- sends its own to the host, which reconciles everyone. ---- Host broadcasts the authoritative CPU-benchmark snapshot to everyone. +--- Host broadcasts the authoritative benchmark snapshot to everyone. ---@param instance UICustomLobbyInstance function BroadcastCpuBenchmarks(instance) instance:BroadcastData({ Type = 'SetCpuBenchmarks', - Benchmarks = CustomLobbyModel.GetSingleton().CpuBenchmarks(), + CpuBenchmarks = CustomLobbyModel.GetSingleton().CpuBenchmarks(), }) end ---- Runs the busy-loop once and returns its `ceil(seconds * 100)` cost, or nil if ---- the lobby was torn down mid-run. Must run inside a forked thread (it yields). +--- Shares the local machine's stored sim-performance benchmark: the host records + +--- broadcasts it; a client sends it to the host. Forked so the brief defer doesn't +--- block the connection callback. ---@param instance UICustomLobbyInstance ----@return number | nil -local function RunOnce(instance) - local TableInsert = table.insert - local totalTime = 0 - local countTime = 0 - local lastTime, currTime, j, k, l, m - local n = {} - for h = 1, 24 do - if IsDestroyed(instance) then - return nil - end - lastTime = GetSystemTimeSeconds() - for i = 1.0, 30.4, 0.0008 do - j = i + i - k = i * i - l = k / j - m = j - i - j = math.pow(i, 4) - l = -i - m = { '1234567890', 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', true } - TableInsert(m, '1234567890') - k = i < j - k = i == j - k = i <= j - k = not k - n[tostring(i)] = m - end - currTime = GetSystemTimeSeconds() - totalTime = totalTime + currTime - lastTime - if totalTime > countTime then - -- yield so the UI keeps ticking during the benchmark - countTime = totalTime + 0.125 - coroutine.yield(1) - end - end - return math.ceil(totalTime * 100) -end - ---- Benchmarks the local CPU (best of three runs) and reports the score: the host ---- records + broadcasts it; a client sends it to the host. ----@param instance UICustomLobbyInstance -function RunBenchmarkThread(instance) - -- defer briefly so connection negotiation isn't starved +function ShareCpuBenchmarkThread(instance) + -- defer briefly so connection negotiation / seating settles first WaitSeconds(2.0) + if IsDestroyed(instance) then + return + end - local best - for run = 1, 3 do - local score = RunOnce(instance) - if not score or IsDestroyed(instance) then - return - end - if not best or score < best then - best = score - end + -- the machine's in-game sim-performance history (nil on a fresh install) + local benchmark = GetPreference('PerformanceTrackingV2') + if not benchmark then + return end local model = CustomLobbyAuthoritativeModel.GetSingleton() local connModel = CustomLobbyModel.GetSingleton() - -- show our own score immediately; the host's snapshot reconciles everyone - CustomLobbyModel.SetCpuBenchmark(connModel, model.LocalPeerId(), best) + -- show our own data immediately; the host's snapshots reconcile everyone + CustomLobbyModel.SetCpuBenchmark(connModel, model.LocalPeerId(), benchmark) if model.IsHost() then BroadcastCpuBenchmarks(instance) else - instance:SendData(model.HostID(), { Type = 'CPUBenchmark', Benchmark = best }) + instance:SendData(model.HostID(), { Type = 'ReportCpuBenchmark', CpuBenchmark = benchmark }) end end diff --git a/lua/ui/lobby/customlobby/CustomLobbyMessages.lua b/lua/ui/lobby/customlobby/CustomLobbyMessages.lua index 019ef110d40..fa9d98859fa 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyMessages.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyMessages.lua @@ -25,8 +25,8 @@ -- unauthorised) and Handler (route to the controller). The instance's DataReceived / -- BroadcastData / SendData run these before anything happens. -- --- This is intentionally a SMALL set for the barebones lobby: enough to see players --- join and to round-trip one interactive change (ready). +-- Each message's payload is typed via a `---@class … : UILobbyReceivedMessage` so the +-- handlers (and any future tooling) know its exact shape. local CustomLobbyAuthoritativeModel = import("/lua/ui/lobby/customlobby/customlobbyauthoritativemodel.lua") local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") @@ -38,28 +38,33 @@ local function AmHost(lobby) end ---@param lobby UICustomLobbyInstance ----@param data table +---@param data UILobbyReceivedMessage ---@return boolean local function IsFromHost(lobby, data) return data.SenderID == CustomLobbyAuthoritativeModel.GetSingleton().HostID() end ---@class UICustomLobbyMessageHandler ----@field Validate fun(lobby: UICustomLobbyInstance, data: table): boolean ----@field Accept fun(lobby: UICustomLobbyInstance, data: table): boolean ----@field Handler fun(lobby: UICustomLobbyInstance, data: table) +---@field Validate fun(lobby: UICustomLobbyInstance, data: UILobbyReceivedMessage): boolean +---@field Accept fun(lobby: UICustomLobbyInstance, data: UILobbyReceivedMessage): boolean +---@field Handler fun(lobby: UICustomLobbyInstance, data: UILobbyReceivedMessage) ---@type table CustomLobbyMessages = { -- A connecting client announces itself to the host. AddPlayer = { + ---@class UICustomLobbyAddPlayerMessage : UILobbyReceivedMessage + ---@field PlayerOptions UICustomLobbyPlayer + + ---@param data UICustomLobbyAddPlayerMessage Validate = function(lobby, data) return data.PlayerOptions ~= nil end, Accept = function(lobby, data) return AmHost(lobby) end, + ---@param data UICustomLobbyAddPlayerMessage Handler = function(lobby, data) CustomLobbyController.ProcessAddPlayer(lobby, data) end, @@ -67,12 +72,17 @@ CustomLobbyMessages = { -- The host's authoritative snapshot of all slots, broadcast on any change. SetPlayers = { + ---@class UICustomLobbySetPlayersMessage : UILobbyReceivedMessage + ---@field Players (UICustomLobbyPlayer | false)[] + + ---@param data UICustomLobbySetPlayersMessage Validate = function(lobby, data) return type(data.Players) == 'table' end, Accept = function(lobby, data) return IsFromHost(lobby, data) end, + ---@param data UICustomLobbySetPlayersMessage Handler = function(lobby, data) CustomLobbyController.ProcessSetPlayers(lobby, data) end, @@ -80,38 +90,53 @@ CustomLobbyMessages = { -- A client asks the host to flip its ready flag. SetReady = { + ---@class UICustomLobbySetReadyMessage : UILobbyReceivedMessage + ---@field Ready boolean + + ---@param data UICustomLobbySetReadyMessage Validate = function(lobby, data) return type(data.Ready) == 'boolean' end, Accept = function(lobby, data) return AmHost(lobby) end, + ---@param data UICustomLobbySetReadyMessage Handler = function(lobby, data) CustomLobbyController.ProcessSetReady(lobby, data) end, }, - -- A client reports its CPU benchmark to the host. The host owns the table. - CPUBenchmark = { + -- A client reports its in-game sim-performance history (the rich benchmark). + ReportCpuBenchmark = { + ---@class UICustomLobbyReportCpuBenchmarkMessage : UILobbyReceivedMessage + ---@field CpuBenchmark UIPerformanceMetrics + + ---@param data UICustomLobbyReportCpuBenchmarkMessage Validate = function(lobby, data) - return type(data.Benchmark) == 'number' + return type(data.CpuBenchmark) == 'table' end, Accept = function(lobby, data) return AmHost(lobby) end, + ---@param data UICustomLobbyReportCpuBenchmarkMessage Handler = function(lobby, data) - CustomLobbyController.ProcessCpuBenchmark(lobby, data) + CustomLobbyController.ProcessReportCpuBenchmark(lobby, data) end, }, - -- The host's authoritative snapshot of everyone's CPU benchmarks. + -- The host's authoritative snapshot of everyone's benchmarks. SetCpuBenchmarks = { + ---@class UICustomLobbySetCpuBenchmarksMessage : UILobbyReceivedMessage + ---@field CpuBenchmarks table + + ---@param data UICustomLobbySetCpuBenchmarksMessage Validate = function(lobby, data) - return type(data.Benchmarks) == 'table' + return type(data.CpuBenchmarks) == 'table' end, Accept = function(lobby, data) return IsFromHost(lobby, data) end, + ---@param data UICustomLobbySetCpuBenchmarksMessage Handler = function(lobby, data) CustomLobbyController.ProcessSetCpuBenchmarks(lobby, data) end, diff --git a/lua/ui/lobby/customlobby/CustomLobbyModel.lua b/lua/ui/lobby/customlobby/CustomLobbyModel.lua index 927c01ea917..bc8fa8fe0cb 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyModel.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyModel.lua @@ -29,7 +29,7 @@ local Create = import("/lua/lazyvar.lua").Create --- Reactive connectivity-state singleton. ---@class UICustomLobbyModel ----@field CpuBenchmarks LazyVar> # peer id -> CPU score (lower is faster) +---@field CpuBenchmarks LazyVar> # peer id -> in-game sim-performance history (see /lua/system/performance.lua) local ModelInstance = nil --- Allocates a fresh connectivity-model singleton, replacing any existing one. @@ -52,14 +52,14 @@ function GetSingleton() return ModelInstance --[[@as UICustomLobbyModel]] end ---- Records (or clears) a peer's CPU benchmark (copy-then-Set). +--- Records a peer's in-game sim-performance history / benchmark (copy-then-Set). ---@param model UICustomLobbyModel ---@param ownerId UILobbyPeerId ----@param score number -function SetCpuBenchmark(model, ownerId, score) - local benchmarks = table.copy(model.CpuBenchmarks()) - benchmarks[ownerId] = score - model.CpuBenchmarks:Set(benchmarks) +---@param benchmark UIPerformanceMetrics +function SetCpuBenchmark(model, ownerId, benchmark) + local all = table.copy(model.CpuBenchmarks()) + all[ownerId] = benchmark + model.CpuBenchmarks:Set(all) end ------------------------------------------------------------------------------- diff --git a/lua/ui/lobby/customlobby/CustomLobbyPerformancePopover.lua b/lua/ui/lobby/customlobby/CustomLobbyPerformancePopover.lua new file mode 100644 index 00000000000..0072be50136 --- /dev/null +++ b/lua/ui/lobby/customlobby/CustomLobbyPerformancePopover.lua @@ -0,0 +1,394 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- A hover popover that visualises a peer's sim-performance history (the +-- `PerformanceTrackingV2` data, see /lua/system/performance.lua). Framed like the +-- chat config, with a hand-built bitmap bar chart (no functional histogram control +-- exists). One bar per simulation-rate bucket: the bar spans the unit-count range +-- [Min, Max] observed at that rate (scaled to the busiest bucket), and its brightness +-- encodes how many samples back it up. Negative rates = the sim lagged; positive = +-- the machine had headroom. +-- +-- Singleton, shown/hidden by the slot rows on CPU-score hover. + +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 + +local Create = import("/lua/lazyvar.lua").Create +local Layouter = LayoutHelpers.ReusedLayoutFor + +local Buckets = 21 -- sim-rate buckets: index k -> rate (k - 11), i.e. -10 .. +10 +local BarWidth = 13 +local BarGap = 2 +local ChartHeight = 78 +local ContentPad = 14 +local AxisWidth = 38 -- left gutter for the unit-count (Y) axis labels +local PopoverWidth = ContentPad * 2 + AxisWidth + Buckets * (BarWidth + BarGap) +local PopoverHeight = 156 + +local GridlineColor = 'ffffffff' -- faint white reference lines +local GridlineAlpha = 0.10 +local CapColor = 'ffe0c020' -- the recommended-unit-cap line (and its label): a clear yellow +local CapAlpha = 0.85 + +--- Formats a unit count compactly for the Y axis (e.g. 5421 -> "5.4k"). +---@param value number +---@return string +local function FormatUnits(value) + if value >= 1000 then + return string.format("%.1fk", value / 1000) + end + return tostring(math.floor(value + 0.5)) +end + +--- The three game-type categories tracked, ordered for the "most played" pick. +local Categories = { 'Skirmish', 'SkirmishWithAI', 'Campaign' } +local CategoryLabels = { + Skirmish = 'Skirmish', + SkirmishWithAI = 'Skirmish vs AI', + Campaign = 'Campaign', +} + +------------------------------------------------------------------------------- +-- One bar: a column whose filled extent is the [Min, Max] unit-count range. + +---@class UIPerformanceBar : Group +---@field Background Bitmap +---@field Bar Bitmap +---@field Label Text +---@field PctBottom LazyVar +---@field PctTop LazyVar +local PerformanceBar = ClassUI(Group) { + + ---@param self UIPerformanceBar + ---@param parent Control + __init = function(self, parent) + Group.__init(self, parent) + + self.PctBottom = Create(0.0) + self.PctTop = Create(0.0) + + self.Background = Bitmap(self) + self.Background:SetSolidColor('ffffffff') + self.Background:SetAlpha(0.06) + self.Background:DisableHitTest() + + self.Bar = Bitmap(self) + self.Bar:SetSolidColor('ff999999') + self.Bar:DisableHitTest() + + self.Label = UIUtil.CreateText(self, '', 9, UIUtil.bodyFont) + self.Label:DisableHitTest() + end, + + ---@param self UIPerformanceBar + __post_init = function(self) + Layouter(self.Background):Fill(self):End() + + -- the bar fills the column horizontally; its vertical extent is driven by + -- the [Min, Max] percentages, measured up from the column's bottom + Layouter(self.Bar):AtLeftIn(self):AtRightIn(self):End() + self.Bar.Bottom:Set(function() return self.Bottom() - self.Height() * self.PctBottom() end) + self.Bar.Top:Set(function() return self.Bottom() - self.Height() * self.PctTop() end) + + Layouter(self.Label):AtHorizontalCenterIn(self):AnchorToBottom(self, 2):End() + end, + + --- @param self UIPerformanceBar + --- @param samples number + --- @param min number + --- @param max number + --- @param maxUnits number # largest Max across all buckets, for scaling + SetData = function(self, samples, min, max, maxUnits) + if samples <= 0 or maxUnits <= 0 then + self.PctBottom:Set(0.0) + self.PctTop:Set(0.0) + return + end + + -- clamp to the chart: a bucket above the scale (e.g. over the recommended + -- cap) pegs at the top rather than overflowing the popover + self.PctBottom:Set(math.clamp(min / maxUnits, 0.0, 1.0)) + self.PctTop:Set(math.clamp(max / maxUnits, 0.0, 1.0)) + + -- brightness grows with confidence (more samples -> whiter) + local c = 1 - 1 / math.sqrt(math.max(samples, 1)) + local v = math.floor(math.clamp(c, 0.15, 1.0) * 255) + self.Bar:SetSolidColor(string.format('ff%02x%02x%02x', v, v, v)) + end, + + ---@param self UIPerformanceBar + ---@param text string + SetLabel = function(self, text) + self.Label:SetText(text) + end, +} + +------------------------------------------------------------------------------- +-- The popover panel. + +---@class UICustomLobbyPerformancePopover : Group +---@field Border Bitmap +---@field Background Bitmap +---@field Title Text +---@field Subtitle Text +---@field Gridlines Bitmap[] # three faint horizontal reference lines (top / mid / bottom) +---@field AxisLabels Text[] # unit-count labels next to those lines (max / half / 0) +---@field CapLine Bitmap # yellow reference line at the recommended unit cap +---@field CapFraction LazyVar # cap as a fraction of the chart's scale (0 = bottom, 1 = top) +---@field Bars UIPerformanceBar[] +local CustomLobbyPerformancePopover = ClassUI(Group) { + + ---@param self UICustomLobbyPerformancePopover + ---@param parent Control + __init = function(self, parent) + Group.__init(self, parent, "CustomLobbyPerformancePopover") + + -- simple 1px frame: a light border bitmap with a dark fill pinned 1px inside + -- (the chat-config border colour, for a consistent look) + self.Border = Bitmap(self) + self.Border:SetSolidColor('ff415055') + self.Border:DisableHitTest() + + self.Background = Bitmap(self) + self.Background:SetSolidColor('f0101418') + self.Background:DisableHitTest() + + self.Title = UIUtil.CreateText(self, "Sim performance", 14, UIUtil.titleFont) + self.Title:DisableHitTest() + self.Subtitle = UIUtil.CreateText(self, "", 10, UIUtil.bodyFont) + self.Subtitle:DisableHitTest() + self.Subtitle:SetColor('ff9aa0a8') + + -- the unit-count (Y) axis: three faint reference lines and their labels. + -- Created before the bars so the bars draw on top of the lines. + self.Gridlines = {} + self.AxisLabels = {} + for k = 1, 3 do + local line = Bitmap(self) + line:SetSolidColor(GridlineColor) + line:SetAlpha(GridlineAlpha) + line:DisableHitTest() + self.Gridlines[k] = line + + local label = UIUtil.CreateText(self, "", 9, UIUtil.bodyFont) + label:SetColor('ff9aa0a8') + label:DisableHitTest() + self.AxisLabels[k] = label + end + + self.Bars = {} + for k = 1, Buckets do + self.Bars[k] = PerformanceBar(self) + local rate = k - 11 + self.Bars[k]:SetLabel(rate >= 0 and ("+" .. rate) or tostring(rate)) + end + + -- the recommended-cap line floats at CapFraction of the chart; it draws over + -- the bars (created last) so it reads as a threshold the bars can exceed. + -- Hidden (alpha 0) until SetData is given a cap. + self.CapFraction = Create(0.0) + self.CapLine = Bitmap(self) + self.CapLine:SetSolidColor(CapColor) + self.CapLine:SetAlpha(0.0) + self.CapLine:DisableHitTest() + + -- purely informational; never capture the mouse (the slot row drives + -- show/hide on hover, and capturing would make it flicker) + self:DisableHitTest(true) + end, + + ---@param self UICustomLobbyPerformancePopover + __post_init = function(self) + Layouter(self):Width(PopoverWidth):Height(PopoverHeight):End() + Layouter(self.Border):Fill(self):End() + Layouter(self.Background) + :AtLeftIn(self, 1):AtRightIn(self, 1):AtTopIn(self, 1):AtBottomIn(self, 1) + :End() + + Layouter(self.Title):AtLeftTopIn(self, ContentPad, 8):End() + Layouter(self.Subtitle):AtLeftIn(self, ContentPad):AnchorToBottom(self.Title, 2):End() + + local chartTop = PopoverHeight - ContentPad - 14 - ChartHeight + for k = 1, Buckets do + local bar = self.Bars[k] + local builder = Layouter(bar):Width(BarWidth):Height(ChartHeight):Top(function() return self.Top() + LayoutHelpers.ScaleNumber(chartTop) end) + if k == 1 then + builder:AtLeftIn(self, ContentPad + AxisWidth) + else + builder:AnchorToRight(self.Bars[k - 1], BarGap) + end + builder:End() + end + + -- reference lines at the top (max), middle (half) and bottom (zero) of the + -- chart, with the unit-count labels right-aligned in the left gutter + local lineYs = { chartTop, chartTop + ChartHeight * 0.5, chartTop + ChartHeight } + for k = 1, 3 do + local y = lineYs[k] + Layouter(self.Gridlines[k]) + :AtLeftIn(self, ContentPad + AxisWidth):AtRightIn(self, ContentPad):Height(1) + :Top(function() return self.Top() + LayoutHelpers.ScaleNumber(y) end) + :End() + Layouter(self.AxisLabels[k]) + :AnchorToLeft(self.Bars[1], 4):AtVerticalCenterIn(self.Gridlines[k]) + :End() + end + + -- the yellow cap line spans the chart; its height up from the baseline is + -- CapFraction of the chart height (1 = top of chart) + Layouter(self.CapLine) + :AtLeftIn(self, ContentPad + AxisWidth):AtRightIn(self, ContentPad):Height(1) + :Top(function() + return self.Top() + + LayoutHelpers.ScaleNumber(chartTop + ChartHeight) + - self.CapFraction() * LayoutHelpers.ScaleNumber(ChartHeight) + end) + :End() + end, + + --- Fills the chart from a peer's performance metrics (the whole + --- `PerformanceTrackingV2` table). Picks the most-played category. + ---@param self UICustomLobbyPerformancePopover + ---@param metrics UIPerformanceMetrics | nil + ---@param unitCap? number # recommended total-unit ceiling (the yellow line), if known + SetData = function(self, metrics, unitCap) + local category, categoryKey, bestSamples = nil, nil, -1 + if metrics then + for _, key in Categories do + local c = metrics[key] + if c and (c.Samples or 0) > bestSamples then + bestSamples = c.Samples or 0 + category = c + categoryKey = key + end + end + end + + if not category or bestSamples <= 0 then + self.Subtitle:SetText("no data shared yet") + for k = 1, Buckets do + self.Bars[k]:SetData(0, 0, 0, 0) + end + for k = 1, 3 do + self.AxisLabels[k]:SetText("") + end + self.CapLine:SetAlpha(0.0) + return + end + + -- busiest unit count actually observed across the category + local observedMax = 0 + for k = 1, Buckets do + local entry = category[k] + if entry and entry.UnitCount and entry.UnitCount.Max > observedMax then + observedMax = entry.UnitCount.Max + end + end + + -- the chart scales to the data, but never below the recommended cap, so the + -- yellow line is always on-chart while the bars are free to rise above it. + local cap = (unitCap and unitCap > 0) and unitCap or nil + local chartMax = math.max(observedMax, cap or 0, 1) + + local subtitle = string.format("%s — %d game(s)", CategoryLabels[categoryKey] or categoryKey, bestSamples) + if cap then + subtitle = subtitle .. string.format(" · rec. cap %s", FormatUnits(cap)) + end + self.Subtitle:SetText(subtitle) + + for k = 1, Buckets do + local entry = category[k] + if entry and entry.UnitCount then + self.Bars[k]:SetData(entry.Samples or 0, entry.UnitCount.Min or 0, entry.UnitCount.Max or 0, chartMax) + else + self.Bars[k]:SetData(0, 0, 0, chartMax) + end + end + + -- label the reference lines (top = busiest / cap, middle = half, bottom = 0) + self.AxisLabels[1]:SetText(FormatUnits(chartMax)) + self.AxisLabels[2]:SetText(FormatUnits(chartMax * 0.5)) + self.AxisLabels[3]:SetText("0") + + -- place (or hide) the yellow recommended-cap line + if cap then + self.CapFraction:Set(cap / chartMax) + self.CapLine:SetAlpha(CapAlpha) + else + self.CapLine:SetAlpha(0.0) + end + end, +} + +------------------------------------------------------------------------------- +-- Singleton + show / hide + +local ModuleTrash = TrashBag() + +---@type UICustomLobbyPerformancePopover | false +local Instance = false + +---@return UICustomLobbyPerformancePopover +local function GetInstance() + if not Instance then + Instance = CustomLobbyPerformancePopover(GetFrame(0)) + ModuleTrash:Add(Instance) + end + return Instance +end + +--- Shows the popover next to `anchor`, filled with `metrics`. +---@param anchor Control +---@param metrics UIPerformanceMetrics | nil +---@param unitCap? number # recommended total-unit ceiling (the yellow line), if known +function Show(anchor, metrics, unitCap) + local popover = GetInstance() + popover:SetData(metrics, unitCap) + + -- float to the right of the hovered control, vertically centred on it + Layouter(popover) + :AnchorToRight(anchor, 12) + :AtVerticalCenterIn(anchor) + :End() + + popover:Show() +end + +function Hide() + if Instance then + Instance:Hide() + end +end + +------------------------------------------------------------------------------- +--#region Debugging + +function __moduleinfo.OnDirty() + ModuleTrash:Destroy() + Instance = false +end + +--#endregion diff --git a/lua/ui/lobby/customlobby/CustomLobbySlotInterface.lua b/lua/ui/lobby/customlobby/CustomLobbySlotInterface.lua index 99bae9b73b8..6f8275f7379 100644 --- a/lua/ui/lobby/customlobby/CustomLobbySlotInterface.lua +++ b/lua/ui/lobby/customlobby/CustomLobbySlotInterface.lua @@ -28,12 +28,14 @@ local UIUtil = import("/lua/ui/uiutil.lua") local LayoutHelpers = import("/lua/maui/layouthelpers.lua") local GameColors = import("/lua/gamecolors.lua").GameColors +local Color = import("/lua/shared/color.lua") local Group = import("/lua/maui/group.lua").Group local Bitmap = import("/lua/maui/bitmap.lua").Bitmap local CustomLobbyAuthoritativeModel = import("/lua/ui/lobby/customlobby/customlobbyauthoritativemodel.lua") local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") local CustomLobbyModel = import("/lua/ui/lobby/customlobby/customlobbymodel.lua") +local CustomLobbyPerformancePopover = import("/lua/ui/lobby/customlobby/customlobbyperformancepopover.lua") local LazyVarDerive = import("/lua/lazyvar.lua").Derive @@ -51,16 +53,108 @@ local function FactionLabel(faction) return "Random" end +--- The recommended total-unit ceiling for the current map and player count: 250 / +--- 375 / 500 units per seated player for 5x5 / 10x10 / 20x20-or-larger maps (the +--- map's largest dimension in ogrids, 51.2 per km). Falls back to the largest tier +--- when no map is set yet. Returns nil when there are no players to scale by. +---@return number | nil +local function RecommendedUnitCap() + local model = CustomLobbyAuthoritativeModel.GetSingleton() + + local players = 0 + for slot = 1, CustomLobbyAuthoritativeModel.MaxSlots do + if model.Players[slot]() then + players = players + 1 + end + end + if players < 1 then + return nil + end + + -- 500 (20x20+) until a map is known; refine from the scenario's dimensions + local perPlayer = 500 + local scenarioFile = model.ScenarioFile() + if scenarioFile then + local scenarioInfo = import("/lua/ui/maputil.lua").LoadScenario(scenarioFile) + if scenarioInfo and scenarioInfo.size then + local maxDim = math.max(scenarioInfo.size[1] or 0, scenarioInfo.size[2] or 0) + if maxDim <= 256 then + perPlayer = 250 -- 5x5 + elseif maxDim <= 512 then + perPlayer = 375 -- 10x10 + else + perPlayer = 500 -- 20x20 or larger + end + end + end + + return perPlayer * players +end + +--- The sim-rate categories tracked in PerformanceTrackingV2, ordered for the +--- "most-played" pick (matches the popover). +local PerformanceCategories = { 'Skirmish', 'SkirmishWithAI', 'Campaign' } + +--- The bucket index for a sim rate (index k holds rate k - 11, so +0 -> 11, -4 -> 7). +---@param rate number +---@return number +local function BucketForRate(rate) + return rate + 11 +end + +--- The most-played category in a benchmark, by sample count, or nil if none. +---@param metrics UIPerformanceMetrics | nil +---@return table | nil +local function PickCategory(metrics) + if not metrics then + return nil + end + local best, bestSamples = nil, -1 + for _, key in PerformanceCategories do + local c = metrics[key] + if c and (c.Samples or 0) > bestSamples then + bestSamples = c.Samples or 0 + best = c + end + end + if not best or bestSamples <= 0 then + return nil + end + return best +end + +--- Compact unit count for the slot label (e.g. 1421 -> "1.4k"). +---@param value number +---@return string +local function FormatUnits(value) + if value >= 1000 then + return string.format("%.1fk", value / 1000) + end + return tostring(math.floor(value + 0.5)) +end + +--- Indicator colour for how far the sim has to slow down to sustain the cap: green +--- when +0 already suffices (step 0), fading to red at -4 or worse (step 4). +---@param step number # sim-rate steps below +0 needed to reach the cap (0..4) +---@return Color +local function StepColor(step) + local t = math.clamp(step / 4, 0.0, 1.0) + local hue = (1.0 - t) * 0.333 -- 0.333 turns = green, 0 = red + return Color.ColorHSV(hue, 1.0, 0.85, 1.0) +end + ---@class UICustomLobbySlotInterface : Group ---@field Trash TrashBag ---@field SlotIndex number ---@field Background Bitmap ---@field ClickArea Bitmap +---@field CpuHover Bitmap ---@field SlotNumber Text ---@field ColorSwatch Bitmap ---@field Name Text ---@field Faction Text ---@field Cpu Text +---@field CpuIndicator Bitmap ---@field Team Text ---@field Ready Text ---@field PlayerObserver LazyVar @@ -88,6 +182,12 @@ local CustomLobbySlotInterface = Class(Group) { self.Name = UIUtil.CreateText(self, "", 14, UIUtil.bodyFont) self.Faction = UIUtil.CreateText(self, "", 14, UIUtil.bodyFont) self.Cpu = UIUtil.CreateText(self, "", 14, UIUtil.bodyFont) + -- a small square left of the CPU label: green when the machine sustains the + -- recommended unit cap at full speed, fading to red the more the sim must slow + self.CpuIndicator = Bitmap(self) + self.CpuIndicator:SetSolidColor('ff7ad97a') + self.CpuIndicator:SetAlpha(0.0) + self.CpuIndicator:DisableHitTest() self.Team = UIUtil.CreateText(self, "", 14, UIUtil.bodyFont) self.Ready = UIUtil.CreateText(self, "", 14, UIUtil.bodyFont) @@ -103,6 +203,23 @@ local CustomLobbySlotInterface = Class(Group) { return false end + -- a hover zone over the CPU score; entering it pops the performance chart + self.CpuHover = Bitmap(self) + self.CpuHover:SetSolidColor('00000000') + self.CpuHover.HandleEvent = function(control, event) + if event.Type == 'MouseEnter' then + self:OnCpuHoverEnter() + return true + elseif event.Type == 'MouseExit' then + CustomLobbyPerformancePopover.Hide() + return true + elseif event.Type == 'ButtonPress' then + self:OnClicked() + return true + end + return false + end + local model = CustomLobbyAuthoritativeModel.GetSingleton() self.PlayerObserver = self.Trash:Add( LazyVarDerive(model.Players[slotIndex], function(playerLazy) @@ -123,6 +240,7 @@ local CustomLobbySlotInterface = Class(Group) { __post_init = function(self, parent) Layouter(self.Background):Fill(self):End() Layouter(self.ClickArea):Fill(self):Over(self, 10):End() + Layouter(self.CpuHover):Fill(self.Cpu):Over(self, 20):End() Layouter(self.SlotNumber):AtLeftIn(self, 6):AtVerticalCenterIn(self):End() Layouter(self.ColorSwatch):AnchorToRight(self.SlotNumber, 8):AtVerticalCenterIn(self):Width(14):Height(14):End() @@ -130,7 +248,8 @@ local CustomLobbySlotInterface = Class(Group) { Layouter(self.Ready):AtRightIn(self, 8):AtVerticalCenterIn(self):End() Layouter(self.Team):AnchorToLeft(self.Ready, 12):AtVerticalCenterIn(self):End() Layouter(self.Cpu):AnchorToLeft(self.Team, 12):AtVerticalCenterIn(self):End() - Layouter(self.Faction):AnchorToLeft(self.Cpu, 12):AtVerticalCenterIn(self):End() + Layouter(self.CpuIndicator):AnchorToLeft(self.Cpu, 5):AtVerticalCenterIn(self):Width(8):Height(12):End() + Layouter(self.Faction):AnchorToLeft(self.CpuIndicator, 10):AtVerticalCenterIn(self):End() end, --- Renders the slot from its player (or the empty state). @@ -162,17 +281,67 @@ local CustomLobbySlotInterface = Class(Group) { self.Ready:SetColor(player.Ready and 'ff7ad97a' or 'ff888888') end, - --- Updates the CPU-score text for this slot's player from the connectivity model. + --- Renders the CPU column from this slot player's shared sim-performance benchmark: + --- the label is the max units the machine handled at full speed (+0), and the square + --- is green if that already covers the recommended cap, fading to red the further the + --- sim has to slow down (down to -4) to reach it. ---@param self UICustomLobbySlotInterface RefreshCpu = function(self) local player = self.CurrentPlayer if not player then self.Cpu:SetText("") + self.CpuIndicator:SetAlpha(0.0) + return + end + + local metrics = CustomLobbyModel.GetSingleton().CpuBenchmarks()[player.OwnerID] + local category = PickCategory(metrics) + local atZero = category and category[BucketForRate(0)] + if not (atZero and atZero.UnitCount) then + self.Cpu:SetText("—") + self.Cpu:SetColor('ff9aa0a8') + self.CpuIndicator:SetAlpha(0.0) return end - local score = CustomLobbyModel.GetSingleton().CpuBenchmarks()[player.OwnerID] - self.Cpu:SetText(score and ("CPU " .. tostring(score)) or "CPU ...") + + local maxAtZero = atZero.UnitCount.Max or 0 + self.Cpu:SetText(FormatUnits(maxAtZero)) self.Cpu:SetColor('ff9aa0a8') + + local cap = RecommendedUnitCap() + if not cap or cap <= 0 then + self.CpuIndicator:SetAlpha(0.0) + return + end + + -- how many sim-rate steps below +0 are needed before the machine sustains the + -- cap (0 = fine at +0); worst case (red) if even -4 falls short + local step = 0 + if maxAtZero < cap then + step = 4 + for s = 1, 4 do + local bucket = category[BucketForRate(-s)] + if bucket and bucket.UnitCount and (bucket.UnitCount.Max or 0) >= cap then + step = s + break + end + end + end + + self.CpuIndicator:SetSolidColor(StepColor(step)) + self.CpuIndicator:SetAlpha(1.0) + end, + + --- Mouse entered the CPU score: show this player's sim-performance popover. + ---@param self UICustomLobbySlotInterface + OnCpuHoverEnter = function(self) + local player = self.CurrentPlayer + if not player then + CustomLobbyPerformancePopover.Hide() + return + end + local benchmark = CustomLobbyModel.GetSingleton().CpuBenchmarks()[player.OwnerID] + CustomLobbyPerformancePopover.Show(self.Cpu, benchmark, RecommendedUnitCap()) end, --- Click on the row. For now, clicking your own slot toggles your ready flag From b8b242a12c11cc333f617fc63f03c6a4fccb4dcb Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Mon, 22 Jun 2026 05:58:28 +0200 Subject: [PATCH 08/98] Add support for players disconnecting (from the host) --- lua/ui/lobby/customlobby/CLAUDE.md | 7 ++++--- .../customlobby/CustomLobbyController.lua | 19 +++++++++++++++++-- .../customlobby/CustomLobbyInterface.lua | 11 +++++++++++ .../lobby/customlobby/CustomLobbyMessages.lua | 19 +++++++++++++++++++ 4 files changed, 51 insertions(+), 5 deletions(-) diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index aee2d361c8d..5a8c6ba639e 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -36,14 +36,15 @@ the automated/matchmaker path). | [CustomLobbyPerformancePopover.lua](CustomLobbyPerformancePopover.lua) | hover popover over the CPU column; hand-built bitmap bar chart of a peer's `PerformanceTrackingV2` history, with a yellow recommended-unit-cap line. | | [CustomLobbyInstance.lua](CustomLobbyInstance.lua) | thin `moho.lobby_methods` shell; validates/dispatches traffic, forwards callbacks to the controller. | | [CustomLobbyController.lua](CustomLobbyController.lua) | host-authority logic (free functions): seating, `Process*` handlers, intents (`RequestSetReady`), sharing the stored CPU benchmark. | -| [CustomLobbyMessages.lua](CustomLobbyMessages.lua) | message registry: `AddPlayer`, `SetPlayers`, `SetReady`, `ReportCpuBenchmark`, `SetCpuBenchmarks`. | +| [CustomLobbyMessages.lua](CustomLobbyMessages.lua) | message registry: `AddPlayer`, `SetPlayers`, `SetReady`, `DisconnectPeer`, `ReportCpuBenchmark`, `SetCpuBenchmarks`. | | [CustomLobbySlotInterface.lua](CustomLobbySlotInterface.lua) | one slot row; subscribes to its slot + CPU benchmarks; CPU column shows max units at +0 with a green→red cap-headroom square; click toggles own ready. | | [CustomLobbyInterface.lua](CustomLobbyInterface.lua) | composition root (no subscriptions of its own); lays out slot rows; `OpenDebug()` / hot-reload. | | [/lua/ui/lobby/lobby.lua](../lobby.lua) | engine entry wrapper (`CreateLobby`/`HostGame`/`JoinGame`) → CustomLobby. Old lobby preserved at `lobby-old.lua`. | Working today: host + clients see each other (host-authoritative player sync), -ready toggles round-trip, and each peer's stored sim-performance benchmark is shared -(no live stress test). Launched via +ready toggles round-trip, each peer's stored sim-performance benchmark is shared +(no live stress test), and a **Leave** button (or Esc) disconnects and returns to the +menu — a leaving client frees its slot for everyone via `OnPeerDisconnected`. Launched via `scripts/LaunchCustomLobby.ps1`, or inspect UI only with `UI_Lua import("/lua/ui/lobby/customlobby/customlobbyinterface.lua").OpenDebug()`. diff --git a/lua/ui/lobby/customlobby/CustomLobbyController.lua b/lua/ui/lobby/customlobby/CustomLobbyController.lua index 69296669008..4faaae998b0 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyController.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyController.lua @@ -152,7 +152,10 @@ function OnConnectionFailed(instance, reason) LOG("CustomLobby: connection failed: " .. tostring(reason)) end ---- Called when a peer disconnects. The host clears its slot and re-broadcasts. +--- Called when a peer disconnects. Only the host acts: it clears the peer's slot, +--- tells everyone still connected to drop their own link to that peer, and sends the +--- new authoritative player snapshot. (A non-host ignores it — the host's broadcasts +--- drive its state and mesh cleanup.) ---@param instance UICustomLobbyInstance ---@param peerName string ---@param uid UILobbyPeerId @@ -161,11 +164,15 @@ function OnPeerDisconnected(instance, peerName, uid) if not model.IsHost() then return end + + -- tell the remaining peers to tear down their direct connection to the leaver + instance:BroadcastData({ Type = 'DisconnectPeer', PeerID = uid }) + local slot = FindSlotForOwner(model, uid) if slot then CustomLobbyAuthoritativeModel.ClearPlayer(model, slot) - BroadcastPlayers(instance, model) end + BroadcastPlayers(instance, model) end --- Called when the game launches. Barebones: not wired yet. @@ -223,6 +230,14 @@ function ProcessSetReady(instance, data) end end +--- A client drops its direct connection to a peer the host says has left. The slot +--- itself is cleared by the SetPlayers snapshot the host sends alongside this. +---@param instance UICustomLobbyInstance +---@param data UICustomLobbyDisconnectPeerMessage +function ProcessDisconnectPeer(instance, data) + instance:DisconnectFromPeer(data.PeerID) +end + --- Host records a peer's benchmark (sim-performance history) and re-broadcasts. ---@param instance UICustomLobbyInstance ---@param data UICustomLobbyReportCpuBenchmarkMessage diff --git a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua index 3015fb09c10..aef21197b4e 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua @@ -28,6 +28,7 @@ local UIUtil = import("/lua/ui/uiutil.lua") local LayoutHelpers = import("/lua/maui/layouthelpers.lua") +local EscapeHandler = import("/lua/ui/dialogs/eschandler.lua") local Group = import("/lua/maui/group.lua").Group local Bitmap = import("/lua/maui/bitmap.lua").Bitmap @@ -47,6 +48,7 @@ local PanelWidth = 520 ---@field Title Text ---@field SlotsPanel Group ---@field Slots UICustomLobbySlotInterface[] +---@field LeaveButton Button ---@field SlotCountObserver LazyVar local CustomLobbyInterface = Class(Group) { @@ -71,6 +73,13 @@ local CustomLobbyInterface = Class(Group) { self.Slots[slot] = CustomLobbySlotInterface.Create(self.SlotsPanel, slot) end + -- leaving disconnects + returns to the menu via the escape handler that + -- lobby.lua registered (one teardown definition, shared with the Esc key) + self.LeaveButton = UIUtil.CreateButtonStd(self, '/scx_menu/small-btn/small', "Leave", 16, 2) + self.LeaveButton.OnClick = function(button, modifiers) + EscapeHandler.HandleEsc(false) + end + local model = CustomLobbyAuthoritativeModel.GetSingleton() self.SlotCountObserver = self.Trash:Add( LazyVarDerive(model.SlotCount, function(slotCountLazy) @@ -106,6 +115,8 @@ local CustomLobbyInterface = Class(Group) { end builder:End() end + + Layouter(self.LeaveButton):AtLeftIn(self, 40):AnchorToBottom(self.SlotsPanel, 24):End() end, --- Shows the active slots (1..count) and hides the rest. diff --git a/lua/ui/lobby/customlobby/CustomLobbyMessages.lua b/lua/ui/lobby/customlobby/CustomLobbyMessages.lua index fa9d98859fa..175c7801131 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyMessages.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyMessages.lua @@ -106,6 +106,25 @@ CustomLobbyMessages = { end, }, + -- The host tells everyone still connected to drop their direct link to a peer + -- that left, so the mesh is cleaned up (the player state follows via SetPlayers). + DisconnectPeer = { + ---@class UICustomLobbyDisconnectPeerMessage : UILobbyReceivedMessage + ---@field PeerID UILobbyPeerId + + ---@param data UICustomLobbyDisconnectPeerMessage + Validate = function(lobby, data) + return data.PeerID ~= nil + end, + Accept = function(lobby, data) + return IsFromHost(lobby, data) + end, + ---@param data UICustomLobbyDisconnectPeerMessage + Handler = function(lobby, data) + CustomLobbyController.ProcessDisconnectPeer(lobby, data) + end, + }, + -- A client reports its in-game sim-performance history (the rich benchmark). ReportCpuBenchmark = { ---@class UICustomLobbyReportCpuBenchmarkMessage : UILobbyReceivedMessage From 0ece4d5e0288dcab6014f00d348127105cf23b55 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Mon, 22 Jun 2026 06:09:42 +0200 Subject: [PATCH 09/98] Add plan for column-based teams --- .../design/team-aware-slot-layout.md | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 lua/ui/lobby/customlobby/design/team-aware-slot-layout.md diff --git a/lua/ui/lobby/customlobby/design/team-aware-slot-layout.md b/lua/ui/lobby/customlobby/design/team-aware-slot-layout.md new file mode 100644 index 00000000000..8ada493d044 --- /dev/null +++ b/lua/ui/lobby/customlobby/design/team-aware-slot-layout.md @@ -0,0 +1,108 @@ +# Team-aware slot layout (parked) + +> **Status: parked (2026-06-22)** pending a decision on **where scenario +> information is stored** — see [Open question](#open-question). Resume once the +> scenario data model is settled. + +A re-imagining of the CustomLobby players pane so the slot arrangement reflects the +team structure, instead of every player sharing one flat list with a per-row "team" +dropdown. Your team becomes *where you are* on screen, which removes a control and +makes balance readable at a glance. + +This is part of the broader "tabs" lobby direction (player table on the left, a +single tabbed panel — Map / Options / Mods / Units — on the right, persistent chat). + +## Layout is driven by `AutoTeams` + +The layout reads the existing [`AutoTeams`](/lua/ui/lobby/lobbyOptions.lua) lobby +option (already host-authoritative and synced) — no new option needed: + +| `AutoTeams` | Meaning | Layout | Column headers | +|---|---|---|---| +| `tvsb` | Top vs Bottom | **2 columns** | `TOP` / `BOTTOM` | +| `lvsr` | Left vs Right | **2 columns** | `LEFT` / `RIGHT` | +| `pvsi` | Odd vs Even | **2 columns** | `ODD` / `EVEN` | +| `manual` | host assigns on map | **flat list** | (team column shown) | +| `none` | no auto teams | **flat list** | (team column shown) | + +The three binary auto-splits are inherently 2-team, so they map straight to two +columns. `none`/`manual` have no reliable 2-team structure, so they fall back to the +flat list. There is **no 4-team grid** — no stock `AutoTeams` mode produces four +fixed teams. (Open to letting `none` try columns when players happen to split 50/50, +but not assumed.) + +## Layouts + +### 2-column (headers follow the mode — shown here for `tvsb`) + +``` ++-------------------------------------------------+-----------------------------------------+ +| PLAYERS Top vs Bottom | [ Map ] Options Mods Units | +| +----------------------+ +----------------------+ +-----------------------------------+ | +| | TOP ~1842 | | BOTTOM ~1790 | | map preview | | +| | NL Jip * UEF ###. x | | US Sprouto AEO ##.. x| | . 2(top) . 4(bot) | | +| | DE Tagada CYB ###. . | | AI Sorian SER #### x| | . 1(top) . 3(bot) | | +| | - open - | | - open - | +-----------------------------------+ | +| | - open - | | - open - | CHAT | +| +----------------------+ +----------------------+ [Jip] hf all | +| OBSERVERS: Zock 1850 [+ obs] | > _ | ++-------------------------------------------------+-----------------------------------------+ +``` + +Headers swap with the mode: `LEFT`/`RIGHT` for `lvsr`, `ODD`/`EVEN` for `pvsi`. Each +block header shows team size + average rating, replacing the legacy "team ratings if +>2 teams" line. + +### Flat list (`none` / `manual`) + +``` +PLAYERS 6 / 8 + # CC name fac col team cpu rdy + 1 NL Jip * UEF [4] 1 ###. [x] + 2 US Sprouto AEO [1] 1 ##.. [x] + 3 DE Tagada CYB [7] 2 ###. [ ] + 4 AI Sorian SER [2] 2 #### [x] + 5 -- - open - + 6 -- - open - +``` + +The per-row team dropdown only exists here (there's no positional rule to lean on). +`manual` is the same list — the host does the actual assignment by clicking map +markers, as today. + +## Interaction model + +- In 2-column mode the per-row team dropdown disappears; the auto-team is derived + from **start position**, so the two columns literally mirror the map split. +- **Join / switch team** = take a spawn on that side: click the map, or click an + `- open -` slot in a column (which picks a free spawn there). Client → host + request; host applies. Host can drag any token (matches legacy swap/move). +- Compact cells: flag · name · faction · cpu · ready; colour swatch + full + rating/ping on hover (reuse the CPU performance popover pattern). Right-click a + cell = kick / move / →observer / AI personality. +- Observers sit in a thin strip under the blocks/list regardless of layout. + +## How it maps onto the MVC structure + +- A new **`CustomLobbyPlayersInterface`** component subscribes to `AutoTeams` + + `SlotCount` + every slot, computes the strategy (2 columns vs flat list), and + arranges the existing `CustomLobbySlotInterface` rows into team-group containers. + The slot-row component barely changes — it gets re-parented / compacted. +- `CustomLobbyInterface` shrinks to: header, this players component (left), the + tabbed panel (right), footer. +- No model changes beyond reading `AutoTeams` (already present on + `CustomLobbyAuthoritativeModel` as part of game options / its own LazyVar). + +## Open question + +**How is a card's column decided for display?** + +1. **Projected from start spot** — top-half spawns → `TOP`, etc. True to how + auto-teams actually resolve at launch and visually consistent with the map, but + needs the map's **spawn coordinates** loaded. +2. **By the `Team` field** — bucket Team 1 vs Team 2. Simple, no map dependency, but + can drift from what auto-teams will do at launch. + +Preference: **(1)** for honesty, with **(2)** as a pre-map fallback. This is exactly +why the design is parked — it depends on where scenario information (spawn data) +ends up being stored. From 5371d6fe17fc94ed481eb61043c52499226890af Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Mon, 22 Jun 2026 06:18:14 +0200 Subject: [PATCH 10/98] Add support for requesting a slot as a player --- lua/ui/lobby/customlobby/CLAUDE.md | 14 +- .../customlobby/CustomLobbyController.lua | 121 +++++++++++++++++- .../lobby/customlobby/CustomLobbyMessages.lua | 19 +++ .../customlobby/CustomLobbySlotInterface.lua | 5 +- 4 files changed, 150 insertions(+), 9 deletions(-) diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index 5a8c6ba639e..2a54c166b76 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -35,16 +35,18 @@ the automated/matchmaker path). | [CustomLobbyModel.lua](CustomLobbyModel.lua) | local connectivity state (CPU benchmarks + per-peer sim-performance history). | | [CustomLobbyPerformancePopover.lua](CustomLobbyPerformancePopover.lua) | hover popover over the CPU column; hand-built bitmap bar chart of a peer's `PerformanceTrackingV2` history, with a yellow recommended-unit-cap line. | | [CustomLobbyInstance.lua](CustomLobbyInstance.lua) | thin `moho.lobby_methods` shell; validates/dispatches traffic, forwards callbacks to the controller. | -| [CustomLobbyController.lua](CustomLobbyController.lua) | host-authority logic (free functions): seating, `Process*` handlers, intents (`RequestSetReady`), sharing the stored CPU benchmark. | -| [CustomLobbyMessages.lua](CustomLobbyMessages.lua) | message registry: `AddPlayer`, `SetPlayers`, `SetReady`, `DisconnectPeer`, `ReportCpuBenchmark`, `SetCpuBenchmarks`. | -| [CustomLobbySlotInterface.lua](CustomLobbySlotInterface.lua) | one slot row; subscribes to its slot + CPU benchmarks; CPU column shows max units at +0 with a green→red cap-headroom square; click toggles own ready. | +| [CustomLobbyController.lua](CustomLobbyController.lua) | host-authority logic (free functions): seating, `Process*` handlers, intents (`RequestSetReady`, `RequestTakeSlot`, `RequestSwapSlots` — also callable from a future chat command), sharing the stored CPU benchmark. | +| [CustomLobbyMessages.lua](CustomLobbyMessages.lua) | message registry: `AddPlayer`, `SetPlayers`, `SetReady`, `TakeSlot`, `DisconnectPeer`, `ReportCpuBenchmark`, `SetCpuBenchmarks`. | +| [CustomLobbySlotInterface.lua](CustomLobbySlotInterface.lua) | one slot row; subscribes to its slot + CPU benchmarks; CPU column shows max units at +0 with a green→red cap-headroom square; click an open slot to take it, your own to toggle ready. | | [CustomLobbyInterface.lua](CustomLobbyInterface.lua) | composition root (no subscriptions of its own); lays out slot rows; `OpenDebug()` / hot-reload. | | [/lua/ui/lobby/lobby.lua](../lobby.lua) | engine entry wrapper (`CreateLobby`/`HostGame`/`JoinGame`) → CustomLobby. Old lobby preserved at `lobby-old.lua`. | Working today: host + clients see each other (host-authoritative player sync), -ready toggles round-trip, each peer's stored sim-performance benchmark is shared -(no live stress test), and a **Leave** button (or Esc) disconnects and returns to the -menu — a leaving client frees its slot for everyone via `OnPeerDisconnected`. Launched via +ready toggles round-trip, players can **take an open slot** (click it) and the host can +**swap two slots** (`RequestSwapSlots`, intent ready for a `/swap` command), each peer's +stored sim-performance benchmark is shared (no live stress test), and a **Leave** button +(or Esc) disconnects and returns to the menu — a leaving client frees its slot for +everyone via `OnPeerDisconnected`. Launched via `scripts/LaunchCustomLobby.ps1`, or inspect UI only with `UI_Lua import("/lua/ui/lobby/customlobby/customlobbyinterface.lua").OpenDebug()`. diff --git a/lua/ui/lobby/customlobby/CustomLobbyController.lua b/lua/ui/lobby/customlobby/CustomLobbyController.lua index 4faaae998b0..c0d6b91fe1d 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyController.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyController.lua @@ -89,6 +89,82 @@ local function BroadcastPlayers(instance, model) instance:BroadcastData({ Type = 'SetPlayers', Players = GatherPlayers(model) }) end +--- Whether `slot` is a real, currently-open seat (in range, empty, not closed). +---@param model UICustomLobbyAuthoritativeModel +---@param slot any +---@return boolean +local function IsOpenSlot(model, slot) + if type(slot) ~= 'number' or slot < 1 or slot > model.SlotCount() then + return false + end + return not model.Players[slot]() and not model.ClosedSlots()[slot] +end + +--- Host-side: moves the player owned by `ownerId` into an open `slot`, forcing it +--- unready and keeping StartSpot mirrored to the seat. Broadcasts the new snapshot. +--- A no-op (and harmless) if the move isn't valid — the host is the gate. +---@param instance UICustomLobbyInstance +---@param model UICustomLobbyAuthoritativeModel +---@param ownerId UILobbyPeerId +---@param slot number +local function TakeSlot(instance, model, ownerId, slot) + if not IsOpenSlot(model, slot) then + return + end + local from = FindSlotForOwner(model, ownerId) + if not from or from == slot then + return + end + + local player = table.copy(model.Players[from]()) + player.StartSpot = slot + player.Ready = false -- moving seats resets readiness + CustomLobbyAuthoritativeModel.ClearPlayer(model, from) + CustomLobbyAuthoritativeModel.SetPlayer(model, slot, player) + BroadcastPlayers(instance, model) +end + +--- Host-side: swaps the contents of two slots (players and/or empties), forcing any +--- moved player unready and keeping StartSpot mirrored to the seat. Broadcasts. +---@param instance UICustomLobbyInstance +---@param model UICustomLobbyAuthoritativeModel +---@param slotA number +---@param slotB number +local function SwapSlots(instance, model, slotA, slotB) + local count = model.SlotCount() + if type(slotA) ~= 'number' or type(slotB) ~= 'number' then + return + end + if slotA == slotB or slotA < 1 or slotA > count or slotB < 1 or slotB > count then + return + end + + local a = model.Players[slotA]() + local b = model.Players[slotB]() + if a then + a = table.copy(a) + a.StartSpot = slotB + a.Ready = false + end + if b then + b = table.copy(b) + b.StartSpot = slotA + b.Ready = false + end + + if b then + CustomLobbyAuthoritativeModel.SetPlayer(model, slotA, b) + else + CustomLobbyAuthoritativeModel.ClearPlayer(model, slotA) + end + if a then + CustomLobbyAuthoritativeModel.SetPlayer(model, slotB, a) + else + CustomLobbyAuthoritativeModel.ClearPlayer(model, slotB) + end + BroadcastPlayers(instance, model) +end + --- Builds the local player's options from the profile / engine name. ---@param instance UICustomLobbyInstance ---@return UICustomLobbyPlayer @@ -230,6 +306,14 @@ function ProcessSetReady(instance, data) end end +--- Host moves a requesting client into the open slot it asked for. +---@param instance UICustomLobbyInstance +---@param data UICustomLobbyTakeSlotMessage +function ProcessTakeSlot(instance, data) + local model = CustomLobbyAuthoritativeModel.GetSingleton() + TakeSlot(instance, model, data.SenderID, data.Slot) +end + --- A client drops its direct connection to a peer the host says has left. The slot --- itself is cleared by the SetPlayers snapshot the host sends alongside this. ---@param instance UICustomLobbyInstance @@ -305,7 +389,42 @@ end --#endregion ------------------------------------------------------------------------------- ---#region Intents (called by the UI) +--#region Intents (called by the UI or a chat command, e.g. `/take 2`, `/swap 2 3`) + +--- The local player takes an open slot. Host applies + broadcasts; a client asks the +--- host. Backs both a click on an open slot and a `/take ` chat command. +---@param slot number +function RequestTakeSlot(slot) + local instance = LobbyInstance + if not instance then + return + end + + local model = CustomLobbyAuthoritativeModel.GetSingleton() + if model.IsHost() then + TakeSlot(instance, model, model.LocalPeerId(), slot) + else + instance:SendData(model.HostID(), { Type = 'TakeSlot', Slot = slot }) + end +end + +--- The host swaps the contents of two slots. Host-only (a client request isn't +--- offered) — backs a host-side drag/menu and a `/swap ` chat command. +---@param slotA number +---@param slotB number +function RequestSwapSlots(slotA, slotB) + local instance = LobbyInstance + if not instance then + return + end + + local model = CustomLobbyAuthoritativeModel.GetSingleton() + if not model.IsHost() then + WARN("CustomLobby: only the host can swap slots") + return + end + SwapSlots(instance, model, slotA, slotB) +end --- The local player toggles their ready flag. Host applies + broadcasts; a client --- asks the host to. diff --git a/lua/ui/lobby/customlobby/CustomLobbyMessages.lua b/lua/ui/lobby/customlobby/CustomLobbyMessages.lua index 175c7801131..685ae3b7cc0 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyMessages.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyMessages.lua @@ -106,6 +106,25 @@ CustomLobbyMessages = { end, }, + -- A client asks the host to move it into an open slot (also reachable via a + -- `/take ` chat command). The host validates the seat and re-broadcasts. + TakeSlot = { + ---@class UICustomLobbyTakeSlotMessage : UILobbyReceivedMessage + ---@field Slot number + + ---@param data UICustomLobbyTakeSlotMessage + Validate = function(lobby, data) + return type(data.Slot) == 'number' + end, + Accept = function(lobby, data) + return AmHost(lobby) + end, + ---@param data UICustomLobbyTakeSlotMessage + Handler = function(lobby, data) + CustomLobbyController.ProcessTakeSlot(lobby, data) + end, + }, + -- The host tells everyone still connected to drop their direct link to a peer -- that left, so the mesh is cleaned up (the player state follows via SetPlayers). DisconnectPeer = { diff --git a/lua/ui/lobby/customlobby/CustomLobbySlotInterface.lua b/lua/ui/lobby/customlobby/CustomLobbySlotInterface.lua index 6f8275f7379..d089c56b3a9 100644 --- a/lua/ui/lobby/customlobby/CustomLobbySlotInterface.lua +++ b/lua/ui/lobby/customlobby/CustomLobbySlotInterface.lua @@ -344,12 +344,13 @@ local CustomLobbySlotInterface = Class(Group) { CustomLobbyPerformancePopover.Show(self.Cpu, benchmark, RecommendedUnitCap()) end, - --- Click on the row. For now, clicking your own slot toggles your ready flag - --- (a controller intent — the host applies and broadcasts it). + --- Click on the row (a controller intent — the host applies and broadcasts it): + --- an open slot is taken by the local player; your own slot toggles ready. ---@param self UICustomLobbySlotInterface OnClicked = function(self) local player = self.CurrentPlayer if not player then + CustomLobbyController.RequestTakeSlot(self.SlotIndex) return end if player.OwnerID == CustomLobbyAuthoritativeModel.GetSingleton().LocalPeerId() then From d0c0e2e1b9b5a2eda4bc28f477754ea4d4b93549 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Mon, 22 Jun 2026 06:55:28 +0200 Subject: [PATCH 11/98] Add support for drag/drop --- lua/ui/lobby/customlobby/CLAUDE.md | 4 +- .../customlobby/CustomLobbyInterface.lua | 126 +++++++++++++++++- .../customlobby/CustomLobbySlotInterface.lua | 96 ++++++++++++- 3 files changed, 217 insertions(+), 9 deletions(-) diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index 2a54c166b76..7f5753964a3 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -37,8 +37,8 @@ the automated/matchmaker path). | [CustomLobbyInstance.lua](CustomLobbyInstance.lua) | thin `moho.lobby_methods` shell; validates/dispatches traffic, forwards callbacks to the controller. | | [CustomLobbyController.lua](CustomLobbyController.lua) | host-authority logic (free functions): seating, `Process*` handlers, intents (`RequestSetReady`, `RequestTakeSlot`, `RequestSwapSlots` — also callable from a future chat command), sharing the stored CPU benchmark. | | [CustomLobbyMessages.lua](CustomLobbyMessages.lua) | message registry: `AddPlayer`, `SetPlayers`, `SetReady`, `TakeSlot`, `DisconnectPeer`, `ReportCpuBenchmark`, `SetCpuBenchmarks`. | -| [CustomLobbySlotInterface.lua](CustomLobbySlotInterface.lua) | one slot row; subscribes to its slot + CPU benchmarks; CPU column shows max units at +0 with a green→red cap-headroom square; click an open slot to take it, your own to toggle ready. | -| [CustomLobbyInterface.lua](CustomLobbyInterface.lua) | composition root (no subscriptions of its own); lays out slot rows; `OpenDebug()` / hot-reload. | +| [CustomLobbySlotInterface.lua](CustomLobbySlotInterface.lua) | one slot row; subscribes to its slot + CPU benchmarks; CPU column shows max units at +0 with a green→red cap-headroom square; click an open slot to take it, your own to toggle ready; the host can drag a row onto another to swap. | +| [CustomLobbyInterface.lua](CustomLobbyInterface.lua) | composition root (one model subscription for SlotCount); lays out slot rows and acts as their drag coordinator (`UICustomLobbySlotCoordinator`: hit-test, drop-highlight, drag ghost → `RequestSwapSlots`); `OpenDebug()` / hot-reload. | | [/lua/ui/lobby/lobby.lua](../lobby.lua) | engine entry wrapper (`CreateLobby`/`HostGame`/`JoinGame`) → CustomLobby. Old lobby preserved at `lobby-old.lua`. | Working today: host + clients see each other (host-authoritative player sync), diff --git a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua index aef21197b4e..85f17758955 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua @@ -33,6 +33,7 @@ local EscapeHandler = import("/lua/ui/dialogs/eschandler.lua") local Group = import("/lua/maui/group.lua").Group local Bitmap = import("/lua/maui/bitmap.lua").Bitmap local CustomLobbyAuthoritativeModel = import("/lua/ui/lobby/customlobby/customlobbyauthoritativemodel.lua") +local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") local CustomLobbySlotInterface = import("/lua/ui/lobby/customlobby/customlobbyslotinterface.lua") local LazyVarDerive = import("/lua/lazyvar.lua").Derive @@ -42,7 +43,7 @@ local Layouter = LayoutHelpers.ReusedLayoutFor local SlotHeight = 24 local PanelWidth = 520 ----@class UICustomLobbyInterface : Group +---@class UICustomLobbyInterface : Group, UICustomLobbySlotCoordinator ---@field Trash TrashBag ---@field Background Bitmap ---@field Title Text @@ -50,6 +51,8 @@ local PanelWidth = 520 ---@field Slots UICustomLobbySlotInterface[] ---@field LeaveButton Button ---@field SlotCountObserver LazyVar +---@field HighlightedSlot number | false # slot currently shown as a drop target +---@field DragGhost Group | false # floating label following the cursor mid-drag local CustomLobbyInterface = Class(Group) { ---@param self UICustomLobbyInterface @@ -58,6 +61,8 @@ local CustomLobbyInterface = Class(Group) { Group.__init(self, parent, "CustomLobbyInterface") self.Trash = TrashBag() + self.HighlightedSlot = false + self.DragGhost = false self.Background = Bitmap(self) self.Background:SetSolidColor('ff0a0a0a') @@ -70,7 +75,7 @@ local CustomLobbyInterface = Class(Group) { -- One row per possible slot; the SlotCount observer reveals the active ones. self.Slots = {} for slot = 1, CustomLobbyAuthoritativeModel.MaxSlots do - self.Slots[slot] = CustomLobbySlotInterface.Create(self.SlotsPanel, slot) + self.Slots[slot] = CustomLobbySlotInterface.Create(self.SlotsPanel, slot, self) end -- leaving disconnects + returns to the menu via the escape handler that @@ -132,6 +137,123 @@ local CustomLobbyInterface = Class(Group) { end end, + --------------------------------------------------------------------------- + --#region Slot drag coordination (UICustomLobbySlotCoordinator) + -- + -- The slot rows raise the gesture; this container owns it because it's the only + -- thing that knows every row's rect. The "what's grabbed / where" state here is + -- purely visual — a drop resolves to the RequestSwapSlots intent, host-authoritative. + + --- Only the host can drag, and only a slot that holds a player (you grab a token). + ---@param self UICustomLobbyInterface + ---@param slot number + ---@return boolean + CanDrag = function(self, slot) + local model = CustomLobbyAuthoritativeModel.GetSingleton() + if not model.IsHost() then + return false + end + return model.Players[slot]() ~= false + end, + + --- The active slot whose row contains the screen point, or nil. + ---@param self UICustomLobbyInterface + ---@param x number + ---@param y number + ---@return number | nil + SlotIndexAt = function(self, x, y) + local count = CustomLobbyAuthoritativeModel.GetSingleton().SlotCount() + for slot = 1, count do + local row = self.Slots[slot] + if row and x >= row.Left() and x <= row.Right() and y >= row.Top() and y <= row.Bottom() then + return slot + end + end + return nil + end, + + --- Mid-drag: follow the cursor with the ghost and highlight the row under it. + ---@param self UICustomLobbyInterface + ---@param source number + ---@param x number + ---@param y number + OnSlotDragMove = function(self, source, x, y) + if not self.DragGhost then + self.DragGhost = self:CreateDragGhost(source) + end + self.DragGhost.Left:Set(x + LayoutHelpers.ScaleNumber(12)) + self.DragGhost.Top:Set(y + LayoutHelpers.ScaleNumber(8)) + self:HighlightSlot(self:SlotIndexAt(x, y)) + end, + + --- Drop: swap the source slot with whatever row the cursor is over (a move if it's + --- empty). Dropping outside any row is a no-op. + ---@param self UICustomLobbyInterface + ---@param source number + ---@param x number + ---@param y number + OnSlotDrop = function(self, source, x, y) + local target = self:SlotIndexAt(x, y) + if target and target ~= source then + CustomLobbyController.RequestSwapSlots(source, target) + end + end, + + --- Clears the transient drag visuals. + ---@param self UICustomLobbyInterface + OnSlotDragEnd = function(self) + self:HighlightSlot(nil) + if self.DragGhost then + self.DragGhost:Destroy() + self.DragGhost = false + end + end, + + --- Moves the drop-target highlight to `slot` (nil clears it). + ---@param self UICustomLobbyInterface + ---@param slot number | nil + HighlightSlot = function(self, slot) + slot = slot or false + if self.HighlightedSlot == slot then + return + end + if self.HighlightedSlot and self.Slots[self.HighlightedSlot] then + self.Slots[self.HighlightedSlot]:SetDropHighlight(false) + end + self.HighlightedSlot = slot + if slot and self.Slots[slot] then + self.Slots[slot]:SetDropHighlight(true) + end + end, + + --- Builds the floating drag label (the grabbed player's name) on the interface so + --- it draws above the rows. Destroyed in OnSlotDragEnd. + ---@param self UICustomLobbyInterface + ---@param source number + ---@return Group + CreateDragGhost = function(self, source) + local player = CustomLobbyAuthoritativeModel.GetSingleton().Players[source]() + local name = (player and player.PlayerName) or ("Slot " .. tostring(source)) + + local ghost = Group(self, "CustomLobbyDragGhost") + ghost:DisableHitTest() + + local bg = Bitmap(ghost) + bg:SetSolidColor('cc101418') + bg:DisableHitTest() + + local label = UIUtil.CreateText(ghost, name, 14, UIUtil.bodyFont) + label:DisableHitTest() + + Layouter(label):AtLeftTopIn(ghost, 6, 3):End() + Layouter(bg):Fill(ghost):End() + ghost.Width:Set(function() return label.Width() + LayoutHelpers.ScaleNumber(12) end) + ghost.Height:Set(function() return label.Height() + LayoutHelpers.ScaleNumber(6) end) + return ghost + end, + + --#endregion + ---@param self UICustomLobbyInterface OnDestroy = function(self) self.Trash:Destroy() diff --git a/lua/ui/lobby/customlobby/CustomLobbySlotInterface.lua b/lua/ui/lobby/customlobby/CustomLobbySlotInterface.lua index d089c56b3a9..a34a74eb6ae 100644 --- a/lua/ui/lobby/customlobby/CustomLobbySlotInterface.lua +++ b/lua/ui/lobby/customlobby/CustomLobbySlotInterface.lua @@ -32,6 +32,7 @@ local Color = import("/lua/shared/color.lua") local Group = import("/lua/maui/group.lua").Group local Bitmap = import("/lua/maui/bitmap.lua").Bitmap +local Dragger = import("/lua/maui/dragger.lua").Dragger local CustomLobbyAuthoritativeModel = import("/lua/ui/lobby/customlobby/customlobbyauthoritativemodel.lua") local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") local CustomLobbyModel = import("/lua/ui/lobby/customlobby/customlobbymodel.lua") @@ -143,10 +144,25 @@ local function StepColor(step) return Color.ColorHSV(hue, 1.0, 0.85, 1.0) end +-- cursor travel (screen px) before a press becomes a drag instead of a click +local DragThreshold = 5 + +--- The container that owns the slot rows and coordinates drag-to-swap. The row only +--- starts the gesture; the coordinator hit-tests across all rows (it's the only thing +--- that knows their rects) and resolves a drop to a controller intent. +---@class UICustomLobbySlotCoordinator +---@field CanDrag fun(self: UICustomLobbySlotCoordinator, slot: number): boolean +---@field SlotIndexAt fun(self: UICustomLobbySlotCoordinator, x: number, y: number): number | nil +---@field OnSlotDragMove fun(self: UICustomLobbySlotCoordinator, source: number, x: number, y: number) +---@field OnSlotDrop fun(self: UICustomLobbySlotCoordinator, source: number, x: number, y: number) +---@field OnSlotDragEnd fun(self: UICustomLobbySlotCoordinator) + ---@class UICustomLobbySlotInterface : Group ---@field Trash TrashBag ---@field SlotIndex number +---@field Coordinator UICustomLobbySlotCoordinator ---@field Background Bitmap +---@field DropHighlight Bitmap ---@field ClickArea Bitmap ---@field CpuHover Bitmap ---@field SlotNumber Text @@ -165,17 +181,25 @@ local CustomLobbySlotInterface = Class(Group) { ---@param self UICustomLobbySlotInterface ---@param parent Control ---@param slotIndex number - __init = function(self, parent, slotIndex) + ---@param coordinator UICustomLobbySlotCoordinator + __init = function(self, parent, slotIndex, coordinator) Group.__init(self, parent, "CustomLobbySlot" .. tostring(slotIndex)) self.Trash = TrashBag() self.SlotIndex = slotIndex + self.Coordinator = coordinator self.CurrentPlayer = false self.Background = Bitmap(self) self.Background:SetSolidColor('22ffffff') self.Background:DisableHitTest() + -- drop-target highlight during a drag (sits above the background, below text) + self.DropHighlight = Bitmap(self) + self.DropHighlight:SetSolidColor('ffffffff') + self.DropHighlight:SetAlpha(0.0) + self.DropHighlight:DisableHitTest() + self.SlotNumber = UIUtil.CreateText(self, tostring(slotIndex), 14, UIUtil.bodyFont) self.ColorSwatch = Bitmap(self) self.ColorSwatch:SetSolidColor('00000000') @@ -197,7 +221,7 @@ local CustomLobbySlotInterface = Class(Group) { self.ClickArea:SetSolidColor('00000000') self.ClickArea.HandleEvent = function(control, event) if event.Type == 'ButtonPress' then - self:OnClicked() + self:OnRowPress(event) return true end return false @@ -214,7 +238,7 @@ local CustomLobbySlotInterface = Class(Group) { CustomLobbyPerformancePopover.Hide() return true elseif event.Type == 'ButtonPress' then - self:OnClicked() + self:OnRowPress(event) return true end return false @@ -239,6 +263,7 @@ local CustomLobbySlotInterface = Class(Group) { ---@param parent Control __post_init = function(self, parent) Layouter(self.Background):Fill(self):End() + Layouter(self.DropHighlight):Fill(self):End() Layouter(self.ClickArea):Fill(self):Over(self, 10):End() Layouter(self.CpuHover):Fill(self.Cpu):Over(self, 20):End() @@ -344,6 +369,66 @@ local CustomLobbySlotInterface = Class(Group) { CustomLobbyPerformancePopover.Show(self.Cpu, benchmark, RecommendedUnitCap()) end, + --- A press on the row: if the coordinator allows dragging this slot (host, holding + --- a player) start a drag-to-swap; otherwise it's a plain click. + ---@param self UICustomLobbySlotInterface + ---@param event KeyEvent + OnRowPress = function(self, event) + if self.Coordinator and self.Coordinator:CanDrag(self.SlotIndex) then + self:BeginDrag(event) + else + self:OnClicked() + end + end, + + --- Starts a drag from this row. The press only becomes a drag once the cursor + --- travels past DragThreshold — under it, the release is treated as a click, so + --- take/ready still work. Movement + drop are routed to the coordinator (it owns + --- the hit-test across rows); a drop resolves to RequestSwapSlots. + ---@param self UICustomLobbySlotInterface + ---@param event KeyEvent + BeginDrag = function(self, event) + -- the press may have started on the CPU hover zone; the captured mouse won't + -- fire MouseExit, so dismiss the popover up front + CustomLobbyPerformancePopover.Hide() + + local startX, startY = event.MouseX, event.MouseY + local moved = false + local source = self.SlotIndex + local coordinator = self.Coordinator + + local drag = Dragger() + drag.OnMove = function(dragSelf, x, y) + if not moved and (math.abs(x - startX) > DragThreshold or math.abs(y - startY) > DragThreshold) then + moved = true + end + if moved then + coordinator:OnSlotDragMove(source, x, y) + end + end + drag.OnRelease = function(dragSelf, x, y) + if moved then + coordinator:OnSlotDrop(source, x, y) + coordinator:OnSlotDragEnd() + else + self:OnClicked() + end + drag:Destroy() + end + drag.OnCancel = function(dragSelf) + coordinator:OnSlotDragEnd() + drag:Destroy() + end + PostDragger(self:GetRootFrame(), event.KeyCode, drag) + end, + + --- Toggles the drop-target highlight (called by the coordinator during a drag). + ---@param self UICustomLobbySlotInterface + ---@param on boolean + SetDropHighlight = function(self, on) + self.DropHighlight:SetAlpha(on and 0.15 or 0.0) + end, + --- Click on the row (a controller intent — the host applies and broadcasts it): --- an open slot is taken by the local player; your own slot toggles ready. ---@param self UICustomLobbySlotInterface @@ -366,7 +451,8 @@ local CustomLobbySlotInterface = Class(Group) { ---@param parent Control ---@param slotIndex number +---@param coordinator UICustomLobbySlotCoordinator ---@return UICustomLobbySlotInterface -Create = function(parent, slotIndex) - return CustomLobbySlotInterface(parent, slotIndex) +Create = function(parent, slotIndex, coordinator) + return CustomLobbySlotInterface(parent, slotIndex, coordinator) end From 052d9a8c71ec40bee0c722d4b3d368022d32f0c1 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Mon, 22 Jun 2026 07:41:06 +0200 Subject: [PATCH 12/98] Add support for a (reuseable) menu context --- .claude/skills/add-slot-menu-item/SKILL.md | 41 +++ lua/ui/lobby/customlobby/CLAUDE.md | 9 +- .../CustomLobbyAuthoritativeModel.lua | 9 + .../customlobby/CustomLobbyContextMenu.lua | 244 ++++++++++++++++++ .../customlobby/CustomLobbyController.lua | 69 ++++- lua/ui/lobby/customlobby/CustomLobbyMenus.lua | 143 ++++++++++ .../lobby/customlobby/CustomLobbyMessages.lua | 1 + .../customlobby/CustomLobbySlotInterface.lua | 23 +- 8 files changed, 531 insertions(+), 8 deletions(-) create mode 100644 .claude/skills/add-slot-menu-item/SKILL.md create mode 100644 lua/ui/lobby/customlobby/CustomLobbyContextMenu.lua create mode 100644 lua/ui/lobby/customlobby/CustomLobbyMenus.lua diff --git a/.claude/skills/add-slot-menu-item/SKILL.md b/.claude/skills/add-slot-menu-item/SKILL.md new file mode 100644 index 00000000000..7b3ef937424 --- /dev/null +++ b/.claude/skills/add-slot-menu-item/SKILL.md @@ -0,0 +1,41 @@ +--- +name: add-slot-menu-item +description: Add an entry to the CustomLobby slot right-click context menu (e.g. Kick, Move to observers, Close slot). Use when the user asks to add, gate, or scaffold a slot context-menu action in the custom lobby. Edits the declarative list in lua/ui/lobby/customlobby/CustomLobbyMenus.lua and, for a new operation, adds a CustomLobbyController intent. +--- + +# Adding a slot context-menu item + +One edit in most cases: append an entry to the `SlotMenu` list in [CustomLobbyMenus.lua](/lua/ui/lobby/customlobby/CustomLobbyMenus.lua). + +```lua +{ + label = "Close slot", -- example of a NEW action (RequestCloseSlot doesn't exist yet — add it) + when = function(ctx) return ctx.isHost and ctx.isOpen end, + action = function(ctx) CustomLobbyController.RequestCloseSlot(ctx.slot) end, + -- enabled = function(ctx) ... end, -- optional; omit for always-enabled. false = greyed, not hidden +}, +``` + +(Already wired, for reference: `Move to observers` → `RequestMoveToObserver`, `Eject` → `RequestEject`, ready toggles, `Take this slot` → `RequestTakeSlot`.) + +`ctx` (see `SlotContext`): `slot`, `player|false`, `isHost`, `isYou`, `isOpen`. + +## Do / Don't + +| | Rule | +|---|------| +| ✅ | Gate visibility with `when(ctx)` — that's how host vs player / open vs occupied menus differ. Use `enabled` only when you want the item shown-but-greyed. | +| ✅ | Have `action(ctx)` call a **`CustomLobbyController` intent** (`RequestX`). Keep it one line. | +| ✅ | If the action is a new operation, add the intent to [CustomLobbyController.lua](/lua/ui/lobby/customlobby/CustomLobbyController.lua) first: host applies + `BroadcastPlayers`; a client `SendData`s a request the host validates (add a typed message in [CustomLobbyMessages.lua](/lua/ui/lobby/customlobby/CustomLobbyMessages.lua) + a `ProcessX` handler). The host stays authoritative. | +| 🚫 | Write to the model from `action` — read it, mutate via a controller intent. | +| 🚫 | Touch [CustomLobbyContextMenu.lua](/lua/ui/lobby/customlobby/CustomLobbyContextMenu.lua) — it's a generic renderer and knows nothing about the lobby. | +| 🚫 | Build the menu in the slot row — the row only calls `BuildSlotMenu(slot)`; definitions live in `CustomLobbyMenus`. | +| 🚫 | Use the `%` operator (FAF is Lua 5.0 — use `math.mod`). | + +## A whole new menu surface (not just a slot item) + +The renderer [CustomLobbyContextMenu.lua](/lua/ui/lobby/customlobby/CustomLobbyContextMenu.lua) is generic — slots are just its first caller. To add another surface (lobby background, observer list, options…): add a new entry list + `BuildXMenu(...)` in `CustomLobbyMenus.lua` (its own `ctx` shape), then have that control's right-click call `CustomLobbyContextMenu.Show(CustomLobbyMenus.BuildXMenu(...), event.MouseX, event.MouseY)`. Don't edit the renderer. + +## Verify + +An empty result simply doesn't show. Right-click a slot in each relevant state (your own / another's / open; as host and as client) and confirm the item appears only `when` it should and its intent round-trips. UI-only check: `import("/lua/ui/lobby/customlobby/customlobbyinterface.lua").OpenDebug()`. diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index 7f5753964a3..f0b8e198c79 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -35,15 +35,18 @@ the automated/matchmaker path). | [CustomLobbyModel.lua](CustomLobbyModel.lua) | local connectivity state (CPU benchmarks + per-peer sim-performance history). | | [CustomLobbyPerformancePopover.lua](CustomLobbyPerformancePopover.lua) | hover popover over the CPU column; hand-built bitmap bar chart of a peer's `PerformanceTrackingV2` history, with a yellow recommended-unit-cap line. | | [CustomLobbyInstance.lua](CustomLobbyInstance.lua) | thin `moho.lobby_methods` shell; validates/dispatches traffic, forwards callbacks to the controller. | -| [CustomLobbyController.lua](CustomLobbyController.lua) | host-authority logic (free functions): seating, `Process*` handlers, intents (`RequestSetReady`, `RequestTakeSlot`, `RequestSwapSlots` — also callable from a future chat command), sharing the stored CPU benchmark. | +| [CustomLobbyController.lua](CustomLobbyController.lua) | host-authority logic (free functions): seating, `Process*` handlers, intents (`RequestSetReady`, `RequestTakeSlot`, `RequestSwapSlots`, `RequestEject`, `RequestMoveToObserver` — the slot-management set, also callable from a chat command), sharing the stored CPU benchmark. | | [CustomLobbyMessages.lua](CustomLobbyMessages.lua) | message registry: `AddPlayer`, `SetPlayers`, `SetReady`, `TakeSlot`, `DisconnectPeer`, `ReportCpuBenchmark`, `SetCpuBenchmarks`. | -| [CustomLobbySlotInterface.lua](CustomLobbySlotInterface.lua) | one slot row; subscribes to its slot + CPU benchmarks; CPU column shows max units at +0 with a green→red cap-headroom square; click an open slot to take it, your own to toggle ready; the host can drag a row onto another to swap. | +| [CustomLobbyContextMenu.lua](CustomLobbyContextMenu.lua) | generic framed floating menu; `Show(entries, x, y)` renders any `{label, action, enabled}` list, dismisses on item click / click-outside / Esc. Knows nothing about the lobby. | +| [CustomLobbyMenus.lua](CustomLobbyMenus.lua) | declarative menu **definitions**: entry lists with `when(ctx)`/`action(ctx)` filtered by lobby state (`BuildSlotMenu`). Adding/state-gating an item is a one-liner here. | +| [CustomLobbySlotInterface.lua](CustomLobbySlotInterface.lua) | one slot row; subscribes to its slot + CPU benchmarks; CPU column shows max units at +0 with a green→red cap-headroom square; left-click an open slot to take it / your own to toggle ready; right-click opens its context menu; the host can drag a row onto another to swap. | | [CustomLobbyInterface.lua](CustomLobbyInterface.lua) | composition root (one model subscription for SlotCount); lays out slot rows and acts as their drag coordinator (`UICustomLobbySlotCoordinator`: hit-test, drop-highlight, drag ghost → `RequestSwapSlots`); `OpenDebug()` / hot-reload. | | [/lua/ui/lobby/lobby.lua](../lobby.lua) | engine entry wrapper (`CreateLobby`/`HostGame`/`JoinGame`) → CustomLobby. Old lobby preserved at `lobby-old.lua`. | Working today: host + clients see each other (host-authoritative player sync), ready toggles round-trip, players can **take an open slot** (click it) and the host can -**swap two slots** (`RequestSwapSlots`, intent ready for a `/swap` command), each peer's +**swap** (drag a row onto another), **eject**, and **move a player to observers** (right-click +→ context menu); observers are synced in the `SetPlayers` snapshot. Each peer's stored sim-performance benchmark is shared (no live stress test), and a **Leave** button (or Esc) disconnects and returns to the menu — a leaving client frees its slot for everyone via `OnPeerDisconnected`. Launched via diff --git a/lua/ui/lobby/customlobby/CustomLobbyAuthoritativeModel.lua b/lua/ui/lobby/customlobby/CustomLobbyAuthoritativeModel.lua index e04c4c362d2..5741b89e45a 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyAuthoritativeModel.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyAuthoritativeModel.lua @@ -190,6 +190,15 @@ function SetScenario(model, scenarioFile) model.ScenarioFile:Set(scenarioFile) end +--- Appends a player to the observer list (copy-then-Set). +---@param model UICustomLobbyAuthoritativeModel +---@param player UICustomLobbyPlayer +function AddObserver(model, player) + local observers = table.copy(model.Observers()) + table.insert(observers, player) + model.Observers:Set(observers) +end + --#endregion ------------------------------------------------------------------------------- diff --git a/lua/ui/lobby/customlobby/CustomLobbyContextMenu.lua b/lua/ui/lobby/customlobby/CustomLobbyContextMenu.lua new file mode 100644 index 00000000000..041a74b9a38 --- /dev/null +++ b/lua/ui/lobby/customlobby/CustomLobbyContextMenu.lua @@ -0,0 +1,244 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- A generic, content-agnostic context menu: hand it a list of entries and a screen +-- position and it draws a framed vertical list, runs the chosen entry's action, and +-- dismisses itself (item click, click-outside, or Esc). It knows nothing about the +-- lobby — what the entries are and when they apply lives in CustomLobbyMenus.lua, so +-- adding or state-gating an item never touches this file. +-- +-- Framed like the rest of the lobby (chat-config border + dark fill). Singleton on +-- GetFrame(0); each Show rebuilds it for the given entries. + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") +local EscapeHandler = import("/lua/ui/dialogs/eschandler.lua") + +local Group = import("/lua/maui/group.lua").Group +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap + +local Layouter = LayoutHelpers.ReusedLayoutFor + +local ItemHeight = 22 +local MenuPad = 4 +local LabelPadX = 10 +local MinWidth = 130 +local FontSize = 13 + +--- One row in a context menu. +---@class UICustomLobbyContextMenuItem +---@field label string +---@field action fun() # run on click +---@field enabled? boolean # default true; a disabled item is greyed and inert + +------------------------------------------------------------------------------- + +---@class UICustomLobbyContextMenuRow : Group +---@field Surface Bitmap +---@field Label Text + +---@class UICustomLobbyContextMenu : Group +---@field Border Bitmap +---@field Background Bitmap +---@field Rows UICustomLobbyContextMenuRow[] +local CustomLobbyContextMenu = ClassUI(Group) { + + ---@param self UICustomLobbyContextMenu + ---@param parent Control + ---@param entries UICustomLobbyContextMenuItem[] + __init = function(self, parent, entries) + Group.__init(self, parent, "CustomLobbyContextMenu") + + self.Border = Bitmap(self) + self.Border:SetSolidColor('ff415055') + self.Border:DisableHitTest() + + self.Background = Bitmap(self) + self.Background:SetSolidColor('f0101418') + self.Background:DisableHitTest() + + self.Rows = {} + for i = 1, table.getn(entries) do + self.Rows[i] = self:CreateRow(entries[i]) + end + end, + + --- Builds one clickable row. The surface bitmap both catches the mouse and is the + --- hover highlight; the label draws on top with hit-testing off. + ---@param self UICustomLobbyContextMenu + ---@param entry UICustomLobbyContextMenuItem + ---@return UICustomLobbyContextMenuRow + CreateRow = function(self, entry) + local enabled = entry.enabled ~= false + + ---@type UICustomLobbyContextMenuRow + local row = Group(self) + + row.Surface = Bitmap(row) + row.Surface:SetSolidColor('ffffffff') + row.Surface:SetAlpha(0.0) + + row.Label = UIUtil.CreateText(row, entry.label, FontSize, UIUtil.bodyFont) + row.Label:SetColor(enabled and 'ffffffff' or 'ff666666') + row.Label:DisableHitTest() + + if enabled then + row.Surface.HandleEvent = function(control, event) + if event.Type == 'MouseEnter' then + control:SetAlpha(0.12) + return true + elseif event.Type == 'MouseExit' then + control:SetAlpha(0.0) + return true + elseif event.Type == 'ButtonPress' then + -- close first, so an action that opens another dialog isn't undone + Hide() + entry.action() + return true + end + return false + end + else + row.Surface:DisableHitTest() + end + + return row + end, + + ---@param self UICustomLobbyContextMenu + __post_init = function(self) + local rowCount = table.getn(self.Rows) + + -- width = widest label (+ padding), floored at a minimum + local widest = 0 + for i = 1, rowCount do + local w = self.Rows[i].Label.Width() + if w > widest then + widest = w + end + end + self.Width:Set(math.max(LayoutHelpers.ScaleNumber(MinWidth), widest + LayoutHelpers.ScaleNumber(LabelPadX * 2))) + self.Height:Set(LayoutHelpers.ScaleNumber(MenuPad * 2 + rowCount * ItemHeight)) + + Layouter(self.Border):Fill(self):End() + Layouter(self.Background) + :AtLeftIn(self, 1):AtRightIn(self, 1):AtTopIn(self, 1):AtBottomIn(self, 1) + :End() + + for i = 1, rowCount do + local row = self.Rows[i] + local top = MenuPad + (i - 1) * ItemHeight + Layouter(row) + :AtLeftIn(self, MenuPad):AtRightIn(self, MenuPad):Height(ItemHeight) + :Top(function() return self.Top() + LayoutHelpers.ScaleNumber(top) end) + :End() + Layouter(row.Surface):Fill(row):End() + Layouter(row.Label):AtLeftIn(row, LabelPadX - MenuPad):AtVerticalCenterIn(row):End() + end + end, +} + +------------------------------------------------------------------------------- +-- Singleton + show / hide + +local ModuleTrash = TrashBag() + +---@type UICustomLobbyContextMenu | false +local Instance = false +---@type Bitmap | false +local Cover = false + +--- A full-screen invisible catcher behind the menu: a click anywhere off the menu +--- dismisses it. +---@return Bitmap +local function CreateCover() + local cover = Bitmap(GetFrame(0)) + cover:SetSolidColor('00000000') + cover.HandleEvent = function(control, event) + if event.Type == 'ButtonPress' then + Hide() + return true + end + return false + end + Layouter(cover):Fill(GetFrame(0)):End() + return cover +end + +--- Opens a context menu at the screen point, populated with `entries` (no-op for an +--- empty list). Replaces any menu already open. +---@param entries UICustomLobbyContextMenuItem[] +---@param x number +---@param y number +function Show(entries, x, y) + Hide() + if not entries or table.empty(entries) then + return + end + + local frame = GetFrame(0) + + -- The slot rows raise their hit area with `Over(self, ...)`, so a default-depth + -- overlay sits *under* them and never catches the click. Pin the cover and the + -- menu above everything (the popup.lua pattern) so click-outside actually fires. + local baseDepth = frame:GetTopmostDepth() + + Cover = CreateCover() + Cover.Depth:Set(baseDepth + 10) + + Instance = CustomLobbyContextMenu(frame, entries) + Instance.Depth:Set(baseDepth + 20) + ModuleTrash:Add(Instance) + + -- keep the menu fully on-screen + local left = math.min(x, frame.Right() - Instance.Width()) + local top = math.min(y, frame.Bottom() - Instance.Height()) + Instance.Left:Set(math.max(0, left)) + Instance.Top:Set(math.max(0, top)) + + -- Esc closes the menu before it would reach the lobby's leave handler + EscapeHandler.PushEscapeHandler(function() Hide() end) +end + +--- Closes the open menu (if any). +function Hide() + if not Instance then + return + end + EscapeHandler.PopEscapeHandler() + Instance:Destroy() + Instance = false + if Cover then + Cover:Destroy() + Cover = false + end +end + +------------------------------------------------------------------------------- +--#region Debugging + +function __moduleinfo.OnDirty() + Hide() + ModuleTrash:Destroy() +end + +--#endregion diff --git a/lua/ui/lobby/customlobby/CustomLobbyController.lua b/lua/ui/lobby/customlobby/CustomLobbyController.lua index c0d6b91fe1d..62b1a961ff7 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyController.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyController.lua @@ -82,11 +82,15 @@ local function GatherPlayers(model) return players end ---- Host broadcasts the authoritative player snapshot to everyone. +--- Host broadcasts the authoritative player + observer snapshot to everyone. ---@param instance UICustomLobbyInstance ---@param model UICustomLobbyAuthoritativeModel local function BroadcastPlayers(instance, model) - instance:BroadcastData({ Type = 'SetPlayers', Players = GatherPlayers(model) }) + instance:BroadcastData({ + Type = 'SetPlayers', + Players = GatherPlayers(model), + Observers = model.Observers(), + }) end --- Whether `slot` is a real, currently-open seat (in range, empty, not closed). @@ -284,7 +288,7 @@ function ProcessAddPlayer(instance, data) BroadcastPlayers(instance, model) end ---- Everyone applies the host's authoritative snapshot. +--- Everyone applies the host's authoritative player + observer snapshot. ---@param instance UICustomLobbyInstance ---@param data table function ProcessSetPlayers(instance, data) @@ -292,6 +296,9 @@ function ProcessSetPlayers(instance, data) for slot = 1, CustomLobbyAuthoritativeModel.MaxSlots do model.Players[slot]:Set(data.Players[slot] or false) end + if data.Observers then + model.Observers:Set(data.Observers) + end end --- Host flips a peer's ready flag and re-broadcasts. @@ -426,6 +433,62 @@ function RequestSwapSlots(slotA, slotB) SwapSlots(instance, model, slotA, slotB) end +--- The host ejects a player. A human is dropped from the network (the resulting +--- PeerDisconnected clears the slot + re-broadcasts); an AI is just cleared. Host-only. +---@param ownerId UILobbyPeerId +function RequestEject(ownerId) + local instance = LobbyInstance + if not instance then + return + end + + local model = CustomLobbyAuthoritativeModel.GetSingleton() + if not model.IsHost() then + WARN("CustomLobby: only the host can eject players") + return + end + + local slot = FindSlotForOwner(model, ownerId) + if not slot then + return + end + local player = model.Players[slot]() + if player and player.Human then + instance:EjectPeer(ownerId, "KickedByHost") + else + CustomLobbyAuthoritativeModel.ClearPlayer(model, slot) + BroadcastPlayers(instance, model) + end +end + +--- The host moves a player out of its slot and into the observer list, then +--- re-broadcasts. Host-only. (Reverse — observer back to a slot — is a later slice.) +---@param ownerId UILobbyPeerId +function RequestMoveToObserver(ownerId) + local instance = LobbyInstance + if not instance then + return + end + + local model = CustomLobbyAuthoritativeModel.GetSingleton() + if not model.IsHost() then + WARN("CustomLobby: only the host can move players to observers") + return + end + + local slot = FindSlotForOwner(model, ownerId) + if not slot then + return + end + local player = model.Players[slot]() + if not player then + return + end + CustomLobbyAuthoritativeModel.AddObserver(model, player) + CustomLobbyAuthoritativeModel.ClearPlayer(model, slot) + BroadcastPlayers(instance, model) +end + --- The local player toggles their ready flag. Host applies + broadcasts; a client --- asks the host to. ---@param ready boolean diff --git a/lua/ui/lobby/customlobby/CustomLobbyMenus.lua b/lua/ui/lobby/customlobby/CustomLobbyMenus.lua new file mode 100644 index 00000000000..5a007f5a675 --- /dev/null +++ b/lua/ui/lobby/customlobby/CustomLobbyMenus.lua @@ -0,0 +1,143 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- What goes in each context menu, declaratively. A `Build*` function gathers a small +-- context table (slot, role, lobby state) and filters the entry list by each entry's +-- `when(ctx)` predicate, so the same definition produces a different menu for a host +-- vs a regular player, an open vs occupied slot, etc. +-- +-- TO ADD AN ITEM: append `{ label, when, action }` to the relevant list. `when(ctx)` +-- decides visibility for the current state; `action(ctx)` runs on click and should +-- call a CustomLobbyController intent (never touch the model directly). `enabled(ctx)` +-- is optional (default true) to show an item greyed-out instead of hiding it. +-- +-- The result feeds CustomLobbyContextMenu.Show (a list of { label, action, enabled }). + +local CustomLobbyAuthoritativeModel = import("/lua/ui/lobby/customlobby/customlobbyauthoritativemodel.lua") +local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") + +------------------------------------------------------------------------------- +-- Slot menu + +--- The state a slot-menu entry is evaluated against. +---@class UICustomLobbySlotMenuContext +---@field slot number +---@field player UICustomLobbyPlayer | false +---@field isHost boolean +---@field isYou boolean +---@field isOpen boolean + +--- A declarative slot-menu entry. +---@class UICustomLobbySlotMenuEntry +---@field label string +---@field when fun(ctx: UICustomLobbySlotMenuContext): boolean +---@field action fun(ctx: UICustomLobbySlotMenuContext) +---@field enabled? fun(ctx: UICustomLobbySlotMenuContext): boolean + +---@type UICustomLobbySlotMenuEntry[] +local SlotMenu = { + { + label = "Take this slot", + when = function(ctx) return ctx.isOpen end, + action = function(ctx) CustomLobbyController.RequestTakeSlot(ctx.slot) end, + }, + { + label = "Ready up", + when = function(ctx) return ctx.isYou and ctx.player and not ctx.player.Ready end, + action = function(ctx) CustomLobbyController.RequestSetReady(true) end, + }, + { + label = "Cancel ready", + when = function(ctx) return ctx.isYou and ctx.player and ctx.player.Ready end, + action = function(ctx) CustomLobbyController.RequestSetReady(false) end, + }, + + -- Host actions: + { + label = "Move to observers", + when = function(ctx) return ctx.isHost and ctx.player and ctx.player.Human end, + action = function(ctx) CustomLobbyController.RequestMoveToObserver(ctx.player.OwnerID) end, + }, + { + label = "Eject", + when = function(ctx) return ctx.isHost and ctx.player and not ctx.isYou end, + action = function(ctx) CustomLobbyController.RequestEject(ctx.player.OwnerID) end, + }, +} + +--- Snapshots the state a slot menu is built from. +---@param slot number +---@return UICustomLobbySlotMenuContext +local function SlotContext(slot) + local model = CustomLobbyAuthoritativeModel.GetSingleton() + local player = model.Players[slot]() + return { + slot = slot, + player = player, + isHost = model.IsHost(), + isYou = (player and player.OwnerID == model.LocalPeerId()) and true or false, + isOpen = not player, + } +end + +--- Filters a declarative entry list against a context into concrete menu items. +---@generic T +---@param entries table[] +---@param ctx T +---@return UICustomLobbyContextMenuItem[] +local function Resolve(entries, ctx) + local items = {} + for i = 1, table.getn(entries) do + local entry = entries[i] + if entry.when(ctx) then + local entryAction = entry.action + local entryEnabled = entry.enabled + table.insert(items, { + label = entry.label, + enabled = entryEnabled == nil or entryEnabled(ctx), + action = function() entryAction(ctx) end, + }) + end + end + return items +end + +--- The context-menu items for a slot, given the current lobby state. +---@param slot number +---@return UICustomLobbyContextMenuItem[] +function BuildSlotMenu(slot) + return Resolve(SlotMenu, SlotContext(slot)) +end + +------------------------------------------------------------------------------- +--#region Debugging + +function __moduleinfo.OnDirty() + ForkThread( + function() + WaitFrames(2) + import(__moduleinfo.name) + end + ) +end + +--#endregion diff --git a/lua/ui/lobby/customlobby/CustomLobbyMessages.lua b/lua/ui/lobby/customlobby/CustomLobbyMessages.lua index 685ae3b7cc0..b6c73ebf15a 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyMessages.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyMessages.lua @@ -74,6 +74,7 @@ CustomLobbyMessages = { SetPlayers = { ---@class UICustomLobbySetPlayersMessage : UILobbyReceivedMessage ---@field Players (UICustomLobbyPlayer | false)[] + ---@field Observers? UICustomLobbyPlayer[] ---@param data UICustomLobbySetPlayersMessage Validate = function(lobby, data) diff --git a/lua/ui/lobby/customlobby/CustomLobbySlotInterface.lua b/lua/ui/lobby/customlobby/CustomLobbySlotInterface.lua index a34a74eb6ae..3c231528a5f 100644 --- a/lua/ui/lobby/customlobby/CustomLobbySlotInterface.lua +++ b/lua/ui/lobby/customlobby/CustomLobbySlotInterface.lua @@ -37,6 +37,8 @@ local CustomLobbyAuthoritativeModel = import("/lua/ui/lobby/customlobby/customlo local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") local CustomLobbyModel = import("/lua/ui/lobby/customlobby/customlobbymodel.lua") local CustomLobbyPerformancePopover = import("/lua/ui/lobby/customlobby/customlobbyperformancepopover.lua") +local CustomLobbyContextMenu = import("/lua/ui/lobby/customlobby/customlobbycontextmenu.lua") +local CustomLobbyMenus = import("/lua/ui/lobby/customlobby/customlobbymenus.lua") local LazyVarDerive = import("/lua/lazyvar.lua").Derive @@ -221,7 +223,11 @@ local CustomLobbySlotInterface = Class(Group) { self.ClickArea:SetSolidColor('00000000') self.ClickArea.HandleEvent = function(control, event) if event.Type == 'ButtonPress' then - self:OnRowPress(event) + if event.Modifiers.Right then + self:OnRowContext(event) + else + self:OnRowPress(event) + end return true end return false @@ -238,7 +244,11 @@ local CustomLobbySlotInterface = Class(Group) { CustomLobbyPerformancePopover.Hide() return true elseif event.Type == 'ButtonPress' then - self:OnRowPress(event) + if event.Modifiers.Right then + self:OnRowContext(event) + else + self:OnRowPress(event) + end return true end return false @@ -381,6 +391,15 @@ local CustomLobbySlotInterface = Class(Group) { end end, + --- Right-click: open this slot's context menu (entries depend on lobby state — + --- see CustomLobbyMenus). Empty menus simply don't show. + ---@param self UICustomLobbySlotInterface + ---@param event KeyEvent + OnRowContext = function(self, event) + CustomLobbyPerformancePopover.Hide() + CustomLobbyContextMenu.Show(CustomLobbyMenus.BuildSlotMenu(self.SlotIndex), event.MouseX, event.MouseY) + end, + --- Starts a drag from this row. The press only becomes a drag once the cursor --- travels past DragThreshold — under it, the release is treated as a click, so --- take/ready still work. Movement + drop are routed to the coordinator (it owns From 66f5707d964be5c3934d8d9a5836957a9ce74cbc Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Mon, 22 Jun 2026 07:41:13 +0200 Subject: [PATCH 13/98] Add an observers area --- lua/ui/lobby/customlobby/CLAUDE.md | 6 +- .../CustomLobbyAuthoritativeModel.lua | 20 ++++ .../customlobby/CustomLobbyController.lua | 21 +++- .../customlobby/CustomLobbyInterface.lua | 15 ++- lua/ui/lobby/customlobby/CustomLobbyMenus.lua | 28 +++++- .../CustomLobbyObserversInterface.lua | 98 +++++++++++++++++++ 6 files changed, 178 insertions(+), 10 deletions(-) create mode 100644 lua/ui/lobby/customlobby/CustomLobbyObserversInterface.lua diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index f0b8e198c79..d473d913a9e 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -40,13 +40,15 @@ the automated/matchmaker path). | [CustomLobbyContextMenu.lua](CustomLobbyContextMenu.lua) | generic framed floating menu; `Show(entries, x, y)` renders any `{label, action, enabled}` list, dismisses on item click / click-outside / Esc. Knows nothing about the lobby. | | [CustomLobbyMenus.lua](CustomLobbyMenus.lua) | declarative menu **definitions**: entry lists with `when(ctx)`/`action(ctx)` filtered by lobby state (`BuildSlotMenu`). Adding/state-gating an item is a one-liner here. | | [CustomLobbySlotInterface.lua](CustomLobbySlotInterface.lua) | one slot row; subscribes to its slot + CPU benchmarks; CPU column shows max units at +0 with a green→red cap-headroom square; left-click an open slot to take it / your own to toggle ready; right-click opens its context menu; the host can drag a row onto another to swap. | -| [CustomLobbyInterface.lua](CustomLobbyInterface.lua) | composition root (one model subscription for SlotCount); lays out slot rows and acts as their drag coordinator (`UICustomLobbySlotCoordinator`: hit-test, drop-highlight, drag ghost → `RequestSwapSlots`); `OpenDebug()` / hot-reload. | +| [CustomLobbyObserversInterface.lua](CustomLobbyObserversInterface.lua) | observer strip; subscribes to the model's `Observers` list and shows the count + names (read-only). | +| [CustomLobbyInterface.lua](CustomLobbyInterface.lua) | composition root (one model subscription for SlotCount); lays out slot rows + the observer strip, and acts as the rows' drag coordinator (`UICustomLobbySlotCoordinator`: hit-test, drop-highlight, drag ghost → `RequestSwapSlots`); `OpenDebug()` / hot-reload. | | [/lua/ui/lobby/lobby.lua](../lobby.lua) | engine entry wrapper (`CreateLobby`/`HostGame`/`JoinGame`) → CustomLobby. Old lobby preserved at `lobby-old.lua`. | Working today: host + clients see each other (host-authoritative player sync), ready toggles round-trip, players can **take an open slot** (click it) and the host can **swap** (drag a row onto another), **eject**, and **move a player to observers** (right-click -→ context menu); observers are synced in the `SetPlayers` snapshot. Each peer's +→ context menu); observers are synced in the `SetPlayers` snapshot, shown in an observer +strip, and an observer rejoins via right-click → **Play this slot**. Each peer's stored sim-performance benchmark is shared (no live stress test), and a **Leave** button (or Esc) disconnects and returns to the menu — a leaving client frees its slot for everyone via `OnPeerDisconnected`. Launched via diff --git a/lua/ui/lobby/customlobby/CustomLobbyAuthoritativeModel.lua b/lua/ui/lobby/customlobby/CustomLobbyAuthoritativeModel.lua index 5741b89e45a..9fd7f85a873 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyAuthoritativeModel.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyAuthoritativeModel.lua @@ -199,6 +199,26 @@ function AddObserver(model, player) model.Observers:Set(observers) end +--- Removes the observer owned by `ownerId` (copy-then-Set) and returns it, or nil. +---@param model UICustomLobbyAuthoritativeModel +---@param ownerId UILobbyPeerId +---@return UICustomLobbyPlayer | nil +function RemoveObserver(model, ownerId) + local observers = model.Observers() + local kept, removed = {}, nil + for i = 1, table.getn(observers) do + if observers[i].OwnerID == ownerId then + removed = observers[i] + else + table.insert(kept, observers[i]) + end + end + if removed then + model.Observers:Set(kept) + end + return removed +end + --#endregion ------------------------------------------------------------------------------- diff --git a/lua/ui/lobby/customlobby/CustomLobbyController.lua b/lua/ui/lobby/customlobby/CustomLobbyController.lua index 62b1a961ff7..7316d873109 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyController.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyController.lua @@ -115,15 +115,26 @@ local function TakeSlot(instance, model, ownerId, slot) if not IsOpenSlot(model, slot) then return end + local from = FindSlotForOwner(model, ownerId) - if not from or from == slot then - return + local player + if from then + if from == slot then + return + end + player = table.copy(model.Players[from]()) + CustomLobbyAuthoritativeModel.ClearPlayer(model, from) + else + -- not in a slot: an observer joining a slot (the reverse of Move to observers) + local observer = CustomLobbyAuthoritativeModel.RemoveObserver(model, ownerId) + if not observer then + return + end + player = table.copy(observer) end - local player = table.copy(model.Players[from]()) player.StartSpot = slot - player.Ready = false -- moving seats resets readiness - CustomLobbyAuthoritativeModel.ClearPlayer(model, from) + player.Ready = false -- (re)seating resets readiness CustomLobbyAuthoritativeModel.SetPlayer(model, slot, player) BroadcastPlayers(instance, model) end diff --git a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua index 85f17758955..2012f0d24e4 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua @@ -35,6 +35,7 @@ local Bitmap = import("/lua/maui/bitmap.lua").Bitmap local CustomLobbyAuthoritativeModel = import("/lua/ui/lobby/customlobby/customlobbyauthoritativemodel.lua") local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") local CustomLobbySlotInterface = import("/lua/ui/lobby/customlobby/customlobbyslotinterface.lua") +local CustomLobbyObserversInterface = import("/lua/ui/lobby/customlobby/customlobbyobserversinterface.lua") local LazyVarDerive = import("/lua/lazyvar.lua").Derive @@ -49,6 +50,7 @@ local PanelWidth = 520 ---@field Title Text ---@field SlotsPanel Group ---@field Slots UICustomLobbySlotInterface[] +---@field ObserversPanel UICustomLobbyObserversInterface ---@field LeaveButton Button ---@field SlotCountObserver LazyVar ---@field HighlightedSlot number | false # slot currently shown as a drop target @@ -78,6 +80,8 @@ local CustomLobbyInterface = Class(Group) { self.Slots[slot] = CustomLobbySlotInterface.Create(self.SlotsPanel, slot, self) end + self.ObserversPanel = CustomLobbyObserversInterface.Create(self) + -- leaving disconnects + returns to the menu via the escape handler that -- lobby.lua registered (one teardown definition, shared with the Esc key) self.LeaveButton = UIUtil.CreateButtonStd(self, '/scx_menu/small-btn/small', "Leave", 16, 2) @@ -121,7 +125,10 @@ local CustomLobbyInterface = Class(Group) { builder:End() end - Layouter(self.LeaveButton):AtLeftIn(self, 40):AnchorToBottom(self.SlotsPanel, 24):End() + Layouter(self.ObserversPanel) + :AtLeftIn(self, 40):AnchorToBottom(self.SlotsPanel, 16):Width(PanelWidth):Height(44) + :End() + Layouter(self.LeaveButton):AtLeftIn(self, 40):AnchorToBottom(self.ObserversPanel, 16):End() end, --- Shows the active slots (1..count) and hides the rest. @@ -322,6 +329,12 @@ function OpenDebug() }) end + -- a couple of observers so the observer strip shows something + model.Observers:Set({ + { PlayerName = "Zock", OwnerID = "10", Human = true }, + { PlayerName = "Spag", OwnerID = "11", Human = true }, + }) + SetupSingleton() end diff --git a/lua/ui/lobby/customlobby/CustomLobbyMenus.lua b/lua/ui/lobby/customlobby/CustomLobbyMenus.lua index 5a007f5a675..706310f0ac5 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyMenus.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyMenus.lua @@ -45,6 +45,7 @@ local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontr ---@field isHost boolean ---@field isYou boolean ---@field isOpen boolean +---@field localIsObserver boolean # the local player is currently spectating (no slot) --- A declarative slot-menu entry. ---@class UICustomLobbySlotMenuEntry @@ -56,8 +57,15 @@ local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontr ---@type UICustomLobbySlotMenuEntry[] local SlotMenu = { { + -- an observer joining a slot is "playing" it + label = "Play this slot", + when = function(ctx) return ctx.isOpen and ctx.localIsObserver end, + action = function(ctx) CustomLobbyController.RequestTakeSlot(ctx.slot) end, + }, + { + -- a seated player relocating to another open slot label = "Take this slot", - when = function(ctx) return ctx.isOpen end, + when = function(ctx) return ctx.isOpen and not ctx.localIsObserver end, action = function(ctx) CustomLobbyController.RequestTakeSlot(ctx.slot) end, }, { @@ -84,18 +92,34 @@ local SlotMenu = { }, } +--- Whether `ownerId` is in the observer list. +---@param model UICustomLobbyAuthoritativeModel +---@param ownerId UILobbyPeerId +---@return boolean +local function IsObserver(model, ownerId) + local observers = model.Observers() + for i = 1, table.getn(observers) do + if observers[i].OwnerID == ownerId then + return true + end + end + return false +end + --- Snapshots the state a slot menu is built from. ---@param slot number ---@return UICustomLobbySlotMenuContext local function SlotContext(slot) local model = CustomLobbyAuthoritativeModel.GetSingleton() local player = model.Players[slot]() + local localId = model.LocalPeerId() return { slot = slot, player = player, isHost = model.IsHost(), - isYou = (player and player.OwnerID == model.LocalPeerId()) and true or false, + isYou = (player and player.OwnerID == localId) and true or false, isOpen = not player, + localIsObserver = IsObserver(model, localId), } end diff --git a/lua/ui/lobby/customlobby/CustomLobbyObserversInterface.lua b/lua/ui/lobby/customlobby/CustomLobbyObserversInterface.lua new file mode 100644 index 00000000000..4f0cd51a572 --- /dev/null +++ b/lua/ui/lobby/customlobby/CustomLobbyObserversInterface.lua @@ -0,0 +1,98 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- The observer strip: subscribes to the model's `Observers` list and renders a header +-- with the count plus the names. Read-only for now — a player becomes an observer via +-- the host's "Move to observers", and rejoins by right-clicking an open slot ("Play +-- this slot"); per-observer host actions can be a later slice. + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") + +local Group = import("/lua/maui/group.lua").Group +local CustomLobbyAuthoritativeModel = import("/lua/ui/lobby/customlobby/customlobbyauthoritativemodel.lua") + +local LazyVarDerive = import("/lua/lazyvar.lua").Derive + +local Layouter = LayoutHelpers.ReusedLayoutFor + +---@class UICustomLobbyObserversInterface : Group +---@field Trash TrashBag +---@field Header Text +---@field Names Text +---@field ObserversObserver LazyVar +local CustomLobbyObserversInterface = Class(Group) { + + ---@param self UICustomLobbyObserversInterface + ---@param parent Control + __init = function(self, parent) + Group.__init(self, parent, "CustomLobbyObservers") + + self.Trash = TrashBag() + + self.Header = UIUtil.CreateText(self, "Observers (0)", 14, UIUtil.titleFont) + self.Names = UIUtil.CreateText(self, "—", 12, UIUtil.bodyFont) + self.Names:SetColor('ff9aa0a8') + + local model = CustomLobbyAuthoritativeModel.GetSingleton() + self.ObserversObserver = self.Trash:Add( + LazyVarDerive(model.Observers, function(observersLazy) + self:OnObserversChanged(observersLazy()) + end)) + end, + + ---@param self UICustomLobbyObserversInterface + __post_init = function(self) + Layouter(self.Header):AtLeftTopIn(self):End() + Layouter(self.Names):AtLeftIn(self):AnchorToBottom(self.Header, 4):End() + end, + + --- Renders the observer count + names. + ---@param self UICustomLobbyObserversInterface + ---@param observers UICustomLobbyPlayer[] + OnObserversChanged = function(self, observers) + local count = table.getn(observers) + self.Header:SetText("Observers (" .. count .. ")") + + if count == 0 then + self.Names:SetText("—") + return + end + + local names = {} + for i = 1, count do + names[i] = observers[i].PlayerName or "?" + end + self.Names:SetText(table.concat(names, ", ")) + end, + + ---@param self UICustomLobbyObserversInterface + OnDestroy = function(self) + self.Trash:Destroy() + end, +} + +---@param parent Control +---@return UICustomLobbyObserversInterface +Create = function(parent) + return CustomLobbyObserversInterface(parent) +end From 89983cdb2469c80a13f148e56afac9cd8c3de812 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Mon, 22 Jun 2026 08:01:48 +0200 Subject: [PATCH 14/98] Make all functionality callable from text (for chat commands) --- .../skills/customlobby-model-choice/SKILL.md | 35 ++++++ lua/ui/lobby/customlobby/CLAUDE.md | 3 +- .../CustomLobbyAuthoritativeModel.lua | 3 + .../customlobby/CustomLobbyController.lua | 46 ++++---- lua/ui/lobby/customlobby/CustomLobbyMenus.lua | 4 +- lua/ui/lobby/customlobby/CustomLobbyRules.lua | 100 ++++++++++++++++++ .../customlobby/CustomLobbySlotInterface.lua | 43 +------- 7 files changed, 173 insertions(+), 61 deletions(-) create mode 100644 .claude/skills/customlobby-model-choice/SKILL.md create mode 100644 lua/ui/lobby/customlobby/CustomLobbyRules.lua diff --git a/.claude/skills/customlobby-model-choice/SKILL.md b/.claude/skills/customlobby-model-choice/SKILL.md new file mode 100644 index 00000000000..8a5a0d8c076 --- /dev/null +++ b/.claude/skills/customlobby-model-choice/SKILL.md @@ -0,0 +1,35 @@ +--- +name: customlobby-model-choice +description: Decide which CustomLobby model new state belongs in — the host-authoritative model (host-dictated, synced, part of the launched scenario) vs the regular connectivity model (local, high-frequency, not synced) — and keep hot-reload working. Use when adding a field/state to the custom lobby, or unsure where lobby state should live. +--- + +# Which CustomLobby model does this state go in? + +Two reactive singletons. Pick by **who owns the truth and whether it's part of the game**: + +| Put it in… | When the state is… | Examples | +|---|---|---| +| **[CustomLobbyAuthoritativeModel.lua](/lua/ui/lobby/customlobby/CustomLobbyAuthoritativeModel.lua)** | **host-dictated** and **part of what launches** — everyone must agree, the host broadcasts it. | players (per-slot), observers, game options, mods, scenario, slot flags (closed / spawn-mex / auto-teams), identity (`LocalPeerId` / `HostID` / `IsHost`). | +| **[CustomLobbyModel.lua](/lua/ui/lobby/customlobby/CustomLobbyModel.lua)** | **local / per-peer / high-frequency** and **not** part of the scenario — churns independently of the synced game state. | CPU benchmarks (now); ping / connection status (later). | + +**Litmus test:** *"Does the launched game need this, and must all peers see the same value?"* → authoritative. *"Is it just this client's view of connectivity/health?"* → regular. Keeping fast-churning local state out of the authoritative model is the whole point — it stops connection noise from dirtying the synced snapshot. + +## Adding a field — also touch OnReload + +Both models hand-copy their LazyVars in `__moduleinfo.OnReload` so a hot-reload doesn't reset live state. When you add a field: + +1. add it to `SetupSingleton` (a `Create(...)` with its default), and +2. **add a matching copy line in that model's `OnReload`** — miss this and the field silently resets on every save-reload (exactly when you're iterating). + +## Gotchas — do / don't + +| | Rule | +|---|------| +| ✅ | Route every write through the model's write helpers (`SetPlayer`, `SetGameOption`, `AddObserver`, `SetCpuBenchmark`, …) — they keep the copy-then-`Set` discipline. | +| ✅ | Add the matching `OnReload` copy line whenever you add a field (see above). | +| ✅ | Mirror state to the authoritative model **only from the controller**, after the host validates; clients send a request message. | +| 🚫 | Put ping / CPU / connection churn in the **authoritative** model — its dirtying would ride along with the synced game snapshot. | +| 🚫 | Put scenario / launch-affecting state in the **regular** model — it won't be part of the game the host dictates or broadcasts. | +| 🚫 | Mutate a LazyVar's held table in place — always `table.copy` → mutate → `:Set` (a new table), or dependents never go dirty. | +| 🚫 | Write either model from a view/component — views read via `Derive` only; the controller is the sole writer. | +| 🚫 | Use the `%` operator (FAF is Lua 5.0 — use `math.mod`). | diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index d473d913a9e..79044e5117b 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -35,7 +35,8 @@ the automated/matchmaker path). | [CustomLobbyModel.lua](CustomLobbyModel.lua) | local connectivity state (CPU benchmarks + per-peer sim-performance history). | | [CustomLobbyPerformancePopover.lua](CustomLobbyPerformancePopover.lua) | hover popover over the CPU column; hand-built bitmap bar chart of a peer's `PerformanceTrackingV2` history, with a yellow recommended-unit-cap line. | | [CustomLobbyInstance.lua](CustomLobbyInstance.lua) | thin `moho.lobby_methods` shell; validates/dispatches traffic, forwards callbacks to the controller. | -| [CustomLobbyController.lua](CustomLobbyController.lua) | host-authority logic (free functions): seating, `Process*` handlers, intents (`RequestSetReady`, `RequestTakeSlot`, `RequestSwapSlots`, `RequestEject`, `RequestMoveToObserver` — the slot-management set, also callable from a chat command), sharing the stored CPU benchmark. | +| [CustomLobbyController.lua](CustomLobbyController.lua) | host-authority logic (free functions): seating, `Process*` handlers, intents (`RequestSetReady`, `RequestTakeSlot`, `RequestSwapSlots`, `RequestEject`, `RequestMoveToObserver` — all keyed by slot index / bool so a chat command can call them too; permission is gated separately), sharing the stored CPU benchmark. | +| [CustomLobbyRules.lua](CustomLobbyRules.lua) | game-rule derivations from lobby state (not view, not networking): `RecommendedUnitCap()` (per-player cap by map size, memoised scenario lookup). | | [CustomLobbyMessages.lua](CustomLobbyMessages.lua) | message registry: `AddPlayer`, `SetPlayers`, `SetReady`, `TakeSlot`, `DisconnectPeer`, `ReportCpuBenchmark`, `SetCpuBenchmarks`. | | [CustomLobbyContextMenu.lua](CustomLobbyContextMenu.lua) | generic framed floating menu; `Show(entries, x, y)` renders any `{label, action, enabled}` list, dismisses on item click / click-outside / Esc. Knows nothing about the lobby. | | [CustomLobbyMenus.lua](CustomLobbyMenus.lua) | declarative menu **definitions**: entry lists with `when(ctx)`/`action(ctx)` filtered by lobby state (`BuildSlotMenu`). Adding/state-gating an item is a one-liner here. | diff --git a/lua/ui/lobby/customlobby/CustomLobbyAuthoritativeModel.lua b/lua/ui/lobby/customlobby/CustomLobbyAuthoritativeModel.lua index 9fd7f85a873..43ceafa0654 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyAuthoritativeModel.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyAuthoritativeModel.lua @@ -226,6 +226,9 @@ end --- Hot-reload hook: rebuilds the singleton on the new module and copies the current --- raw LazyVar values across so observers don't see a state reset. +--- +--- NOTE: this list is maintained by hand — when you add a field to the model, add a +--- copy line here too, or its value is lost on every hot-reload. ---@param newModule any function __moduleinfo.OnReload(newModule) if ModelInstance then diff --git a/lua/ui/lobby/customlobby/CustomLobbyController.lua b/lua/ui/lobby/customlobby/CustomLobbyController.lua index 7316d873109..a41ef329118 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyController.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyController.lua @@ -444,10 +444,24 @@ function RequestSwapSlots(slotA, slotB) SwapSlots(instance, model, slotA, slotB) end ---- The host ejects a player. A human is dropped from the network (the resulting ---- PeerDisconnected clears the slot + re-broadcasts); an AI is just cleared. Host-only. ----@param ownerId UILobbyPeerId -function RequestEject(ownerId) +--- The player occupying `slot` within the active slot range, or nil. Tolerant of the +--- arbitrary slot values a chat command might pass. +---@param model UICustomLobbyAuthoritativeModel +---@param slot any +---@return UICustomLobbyPlayer | nil +local function PlayerInSlot(model, slot) + if type(slot) ~= 'number' or slot < 1 or slot > CustomLobbyAuthoritativeModel.MaxSlots then + return nil + end + return model.Players[slot]() or nil +end + +--- Ejects the player in `slot`: a human is dropped from the network (the resulting +--- PeerDisconnected clears the slot + re-broadcasts), an AI is just cleared. Host-only. +--- Slot-keyed so a chat command (`/eject `) can call it too; whether the caller +--- is permitted to is gated separately. +---@param slot number +function RequestEject(slot) local instance = LobbyInstance if not instance then return @@ -459,23 +473,23 @@ function RequestEject(ownerId) return end - local slot = FindSlotForOwner(model, ownerId) - if not slot then + local player = PlayerInSlot(model, slot) + if not player then return end - local player = model.Players[slot]() - if player and player.Human then - instance:EjectPeer(ownerId, "KickedByHost") + if player.Human then + instance:EjectPeer(player.OwnerID, "KickedByHost") else CustomLobbyAuthoritativeModel.ClearPlayer(model, slot) BroadcastPlayers(instance, model) end end ---- The host moves a player out of its slot and into the observer list, then ---- re-broadcasts. Host-only. (Reverse — observer back to a slot — is a later slice.) ----@param ownerId UILobbyPeerId -function RequestMoveToObserver(ownerId) +--- Moves the player in `slot` into the observer list, then re-broadcasts. Host-only. +--- Slot-keyed for chat (`/observe `). The reverse (observer → slot) is +--- `RequestTakeSlot` ("Play this slot"). +---@param slot number +function RequestMoveToObserver(slot) local instance = LobbyInstance if not instance then return @@ -487,11 +501,7 @@ function RequestMoveToObserver(ownerId) return end - local slot = FindSlotForOwner(model, ownerId) - if not slot then - return - end - local player = model.Players[slot]() + local player = PlayerInSlot(model, slot) if not player then return end diff --git a/lua/ui/lobby/customlobby/CustomLobbyMenus.lua b/lua/ui/lobby/customlobby/CustomLobbyMenus.lua index 706310f0ac5..09d805c8918 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyMenus.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyMenus.lua @@ -83,12 +83,12 @@ local SlotMenu = { { label = "Move to observers", when = function(ctx) return ctx.isHost and ctx.player and ctx.player.Human end, - action = function(ctx) CustomLobbyController.RequestMoveToObserver(ctx.player.OwnerID) end, + action = function(ctx) CustomLobbyController.RequestMoveToObserver(ctx.slot) end, }, { label = "Eject", when = function(ctx) return ctx.isHost and ctx.player and not ctx.isYou end, - action = function(ctx) CustomLobbyController.RequestEject(ctx.player.OwnerID) end, + action = function(ctx) CustomLobbyController.RequestEject(ctx.slot) end, }, } diff --git a/lua/ui/lobby/customlobby/CustomLobbyRules.lua b/lua/ui/lobby/customlobby/CustomLobbyRules.lua new file mode 100644 index 00000000000..3b965e6e773 --- /dev/null +++ b/lua/ui/lobby/customlobby/CustomLobbyRules.lua @@ -0,0 +1,100 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- Game-rule derivations from the lobby state — neither view nor host-authority logic. +-- Views call these for display hints; the controller may call them at launch. Keep the +-- model the source of truth; this layer only derives. + +local CustomLobbyAuthoritativeModel = import("/lua/ui/lobby/customlobby/customlobbyauthoritativemodel.lua") + +-- Scenario file -> largest map dimension (ogrids). Loading a scenario is I/O, so the +-- result is memoised per file (a map rarely changes, and the key changes if it does). +local MapDimensionCache = {} + +--- Units-per-player tier from the map's largest dimension (51.2 ogrids = 1 km), i.e. +--- 250 / 375 / 500 for 5x5 / 10x10 / 20x20-or-larger. Unknown size (0) → the top tier. +---@param maxDimension number +---@return number +local function UnitsPerPlayer(maxDimension) + if maxDimension > 0 and maxDimension <= 256 then + return 250 -- 5x5 + elseif maxDimension > 0 and maxDimension <= 512 then + return 375 -- 10x10 + end + return 500 -- 20x20 or larger / not yet known +end + +--- Largest dimension (ogrids) of the current scenario, or 0 when no map is set. +---@param model UICustomLobbyAuthoritativeModel +---@return number +local function CurrentMapDimension(model) + local scenarioFile = model.ScenarioFile() + if not scenarioFile then + return 0 + end + + local cached = MapDimensionCache[scenarioFile] + if cached ~= nil then + return cached + end + + local dimension = 0 + local scenarioInfo = import("/lua/ui/maputil.lua").LoadScenario(scenarioFile) + if scenarioInfo and scenarioInfo.size then + dimension = math.max(scenarioInfo.size[1] or 0, scenarioInfo.size[2] or 0) + end + MapDimensionCache[scenarioFile] = dimension + return dimension +end + +--- The recommended total-unit ceiling for the current map and seated-player count. +--- Returns nil when there are no players to scale by. +---@return number | nil +function RecommendedUnitCap() + local model = CustomLobbyAuthoritativeModel.GetSingleton() + + local players = 0 + for slot = 1, CustomLobbyAuthoritativeModel.MaxSlots do + if model.Players[slot]() then + players = players + 1 + end + end + if players < 1 then + return nil + end + + return UnitsPerPlayer(CurrentMapDimension(model)) * players +end + +------------------------------------------------------------------------------- +--#region Debugging + +function __moduleinfo.OnDirty() + ForkThread( + function() + WaitFrames(2) + import(__moduleinfo.name) + end + ) +end + +--#endregion diff --git a/lua/ui/lobby/customlobby/CustomLobbySlotInterface.lua b/lua/ui/lobby/customlobby/CustomLobbySlotInterface.lua index 3c231528a5f..5ceaced4322 100644 --- a/lua/ui/lobby/customlobby/CustomLobbySlotInterface.lua +++ b/lua/ui/lobby/customlobby/CustomLobbySlotInterface.lua @@ -39,6 +39,7 @@ local CustomLobbyModel = import("/lua/ui/lobby/customlobby/customlobbymodel.lua" local CustomLobbyPerformancePopover = import("/lua/ui/lobby/customlobby/customlobbyperformancepopover.lua") local CustomLobbyContextMenu = import("/lua/ui/lobby/customlobby/customlobbycontextmenu.lua") local CustomLobbyMenus = import("/lua/ui/lobby/customlobby/customlobbymenus.lua") +local CustomLobbyRules = import("/lua/ui/lobby/customlobby/customlobbyrules.lua") local LazyVarDerive = import("/lua/lazyvar.lua").Derive @@ -56,44 +57,6 @@ local function FactionLabel(faction) return "Random" end ---- The recommended total-unit ceiling for the current map and player count: 250 / ---- 375 / 500 units per seated player for 5x5 / 10x10 / 20x20-or-larger maps (the ---- map's largest dimension in ogrids, 51.2 per km). Falls back to the largest tier ---- when no map is set yet. Returns nil when there are no players to scale by. ----@return number | nil -local function RecommendedUnitCap() - local model = CustomLobbyAuthoritativeModel.GetSingleton() - - local players = 0 - for slot = 1, CustomLobbyAuthoritativeModel.MaxSlots do - if model.Players[slot]() then - players = players + 1 - end - end - if players < 1 then - return nil - end - - -- 500 (20x20+) until a map is known; refine from the scenario's dimensions - local perPlayer = 500 - local scenarioFile = model.ScenarioFile() - if scenarioFile then - local scenarioInfo = import("/lua/ui/maputil.lua").LoadScenario(scenarioFile) - if scenarioInfo and scenarioInfo.size then - local maxDim = math.max(scenarioInfo.size[1] or 0, scenarioInfo.size[2] or 0) - if maxDim <= 256 then - perPlayer = 250 -- 5x5 - elseif maxDim <= 512 then - perPlayer = 375 -- 10x10 - else - perPlayer = 500 -- 20x20 or larger - end - end - end - - return perPlayer * players -end - --- The sim-rate categories tracked in PerformanceTrackingV2, ordered for the --- "most-played" pick (matches the popover). local PerformanceCategories = { 'Skirmish', 'SkirmishWithAI', 'Campaign' } @@ -343,7 +306,7 @@ local CustomLobbySlotInterface = Class(Group) { self.Cpu:SetText(FormatUnits(maxAtZero)) self.Cpu:SetColor('ff9aa0a8') - local cap = RecommendedUnitCap() + local cap = CustomLobbyRules.RecommendedUnitCap() if not cap or cap <= 0 then self.CpuIndicator:SetAlpha(0.0) return @@ -376,7 +339,7 @@ local CustomLobbySlotInterface = Class(Group) { return end local benchmark = CustomLobbyModel.GetSingleton().CpuBenchmarks()[player.OwnerID] - CustomLobbyPerformancePopover.Show(self.Cpu, benchmark, RecommendedUnitCap()) + CustomLobbyPerformancePopover.Show(self.Cpu, benchmark, CustomLobbyRules.RecommendedUnitCap()) end, --- A press on the row: if the coordinator allows dragging this slot (host, holding From c72549e0636a852abf98dd3eff1a8b6f18db2332 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Mon, 22 Jun 2026 08:41:38 +0200 Subject: [PATCH 15/98] Add support for a map preview --- lua/ui/lobby/customlobby/CLAUDE.md | 7 +- .../customlobby/CustomLobbyController.lua | 44 +++ .../customlobby/CustomLobbyInterface.lua | 15 + .../customlobby/CustomLobbyMapPreview.lua | 345 ++++++++++++++++++ .../CustomLobbyMapPreviewSpawn.lua | 111 ++++++ .../lobby/customlobby/CustomLobbyMessages.lua | 26 ++ 6 files changed, 546 insertions(+), 2 deletions(-) create mode 100644 lua/ui/lobby/customlobby/CustomLobbyMapPreview.lua create mode 100644 lua/ui/lobby/customlobby/CustomLobbyMapPreviewSpawn.lua diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index 79044e5117b..363560e957a 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -37,15 +37,18 @@ the automated/matchmaker path). | [CustomLobbyInstance.lua](CustomLobbyInstance.lua) | thin `moho.lobby_methods` shell; validates/dispatches traffic, forwards callbacks to the controller. | | [CustomLobbyController.lua](CustomLobbyController.lua) | host-authority logic (free functions): seating, `Process*` handlers, intents (`RequestSetReady`, `RequestTakeSlot`, `RequestSwapSlots`, `RequestEject`, `RequestMoveToObserver` — all keyed by slot index / bool so a chat command can call them too; permission is gated separately), sharing the stored CPU benchmark. | | [CustomLobbyRules.lua](CustomLobbyRules.lua) | game-rule derivations from lobby state (not view, not networking): `RecommendedUnitCap()` (per-player cap by map size, memoised scenario lookup). | -| [CustomLobbyMessages.lua](CustomLobbyMessages.lua) | message registry: `AddPlayer`, `SetPlayers`, `SetReady`, `TakeSlot`, `DisconnectPeer`, `ReportCpuBenchmark`, `SetCpuBenchmarks`. | +| [CustomLobbyMessages.lua](CustomLobbyMessages.lua) | message registry: `AddPlayer`, `SetPlayers`, `SentLaunchInfo` (full launch-config snapshot: scenario / options / mods / slot flags), `SetReady`, `TakeSlot`, `DisconnectPeer`, `ReportCpuBenchmark`, `SetCpuBenchmarks`. | | [CustomLobbyContextMenu.lua](CustomLobbyContextMenu.lua) | generic framed floating menu; `Show(entries, x, y)` renders any `{label, action, enabled}` list, dismisses on item click / click-outside / Esc. Knows nothing about the lobby. | | [CustomLobbyMenus.lua](CustomLobbyMenus.lua) | declarative menu **definitions**: entry lists with `when(ctx)`/`action(ctx)` filtered by lobby state (`BuildSlotMenu`). Adding/state-gating an item is a one-liner here. | | [CustomLobbySlotInterface.lua](CustomLobbySlotInterface.lua) | one slot row; subscribes to its slot + CPU benchmarks; CPU column shows max units at +0 with a green→red cap-headroom square; left-click an open slot to take it / your own to toggle ready; right-click opens its context menu; the host can drag a row onto another to swap. | | [CustomLobbyObserversInterface.lua](CustomLobbyObserversInterface.lua) | observer strip; subscribes to the model's `Observers` list and shows the count + names (read-only). | +| [CustomLobbyMapPreview.lua](CustomLobbyMapPreview.lua) / [CustomLobbyMapPreviewSpawn.lua](CustomLobbyMapPreviewSpawn.lua) | map preview (copied from the autolobby's, adapted to this model): subscribes to `ScenarioFile` (full render) + each slot (spawn-icon refresh); hidden until a scenario is set. Reuses the shared `/lua/ui/controls/mappreview.lua`. | | [CustomLobbyInterface.lua](CustomLobbyInterface.lua) | composition root (one model subscription for SlotCount); lays out slot rows + the observer strip, and acts as the rows' drag coordinator (`UICustomLobbySlotCoordinator`: hit-test, drop-highlight, drag ghost → `RequestSwapSlots`); `OpenDebug()` / hot-reload. | | [/lua/ui/lobby/lobby.lua](../lobby.lua) | engine entry wrapper (`CreateLobby`/`HostGame`/`JoinGame`) → CustomLobby. Old lobby preserved at `lobby-old.lua`. | -Working today: host + clients see each other (host-authoritative player sync), +Working today: host + clients see each other (host-authoritative player sync), the +host's launch config (scenario / options / mods / slot flags) is pushed to clients as a +whole `SentLaunchInfo` snapshot (on join and on change) so the map preview etc. render, ready toggles round-trip, players can **take an open slot** (click it) and the host can **swap** (drag a row onto another), **eject**, and **move a player to observers** (right-click → context menu); observers are synced in the `SetPlayers` snapshot, shown in an observer diff --git a/lua/ui/lobby/customlobby/CustomLobbyController.lua b/lua/ui/lobby/customlobby/CustomLobbyController.lua index a41ef329118..8a4c8331de6 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyController.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyController.lua @@ -297,6 +297,9 @@ function ProcessAddPlayer(instance, data) CustomLobbyAuthoritativeModel.SetPlayer(model, slot, player) BroadcastPlayers(instance, model) + -- a fresh peer needs the current launch config (scenario / options / mods), not just + -- the player list — without it their map preview etc. have nothing to render + BroadcastLaunchInfo(instance) end --- Everyone applies the host's authoritative player + observer snapshot. @@ -312,6 +315,21 @@ function ProcessSetPlayers(instance, data) end end +--- Everyone applies the host's authoritative launch config (scenario / options / mods / +--- slot flags) — everything launch needs beyond the player list. +---@param instance UICustomLobbyInstance +---@param data UICustomLobbySentLaunchInfoMessage +function ProcessSentLaunchInfo(instance, data) + local model = CustomLobbyAuthoritativeModel.GetSingleton() + model.ScenarioFile:Set(data.ScenarioFile or false) + model.GameOptions:Set(data.GameOptions or {}) + model.GameMods:Set(data.GameMods or {}) + model.ClosedSlots:Set(data.ClosedSlots or {}) + model.SpawnMex:Set(data.SpawnMex or {}) + model.AutoTeams:Set(data.AutoTeams or {}) + model.SlotCount:Set(data.SlotCount or model.SlotCount()) +end + --- Host flips a peer's ready flag and re-broadcasts. ---@param instance UICustomLobbyInstance ---@param data table @@ -357,6 +375,32 @@ end --#endregion +------------------------------------------------------------------------------- +--#region Launch info +-- +-- The host's launch configuration — everything the launch needs beyond the players: +-- scenario, options, mods, slot flags. Kept deliberately simple: rather than syncing +-- each field with its own message, the host broadcasts the whole snapshot whenever any +-- of it changes (and to each peer as it joins). Call this after any host-side change. + +--- Host broadcasts the full launch-config snapshot to everyone. +---@param instance UICustomLobbyInstance +function BroadcastLaunchInfo(instance) + local model = CustomLobbyAuthoritativeModel.GetSingleton() + instance:BroadcastData({ + Type = 'SentLaunchInfo', + ScenarioFile = model.ScenarioFile(), + GameOptions = model.GameOptions(), + GameMods = model.GameMods(), + ClosedSlots = model.ClosedSlots(), + SpawnMex = model.SpawnMex(), + AutoTeams = model.AutoTeams(), + SlotCount = model.SlotCount(), + }) +end + +--#endregion + ------------------------------------------------------------------------------- --#region CPU benchmark -- diff --git a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua index 2012f0d24e4..4ee05e33f46 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua @@ -36,6 +36,7 @@ local CustomLobbyAuthoritativeModel = import("/lua/ui/lobby/customlobby/customlo local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") local CustomLobbySlotInterface = import("/lua/ui/lobby/customlobby/customlobbyslotinterface.lua") local CustomLobbyObserversInterface = import("/lua/ui/lobby/customlobby/customlobbyobserversinterface.lua") +local CustomLobbyMapPreview = import("/lua/ui/lobby/customlobby/customlobbymappreview.lua") local LazyVarDerive = import("/lua/lazyvar.lua").Derive @@ -51,6 +52,7 @@ local PanelWidth = 520 ---@field SlotsPanel Group ---@field Slots UICustomLobbySlotInterface[] ---@field ObserversPanel UICustomLobbyObserversInterface +---@field MapPreview UICustomLobbyMapPreview ---@field LeaveButton Button ---@field SlotCountObserver LazyVar ---@field HighlightedSlot number | false # slot currently shown as a drop target @@ -81,6 +83,7 @@ local CustomLobbyInterface = Class(Group) { end self.ObserversPanel = CustomLobbyObserversInterface.Create(self) + self.MapPreview = CustomLobbyMapPreview.Create(self) -- leaving disconnects + returns to the menu via the escape handler that -- lobby.lua registered (one teardown definition, shared with the Esc key) @@ -110,6 +113,14 @@ local CustomLobbyInterface = Class(Group) { :Height(CustomLobbyAuthoritativeModel.MaxSlots * SlotHeight) :End() + -- map preview to the right of the slots; hides itself until a scenario is set + Layouter(self.MapPreview) + :AnchorToRight(self.SlotsPanel, 24) + :AtTopIn(self, 80) + :Width(320) + :Height(320) + :End() + -- stack the rows top-to-bottom inside the panel via sibling anchoring for slot = 1, CustomLobbyAuthoritativeModel.MaxSlots do local row = self.Slots[slot] @@ -335,6 +346,10 @@ function OpenDebug() { PlayerName = "Spag", OwnerID = "11", Human = true }, }) + -- a stock map so the preview renders; swap to any installed scenario if this + -- one isn't present (an unknown path just leaves the preview frame empty) + CustomLobbyAuthoritativeModel.SetScenario(model, "/maps/scmp_009/scmp_009_scenario.lua") + SetupSingleton() end diff --git a/lua/ui/lobby/customlobby/CustomLobbyMapPreview.lua b/lua/ui/lobby/customlobby/CustomLobbyMapPreview.lua new file mode 100644 index 00000000000..2c01a2e5d79 --- /dev/null +++ b/lua/ui/lobby/customlobby/CustomLobbyMapPreview.lua @@ -0,0 +1,345 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- Map preview for the custom lobby. Copied from the autolobby's AutolobbyMapPreview and +-- adapted to this lobby's model: the autolobby observes one `Scenario` bundle, but here +-- the scenario file and the players live separately (`ScenarioFile` + per-slot `Players`), +-- so we observe the file (full re-render) and each slot (cheap spawn-icon refresh against +-- the cached scenario). The rendering itself (preview texture, resource markers, spawn +-- icons) is unchanged from the autolobby and uses the shared MapPreview control. + +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 CustomLobbyMapPreviewSpawn = import("/lua/ui/lobby/customlobby/customlobbymappreviewspawn.lua") +local CustomLobbyAuthoritativeModel = import("/lua/ui/lobby/customlobby/customlobbyauthoritativemodel.lua") + +local LazyVarDerive = import("/lua/lazyvar.lua").Derive + +---@class UICustomLobbyMapPreview : Group +---@field Trash TrashBag +---@field Preview MapPreview +---@field Overlay Bitmap +---@field PathToScenarioFile? FileName +---@field ScenarioInfo? UILobbyScenarioInfo +---@field ScenarioSave? UIScenarioSaveFile +---@field PlayerOptions? table +---@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 UICustomLobbyMapPreviewSpawn[] +---@field ScenarioObserver LazyVar +---@field PlayerObservers LazyVar[] +local CustomLobbyMapPreview = ClassUI(Group) { + + ---@param self UICustomLobbyMapPreview + ---@param parent Control + __init = function(self, parent) + Group.__init(self, parent) + + self.Trash = TrashBag() + + self.Preview = MapPreview(self) + + 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() + + local model = CustomLobbyAuthoritativeModel.GetSingleton() + + -- the scenario file drives the whole preview: render on change, hide when unset + self.ScenarioObserver = self.Trash:Add( + LazyVarDerive(model.ScenarioFile, function(scenarioFileLazy) + self:OnScenarioFileChanged(scenarioFileLazy()) + end)) + + -- each slot drives only the spawn icons (faction / position) — refreshed against + -- the already-loaded scenario, so a take/swap/faction change doesn't reload the map + self.PlayerObservers = {} + for slot = 1, CustomLobbyAuthoritativeModel.MaxSlots do + self.PlayerObservers[slot] = self.Trash:Add( + LazyVarDerive(model.Players[slot], function(playerLazy) + playerLazy() + self:OnPlayersChanged() + end)) + end + end, + + ---@param self UICustomLobbyMapPreview + OnDestroy = function(self) + self.Trash:Destroy() + end, + + --- Show + render the preview once a scenario file is set, hide it otherwise. + ---@param self UICustomLobbyMapPreview + ---@param scenarioFile FileName | false + OnScenarioFileChanged = function(self, scenarioFile) + if scenarioFile then + self:Show() + self:UpdateScenario(scenarioFile, self:_GatherPlayerOptions()) + else + self:Hide() + end + end, + + --- A slot changed: refresh just the spawn icons, reusing the loaded scenario. + ---@param self UICustomLobbyMapPreview + OnPlayersChanged = function(self) + if self.ScenarioInfo and self.ScenarioSave then + self.PlayerOptions = self:_GatherPlayerOptions() + self:_UpdateSpawnLocations(self.ScenarioInfo, self.ScenarioSave, self.PlayerOptions) + end + end, + + --- Collects the seated players keyed by start spot (the spawn id the preview uses). + ---@param self UICustomLobbyMapPreview + ---@return table + _GatherPlayerOptions = function(self) + local model = CustomLobbyAuthoritativeModel.GetSingleton() + local options = {} + for slot = 1, CustomLobbyAuthoritativeModel.MaxSlots do + local player = model.Players[slot]() + if player then + options[player.StartSpot or slot] = player + end + end + return options + end, + + ---@param self UICustomLobbyMapPreview + ---@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, + + --- Places an icon at a map coordinate. Private. + ---@param self UICustomLobbyMapPreview + ---@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 + 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 + + icon.Left:Set(function() return self.Preview.Left() + x - 0.5 * icon.Width() end) + icon.Top:Set(function() return self.Preview.Top() + z - 0.5 * icon.Height() end) + + return icon + end, + + --- Sets the preview texture. Private. + ---@param self UICustomLobbyMapPreview + ---@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. Private. + ---@param self UICustomLobbyMapPreview + ---@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. Private. + ---@param self UICustomLobbyMapPreview + ---@param scenarioInfo UILobbyScenarioInfo + ---@param scenarioSave UIScenarioSaveFile + _UpdateWreckages = function(self, scenarioInfo, scenarioSave) + -- TODO + end, + + --- Creates/updates spawn-location icons. Private. + ---@param self UICustomLobbyMapPreview + ---@param scenarioInfo UILobbyScenarioInfo + ---@param scenarioSave UIScenarioSaveFile + ---@param playerOptions table + _UpdateSpawnLocations = function(self, scenarioInfo, scenarioSave, playerOptions) + local spawnIcons = self.SpawnIcons + local positions = MapUtil.GetStartPositionsFromScenario(scenarioInfo, scenarioSave) + if not positions then + for id, icon in spawnIcons do + icon:Destroy() + end + return + end + + -- clean up icons whose position no longer exists + for id, icon in spawnIcons do + if not positions[id] then + icon:Destroy() + end + end + + for id, position in positions do + local icon = spawnIcons[id] + if not icon then + icon = CustomLobbyMapPreviewSpawn.Create(self) + end + + spawnIcons[id] = icon + + self:PositionIcon( + icon, scenarioInfo.size[1], scenarioInfo.size[2], + position[1], position[2] + ) + + local options = playerOptions[id] + if options then + icon:Update(options.Faction) + else + icon:Reset() + end + end + end, + + --- Renders the preview for a scenario, including resource and spawn icons. + ---@param self UICustomLobbyMapPreview + ---@param pathToScenarioInfo FileName # a reference to a _scenario.lua file + ---@param playerOptions table + UpdateScenario = function(self, pathToScenarioInfo, playerOptions) + self.IconTrash:Destroy() + self.Preview:ClearTexture() + self.PathToScenarioFile = pathToScenarioInfo + + local scenarioInfo = MapUtil.LoadScenario(pathToScenarioInfo) + if not scenarioInfo then + -- TODO: show a default image that indicates something is off + self.ScenarioInfo = nil + self.ScenarioSave = nil + return + end + + self.ScenarioInfo = scenarioInfo + self:_UpdatePreview(scenarioInfo) + + local scenarioSave = MapUtil.LoadScenarioSaveFile(scenarioInfo.save) + if not scenarioSave then + self.ScenarioSave = nil + 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 UICustomLobbyMapPreview + 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 UICustomLobbyMapPreview +Create = function(parent) + return CustomLobbyMapPreview(parent) +end diff --git a/lua/ui/lobby/customlobby/CustomLobbyMapPreviewSpawn.lua b/lua/ui/lobby/customlobby/CustomLobbyMapPreviewSpawn.lua new file mode 100644 index 00000000000..561f97a2dc4 --- /dev/null +++ b/lua/ui/lobby/customlobby/CustomLobbyMapPreviewSpawn.lua @@ -0,0 +1,111 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- A start-position marker on the map preview: a faction icon placed at a spawn. Copied +-- from the autolobby's AutolobbyMapPreviewSpawn and kept standalone so the custom lobby +-- owns its own preview stack (see CustomLobbyMapPreview.lua). Faction-only — no lobby +-- coupling. + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") + +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap + +---@class UICustomLobbyMapPreviewSpawn : Bitmap +---@field Icon Bitmap +---@field Faction? number +local CustomLobbyMapPreviewSpawn = 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 = { + "/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 UICustomLobbyMapPreviewSpawn + ---@param parent Control + __init = function(self, parent) + Bitmap.__init(self, parent, self.EmptyPath) + + self.Faction = nil + self:Hide() + end, + + ---@param self UICustomLobbyMapPreviewSpawn + ---@param parent Control + __post_init = function(self, parent) + LayoutHelpers.ReusedLayoutFor(self) + :Width(32) + :Height(32) + :Over(parent, 32) + :End() + end, + + ---@param self UICustomLobbyMapPreviewSpawn + 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 UICustomLobbyMapPreviewSpawn + Show = function(self) + if self.Faction then + Bitmap.Show(self) + else + self:Hide() + end + end, + + ---@param self UICustomLobbyMapPreviewSpawn + ---@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 UICustomLobbyMapPreviewSpawn +Create = function(parent) + return CustomLobbyMapPreviewSpawn(parent) +end diff --git a/lua/ui/lobby/customlobby/CustomLobbyMessages.lua b/lua/ui/lobby/customlobby/CustomLobbyMessages.lua index b6c73ebf15a..a8711da8a57 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyMessages.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyMessages.lua @@ -89,6 +89,32 @@ CustomLobbyMessages = { end, }, + -- The host's authoritative game configuration — everything the launch needs beyond + -- the player list (scenario, options, mods, slot flags). Broadcast on any change and + -- to each peer as it joins, so the whole snapshot is sent rather than per-field deltas. + SentLaunchInfo = { + ---@class UICustomLobbySentLaunchInfoMessage : UILobbyReceivedMessage + ---@field ScenarioFile FileName | false + ---@field GameOptions table + ---@field GameMods table + ---@field ClosedSlots table + ---@field SpawnMex table + ---@field AutoTeams table + ---@field SlotCount number + + ---@param data UICustomLobbySentLaunchInfoMessage + Validate = function(lobby, data) + return type(data.GameOptions) == 'table' and type(data.GameMods) == 'table' + end, + Accept = function(lobby, data) + return IsFromHost(lobby, data) + end, + ---@param data UICustomLobbySentLaunchInfoMessage + Handler = function(lobby, data) + CustomLobbyController.ProcessSentLaunchInfo(lobby, data) + end, + }, + -- A client asks the host to flip its ready flag. SetReady = { ---@class UICustomLobbySetReadyMessage : UILobbyReceivedMessage From 8327c054205c708b1cb4c001b752c4ba0d86c29a Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Mon, 22 Jun 2026 08:56:35 +0200 Subject: [PATCH 16/98] Separate the state between what is relevant for the game, the host and the local peer --- .../skills/customlobby-model-choice/SKILL.md | 42 ++- lua/ui/lobby/customlobby/CLAUDE.md | 37 +- .../customlobby/CustomLobbyController.lua | 316 +++++++++--------- .../customlobby/CustomLobbyInterface.lua | 40 ++- ...veModel.lua => CustomLobbyLaunchModel.lua} | 105 +++--- ...bbyModel.lua => CustomLobbyLocalModel.lua} | 55 ++- .../customlobby/CustomLobbyMapPreview.lua | 10 +- lua/ui/lobby/customlobby/CustomLobbyMenus.lua | 20 +- .../lobby/customlobby/CustomLobbyMessages.lua | 34 +- .../CustomLobbyObserversInterface.lua | 4 +- lua/ui/lobby/customlobby/CustomLobbyRules.lua | 8 +- .../customlobby/CustomLobbySessionModel.lua | 119 +++++++ .../customlobby/CustomLobbySlotInterface.lua | 14 +- .../design/team-aware-slot-layout.md | 2 +- lua/ui/lobby/lobby.lua | 12 +- 15 files changed, 503 insertions(+), 315 deletions(-) rename lua/ui/lobby/customlobby/{CustomLobbyAuthoritativeModel.lua => CustomLobbyLaunchModel.lua} (64%) rename lua/ui/lobby/customlobby/{CustomLobbyModel.lua => CustomLobbyLocalModel.lua} (54%) create mode 100644 lua/ui/lobby/customlobby/CustomLobbySessionModel.lua diff --git a/.claude/skills/customlobby-model-choice/SKILL.md b/.claude/skills/customlobby-model-choice/SKILL.md index 8a5a0d8c076..e0d4b3fc2f3 100644 --- a/.claude/skills/customlobby-model-choice/SKILL.md +++ b/.claude/skills/customlobby-model-choice/SKILL.md @@ -1,35 +1,51 @@ --- name: customlobby-model-choice -description: Decide which CustomLobby model new state belongs in — the host-authoritative model (host-dictated, synced, part of the launched scenario) vs the regular connectivity model (local, high-frequency, not synced) — and keep hot-reload working. Use when adding a field/state to the custom lobby, or unsure where lobby state should live. +description: Decide which of the three CustomLobby models new state belongs in — Launch (shared + launched), Session (shared, lobby-room only), or Local (per-peer, never synced) — and keep hot-reload working. Use when adding a field/state to the custom lobby, or unsure where lobby state should live. --- # Which CustomLobby model does this state go in? -Two reactive singletons. Pick by **who owns the truth and whether it's part of the game**: +Three reactive singletons, chosen by two questions: **is it shared (host-dictated → broadcast to everyone)?** and **does it get launched (becomes part of the game)?** -| Put it in… | When the state is… | Examples | +| Put it in… | Shared? | Launched? | Examples | +|---|---|---|---| +| **[CustomLobbyLaunchModel.lua](/lua/ui/lobby/customlobby/CustomLobbyLaunchModel.lua)** | ✅ | ✅ | players (per-slot), observers, scenario, game options, mods, auto-teams, spawn-mex. | +| **[CustomLobbySessionModel.lua](/lua/ui/lobby/customlobby/CustomLobbySessionModel.lua)** | ✅ | ❌ | slot count, closed slots (later: lobby title, password, kick log). | +| **[CustomLobbyLocalModel.lua](/lua/ui/lobby/customlobby/CustomLobbyLocalModel.lua)** | ❌ | — | identity (`LocalPeerId` / `HostID` / `IsHost`), CPU benchmarks (later: ping). | + +**Decide with two litmus questions:** +1. *Does the launched game consume this?* → **Launch**. (A closed slot is just empty at launch and slot count is map-derived presentation — neither reaches the scenario, so they're **Session**, not Launch.) +2. *Is it this client's own state?* → **Local**. Identity is per-peer (the host's `IsHost` is true, a client's is false); broadcasting it would corrupt the receiver — it never goes on the wire. + +Everything else host-dictated that isn't launched is **Session**. + +## Sync mapping (host → clients) + +| Model | Message(s) | Broadcast by | |---|---|---| -| **[CustomLobbyAuthoritativeModel.lua](/lua/ui/lobby/customlobby/CustomLobbyAuthoritativeModel.lua)** | **host-dictated** and **part of what launches** — everyone must agree, the host broadcasts it. | players (per-slot), observers, game options, mods, scenario, slot flags (closed / spawn-mex / auto-teams), identity (`LocalPeerId` / `HostID` / `IsHost`). | -| **[CustomLobbyModel.lua](/lua/ui/lobby/customlobby/CustomLobbyModel.lua)** | **local / per-peer / high-frequency** and **not** part of the scenario — churns independently of the synced game state. | CPU benchmarks (now); ping / connection status (later). | +| Launch | `SetPlayers` (players + observers) and `SentLaunchInfo` (scenario / options / mods / teams / spawn) | `BroadcastPlayers` / `BroadcastLaunchInfo` | +| Session | `SetSessionState` | `BroadcastSessionState` | +| Local | — (never synced; set on the connection handshake) | — | -**Litmus test:** *"Does the launched game need this, and must all peers see the same value?"* → authoritative. *"Is it just this client's view of connectivity/health?"* → regular. Keeping fast-churning local state out of the authoritative model is the whole point — it stops connection noise from dirtying the synced snapshot. +The host pushes **whole snapshots** on join and on change. Adding a synced field means adding it to the relevant broadcast *and* its `Process*` handler in the controller. ## Adding a field — also touch OnReload -Both models hand-copy their LazyVars in `__moduleinfo.OnReload` so a hot-reload doesn't reset live state. When you add a field: +Each model hand-copies its LazyVars in `__moduleinfo.OnReload` so a hot-reload doesn't reset live state. When you add a field: -1. add it to `SetupSingleton` (a `Create(...)` with its default), and +1. add it to that model's `SetupSingleton` (a `Create(...)` with its default), and 2. **add a matching copy line in that model's `OnReload`** — miss this and the field silently resets on every save-reload (exactly when you're iterating). ## Gotchas — do / don't | | Rule | |---|------| -| ✅ | Route every write through the model's write helpers (`SetPlayer`, `SetGameOption`, `AddObserver`, `SetCpuBenchmark`, …) — they keep the copy-then-`Set` discipline. | +| ✅ | Route every write through the owning model's helpers (`SetPlayer`, `SetGameOption`, `AddObserver`, `SetClosed`, `SetCpuBenchmark`, …) — they keep the copy-then-`Set` discipline. | | ✅ | Add the matching `OnReload` copy line whenever you add a field (see above). | -| ✅ | Mirror state to the authoritative model **only from the controller**, after the host validates; clients send a request message. | -| 🚫 | Put ping / CPU / connection churn in the **authoritative** model — its dirtying would ride along with the synced game snapshot. | -| 🚫 | Put scenario / launch-affecting state in the **regular** model — it won't be part of the game the host dictates or broadcasts. | +| ✅ | Write the shared (Launch / Session) models **only from the controller**, after the host validates; clients send a request message. | +| 🚫 | Put ping / CPU / connection churn in **Launch** or **Session** — its dirtying would ride along with a synced snapshot. It's **Local**. | +| 🚫 | Put launch-affecting state in **Session** (it won't reach the game) or **Local** (it won't be shared). If the game needs it and all peers must agree → **Launch**. | +| 🚫 | Broadcast anything from **Local** — identity/connectivity is per-peer; sending it corrupts the receiver. | | 🚫 | Mutate a LazyVar's held table in place — always `table.copy` → mutate → `:Set` (a new table), or dependents never go dirty. | -| 🚫 | Write either model from a view/component — views read via `Derive` only; the controller is the sole writer. | +| 🚫 | Write any model from a view/component — views read via `Derive` only; the controller is the sole writer. | | 🚫 | Use the `%` operator (FAF is Lua 5.0 — use `math.mod`). | diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index 363560e957a..28a26d6ffdf 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -16,28 +16,36 @@ Folder `customlobby/`, class/module prefix `CustomLobby` — pairs with `autolob `Autolobby*`. "Custom" is FAF's own term for non-matchmaker games (the autolobby is the automated/matchmaker path). -## Two models +## Three models -- **[CustomLobbyAuthoritativeModel.lua](CustomLobbyAuthoritativeModel.lua)** — the - state the **host dictates** and that becomes part of the launched scenario: players, - game options, mods, scenario, slot flags, identity. `Players` is an **array of - per-slot LazyVars** so one slot's change re-fires only that row. Write helpers - (`SetPlayer`, `SetPlayerField`, …) keep the copy-then-`Set` discipline. -- **[CustomLobbyModel.lua](CustomLobbyModel.lua)** — general, local, high-frequency - state that is **not** part of the scenario (CPU benchmarks now; ping / connection - status later). Kept separate so its churn doesn't dirty the authoritative snapshot. +State is split by two questions — *is it shared (host-dictated → everyone)?* and *does +it get launched (becomes part of the game)?* See the `customlobby-model-choice` skill. + +- **[CustomLobbyLaunchModel.lua](CustomLobbyLaunchModel.lua)** — shared **and** launched: + the launch payload (players, observers, scenario, game options, mods, auto-teams, spawn + mex) + `MaxSlots` + the `UICustomLobbyPlayer` shape. `Players` is an **array of per-slot + LazyVars** so one slot's change re-fires only that row. Write helpers (`SetPlayer`, + `SetPlayerField`, `AddObserver`, …) keep the copy-then-`Set` discipline. +- **[CustomLobbySessionModel.lua](CustomLobbySessionModel.lua)** — shared but **not** + launched: lobby-room management (slot count, closed slots). A closed slot is just empty + at launch and slot count is map-derived presentation — neither reaches the scenario. +- **[CustomLobbyLocalModel.lua](CustomLobbyLocalModel.lua)** — **per-peer, never synced**: + identity (`LocalPeerId` / `HostID` / `IsHost`, set on the connection handshake) + + connectivity (CPU benchmarks now; ping later). Broadcasting identity would corrupt the + receiver's sense of itself, so it never goes on the wire. ## Status | File | Role | |------|------| -| [CustomLobbyAuthoritativeModel.lua](CustomLobbyAuthoritativeModel.lua) | host-dictated / scenario state (see above). | -| [CustomLobbyModel.lua](CustomLobbyModel.lua) | local connectivity state (CPU benchmarks + per-peer sim-performance history). | +| [CustomLobbyLaunchModel.lua](CustomLobbyLaunchModel.lua) | shared + launched state — the launch payload (see above). | +| [CustomLobbySessionModel.lua](CustomLobbySessionModel.lua) | shared, lobby-room-only state (slot count, closed slots). | +| [CustomLobbyLocalModel.lua](CustomLobbyLocalModel.lua) | per-peer state, never synced: identity + CPU benchmarks. | | [CustomLobbyPerformancePopover.lua](CustomLobbyPerformancePopover.lua) | hover popover over the CPU column; hand-built bitmap bar chart of a peer's `PerformanceTrackingV2` history, with a yellow recommended-unit-cap line. | | [CustomLobbyInstance.lua](CustomLobbyInstance.lua) | thin `moho.lobby_methods` shell; validates/dispatches traffic, forwards callbacks to the controller. | | [CustomLobbyController.lua](CustomLobbyController.lua) | host-authority logic (free functions): seating, `Process*` handlers, intents (`RequestSetReady`, `RequestTakeSlot`, `RequestSwapSlots`, `RequestEject`, `RequestMoveToObserver` — all keyed by slot index / bool so a chat command can call them too; permission is gated separately), sharing the stored CPU benchmark. | | [CustomLobbyRules.lua](CustomLobbyRules.lua) | game-rule derivations from lobby state (not view, not networking): `RecommendedUnitCap()` (per-player cap by map size, memoised scenario lookup). | -| [CustomLobbyMessages.lua](CustomLobbyMessages.lua) | message registry: `AddPlayer`, `SetPlayers`, `SentLaunchInfo` (full launch-config snapshot: scenario / options / mods / slot flags), `SetReady`, `TakeSlot`, `DisconnectPeer`, `ReportCpuBenchmark`, `SetCpuBenchmarks`. | +| [CustomLobbyMessages.lua](CustomLobbyMessages.lua) | message registry: `AddPlayer`, `SetPlayers` (launch model: players + observers), `SentLaunchInfo` (launch model: scenario / options / mods / teams / spawn mex), `SetSessionState` (session model: slot count / closed slots), `SetReady`, `TakeSlot`, `DisconnectPeer`, `ReportCpuBenchmark`, `SetCpuBenchmarks`. | | [CustomLobbyContextMenu.lua](CustomLobbyContextMenu.lua) | generic framed floating menu; `Show(entries, x, y)` renders any `{label, action, enabled}` list, dismisses on item click / click-outside / Esc. Knows nothing about the lobby. | | [CustomLobbyMenus.lua](CustomLobbyMenus.lua) | declarative menu **definitions**: entry lists with `when(ctx)`/`action(ctx)` filtered by lobby state (`BuildSlotMenu`). Adding/state-gating an item is a one-liner here. | | [CustomLobbySlotInterface.lua](CustomLobbySlotInterface.lua) | one slot row; subscribes to its slot + CPU benchmarks; CPU column shows max units at +0 with a green→red cap-headroom square; left-click an open slot to take it / your own to toggle ready; right-click opens its context menu; the host can drag a row onto another to swap. | @@ -47,8 +55,9 @@ the automated/matchmaker path). | [/lua/ui/lobby/lobby.lua](../lobby.lua) | engine entry wrapper (`CreateLobby`/`HostGame`/`JoinGame`) → CustomLobby. Old lobby preserved at `lobby-old.lua`. | Working today: host + clients see each other (host-authoritative player sync), the -host's launch config (scenario / options / mods / slot flags) is pushed to clients as a -whole `SentLaunchInfo` snapshot (on join and on change) so the map preview etc. render, +host's launch config (scenario / options / mods) and session state (slot count / closed +slots) are pushed to clients as whole snapshots (`SentLaunchInfo` + `SetSessionState`, on +join and on change) so the map preview / slot grid render, ready toggles round-trip, players can **take an open slot** (click it) and the host can **swap** (drag a row onto another), **eject**, and **move a player to observers** (right-click → context menu); observers are synced in the `SetPlayers` snapshot, shown in an observer diff --git a/lua/ui/lobby/customlobby/CustomLobbyController.lua b/lua/ui/lobby/customlobby/CustomLobbyController.lua index 8a4c8331de6..b4eb1f3e56b 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyController.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyController.lua @@ -22,20 +22,24 @@ -- Lobby logic for the custom lobby, as free functions (the autolobby pattern). The -- engine instantiates the thin CustomLobbyInstance and forwards its callbacks here, --- passing itself as `instance`. All state goes to CustomLobbyAuthoritativeModel via its write --- helpers; the host is authoritative. +-- passing itself as `instance`. The host is authoritative; state goes to the three +-- models via their write helpers: +-- * CustomLobbyLaunchModel — shared, launched (players, scenario, options, mods). +-- * CustomLobbySessionModel — shared, lobby-room only (slot count, closed slots). +-- * CustomLobbyLocalModel — per-peer, never synced (identity, CPU benchmarks). -- -- Barebones host-authority flow: -- host : OnHosting -> seats itself in slot 1 -- client : OnConnectionToHostEstablished -> SendData(host, AddPlayer) --- host : ProcessAddPlayer -> seats the peer -> broadcasts SetPlayers (full snapshot) --- everyone: ProcessSetPlayers -> applies the snapshot -> slot rows react +-- host : ProcessAddPlayer -> seats the peer -> broadcasts players + launch + session +-- everyone: Process* -> applies the snapshot -> components react -- ready : RequestSetReady (intent) -> host applies + broadcasts SetPlayers -- -- See /lua/ui/lobby/TARGET_ARCHITECTURE.md § 5. -local CustomLobbyAuthoritativeModel = import("/lua/ui/lobby/customlobby/customlobbyauthoritativemodel.lua") -local CustomLobbyModel = import("/lua/ui/lobby/customlobby/customlobbymodel.lua") +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") +local CustomLobbySessionModel = import("/lua/ui/lobby/customlobby/customlobbysessionmodel.lua") +local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/customlobbylocalmodel.lua") --- The live lobby object, set by the first engine callback. UI-triggered intents --- (RequestSetReady, …) reach the network through it without threading it everywhere. @@ -46,11 +50,12 @@ local LobbyInstance = false --#region Helpers --- First empty player slot within the active slot count, or nil. ----@param model UICustomLobbyAuthoritativeModel ---@return number | nil -local function FindFreeSlot(model) - for slot = 1, model.SlotCount() do - if not model.Players[slot]() then +local function FindFreeSlot() + local launch = CustomLobbyLaunchModel.GetSingleton() + local session = CustomLobbySessionModel.GetSingleton() + for slot = 1, session.SlotCount() do + if not launch.Players[slot]() then return slot end end @@ -58,12 +63,12 @@ local function FindFreeSlot(model) end --- The slot a peer occupies, or nil. ----@param model UICustomLobbyAuthoritativeModel ---@param ownerId UILobbyPeerId ---@return number | nil -local function FindSlotForOwner(model, ownerId) - for slot = 1, CustomLobbyAuthoritativeModel.MaxSlots do - local player = model.Players[slot]() +local function FindSlotForOwner(ownerId) + local launch = CustomLobbyLaunchModel.GetSingleton() + for slot = 1, CustomLobbyLaunchModel.MaxSlots do + local player = launch.Players[slot]() if player and player.OwnerID == ownerId then return slot end @@ -71,62 +76,73 @@ local function FindSlotForOwner(model, ownerId) return nil end +--- The player occupying `slot` within the player array, or nil. Tolerant of the +--- arbitrary slot values a chat command might pass. +---@param slot any +---@return UICustomLobbyPlayer | nil +local function PlayerInSlot(slot) + if type(slot) ~= 'number' or slot < 1 or slot > CustomLobbyLaunchModel.MaxSlots then + return nil + end + return CustomLobbyLaunchModel.GetSingleton().Players[slot]() or nil +end + --- A plain per-slot snapshot of all players (false for empty), for the wire. ----@param model UICustomLobbyAuthoritativeModel ---@return table -local function GatherPlayers(model) +local function GatherPlayers() + local launch = CustomLobbyLaunchModel.GetSingleton() local players = {} - for slot = 1, CustomLobbyAuthoritativeModel.MaxSlots do - players[slot] = model.Players[slot]() or false + for slot = 1, CustomLobbyLaunchModel.MaxSlots do + players[slot] = launch.Players[slot]() or false end return players end ---- Host broadcasts the authoritative player + observer snapshot to everyone. +--- Host broadcasts the player + observer snapshot (part of the launch state) to everyone. ---@param instance UICustomLobbyInstance ----@param model UICustomLobbyAuthoritativeModel -local function BroadcastPlayers(instance, model) +local function BroadcastPlayers(instance) + local launch = CustomLobbyLaunchModel.GetSingleton() instance:BroadcastData({ Type = 'SetPlayers', - Players = GatherPlayers(model), - Observers = model.Observers(), + Players = GatherPlayers(), + Observers = launch.Observers(), }) end --- Whether `slot` is a real, currently-open seat (in range, empty, not closed). ----@param model UICustomLobbyAuthoritativeModel ---@param slot any ---@return boolean -local function IsOpenSlot(model, slot) - if type(slot) ~= 'number' or slot < 1 or slot > model.SlotCount() then +local function IsOpenSlot(slot) + local session = CustomLobbySessionModel.GetSingleton() + if type(slot) ~= 'number' or slot < 1 or slot > session.SlotCount() then return false end - return not model.Players[slot]() and not model.ClosedSlots()[slot] + return not CustomLobbyLaunchModel.GetSingleton().Players[slot]() and not session.ClosedSlots()[slot] end ---- Host-side: moves the player owned by `ownerId` into an open `slot`, forcing it ---- unready and keeping StartSpot mirrored to the seat. Broadcasts the new snapshot. ---- A no-op (and harmless) if the move isn't valid — the host is the gate. +--- Host-side: moves the player owned by `ownerId` into an open `slot` (or seats it from +--- the observer list), forcing it unready and keeping StartSpot mirrored to the seat. +--- Broadcasts the new snapshot. A no-op if the move isn't valid — the host is the gate. ---@param instance UICustomLobbyInstance ----@param model UICustomLobbyAuthoritativeModel ---@param ownerId UILobbyPeerId ---@param slot number -local function TakeSlot(instance, model, ownerId, slot) - if not IsOpenSlot(model, slot) then +local function TakeSlot(instance, ownerId, slot) + if not IsOpenSlot(slot) then return end - local from = FindSlotForOwner(model, ownerId) + local launch = CustomLobbyLaunchModel.GetSingleton() + local from = FindSlotForOwner(ownerId) local player if from then if from == slot then return end - player = table.copy(model.Players[from]()) - CustomLobbyAuthoritativeModel.ClearPlayer(model, from) + player = table.copy(launch.Players[from]()) + CustomLobbyLaunchModel.ClearPlayer(launch, from) else -- not in a slot: an observer joining a slot (the reverse of Move to observers) - local observer = CustomLobbyAuthoritativeModel.RemoveObserver(model, ownerId) + local observer = CustomLobbyLaunchModel.RemoveObserver(launch, ownerId) if not observer then return end @@ -135,18 +151,17 @@ local function TakeSlot(instance, model, ownerId, slot) player.StartSpot = slot player.Ready = false -- (re)seating resets readiness - CustomLobbyAuthoritativeModel.SetPlayer(model, slot, player) - BroadcastPlayers(instance, model) + CustomLobbyLaunchModel.SetPlayer(launch, slot, player) + BroadcastPlayers(instance) end --- Host-side: swaps the contents of two slots (players and/or empties), forcing any --- moved player unready and keeping StartSpot mirrored to the seat. Broadcasts. ---@param instance UICustomLobbyInstance ----@param model UICustomLobbyAuthoritativeModel ---@param slotA number ---@param slotB number -local function SwapSlots(instance, model, slotA, slotB) - local count = model.SlotCount() +local function SwapSlots(instance, slotA, slotB) + local count = CustomLobbySessionModel.GetSingleton().SlotCount() if type(slotA) ~= 'number' or type(slotB) ~= 'number' then return end @@ -154,8 +169,9 @@ local function SwapSlots(instance, model, slotA, slotB) return end - local a = model.Players[slotA]() - local b = model.Players[slotB]() + local launch = CustomLobbyLaunchModel.GetSingleton() + local a = launch.Players[slotA]() + local b = launch.Players[slotB]() if a then a = table.copy(a) a.StartSpot = slotB @@ -168,16 +184,16 @@ local function SwapSlots(instance, model, slotA, slotB) end if b then - CustomLobbyAuthoritativeModel.SetPlayer(model, slotA, b) + CustomLobbyLaunchModel.SetPlayer(launch, slotA, b) else - CustomLobbyAuthoritativeModel.ClearPlayer(model, slotA) + CustomLobbyLaunchModel.ClearPlayer(launch, slotA) end if a then - CustomLobbyAuthoritativeModel.SetPlayer(model, slotB, a) + CustomLobbyLaunchModel.SetPlayer(launch, slotB, a) else - CustomLobbyAuthoritativeModel.ClearPlayer(model, slotB) + CustomLobbyLaunchModel.ClearPlayer(launch, slotB) end - BroadcastPlayers(instance, model) + BroadcastPlayers(instance) end --- Builds the local player's options from the profile / engine name. @@ -198,18 +214,19 @@ end function OnHosting(instance) LobbyInstance = instance - local model = CustomLobbyAuthoritativeModel.GetSingleton() + local localModel = CustomLobbyLocalModel.GetSingleton() local id = instance:GetLocalPlayerID() - model.LocalPeerId:Set(id) - model.HostID:Set(id) - model.IsHost:Set(true) + localModel.LocalPeerId:Set(id) + localModel.HostID:Set(id) + localModel.IsHost:Set(true) + local launch = CustomLobbyLaunchModel.GetSingleton() local player = CreateLocalPlayer(instance) player.OwnerID = id player.StartSpot = 1 player.PlayerColor = 1 player.ArmyColor = 1 - CustomLobbyAuthoritativeModel.SetPlayer(model, 1, player) + CustomLobbyLaunchModel.SetPlayer(launch, 1, player) instance.Trash:Add(ForkThread(ShareCpuBenchmarkThread, instance)) end @@ -222,10 +239,10 @@ end function OnConnectionToHostEstablished(instance, localId, localName, hostId) LobbyInstance = instance - local model = CustomLobbyAuthoritativeModel.GetSingleton() - model.LocalPeerId:Set(localId) - model.HostID:Set(hostId) - model.IsHost:Set(false) + local localModel = CustomLobbyLocalModel.GetSingleton() + localModel.LocalPeerId:Set(localId) + localModel.HostID:Set(hostId) + localModel.IsHost:Set(false) local player = CreateLocalPlayer(instance) player.OwnerID = localId @@ -251,19 +268,18 @@ end ---@param peerName string ---@param uid UILobbyPeerId function OnPeerDisconnected(instance, peerName, uid) - local model = CustomLobbyAuthoritativeModel.GetSingleton() - if not model.IsHost() then + if not CustomLobbyLocalModel.GetSingleton().IsHost() then return end -- tell the remaining peers to tear down their direct connection to the leaver instance:BroadcastData({ Type = 'DisconnectPeer', PeerID = uid }) - local slot = FindSlotForOwner(model, uid) + local slot = FindSlotForOwner(uid) if slot then - CustomLobbyAuthoritativeModel.ClearPlayer(model, slot) + CustomLobbyLaunchModel.ClearPlayer(CustomLobbyLaunchModel.GetSingleton(), slot) end - BroadcastPlayers(instance, model) + BroadcastPlayers(instance) end --- Called when the game launches. Barebones: not wired yet. @@ -277,68 +293,78 @@ end ------------------------------------------------------------------------------- --#region Message handlers (run after Validate + Accept) ---- Host seats a connecting peer and broadcasts the new snapshot. +--- Host seats a connecting peer and brings it up to date (players + launch + session). ---@param instance UICustomLobbyInstance ---@param data table function ProcessAddPlayer(instance, data) - local model = CustomLobbyAuthoritativeModel.GetSingleton() - local slot = FindFreeSlot(model) + local slot = FindFreeSlot() if not slot then WARN("CustomLobby: no free slot for joining peer " .. tostring(data.SenderID)) return end + local launch = CustomLobbyLaunchModel.GetSingleton() + ---@type UICustomLobbyPlayer local player = data.PlayerOptions player.OwnerID = data.SenderID player.StartSpot = slot player.PlayerColor = slot player.ArmyColor = slot - CustomLobbyAuthoritativeModel.SetPlayer(model, slot, player) + CustomLobbyLaunchModel.SetPlayer(launch, slot, player) - BroadcastPlayers(instance, model) - -- a fresh peer needs the current launch config (scenario / options / mods), not just - -- the player list — without it their map preview etc. have nothing to render + BroadcastPlayers(instance) + -- a fresh peer also needs the current launch config (scenario / options / mods) and + -- the session state (slot count / closed slots), not just the player list — without + -- them the map preview / slot grid have nothing to render BroadcastLaunchInfo(instance) + BroadcastSessionState(instance) end ---- Everyone applies the host's authoritative player + observer snapshot. +--- Everyone applies the host's player + observer snapshot (launch state). ---@param instance UICustomLobbyInstance ---@param data table function ProcessSetPlayers(instance, data) - local model = CustomLobbyAuthoritativeModel.GetSingleton() - for slot = 1, CustomLobbyAuthoritativeModel.MaxSlots do - model.Players[slot]:Set(data.Players[slot] or false) + local launch = CustomLobbyLaunchModel.GetSingleton() + for slot = 1, CustomLobbyLaunchModel.MaxSlots do + launch.Players[slot]:Set(data.Players[slot] or false) end if data.Observers then - model.Observers:Set(data.Observers) + launch.Observers:Set(data.Observers) end end ---- Everyone applies the host's authoritative launch config (scenario / options / mods / ---- slot flags) — everything launch needs beyond the player list. +--- Everyone applies the host's launch config (scenario / options / mods / teams / spawn +--- mex) — the part of the launch state that isn't the player list. ---@param instance UICustomLobbyInstance ---@param data UICustomLobbySentLaunchInfoMessage function ProcessSentLaunchInfo(instance, data) - local model = CustomLobbyAuthoritativeModel.GetSingleton() - model.ScenarioFile:Set(data.ScenarioFile or false) - model.GameOptions:Set(data.GameOptions or {}) - model.GameMods:Set(data.GameMods or {}) - model.ClosedSlots:Set(data.ClosedSlots or {}) - model.SpawnMex:Set(data.SpawnMex or {}) - model.AutoTeams:Set(data.AutoTeams or {}) - model.SlotCount:Set(data.SlotCount or model.SlotCount()) + local launch = CustomLobbyLaunchModel.GetSingleton() + launch.ScenarioFile:Set(data.ScenarioFile or false) + launch.GameOptions:Set(data.GameOptions or {}) + launch.GameMods:Set(data.GameMods or {}) + launch.AutoTeams:Set(data.AutoTeams or {}) + launch.SpawnMex:Set(data.SpawnMex or {}) +end + +--- Everyone applies the host's session state (slot count / closed slots) — lobby-room +--- management, not part of the launch. +---@param instance UICustomLobbyInstance +---@param data UICustomLobbySetSessionStateMessage +function ProcessSetSessionState(instance, data) + local session = CustomLobbySessionModel.GetSingleton() + session.SlotCount:Set(data.SlotCount or session.SlotCount()) + session.ClosedSlots:Set(data.ClosedSlots or {}) end --- Host flips a peer's ready flag and re-broadcasts. ---@param instance UICustomLobbyInstance ---@param data table function ProcessSetReady(instance, data) - local model = CustomLobbyAuthoritativeModel.GetSingleton() - local slot = FindSlotForOwner(model, data.SenderID) + local slot = FindSlotForOwner(data.SenderID) if slot then - CustomLobbyAuthoritativeModel.SetPlayerField(model, slot, 'Ready', data.Ready and true or false) - BroadcastPlayers(instance, model) + CustomLobbyLaunchModel.SetPlayerField(CustomLobbyLaunchModel.GetSingleton(), slot, 'Ready', data.Ready and true or false) + BroadcastPlayers(instance) end end @@ -346,8 +372,7 @@ end ---@param instance UICustomLobbyInstance ---@param data UICustomLobbyTakeSlotMessage function ProcessTakeSlot(instance, data) - local model = CustomLobbyAuthoritativeModel.GetSingleton() - TakeSlot(instance, model, data.SenderID, data.Slot) + TakeSlot(instance, data.SenderID, data.Slot) end --- A client drops its direct connection to a peer the host says has left. The slot @@ -362,7 +387,7 @@ end ---@param instance UICustomLobbyInstance ---@param data UICustomLobbyReportCpuBenchmarkMessage function ProcessReportCpuBenchmark(instance, data) - CustomLobbyModel.SetCpuBenchmark(CustomLobbyModel.GetSingleton(), data.SenderID, data.CpuBenchmark) + CustomLobbyLocalModel.SetCpuBenchmark(CustomLobbyLocalModel.GetSingleton(), data.SenderID, data.CpuBenchmark) BroadcastCpuBenchmarks(instance) end @@ -370,32 +395,40 @@ end ---@param instance UICustomLobbyInstance ---@param data UICustomLobbySetCpuBenchmarksMessage function ProcessSetCpuBenchmarks(instance, data) - CustomLobbyModel.GetSingleton().CpuBenchmarks:Set(data.CpuBenchmarks) + CustomLobbyLocalModel.GetSingleton().CpuBenchmarks:Set(data.CpuBenchmarks) end --#endregion ------------------------------------------------------------------------------- ---#region Launch info +--#region Shared-state broadcasts -- --- The host's launch configuration — everything the launch needs beyond the players: --- scenario, options, mods, slot flags. Kept deliberately simple: rather than syncing --- each field with its own message, the host broadcasts the whole snapshot whenever any --- of it changes (and to each peer as it joins). Call this after any host-side change. +-- The host owns the shared models and pushes whole snapshots: rather than syncing each +-- field with its own message, it broadcasts the relevant snapshot whenever any of it +-- changes (and to each peer as it joins). Call these after a host-side change. ---- Host broadcasts the full launch-config snapshot to everyone. +--- Host broadcasts the launch config snapshot (scenario / options / mods / teams / spawn). ---@param instance UICustomLobbyInstance function BroadcastLaunchInfo(instance) - local model = CustomLobbyAuthoritativeModel.GetSingleton() + local launch = CustomLobbyLaunchModel.GetSingleton() instance:BroadcastData({ Type = 'SentLaunchInfo', - ScenarioFile = model.ScenarioFile(), - GameOptions = model.GameOptions(), - GameMods = model.GameMods(), - ClosedSlots = model.ClosedSlots(), - SpawnMex = model.SpawnMex(), - AutoTeams = model.AutoTeams(), - SlotCount = model.SlotCount(), + ScenarioFile = launch.ScenarioFile(), + GameOptions = launch.GameOptions(), + GameMods = launch.GameMods(), + AutoTeams = launch.AutoTeams(), + SpawnMex = launch.SpawnMex(), + }) +end + +--- Host broadcasts the session state snapshot (slot count / closed slots). +---@param instance UICustomLobbyInstance +function BroadcastSessionState(instance) + local session = CustomLobbySessionModel.GetSingleton() + instance:BroadcastData({ + Type = 'SetSessionState', + SlotCount = session.SlotCount(), + ClosedSlots = session.ClosedSlots(), }) end @@ -414,7 +447,7 @@ end function BroadcastCpuBenchmarks(instance) instance:BroadcastData({ Type = 'SetCpuBenchmarks', - CpuBenchmarks = CustomLobbyModel.GetSingleton().CpuBenchmarks(), + CpuBenchmarks = CustomLobbyLocalModel.GetSingleton().CpuBenchmarks(), }) end @@ -435,16 +468,15 @@ function ShareCpuBenchmarkThread(instance) return end - local model = CustomLobbyAuthoritativeModel.GetSingleton() - local connModel = CustomLobbyModel.GetSingleton() + local localModel = CustomLobbyLocalModel.GetSingleton() -- show our own data immediately; the host's snapshots reconcile everyone - CustomLobbyModel.SetCpuBenchmark(connModel, model.LocalPeerId(), benchmark) + CustomLobbyLocalModel.SetCpuBenchmark(localModel, localModel.LocalPeerId(), benchmark) - if model.IsHost() then + if localModel.IsHost() then BroadcastCpuBenchmarks(instance) else - instance:SendData(model.HostID(), { Type = 'ReportCpuBenchmark', CpuBenchmark = benchmark }) + instance:SendData(localModel.HostID(), { Type = 'ReportCpuBenchmark', CpuBenchmark = benchmark }) end end @@ -462,11 +494,11 @@ function RequestTakeSlot(slot) return end - local model = CustomLobbyAuthoritativeModel.GetSingleton() - if model.IsHost() then - TakeSlot(instance, model, model.LocalPeerId(), slot) + local localModel = CustomLobbyLocalModel.GetSingleton() + if localModel.IsHost() then + TakeSlot(instance, localModel.LocalPeerId(), slot) else - instance:SendData(model.HostID(), { Type = 'TakeSlot', Slot = slot }) + instance:SendData(localModel.HostID(), { Type = 'TakeSlot', Slot = slot }) end end @@ -480,24 +512,11 @@ function RequestSwapSlots(slotA, slotB) return end - local model = CustomLobbyAuthoritativeModel.GetSingleton() - if not model.IsHost() then + if not CustomLobbyLocalModel.GetSingleton().IsHost() then WARN("CustomLobby: only the host can swap slots") return end - SwapSlots(instance, model, slotA, slotB) -end - ---- The player occupying `slot` within the active slot range, or nil. Tolerant of the ---- arbitrary slot values a chat command might pass. ----@param model UICustomLobbyAuthoritativeModel ----@param slot any ----@return UICustomLobbyPlayer | nil -local function PlayerInSlot(model, slot) - if type(slot) ~= 'number' or slot < 1 or slot > CustomLobbyAuthoritativeModel.MaxSlots then - return nil - end - return model.Players[slot]() or nil + SwapSlots(instance, slotA, slotB) end --- Ejects the player in `slot`: a human is dropped from the network (the resulting @@ -511,21 +530,20 @@ function RequestEject(slot) return end - local model = CustomLobbyAuthoritativeModel.GetSingleton() - if not model.IsHost() then + if not CustomLobbyLocalModel.GetSingleton().IsHost() then WARN("CustomLobby: only the host can eject players") return end - local player = PlayerInSlot(model, slot) + local player = PlayerInSlot(slot) if not player then return end if player.Human then instance:EjectPeer(player.OwnerID, "KickedByHost") else - CustomLobbyAuthoritativeModel.ClearPlayer(model, slot) - BroadcastPlayers(instance, model) + CustomLobbyLaunchModel.ClearPlayer(CustomLobbyLaunchModel.GetSingleton(), slot) + BroadcastPlayers(instance) end end @@ -539,19 +557,19 @@ function RequestMoveToObserver(slot) return end - local model = CustomLobbyAuthoritativeModel.GetSingleton() - if not model.IsHost() then + if not CustomLobbyLocalModel.GetSingleton().IsHost() then WARN("CustomLobby: only the host can move players to observers") return end - local player = PlayerInSlot(model, slot) + local player = PlayerInSlot(slot) if not player then return end - CustomLobbyAuthoritativeModel.AddObserver(model, player) - CustomLobbyAuthoritativeModel.ClearPlayer(model, slot) - BroadcastPlayers(instance, model) + local launch = CustomLobbyLaunchModel.GetSingleton() + CustomLobbyLaunchModel.AddObserver(launch, player) + CustomLobbyLaunchModel.ClearPlayer(launch, slot) + BroadcastPlayers(instance) end --- The local player toggles their ready flag. Host applies + broadcasts; a client @@ -563,15 +581,15 @@ function RequestSetReady(ready) return end - local model = CustomLobbyAuthoritativeModel.GetSingleton() - if model.IsHost() then - local slot = FindSlotForOwner(model, model.LocalPeerId()) + local localModel = CustomLobbyLocalModel.GetSingleton() + if localModel.IsHost() then + local slot = FindSlotForOwner(localModel.LocalPeerId()) if slot then - CustomLobbyAuthoritativeModel.SetPlayerField(model, slot, 'Ready', ready and true or false) - BroadcastPlayers(instance, model) + CustomLobbyLaunchModel.SetPlayerField(CustomLobbyLaunchModel.GetSingleton(), slot, 'Ready', ready and true or false) + BroadcastPlayers(instance) end else - instance:SendData(model.HostID(), { Type = 'SetReady', Ready = ready and true or false }) + instance:SendData(localModel.HostID(), { Type = 'SetReady', Ready = ready and true or false }) end end diff --git a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua index 4ee05e33f46..9af8da7f72d 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua @@ -32,7 +32,9 @@ local EscapeHandler = import("/lua/ui/dialogs/eschandler.lua") local Group = import("/lua/maui/group.lua").Group local Bitmap = import("/lua/maui/bitmap.lua").Bitmap -local CustomLobbyAuthoritativeModel = import("/lua/ui/lobby/customlobby/customlobbyauthoritativemodel.lua") +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") +local CustomLobbySessionModel = import("/lua/ui/lobby/customlobby/customlobbysessionmodel.lua") +local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/customlobbylocalmodel.lua") local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") local CustomLobbySlotInterface = import("/lua/ui/lobby/customlobby/customlobbyslotinterface.lua") local CustomLobbyObserversInterface = import("/lua/ui/lobby/customlobby/customlobbyobserversinterface.lua") @@ -78,7 +80,7 @@ local CustomLobbyInterface = Class(Group) { -- One row per possible slot; the SlotCount observer reveals the active ones. self.Slots = {} - for slot = 1, CustomLobbyAuthoritativeModel.MaxSlots do + for slot = 1, CustomLobbyLaunchModel.MaxSlots do self.Slots[slot] = CustomLobbySlotInterface.Create(self.SlotsPanel, slot, self) end @@ -92,9 +94,9 @@ local CustomLobbyInterface = Class(Group) { EscapeHandler.HandleEsc(false) end - local model = CustomLobbyAuthoritativeModel.GetSingleton() + local session = CustomLobbySessionModel.GetSingleton() self.SlotCountObserver = self.Trash:Add( - LazyVarDerive(model.SlotCount, function(slotCountLazy) + LazyVarDerive(session.SlotCount, function(slotCountLazy) self:OnSlotCountChanged(slotCountLazy()) end)) end, @@ -110,7 +112,7 @@ local CustomLobbyInterface = Class(Group) { Layouter(self.SlotsPanel) :AtLeftTopIn(self, 40, 80) :Width(PanelWidth) - :Height(CustomLobbyAuthoritativeModel.MaxSlots * SlotHeight) + :Height(CustomLobbyLaunchModel.MaxSlots * SlotHeight) :End() -- map preview to the right of the slots; hides itself until a scenario is set @@ -122,7 +124,7 @@ local CustomLobbyInterface = Class(Group) { :End() -- stack the rows top-to-bottom inside the panel via sibling anchoring - for slot = 1, CustomLobbyAuthoritativeModel.MaxSlots do + for slot = 1, CustomLobbyLaunchModel.MaxSlots do local row = self.Slots[slot] local builder = Layouter(row) :AtLeftIn(self.SlotsPanel) @@ -146,7 +148,7 @@ local CustomLobbyInterface = Class(Group) { ---@param self UICustomLobbyInterface ---@param count number OnSlotCountChanged = function(self, count) - for slot = 1, CustomLobbyAuthoritativeModel.MaxSlots do + for slot = 1, CustomLobbyLaunchModel.MaxSlots do if slot <= count then self.Slots[slot]:Show() else @@ -167,11 +169,10 @@ local CustomLobbyInterface = Class(Group) { ---@param slot number ---@return boolean CanDrag = function(self, slot) - local model = CustomLobbyAuthoritativeModel.GetSingleton() - if not model.IsHost() then + if not CustomLobbyLocalModel.GetSingleton().IsHost() then return false end - return model.Players[slot]() ~= false + return CustomLobbyLaunchModel.GetSingleton().Players[slot]() ~= false end, --- The active slot whose row contains the screen point, or nil. @@ -180,7 +181,7 @@ local CustomLobbyInterface = Class(Group) { ---@param y number ---@return number | nil SlotIndexAt = function(self, x, y) - local count = CustomLobbyAuthoritativeModel.GetSingleton().SlotCount() + local count = CustomLobbySessionModel.GetSingleton().SlotCount() for slot = 1, count do local row = self.Slots[slot] if row and x >= row.Left() and x <= row.Right() and y >= row.Top() and y <= row.Bottom() then @@ -250,7 +251,7 @@ local CustomLobbyInterface = Class(Group) { ---@param source number ---@return Group CreateDragGhost = function(self, source) - local player = CustomLobbyAuthoritativeModel.GetSingleton().Players[source]() + local player = CustomLobbyLaunchModel.GetSingleton().Players[source]() local name = (player and player.PlayerName) or ("Slot " .. tostring(source)) local ghost = Group(self, "CustomLobbyDragGhost") @@ -319,13 +320,16 @@ end --- `UI_Lua import("/lua/ui/lobby/customlobby/customlobbyinterface.lua").OpenDebug()` function OpenDebug() local slotCount = 6 - local model = CustomLobbyAuthoritativeModel.SetupSingleton(slotCount) - model.LocalPeerId:Set("1") - model.IsHost:Set(true) + local launch = CustomLobbyLaunchModel.SetupSingleton() + CustomLobbySessionModel.SetupSingleton(slotCount) + + local localModel = CustomLobbyLocalModel.SetupSingleton() + localModel.LocalPeerId:Set("1") + localModel.IsHost:Set(true) -- four players in the first four slots, last two left open for slot = 1, 4 do - CustomLobbyAuthoritativeModel.SetPlayer(model, slot, { + CustomLobbyLaunchModel.SetPlayer(launch, slot, { PlayerName = "Player " .. slot, OwnerID = tostring(slot), Human = slot ~= 4, -- slot 4 is an AI, to show the AI colour @@ -341,14 +345,14 @@ function OpenDebug() end -- a couple of observers so the observer strip shows something - model.Observers:Set({ + launch.Observers:Set({ { PlayerName = "Zock", OwnerID = "10", Human = true }, { PlayerName = "Spag", OwnerID = "11", Human = true }, }) -- a stock map so the preview renders; swap to any installed scenario if this -- one isn't present (an unknown path just leaves the preview frame empty) - CustomLobbyAuthoritativeModel.SetScenario(model, "/maps/scmp_009/scmp_009_scenario.lua") + CustomLobbyLaunchModel.SetScenario(launch, "/maps/scmp_009/scmp_009_scenario.lua") SetupSingleton() end diff --git a/lua/ui/lobby/customlobby/CustomLobbyAuthoritativeModel.lua b/lua/ui/lobby/customlobby/CustomLobbyLaunchModel.lua similarity index 64% rename from lua/ui/lobby/customlobby/CustomLobbyAuthoritativeModel.lua rename to lua/ui/lobby/customlobby/CustomLobbyLaunchModel.lua index 43ceafa0654..d222d266b18 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyAuthoritativeModel.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyLaunchModel.lua @@ -20,17 +20,19 @@ --** SOFTWARE. --****************************************************************************************************** --- Reactive state singleton for the (custom-games) lobby — the single source of truth --- that the controller writes to and the components observe via `Derive`. It holds no --- UI references and no networking. +-- The **launch** state: everything the host dictates that becomes part of the launched +-- game (the gameInfo replacement from TARGET_ARCHITECTURE.md). This model IS the launch +-- payload — the host broadcasts it whole (see CustomLobbyController.BroadcastLaunchInfo / +-- BroadcastPlayers), and what the game launches with is exactly what's here. -- --- This is the gameInfo replacement from TARGET_ARCHITECTURE.md. Players live as an --- array of per-slot LazyVars so a change to one slot only re-fires that slot's row --- (replacing the legacy `SetSlotInfo` whole-row sledgehammer). +-- It is one of three lobby models — see /lua/ui/lobby/customlobby/CLAUDE.md: +-- * LaunchModel (this) — shared, launched. +-- * SessionModel — shared, lobby-room only (slot count / closed slots). +-- * LocalModel — per-peer, never synced (identity, CPU benchmarks). -- --- Patterns mirror /lua/ui/lobby/autolobby/AutolobbyModel.lua and --- /lua/ui/game/chat/ChatModel.lua. See /lua/ui/CLAUDE.md for the reactivity rules --- (notably: never mutate a held table in place — copy then `:Set`). +-- Players live as an array of per-slot LazyVars so a change to one slot only re-fires +-- that slot's row. Write helpers keep the copy-then-`Set` discipline (see /lua/ui/CLAUDE.md +-- § 2 — never mutate a held table in place). local Create = import("/lua/lazyvar.lua").Create @@ -65,62 +67,49 @@ MaxSlots = 16 ------------------------------------------------------------------------------- --#region Reactive model ---- Reactive lobby-state singleton. ----@class UICustomLobbyAuthoritativeModel ----@field SlotCount LazyVar # player slots the current map supports ----@field Players LazyVar[] # one LazyVar per slot (1..MaxSlots); false = empty ----@field Observers LazyVar # observer list ----@field ClosedSlots LazyVar> ----@field SpawnMex LazyVar> ----@field AutoTeams LazyVar> ----@field GameOptions LazyVar
----@field GameMods LazyVar
+--- Reactive launch-state singleton (shared, host-dictated, part of the launch). +---@class UICustomLobbyLaunchModel +---@field Players LazyVar[] # one LazyVar per slot (1..MaxSlots); false = empty +---@field Observers LazyVar # observer list +---@field SpawnMex LazyVar> # adaptive-map spawn-mex flags (embedded into the scenario at launch) +---@field AutoTeams LazyVar> +---@field GameOptions LazyVar
+---@field GameMods LazyVar
---@field ScenarioFile LazyVar ----@field LocalPeerId LazyVar ----@field HostID LazyVar ----@field IsHost LazyVar ----@type UICustomLobbyAuthoritativeModel | nil +---@type UICustomLobbyLaunchModel | nil local ModelInstance = nil ---- Allocates a fresh model singleton, replacing any existing instance. ----@param slotCount? number ----@return UICustomLobbyAuthoritativeModel -function SetupSingleton(slotCount) - slotCount = slotCount or 8 - +--- Allocates a fresh launch-model singleton, replacing any existing instance. +---@return UICustomLobbyLaunchModel +function SetupSingleton() local players = {} for slot = 1, MaxSlots do players[slot] = Create(false) end - ---@type UICustomLobbyAuthoritativeModel + ---@type UICustomLobbyLaunchModel local model = { - SlotCount = Create(slotCount), Players = players, Observers = Create({}), - ClosedSlots = Create({}), SpawnMex = Create({}), AutoTeams = Create({}), GameOptions = Create({}), GameMods = Create({}), ScenarioFile = Create(false), - LocalPeerId = Create("-1"), - HostID = Create("-1"), - IsHost = Create(false), } ModelInstance = model return model end ---- Returns the model singleton, creating it on first access. ----@return UICustomLobbyAuthoritativeModel +--- Returns the launch-model singleton, creating it on first access. +---@return UICustomLobbyLaunchModel function GetSingleton() if not ModelInstance then SetupSingleton() end - return ModelInstance --[[@as UICustomLobbyAuthoritativeModel]] + return ModelInstance --[[@as UICustomLobbyLaunchModel]] end --#endregion @@ -130,11 +119,10 @@ end -- -- The synced tables are LazyVar values, so a write must build a NEW table/value and -- `:Set` it — mutating in place never marks dependents dirty (see /lua/ui/CLAUDE.md --- § 2). These helpers keep that discipline in one place so the controller can't get --- it wrong. +-- § 2). These helpers keep that discipline in one place. --- Places (or replaces) a player at a slot. ----@param model UICustomLobbyAuthoritativeModel +---@param model UICustomLobbyLaunchModel ---@param slot number ---@param player UICustomLobbyPlayer function SetPlayer(model, slot, player) @@ -142,14 +130,14 @@ function SetPlayer(model, slot, player) end --- Empties a slot. ----@param model UICustomLobbyAuthoritativeModel +---@param model UICustomLobbyLaunchModel ---@param slot number function ClearPlayer(model, slot) model.Players[slot]:Set(false) end --- Sets a single field on the player in a slot (copy-then-Set on that slot only). ----@param model UICustomLobbyAuthoritativeModel +---@param model UICustomLobbyLaunchModel ---@param slot number ---@param key string ---@param value any @@ -164,7 +152,7 @@ function SetPlayerField(model, slot, key, value) end --- Sets a single game option (copy-then-Set). ----@param model UICustomLobbyAuthoritativeModel +---@param model UICustomLobbyLaunchModel ---@param key string ---@param value any function SetGameOption(model, key, value) @@ -173,25 +161,15 @@ function SetGameOption(model, key, value) model.GameOptions:Set(options) end ---- Sets the closed flag for a slot (copy-then-Set). ----@param model UICustomLobbyAuthoritativeModel ----@param slot number ----@param closed boolean -function SetClosed(model, slot, closed) - local closedSlots = table.copy(model.ClosedSlots()) - closedSlots[slot] = closed - model.ClosedSlots:Set(closedSlots) -end - --- Sets the scenario file. ----@param model UICustomLobbyAuthoritativeModel +---@param model UICustomLobbyLaunchModel ---@param scenarioFile FileName | false function SetScenario(model, scenarioFile) model.ScenarioFile:Set(scenarioFile) end --- Appends a player to the observer list (copy-then-Set). ----@param model UICustomLobbyAuthoritativeModel +---@param model UICustomLobbyLaunchModel ---@param player UICustomLobbyPlayer function AddObserver(model, player) local observers = table.copy(model.Observers()) @@ -200,7 +178,7 @@ function AddObserver(model, player) end --- Removes the observer owned by `ownerId` (copy-then-Set) and returns it, or nil. ----@param model UICustomLobbyAuthoritativeModel +---@param model UICustomLobbyLaunchModel ---@param ownerId UILobbyPeerId ---@return UICustomLobbyPlayer | nil function RemoveObserver(model, ownerId) @@ -224,28 +202,23 @@ end ------------------------------------------------------------------------------- --#region Debugging ---- Hot-reload hook: rebuilds the singleton on the new module and copies the current ---- raw LazyVar values across so observers don't see a state reset. +--- Hot-reload hook: rebuilds the singleton and copies the current values across. --- ---- NOTE: this list is maintained by hand — when you add a field to the model, add a ---- copy line here too, or its value is lost on every hot-reload. +--- NOTE: maintained by hand — add a field to the model, add a copy line here too, or +--- its value is lost on every hot-reload. ---@param newModule any function __moduleinfo.OnReload(newModule) if ModelInstance then - local handle = newModule.SetupSingleton(ModelInstance.SlotCount()) + local handle = newModule.SetupSingleton() for slot = 1, MaxSlots do handle.Players[slot]:Set(ModelInstance.Players[slot]()) end handle.Observers:Set(ModelInstance.Observers()) - handle.ClosedSlots:Set(ModelInstance.ClosedSlots()) handle.SpawnMex:Set(ModelInstance.SpawnMex()) handle.AutoTeams:Set(ModelInstance.AutoTeams()) handle.GameOptions:Set(ModelInstance.GameOptions()) handle.GameMods:Set(ModelInstance.GameMods()) handle.ScenarioFile:Set(ModelInstance.ScenarioFile()) - handle.LocalPeerId:Set(ModelInstance.LocalPeerId()) - handle.HostID:Set(ModelInstance.HostID()) - handle.IsHost:Set(ModelInstance.IsHost()) end end diff --git a/lua/ui/lobby/customlobby/CustomLobbyModel.lua b/lua/ui/lobby/customlobby/CustomLobbyLocalModel.lua similarity index 54% rename from lua/ui/lobby/customlobby/CustomLobbyModel.lua rename to lua/ui/lobby/customlobby/CustomLobbyLocalModel.lua index bc8fa8fe0cb..d5136594936 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyModel.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyLocalModel.lua @@ -20,40 +20,62 @@ --** SOFTWARE. --****************************************************************************************************** --- Local, high-frequency lobby state that is NOT part of the host's authoritative --- game snapshot: CPU benchmarks (and later ping / connection status). Kept separate --- from CustomLobbyAuthoritativeModel so its churn doesn't dirty the synced game state. See --- /lua/ui/lobby/TARGET_ARCHITECTURE.md § 3 (LobbyConnectivityModel). +-- The **local** state: this peer's own state, NOT shared and NOT broadcast. Two kinds: +-- * identity — who this client is in the lobby (`LocalPeerId`, `HostID`, `IsHost`), +-- established by the connection handshake (OnHosting / OnConnectionToHostEstablished). +-- These are per-peer: the host's `IsHost` is true, a client's is false — broadcasting +-- them would corrupt the receiver's sense of itself, so they never go on the wire. +-- * connectivity — high-frequency local data (CPU benchmarks now; ping / connection +-- status later) whose churn must not dirty the synced launch/session snapshots. +-- +-- One of three lobby models — see /lua/ui/lobby/customlobby/CLAUDE.md: +-- * LaunchModel — shared, launched. +-- * SessionModel — shared, lobby-room only. +-- * LocalModel (this) — per-peer, never synced. local Create = import("/lua/lazyvar.lua").Create ---- Reactive connectivity-state singleton. ----@class UICustomLobbyModel ----@field CpuBenchmarks LazyVar> # peer id -> in-game sim-performance history (see /lua/system/performance.lua) +------------------------------------------------------------------------------- +--#region Reactive model + +--- Reactive local-state singleton (per-peer, never synced). +---@class UICustomLobbyLocalModel +---@field LocalPeerId LazyVar # this client's peer id +---@field HostID LazyVar # the host's peer id +---@field IsHost LazyVar # whether this client is the host +---@field CpuBenchmarks LazyVar> # peer id -> in-game sim-performance history (see /lua/system/performance.lua) local ModelInstance = nil ---- Allocates a fresh connectivity-model singleton, replacing any existing one. ----@return UICustomLobbyModel +--- Allocates a fresh local-model singleton, replacing any existing one. +---@return UICustomLobbyLocalModel function SetupSingleton() - ---@type UICustomLobbyModel + ---@type UICustomLobbyLocalModel local model = { + LocalPeerId = Create("-1"), + HostID = Create("-1"), + IsHost = Create(false), CpuBenchmarks = Create({}), } ModelInstance = model return model end ---- Returns the connectivity-model singleton, creating it on first access. ----@return UICustomLobbyModel +--- Returns the local-model singleton, creating it on first access. +---@return UICustomLobbyLocalModel function GetSingleton() if not ModelInstance then SetupSingleton() end - return ModelInstance --[[@as UICustomLobbyModel]] + return ModelInstance --[[@as UICustomLobbyLocalModel]] end +--#endregion + +------------------------------------------------------------------------------- +--#region Write helpers + --- Records a peer's in-game sim-performance history / benchmark (copy-then-Set). ----@param model UICustomLobbyModel +---@param model UICustomLobbyLocalModel ---@param ownerId UILobbyPeerId ---@param benchmark UIPerformanceMetrics function SetCpuBenchmark(model, ownerId, benchmark) @@ -62,6 +84,8 @@ function SetCpuBenchmark(model, ownerId, benchmark) model.CpuBenchmarks:Set(all) end +--#endregion + ------------------------------------------------------------------------------- --#region Debugging @@ -70,6 +94,9 @@ end function __moduleinfo.OnReload(newModule) if ModelInstance then local handle = newModule.SetupSingleton() + handle.LocalPeerId:Set(ModelInstance.LocalPeerId()) + handle.HostID:Set(ModelInstance.HostID()) + handle.IsHost:Set(ModelInstance.IsHost()) handle.CpuBenchmarks:Set(ModelInstance.CpuBenchmarks()) end end diff --git a/lua/ui/lobby/customlobby/CustomLobbyMapPreview.lua b/lua/ui/lobby/customlobby/CustomLobbyMapPreview.lua index 2c01a2e5d79..9857479aebb 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyMapPreview.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyMapPreview.lua @@ -34,7 +34,7 @@ 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 CustomLobbyMapPreviewSpawn = import("/lua/ui/lobby/customlobby/customlobbymappreviewspawn.lua") -local CustomLobbyAuthoritativeModel = import("/lua/ui/lobby/customlobby/customlobbyauthoritativemodel.lua") +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") local LazyVarDerive = import("/lua/lazyvar.lua").Derive @@ -75,7 +75,7 @@ local CustomLobbyMapPreview = ClassUI(Group) { self.IconTrash = TrashBag() - local model = CustomLobbyAuthoritativeModel.GetSingleton() + local model = CustomLobbyLaunchModel.GetSingleton() -- the scenario file drives the whole preview: render on change, hide when unset self.ScenarioObserver = self.Trash:Add( @@ -86,7 +86,7 @@ local CustomLobbyMapPreview = ClassUI(Group) { -- each slot drives only the spawn icons (faction / position) — refreshed against -- the already-loaded scenario, so a take/swap/faction change doesn't reload the map self.PlayerObservers = {} - for slot = 1, CustomLobbyAuthoritativeModel.MaxSlots do + for slot = 1, CustomLobbyLaunchModel.MaxSlots do self.PlayerObservers[slot] = self.Trash:Add( LazyVarDerive(model.Players[slot], function(playerLazy) playerLazy() @@ -125,9 +125,9 @@ local CustomLobbyMapPreview = ClassUI(Group) { ---@param self UICustomLobbyMapPreview ---@return table _GatherPlayerOptions = function(self) - local model = CustomLobbyAuthoritativeModel.GetSingleton() + local model = CustomLobbyLaunchModel.GetSingleton() local options = {} - for slot = 1, CustomLobbyAuthoritativeModel.MaxSlots do + for slot = 1, CustomLobbyLaunchModel.MaxSlots do local player = model.Players[slot]() if player then options[player.StartSpot or slot] = player diff --git a/lua/ui/lobby/customlobby/CustomLobbyMenus.lua b/lua/ui/lobby/customlobby/CustomLobbyMenus.lua index 09d805c8918..1b010a1171f 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyMenus.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyMenus.lua @@ -32,7 +32,8 @@ -- -- The result feeds CustomLobbyContextMenu.Show (a list of { label, action, enabled }). -local CustomLobbyAuthoritativeModel = import("/lua/ui/lobby/customlobby/customlobbyauthoritativemodel.lua") +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") +local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/customlobbylocalmodel.lua") local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") ------------------------------------------------------------------------------- @@ -93,11 +94,11 @@ local SlotMenu = { } --- Whether `ownerId` is in the observer list. ----@param model UICustomLobbyAuthoritativeModel +---@param launch UICustomLobbyLaunchModel ---@param ownerId UILobbyPeerId ---@return boolean -local function IsObserver(model, ownerId) - local observers = model.Observers() +local function IsObserver(launch, ownerId) + local observers = launch.Observers() for i = 1, table.getn(observers) do if observers[i].OwnerID == ownerId then return true @@ -110,16 +111,17 @@ end ---@param slot number ---@return UICustomLobbySlotMenuContext local function SlotContext(slot) - local model = CustomLobbyAuthoritativeModel.GetSingleton() - local player = model.Players[slot]() - local localId = model.LocalPeerId() + local launch = CustomLobbyLaunchModel.GetSingleton() + local localModel = CustomLobbyLocalModel.GetSingleton() + local player = launch.Players[slot]() + local localId = localModel.LocalPeerId() return { slot = slot, player = player, - isHost = model.IsHost(), + isHost = localModel.IsHost(), isYou = (player and player.OwnerID == localId) and true or false, isOpen = not player, - localIsObserver = IsObserver(model, localId), + localIsObserver = IsObserver(launch, localId), } end diff --git a/lua/ui/lobby/customlobby/CustomLobbyMessages.lua b/lua/ui/lobby/customlobby/CustomLobbyMessages.lua index a8711da8a57..4102e111933 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyMessages.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyMessages.lua @@ -28,8 +28,8 @@ -- Each message's payload is typed via a `---@class … : UILobbyReceivedMessage` so the -- handlers (and any future tooling) know its exact shape. -local CustomLobbyAuthoritativeModel = import("/lua/ui/lobby/customlobby/customlobbyauthoritativemodel.lua") local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") +local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/customlobbylocalmodel.lua") ---@param lobby UICustomLobbyInstance ---@return boolean @@ -41,7 +41,7 @@ end ---@param data UILobbyReceivedMessage ---@return boolean local function IsFromHost(lobby, data) - return data.SenderID == CustomLobbyAuthoritativeModel.GetSingleton().HostID() + return data.SenderID == CustomLobbyLocalModel.GetSingleton().HostID() end ---@class UICustomLobbyMessageHandler @@ -89,18 +89,16 @@ CustomLobbyMessages = { end, }, - -- The host's authoritative game configuration — everything the launch needs beyond - -- the player list (scenario, options, mods, slot flags). Broadcast on any change and - -- to each peer as it joins, so the whole snapshot is sent rather than per-field deltas. + -- The host's launch configuration — the launch-state fields that aren't the player + -- list (scenario, options, mods, teams, spawn mex). Broadcast on any change and to + -- each peer as it joins; the whole snapshot is sent rather than per-field deltas. SentLaunchInfo = { ---@class UICustomLobbySentLaunchInfoMessage : UILobbyReceivedMessage ---@field ScenarioFile FileName | false ---@field GameOptions table ---@field GameMods table - ---@field ClosedSlots table - ---@field SpawnMex table ---@field AutoTeams table - ---@field SlotCount number + ---@field SpawnMex table ---@param data UICustomLobbySentLaunchInfoMessage Validate = function(lobby, data) @@ -115,6 +113,26 @@ CustomLobbyMessages = { end, }, + -- The host's session state — lobby-room management that is NOT launched (slot count, + -- closed slots). A separate snapshot from the launch config so each stays focused. + SetSessionState = { + ---@class UICustomLobbySetSessionStateMessage : UILobbyReceivedMessage + ---@field SlotCount number + ---@field ClosedSlots table + + ---@param data UICustomLobbySetSessionStateMessage + Validate = function(lobby, data) + return type(data.SlotCount) == 'number' + end, + Accept = function(lobby, data) + return IsFromHost(lobby, data) + end, + ---@param data UICustomLobbySetSessionStateMessage + Handler = function(lobby, data) + CustomLobbyController.ProcessSetSessionState(lobby, data) + end, + }, + -- A client asks the host to flip its ready flag. SetReady = { ---@class UICustomLobbySetReadyMessage : UILobbyReceivedMessage diff --git a/lua/ui/lobby/customlobby/CustomLobbyObserversInterface.lua b/lua/ui/lobby/customlobby/CustomLobbyObserversInterface.lua index 4f0cd51a572..369c8d4255a 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyObserversInterface.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyObserversInterface.lua @@ -29,7 +29,7 @@ local UIUtil = import("/lua/ui/uiutil.lua") local LayoutHelpers = import("/lua/maui/layouthelpers.lua") local Group = import("/lua/maui/group.lua").Group -local CustomLobbyAuthoritativeModel = import("/lua/ui/lobby/customlobby/customlobbyauthoritativemodel.lua") +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") local LazyVarDerive = import("/lua/lazyvar.lua").Derive @@ -53,7 +53,7 @@ local CustomLobbyObserversInterface = Class(Group) { self.Names = UIUtil.CreateText(self, "—", 12, UIUtil.bodyFont) self.Names:SetColor('ff9aa0a8') - local model = CustomLobbyAuthoritativeModel.GetSingleton() + local model = CustomLobbyLaunchModel.GetSingleton() self.ObserversObserver = self.Trash:Add( LazyVarDerive(model.Observers, function(observersLazy) self:OnObserversChanged(observersLazy()) diff --git a/lua/ui/lobby/customlobby/CustomLobbyRules.lua b/lua/ui/lobby/customlobby/CustomLobbyRules.lua index 3b965e6e773..65b276d7d6a 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyRules.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyRules.lua @@ -24,7 +24,7 @@ -- Views call these for display hints; the controller may call them at launch. Keep the -- model the source of truth; this layer only derives. -local CustomLobbyAuthoritativeModel = import("/lua/ui/lobby/customlobby/customlobbyauthoritativemodel.lua") +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") -- Scenario file -> largest map dimension (ogrids). Loading a scenario is I/O, so the -- result is memoised per file (a map rarely changes, and the key changes if it does). @@ -44,7 +44,7 @@ local function UnitsPerPlayer(maxDimension) end --- Largest dimension (ogrids) of the current scenario, or 0 when no map is set. ----@param model UICustomLobbyAuthoritativeModel +---@param model UICustomLobbyLaunchModel ---@return number local function CurrentMapDimension(model) local scenarioFile = model.ScenarioFile() @@ -70,10 +70,10 @@ end --- Returns nil when there are no players to scale by. ---@return number | nil function RecommendedUnitCap() - local model = CustomLobbyAuthoritativeModel.GetSingleton() + local model = CustomLobbyLaunchModel.GetSingleton() local players = 0 - for slot = 1, CustomLobbyAuthoritativeModel.MaxSlots do + for slot = 1, CustomLobbyLaunchModel.MaxSlots do if model.Players[slot]() then players = players + 1 end diff --git a/lua/ui/lobby/customlobby/CustomLobbySessionModel.lua b/lua/ui/lobby/customlobby/CustomLobbySessionModel.lua new file mode 100644 index 00000000000..c2d49128890 --- /dev/null +++ b/lua/ui/lobby/customlobby/CustomLobbySessionModel.lua @@ -0,0 +1,119 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- The **session** state: host-dictated lobby-room management that is shared with everyone +-- but is NOT part of the launched game. A closed slot just means "no army there" at launch +-- and the slot count is map-derived presentation — neither reaches the scenario, so they +-- live here rather than in the launch payload. +-- +-- One of three lobby models — see /lua/ui/lobby/customlobby/CLAUDE.md: +-- * LaunchModel — shared, launched. +-- * SessionModel (this) — shared, lobby-room only. +-- * LocalModel — per-peer, never synced. +-- +-- Synced host -> clients as a whole snapshot (CustomLobbyController.BroadcastSessionState). + +local Create = import("/lua/lazyvar.lua").Create + +------------------------------------------------------------------------------- +--#region Reactive model + +--- Reactive session-state singleton (shared, host-dictated, not launched). +---@class UICustomLobbySessionModel +---@field SlotCount LazyVar # player slots the current map supports +---@field ClosedSlots LazyVar> + +---@type UICustomLobbySessionModel | nil +local ModelInstance = nil + +--- Allocates a fresh session-model singleton, replacing any existing instance. +---@param slotCount? number +---@return UICustomLobbySessionModel +function SetupSingleton(slotCount) + ---@type UICustomLobbySessionModel + local model = { + SlotCount = Create(slotCount or 8), + ClosedSlots = Create({}), + } + + ModelInstance = model + return model +end + +--- Returns the session-model singleton, creating it on first access. +---@return UICustomLobbySessionModel +function GetSingleton() + if not ModelInstance then + SetupSingleton() + end + return ModelInstance --[[@as UICustomLobbySessionModel]] +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Write helpers + +--- Sets the number of active slots. +---@param model UICustomLobbySessionModel +---@param slotCount number +function SetSlotCount(model, slotCount) + model.SlotCount:Set(slotCount) +end + +--- Sets the closed flag for a slot (copy-then-Set). +---@param model UICustomLobbySessionModel +---@param slot number +---@param closed boolean +function SetClosed(model, slot, closed) + local closedSlots = table.copy(model.ClosedSlots()) + closedSlots[slot] = closed + model.ClosedSlots:Set(closedSlots) +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Debugging + +--- Hot-reload hook: rebuilds the singleton and copies the current values across. +--- +--- NOTE: maintained by hand — add a field to the model, add a copy line here too. +---@param newModule any +function __moduleinfo.OnReload(newModule) + if ModelInstance then + local handle = newModule.SetupSingleton(ModelInstance.SlotCount()) + handle.ClosedSlots:Set(ModelInstance.ClosedSlots()) + end +end + +--- Hot-reload hook: re-imports this module after a couple of frames. +function __moduleinfo.OnDirty() + ForkThread( + function() + WaitFrames(2) + import(__moduleinfo.name) + end + ) +end + +--#endregion diff --git a/lua/ui/lobby/customlobby/CustomLobbySlotInterface.lua b/lua/ui/lobby/customlobby/CustomLobbySlotInterface.lua index 5ceaced4322..1cbcdce36c3 100644 --- a/lua/ui/lobby/customlobby/CustomLobbySlotInterface.lua +++ b/lua/ui/lobby/customlobby/CustomLobbySlotInterface.lua @@ -33,9 +33,9 @@ local Color = import("/lua/shared/color.lua") local Group = import("/lua/maui/group.lua").Group local Bitmap = import("/lua/maui/bitmap.lua").Bitmap local Dragger = import("/lua/maui/dragger.lua").Dragger -local CustomLobbyAuthoritativeModel = import("/lua/ui/lobby/customlobby/customlobbyauthoritativemodel.lua") +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") -local CustomLobbyModel = import("/lua/ui/lobby/customlobby/customlobbymodel.lua") +local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/customlobbylocalmodel.lua") local CustomLobbyPerformancePopover = import("/lua/ui/lobby/customlobby/customlobbyperformancepopover.lua") local CustomLobbyContextMenu = import("/lua/ui/lobby/customlobby/customlobbycontextmenu.lua") local CustomLobbyMenus = import("/lua/ui/lobby/customlobby/customlobbymenus.lua") @@ -217,14 +217,14 @@ local CustomLobbySlotInterface = Class(Group) { return false end - local model = CustomLobbyAuthoritativeModel.GetSingleton() + local model = CustomLobbyLaunchModel.GetSingleton() self.PlayerObserver = self.Trash:Add( LazyVarDerive(model.Players[slotIndex], function(playerLazy) self:OnPlayerChanged(playerLazy()) end)) self.CpuObserver = self.Trash:Add( - LazyVarDerive(CustomLobbyModel.GetSingleton().CpuBenchmarks, function(benchmarksLazy) + LazyVarDerive(CustomLobbyLocalModel.GetSingleton().CpuBenchmarks, function(benchmarksLazy) -- read the lazy so the dependency edge (re)forms; the value itself -- is read from the model inside RefreshCpu benchmarksLazy() @@ -292,7 +292,7 @@ local CustomLobbySlotInterface = Class(Group) { return end - local metrics = CustomLobbyModel.GetSingleton().CpuBenchmarks()[player.OwnerID] + local metrics = CustomLobbyLocalModel.GetSingleton().CpuBenchmarks()[player.OwnerID] local category = PickCategory(metrics) local atZero = category and category[BucketForRate(0)] if not (atZero and atZero.UnitCount) then @@ -338,7 +338,7 @@ local CustomLobbySlotInterface = Class(Group) { CustomLobbyPerformancePopover.Hide() return end - local benchmark = CustomLobbyModel.GetSingleton().CpuBenchmarks()[player.OwnerID] + local benchmark = CustomLobbyLocalModel.GetSingleton().CpuBenchmarks()[player.OwnerID] CustomLobbyPerformancePopover.Show(self.Cpu, benchmark, CustomLobbyRules.RecommendedUnitCap()) end, @@ -420,7 +420,7 @@ local CustomLobbySlotInterface = Class(Group) { CustomLobbyController.RequestTakeSlot(self.SlotIndex) return end - if player.OwnerID == CustomLobbyAuthoritativeModel.GetSingleton().LocalPeerId() then + if player.OwnerID == CustomLobbyLocalModel.GetSingleton().LocalPeerId() then CustomLobbyController.RequestSetReady(not player.Ready) end end, diff --git a/lua/ui/lobby/customlobby/design/team-aware-slot-layout.md b/lua/ui/lobby/customlobby/design/team-aware-slot-layout.md index 8ada493d044..80916857f9b 100644 --- a/lua/ui/lobby/customlobby/design/team-aware-slot-layout.md +++ b/lua/ui/lobby/customlobby/design/team-aware-slot-layout.md @@ -91,7 +91,7 @@ markers, as today. - `CustomLobbyInterface` shrinks to: header, this players component (left), the tabbed panel (right), footer. - No model changes beyond reading `AutoTeams` (already present on - `CustomLobbyAuthoritativeModel` as part of game options / its own LazyVar). + `CustomLobbyLaunchModel` as its own LazyVar) and `SlotCount` (on `CustomLobbySessionModel`). ## Open question diff --git a/lua/ui/lobby/lobby.lua b/lua/ui/lobby/lobby.lua index 5021ba6e8e8..4f6a8f07d0d 100644 --- a/lua/ui/lobby/lobby.lua +++ b/lua/ui/lobby/lobby.lua @@ -35,8 +35,9 @@ local MenuCommon = import("/lua/ui/menus/menucommon.lua") local EscapeHandler = import("/lua/ui/dialogs/eschandler.lua") -local CustomLobbyAuthoritativeModel = import("/lua/ui/lobby/customlobby/customlobbyauthoritativemodel.lua") -local CustomLobbyModel = import("/lua/ui/lobby/customlobby/customlobbymodel.lua") +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") +local CustomLobbySessionModel = import("/lua/ui/lobby/customlobby/customlobbysessionmodel.lua") +local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/customlobbylocalmodel.lua") local CustomLobbyInterface = import("/lua/ui/lobby/customlobby/customlobbyinterface.lua") local maxConnections = 16 @@ -68,8 +69,9 @@ function CreateLobby(protocol, localPort, desiredPlayerName, localPlayerUID, nat -- Models first, then the view (so its components subscribe to a live model), -- then the lobby object (whose callbacks write the model). -- TODO: derive SlotCount from the chosen map instead of a fixed default. - CustomLobbyAuthoritativeModel.SetupSingleton(8) - CustomLobbyModel.SetupSingleton() + CustomLobbyLaunchModel.SetupSingleton() + CustomLobbySessionModel.SetupSingleton(8) + CustomLobbyLocalModel.SetupSingleton() CustomLobbyInterface.SetupSingleton() Instance = InternalCreateLobby( @@ -100,7 +102,7 @@ function HostGame(desiredGameName, scenarioFileName, inSinglePlayer) return end local scenario = string.gsub(scenarioFileName, ".v%d%d%d%d_scenario.lua", "_scenario.lua") --[[@as FileName]] - CustomLobbyAuthoritativeModel.SetScenario(CustomLobbyAuthoritativeModel.GetSingleton(), scenario) + CustomLobbyLaunchModel.SetScenario(CustomLobbyLaunchModel.GetSingleton(), scenario) Instance:HostGame() end From f620e02118394d9d625852b575764b4649082497 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Mon, 22 Jun 2026 09:45:14 +0200 Subject: [PATCH 17/98] Add a basic map selection dialog --- .../skills/customlobby-model-choice/SKILL.md | 8 + lua/ui/lobby/customlobby/CLAUDE.md | 20 +- .../customlobby/CustomLobbyController.lua | 23 ++ .../customlobby/CustomLobbyInterface.lua | 33 ++ .../customlobby/CustomLobbyMapCatalog.lua | 89 +++++ .../customlobby/CustomLobbyMapSelect.lua | 362 ++++++++++++++++++ 6 files changed, 530 insertions(+), 5 deletions(-) create mode 100644 lua/ui/lobby/customlobby/CustomLobbyMapCatalog.lua create mode 100644 lua/ui/lobby/customlobby/CustomLobbyMapSelect.lua diff --git a/.claude/skills/customlobby-model-choice/SKILL.md b/.claude/skills/customlobby-model-choice/SKILL.md index e0d4b3fc2f3..6c590815a81 100644 --- a/.claude/skills/customlobby-model-choice/SKILL.md +++ b/.claude/skills/customlobby-model-choice/SKILL.md @@ -19,6 +19,14 @@ Three reactive singletons, chosen by two questions: **is it shared (host-dictate Everything else host-dictated that isn't launched is **Session**. +**Not all state is a model.** *Reference data* — identical on every peer and derived from disk +or computed from synced inputs — belongs in **neither**. It's a cached module, not a LazyVar +singleton on the wire. Examples: the **map catalog** ([CustomLobbyMapCatalog.lua](/lua/ui/lobby/customlobby/CustomLobbyMapCatalog.lua), +enumerated scenarios), and the upcoming **option schema** (derived from `ScenarioFile` + `GameMods`). +Only the host's *choice* (the `ScenarioFile` / `GameMods` / `GameOptions` values in Launch) syncs; +each peer recomputes the catalog/schema locally. Filter/search state in a picker is per-peer UI +preference (component-local + Prefs), not a model either. + ## Sync mapping (host → clients) | Model | Message(s) | Broadcast by | diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index 28a26d6ffdf..85be848e0a5 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -43,7 +43,7 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | [CustomLobbyLocalModel.lua](CustomLobbyLocalModel.lua) | per-peer state, never synced: identity + CPU benchmarks. | | [CustomLobbyPerformancePopover.lua](CustomLobbyPerformancePopover.lua) | hover popover over the CPU column; hand-built bitmap bar chart of a peer's `PerformanceTrackingV2` history, with a yellow recommended-unit-cap line. | | [CustomLobbyInstance.lua](CustomLobbyInstance.lua) | thin `moho.lobby_methods` shell; validates/dispatches traffic, forwards callbacks to the controller. | -| [CustomLobbyController.lua](CustomLobbyController.lua) | host-authority logic (free functions): seating, `Process*` handlers, intents (`RequestSetReady`, `RequestTakeSlot`, `RequestSwapSlots`, `RequestEject`, `RequestMoveToObserver` — all keyed by slot index / bool so a chat command can call them too; permission is gated separately), sharing the stored CPU benchmark. | +| [CustomLobbyController.lua](CustomLobbyController.lua) | host-authority logic (free functions): seating, `Process*` handlers, intents (`RequestSetReady`, `RequestTakeSlot`, `RequestSwapSlots`, `RequestEject`, `RequestMoveToObserver`, `RequestSetScenario` — all keyed by slot/bool/file so a chat command can call them too; permission is gated separately), sharing the stored CPU benchmark. | | [CustomLobbyRules.lua](CustomLobbyRules.lua) | game-rule derivations from lobby state (not view, not networking): `RecommendedUnitCap()` (per-player cap by map size, memoised scenario lookup). | | [CustomLobbyMessages.lua](CustomLobbyMessages.lua) | message registry: `AddPlayer`, `SetPlayers` (launch model: players + observers), `SentLaunchInfo` (launch model: scenario / options / mods / teams / spawn mex), `SetSessionState` (session model: slot count / closed slots), `SetReady`, `TakeSlot`, `DisconnectPeer`, `ReportCpuBenchmark`, `SetCpuBenchmarks`. | | [CustomLobbyContextMenu.lua](CustomLobbyContextMenu.lua) | generic framed floating menu; `Show(entries, x, y)` renders any `{label, action, enabled}` list, dismisses on item click / click-outside / Esc. Knows nothing about the lobby. | @@ -51,6 +51,8 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | [CustomLobbySlotInterface.lua](CustomLobbySlotInterface.lua) | one slot row; subscribes to its slot + CPU benchmarks; CPU column shows max units at +0 with a green→red cap-headroom square; left-click an open slot to take it / your own to toggle ready; right-click opens its context menu; the host can drag a row onto another to swap. | | [CustomLobbyObserversInterface.lua](CustomLobbyObserversInterface.lua) | observer strip; subscribes to the model's `Observers` list and shows the count + names (read-only). | | [CustomLobbyMapPreview.lua](CustomLobbyMapPreview.lua) / [CustomLobbyMapPreviewSpawn.lua](CustomLobbyMapPreviewSpawn.lua) | map preview (copied from the autolobby's, adapted to this model): subscribes to `ScenarioFile` (full render) + each slot (spawn-icon refresh); hidden until a scenario is set. Reuses the shared `/lua/ui/controls/mappreview.lua`. | +| [CustomLobbyMapCatalog.lua](CustomLobbyMapCatalog.lua) | cached enumeration of playable skirmish scenarios (`MapUtil.EnumerateSkirmishScenarios`). **Reference data — not a sync model**: identical on every peer, derived from disk, never on the wire. | +| [CustomLobbyMapSelect.lua](CustomLobbyMapSelect.lua) | the map-select dialog (transient `Popup`, host-only): searchable list (from the catalog) + candidate preview + info; Select calls the `RequestSetScenario` intent. Owns no synced state. First slice of splitting the legacy `dialogs/mapselect.lua` god-dialog — map selection only (options / mods / units become their own components). | | [CustomLobbyInterface.lua](CustomLobbyInterface.lua) | composition root (one model subscription for SlotCount); lays out slot rows + the observer strip, and acts as the rows' drag coordinator (`UICustomLobbySlotCoordinator`: hit-test, drop-highlight, drag ghost → `RequestSwapSlots`); `OpenDebug()` / hot-reload. | | [/lua/ui/lobby/lobby.lua](../lobby.lua) | engine entry wrapper (`CreateLobby`/`HostGame`/`JoinGame`) → CustomLobby. Old lobby preserved at `lobby-old.lua`. | @@ -64,15 +66,23 @@ ready toggles round-trip, players can **take an open slot** (click it) and the h strip, and an observer rejoins via right-click → **Play this slot**. Each peer's stored sim-performance benchmark is shared (no live stress test), and a **Leave** button (or Esc) disconnects and returns to the menu — a leaving client frees its slot for -everyone via `OnPeerDisconnected`. Launched via +everyone via `OnPeerDisconnected`. The host can **pick the map** via a Change-Map button → +the map-select dialog (searchable list + preview), which sets `ScenarioFile` through the +`RequestSetScenario` intent and broadcasts it so every peer's preview updates. Launched via `scripts/LaunchCustomLobby.ps1`, or inspect UI only with `UI_Lua import("/lua/ui/lobby/customlobby/customlobbyinterface.lua").OpenDebug()`. ## Next slices (per TARGET_ARCHITECTURE.md) -1. Make slot controls interactive (faction/colour/team → controller **intents**). -2. Options panel, map preview, observer list, footer, chat — each its own subscribing component. -3. Sub-dialogs (map select, mods, units, presets, prefs) as mini-MVC. +1. **Options panel** — the next slice. The legacy `dialogs/mapselect.lua` bundled game options + into the map dialog; we're splitting them out. Model the **option schema as a derivation** of + `ScenarioFile` + `GameMods` (static lobby options ∪ the map's `_options.lua` ∪ mod options — + computed per peer, *not* synced, like the map catalog), rendered against the synced + `GameOptions` *values*. When the scenario/mods change, the controller **reconciles** the values + (drop stale keys, seed new defaults) — see the `TODO` in `RequestSetScenario`. Merge rule: + start with map/mod options only *adding* keys (no overriding base lobby options). +2. Remaining sub-dialogs (mods, units, presets, prefs) as mini-MVC, following the map-select shape. +3. Make slot controls interactive (faction/colour/team → controller **intents**). 4. Map-derived `SlotCount`; the GPGNet (`localPort == -1`) FAF-client path. ## Rules (same as autolobby) diff --git a/lua/ui/lobby/customlobby/CustomLobbyController.lua b/lua/ui/lobby/customlobby/CustomLobbyController.lua index b4eb1f3e56b..131395ead49 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyController.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyController.lua @@ -502,6 +502,29 @@ function RequestTakeSlot(slot) end end +--- The host picks the scenario (map). Host-only — backs the map-select dialog and a +--- `/map ` chat command. Sets the scenario in the launch model and broadcasts the +--- launch config; the map preview / unit-cap react to the new `ScenarioFile`. +--- +--- TODO (options slice): when the scenario changes, the game-options *schema* changes too +--- (the map contributes its own options). Reconcile `GameOptions` here — drop values whose +--- keys no longer exist, seed defaults for new keys — before broadcasting. See CLAUDE.md. +---@param scenarioFile FileName +function RequestSetScenario(scenarioFile) + local instance = LobbyInstance + if not instance then + return + end + + if not CustomLobbyLocalModel.GetSingleton().IsHost() then + WARN("CustomLobby: only the host can change the map") + return + end + + CustomLobbyLaunchModel.SetScenario(CustomLobbyLaunchModel.GetSingleton(), scenarioFile) + BroadcastLaunchInfo(instance) +end + --- The host swaps the contents of two slots. Host-only (a client request isn't --- offered) — backs a host-side drag/menu and a `/swap ` chat command. ---@param slotA number diff --git a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua index 9af8da7f72d..77f772d6e2c 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua @@ -39,6 +39,7 @@ local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontr local CustomLobbySlotInterface = import("/lua/ui/lobby/customlobby/customlobbyslotinterface.lua") local CustomLobbyObserversInterface = import("/lua/ui/lobby/customlobby/customlobbyobserversinterface.lua") local CustomLobbyMapPreview = import("/lua/ui/lobby/customlobby/customlobbymappreview.lua") +local CustomLobbyMapSelect = import("/lua/ui/lobby/customlobby/customlobbymapselect.lua") local LazyVarDerive = import("/lua/lazyvar.lua").Derive @@ -55,8 +56,10 @@ local PanelWidth = 520 ---@field Slots UICustomLobbySlotInterface[] ---@field ObserversPanel UICustomLobbyObserversInterface ---@field MapPreview UICustomLobbyMapPreview +---@field MapButton Button ---@field LeaveButton Button ---@field SlotCountObserver LazyVar +---@field IsHostObserver LazyVar ---@field HighlightedSlot number | false # slot currently shown as a drop target ---@field DragGhost Group | false # floating label following the cursor mid-drag local CustomLobbyInterface = Class(Group) { @@ -87,6 +90,13 @@ local CustomLobbyInterface = Class(Group) { self.ObserversPanel = CustomLobbyObserversInterface.Create(self) self.MapPreview = CustomLobbyMapPreview.Create(self) + -- only the host picks the map; the button hides for everyone else (and the + -- RequestSetScenario intent is host-gated regardless) + self.MapButton = UIUtil.CreateButtonStd(self, '/scx_menu/small-btn/small', "Change Map", 16, 2) + self.MapButton.OnClick = function(button, modifiers) + CustomLobbyMapSelect.Open(GetFrame(0)) + end + -- leaving disconnects + returns to the menu via the escape handler that -- lobby.lua registered (one teardown definition, shared with the Esc key) self.LeaveButton = UIUtil.CreateButtonStd(self, '/scx_menu/small-btn/small', "Leave", 16, 2) @@ -99,6 +109,12 @@ local CustomLobbyInterface = Class(Group) { LazyVarDerive(session.SlotCount, function(slotCountLazy) self:OnSlotCountChanged(slotCountLazy()) end)) + + local localModel = CustomLobbyLocalModel.GetSingleton() + self.IsHostObserver = self.Trash:Add( + LazyVarDerive(localModel.IsHost, function(isHostLazy) + self:OnIsHostChanged(isHostLazy()) + end)) end, ---@param self UICustomLobbyInterface @@ -123,6 +139,12 @@ local CustomLobbyInterface = Class(Group) { :Height(320) :End() + -- map-change button under the preview (host-only; visibility set by OnIsHostChanged) + Layouter(self.MapButton) + :AnchorToBottom(self.MapPreview, 12) + :AtLeftIn(self.MapPreview) + :End() + -- stack the rows top-to-bottom inside the panel via sibling anchoring for slot = 1, CustomLobbyLaunchModel.MaxSlots do local row = self.Slots[slot] @@ -157,6 +179,17 @@ local CustomLobbyInterface = Class(Group) { end end, + --- Shows the host-only controls (the map-change button) only to the host. + ---@param self UICustomLobbyInterface + ---@param isHost boolean + OnIsHostChanged = function(self, isHost) + if isHost then + self.MapButton:Show() + else + self.MapButton:Hide() + end + end, + --------------------------------------------------------------------------- --#region Slot drag coordination (UICustomLobbySlotCoordinator) -- diff --git a/lua/ui/lobby/customlobby/CustomLobbyMapCatalog.lua b/lua/ui/lobby/customlobby/CustomLobbyMapCatalog.lua new file mode 100644 index 00000000000..9c99fe86120 --- /dev/null +++ b/lua/ui/lobby/customlobby/CustomLobbyMapCatalog.lua @@ -0,0 +1,89 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- The map catalog: the custom lobby's cached list of playable skirmish scenarios. +-- +-- `MapUtil.EnumerateSkirmishScenarios` is expensive — it `doscript`s every `_scenario.lua` +-- on disk plus all mod maps — so we read it once and cache it here, rather than re-enumerating +-- each time the map-select dialog opens. +-- +-- This is deliberately NOT one of the three lobby models (Launch / Session / Local): it's +-- read-only *reference data*, identical on every peer, derived from disk rather than dictated +-- by the host. It never goes on the wire — only the host's *choice* (ScenarioFile, in the +-- launch model) is synced. See /lua/ui/lobby/customlobby/CLAUDE.md and the +-- `customlobby-model-choice` skill. +-- +-- The enumerated entries are full `UILobbyScenarioInfo` tables (name / preview / map / size / +-- Configurations / options), so the dialog can render the list, preview and info without any +-- further disk reads. + +local MapUtil = import("/lua/ui/maputil.lua") + +---@type UILobbyScenarioInfo[] | nil +local Scenarios = nil + +--- Returns all playable skirmish scenarios, enumerating + caching on first call. +---@param force? boolean # re-read from disk even if cached (e.g. maps changed on disk) +---@return UILobbyScenarioInfo[] +function GetScenarios(force) + if not Scenarios or force then + Scenarios = MapUtil.EnumerateSkirmishScenarios() + end + return Scenarios +end + +--- Finds the cached scenario whose file path matches `scenarioFile` (case-insensitive), or nil. +---@param scenarioFile FileName | false +---@return UILobbyScenarioInfo | nil +function FindByFile(scenarioFile) + if not scenarioFile then + return nil + end + local target = string.lower(scenarioFile) + for _, scenario in GetScenarios() do + if string.lower(scenario.file) == target then + return scenario + end + end + return nil +end + +--- Drops the cache so the next `GetScenarios` re-reads from disk. +function Refresh() + Scenarios = nil +end + +------------------------------------------------------------------------------- +--#region Debugging + +--- Hot-reload hook: re-imports this module after a couple of frames. The cache is just a +--- perf optimisation, so letting it rebuild on the next access is harmless. +function __moduleinfo.OnDirty() + ForkThread( + function() + WaitFrames(2) + import(__moduleinfo.name) + end + ) +end + +--#endregion diff --git a/lua/ui/lobby/customlobby/CustomLobbyMapSelect.lua b/lua/ui/lobby/customlobby/CustomLobbyMapSelect.lua new file mode 100644 index 00000000000..fbf865f9f42 --- /dev/null +++ b/lua/ui/lobby/customlobby/CustomLobbyMapSelect.lua @@ -0,0 +1,362 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- The map-select dialog: a searchable list of scenarios with a preview + info, and +-- Select / Cancel. It is a transient picker, NOT a persistent model component: +-- +-- * it reads the catalog (CustomLobbyMapCatalog) for the scenario list — reference data, +-- * it previews the *highlighted candidate*, which is decoupled from the launch model +-- (you're browsing, not committing), so it drives the shared MapPreview control directly +-- rather than the model-bound CustomLobbyMapPreview, and +-- * on Select it calls the controller intent `RequestSetScenario(file)` — the host sets the +-- scenario in the launch model and broadcasts it, the same path a `/map ` chat +-- command would use. It owns no synced state of its own. +-- +-- This is the first of the sub-dialogs that the legacy `dialogs/mapselect.lua` god-dialog is +-- being split into: it does map selection ONLY. Game options, mods and unit restrictions — +-- which the legacy dialog also bundled — become their own components (the options panel loads +-- its schema from the selected map + mods; see CLAUDE.md "Next slices"). + +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 +local Edit = import("/lua/maui/edit.lua").Edit +local ItemList = import("/lua/maui/itemlist.lua").ItemList +local MapPreview = import("/lua/ui/controls/mappreview.lua").MapPreview +local Popup = import("/lua/ui/controls/popups/popup.lua").Popup + +local CustomLobbyMapCatalog = import("/lua/ui/lobby/customlobby/customlobbymapcatalog.lua") +local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") + +local Layouter = LayoutHelpers.ReusedLayoutFor + +local DialogWidth = 740 +local DialogHeight = 520 +local ListWidth = 300 +local PreviewSize = 300 + +---@class UICustomLobbyMapSelect : Group +---@field Trash TrashBag +---@field Title Text +---@field Search Edit +---@field MapList ItemList +---@field Preview MapPreview +---@field PreviewBg Bitmap +---@field InfoTitle Text +---@field InfoName Text +---@field InfoSize Text +---@field InfoPlayers Text +---@field SelectButton Button +---@field CancelButton Button +---@field OnConfirmCb fun(scenarioFile: FileName) +---@field OnCancelCb fun() +---@field Scenarios UILobbyScenarioInfo[] +---@field Filtered UILobbyScenarioInfo[] # the currently-listed (filtered) subset, 1-based by row +---@field Selected? UILobbyScenarioInfo # the highlighted candidate +local CustomLobbyMapSelect = ClassUI(Group) { + + ---@param self UICustomLobbyMapSelect + ---@param parent Control + ---@param onConfirm fun(scenarioFile: FileName) + ---@param onCancel fun() + __init = function(self, parent, onConfirm, onCancel) + Group.__init(self, parent, "CustomLobbyMapSelect") + + self.Trash = TrashBag() + self.OnConfirmCb = onConfirm + self.OnCancelCb = onCancel + + self.Scenarios = CustomLobbyMapCatalog.GetScenarios() + self.Filtered = {} + -- default the highlight to whatever map is currently set (read-only model peek) + self.Selected = CustomLobbyMapCatalog.FindByFile(CustomLobbyLaunchModel.GetSingleton().ScenarioFile()) + + self.Title = UIUtil.CreateText(self, "Select Map", 22, UIUtil.titleFont) + + self.Search = Edit(self) + -- An Edit reads its own bounds when the font is set, which is circular before the + -- control is anchored — give it placeholder dimensions first (see /lua/ui/CLAUDE.md + -- § 1); __post_init lays it out for real. + Layouter(self.Search):Left(0):Top(0):Width(ListWidth):Height(22):End() + self.Search:SetFont(UIUtil.bodyFont, 16) + self.Search:SetForegroundColor(UIUtil.fontColor) + self.Search:ShowBackground(true) + self.Search:SetBackgroundColor('77778888') + self.Search:SetText("") + self.Search.OnTextChanged = function(control, newText, oldText) + self:Populate() + end + + -- ItemList is legacy (see /lua/ui/CLAUDE.md §6.1) but fine for a flat string list; + -- swap for a pooled Group list if rows ever need richer content (thumbnails, badges). + self.MapList = ItemList(self, "customlobby:maplist") + self.MapList:SetFont(UIUtil.bodyFont, 14) + self.MapList:SetColors(UIUtil.fontColor, "00000000", "FF000000", UIUtil.highlightColor, "ffbcfffe") + self.MapList:ShowMouseoverItem(true) + self.MapList.OnClick = function(control, row) + self:OnMapHighlighted(row) + end + self.MapList.OnKeySelect = function(control, row) + self:OnMapHighlighted(row) + end + self.MapList.OnDoubleClick = function(control, row) + self:OnMapHighlighted(row) + self:Confirm() + end + + -- preview of the highlighted candidate (decoupled from the launch model) + self.PreviewBg = Bitmap(self) + self.PreviewBg:SetSolidColor('ff000000') + self.PreviewBg:DisableHitTest() + self.Preview = MapPreview(self) + + self.InfoTitle = UIUtil.CreateText(self, "Map Info", 16, UIUtil.titleFont) + self.InfoName = UIUtil.CreateText(self, "", 14, UIUtil.bodyFont) + self.InfoSize = UIUtil.CreateText(self, "", 14, UIUtil.bodyFont) + self.InfoPlayers = UIUtil.CreateText(self, "", 14, UIUtil.bodyFont) + + self.SelectButton = UIUtil.CreateButtonStd(self, '/scx_menu/small-btn/small', "Select", 16, 2) + self.SelectButton.OnClick = function(button, modifiers) + self:Confirm() + end + + self.CancelButton = UIUtil.CreateButtonStd(self, '/scx_menu/small-btn/small', "Cancel", 16, 2) + self.CancelButton.OnClick = function(button, modifiers) + self.OnCancelCb() + end + end, + + ---@param self UICustomLobbyMapSelect + __post_init = function(self) + self.Width:Set(LayoutHelpers.ScaleNumber(DialogWidth)) + self.Height:Set(LayoutHelpers.ScaleNumber(DialogHeight)) + + Layouter(self.Title):AtTopIn(self, 14):AtHorizontalCenterIn(self):End() + + Layouter(self.Search) + :AtLeftIn(self, 18):AtTopIn(self, 54):Width(ListWidth):Height(22) + :End() + + Layouter(self.CancelButton):AtRightIn(self, 18):AtBottomIn(self, 14):End() + Layouter(self.SelectButton):AnchorToLeft(self.CancelButton, 16):AtVerticalCenterIn(self.CancelButton):End() + + -- list fills the left column between the search box and the buttons + Layouter(self.MapList) + :AtLeftIn(self, 18) + :Width(ListWidth - 16) -- leave room for the scrollbar + :AnchorToBottom(self.Search, 10) + :AnchorToTop(self.SelectButton, 14) + :End() + UIUtil.CreateVertScrollbarFor(self.MapList) + + -- preview + info in the right column + Layouter(self.Preview) + :AtRightIn(self, 18):AtTopIn(self, 54):Width(PreviewSize):Height(PreviewSize) + :End() + Layouter(self.PreviewBg):Fill(self.Preview):End() + self.PreviewBg.Depth:Set(function() return self.Preview.Depth() - 1 end) + + Layouter(self.InfoTitle):AtLeftIn(self.Preview):AnchorToBottom(self.Preview, 14):End() + Layouter(self.InfoName):AtLeftIn(self.Preview):AnchorToBottom(self.InfoTitle, 6):End() + Layouter(self.InfoSize):AtLeftIn(self.Preview):AnchorToBottom(self.InfoName, 4):End() + Layouter(self.InfoPlayers):AtLeftIn(self.Preview):AnchorToBottom(self.InfoSize, 4):End() + + self.MapList:AcquireKeyboardFocus(true) + end, + + --- Populates the list. The opener calls this AFTER the dialog is mounted + centred by + --- Popup: `Populate` scrolls the list (`ShowItem`), which forces a concrete geometry + --- read, and the dialog's own rect isn't settled during __post_init (Popup re-parents + --- and re-centres it afterwards). This is the three-phase init pattern — see + --- /lua/ui/CLAUDE.md § 1. + ---@param self UICustomLobbyMapSelect + Initialize = function(self) + self:Populate() + end, + + --- Rebuilds the list from the catalog, applying the name filter and keeping the current + --- highlight selected (falling back to the first row). + ---@param self UICustomLobbyMapSelect + Populate = function(self) + self.MapList:DeleteAllItems() + + local search = string.lower(self.Search:GetText() or "") + self.Filtered = {} + local selectedRow = 0 + + for _, scenario in self.Scenarios do + if search == "" or string.find(string.lower(scenario.name), search, 1, true) then + table.insert(self.Filtered, scenario) + self.MapList:AddItem(LOC(scenario.name)) + if self.Selected and string.lower(scenario.file) == string.lower(self.Selected.file) then + selectedRow = table.getn(self.Filtered) - 1 + end + end + end + + if table.getn(self.Filtered) > 0 then + self.MapList:SetSelection(selectedRow) + self.MapList:ShowItem(selectedRow) + self:OnMapHighlighted(selectedRow) + else + self.Selected = nil + self.Preview:ClearTexture() + self:UpdateInfo(nil) + self.SelectButton:Disable() + end + end, + + --- A row was highlighted: make it the candidate and refresh the preview + info. + ---@param self UICustomLobbyMapSelect + ---@param row number # 0-based ItemList row + OnMapHighlighted = function(self, row) + local scenario = self.Filtered[row + 1] + if not scenario then + return + end + self.Selected = scenario + self.MapList:SetSelection(row) + self:UpdatePreview(scenario) + self:UpdateInfo(scenario) + self.SelectButton:Enable() + end, + + --- Sets the preview texture for a scenario (preview image, falling back to the heightmap). + ---@param self UICustomLobbyMapSelect + ---@param scenario UILobbyScenarioInfo + UpdatePreview = function(self, scenario) + if not self.Preview:SetTexture(scenario.preview) then + self.Preview:SetTextureFromMap(scenario.map) + end + end, + + --- Fills the info lines for a scenario (nil clears them). + ---@param self UICustomLobbyMapSelect + ---@param scenario UILobbyScenarioInfo | nil + UpdateInfo = function(self, scenario) + if not scenario then + self.InfoName:SetText("") + self.InfoSize:SetText("") + self.InfoPlayers:SetText("") + return + end + + self.InfoName:SetText(LOC(scenario.name) or "?") + + if scenario.size then + self.InfoSize:SetText(LOCF("Map Size: %dkm x %dkm", scenario.size[1] / 50, scenario.size[2] / 50)) + else + self.InfoSize:SetText("") + end + + local armies = scenario.Configurations + and scenario.Configurations.standard + and scenario.Configurations.standard.teams + and scenario.Configurations.standard.teams[1] + and scenario.Configurations.standard.teams[1].armies + if armies then + self.InfoPlayers:SetText(LOCF("Max Players: %d", table.getsize(armies))) + else + self.InfoPlayers:SetText("") + end + end, + + --- Commits the highlighted candidate via the controller intent. + ---@param self UICustomLobbyMapSelect + Confirm = function(self) + if not self.Selected then + return + end + self.OnConfirmCb(self.Selected.file) + end, + + ---@param self UICustomLobbyMapSelect + OnDestroy = function(self) + self.MapList:AbandonKeyboardFocus() + self.Trash:Destroy() + end, +} + +------------------------------------------------------------------------------- +--#region Singleton + open / close + +---@type Popup | false +local Instance = false + +--- Opens the map-select dialog over `parent` (defaults to the root frame). Confirming sets +--- the scenario through the host-authoritative `RequestSetScenario` intent. Replaces any +--- dialog already open. +---@param parent? Control +function Open(parent) + parent = parent or GetFrame(0) + if Instance then + Instance:Close() + end + + local popup + local content = CustomLobbyMapSelect(parent, + function(scenarioFile) + CustomLobbyController.RequestSetScenario(scenarioFile) + if popup then + popup:Close() + end + end, + function() + if popup then + popup:Close() + end + end) + + popup = Popup(parent, content) + local baseOnClosed = popup.OnClosed + popup.OnClosed = function(self) + baseOnClosed(self) + Instance = false + end + Instance = popup + + -- now that Popup has mounted + centred the content, it's safe to populate (the list + -- scroll reads concrete geometry) + content:Initialize() +end + +--- Closes the dialog if open. +function Close() + if Instance then + Instance:Close() + Instance = false + end +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Debugging + +function __moduleinfo.OnDirty() + Close() +end + +--#endregion From 79ca4df008879b3313701488df8a2a6d5fe769c1 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Mon, 22 Jun 2026 11:06:58 +0200 Subject: [PATCH 18/98] Polish the map selection dialog --- lua/ui/lobby/customlobby/CLAUDE.md | 23 +- .../customlobby/CustomLobbyMapCatalog.lua | 157 ++- .../lobby/customlobby/CustomLobbyMapList.lua | 335 +++++++ .../customlobby/CustomLobbyMapSelect.lua | 894 +++++++++++++++--- 4 files changed, 1254 insertions(+), 155 deletions(-) create mode 100644 lua/ui/lobby/customlobby/CustomLobbyMapList.lua diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index 85be848e0a5..ec91090c279 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -51,8 +51,9 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | [CustomLobbySlotInterface.lua](CustomLobbySlotInterface.lua) | one slot row; subscribes to its slot + CPU benchmarks; CPU column shows max units at +0 with a green→red cap-headroom square; left-click an open slot to take it / your own to toggle ready; right-click opens its context menu; the host can drag a row onto another to swap. | | [CustomLobbyObserversInterface.lua](CustomLobbyObserversInterface.lua) | observer strip; subscribes to the model's `Observers` list and shows the count + names (read-only). | | [CustomLobbyMapPreview.lua](CustomLobbyMapPreview.lua) / [CustomLobbyMapPreviewSpawn.lua](CustomLobbyMapPreviewSpawn.lua) | map preview (copied from the autolobby's, adapted to this model): subscribes to `ScenarioFile` (full render) + each slot (spawn-icon refresh); hidden until a scenario is set. Reuses the shared `/lua/ui/controls/mappreview.lua`. | -| [CustomLobbyMapCatalog.lua](CustomLobbyMapCatalog.lua) | cached enumeration of playable skirmish scenarios (`MapUtil.EnumerateSkirmishScenarios`). **Reference data — not a sync model**: identical on every peer, derived from disk, never on the wire. | -| [CustomLobbyMapSelect.lua](CustomLobbyMapSelect.lua) | the map-select dialog (transient `Popup`, host-only): searchable list (from the catalog) + candidate preview + info; Select calls the `RequestSetScenario` intent. Owns no synced state. First slice of splitting the legacy `dialogs/mapselect.lua` god-dialog — map selection only (options / mods / units become their own components). | +| [CustomLobbyMapCatalog.lua](CustomLobbyMapCatalog.lua) | the lobby's OWN async enumeration of playable skirmish maps (does *not* use `MapUtil.EnumerateSkirmishScenarios`; borrows only `LoadScenarioInfoFile`, loading map *info* — not options/strings). Streams maps into a `Scenarios` `LazyVar` across frames (`EnsureLoaded`) so the open doesn't block and the count updates live. **Reference data — not a sync model**: the LazyVar is local progress reactivity, never on the wire; only the host's `ScenarioFile` choice syncs. | +| [CustomLobbyMapSelect.lua](CustomLobbyMapSelect.lua) | the map-select dialog (transient `Popup`, host-only). Laid out in **areas** (Group containers — title / left {filters, selection, stats} / preview / actions; flip the `Debug` flag to tint them). Searchable list + size & player-count filters each with a comparison operator (`=` / `>=` / `<=`), persisted to Prefs; "X of Y maps" footer with a loading spinner; candidate preview (name overlaid on top) with centred toggle-able overlays — start spots (numbered), resources (mass/hydro icons), wrecks; info (size / players / version / AI markers) + a **scrollable** description; a file-health check that warns and disables Select for broken maps; double-click / Enter confirms, Esc cancels, Random (centred under the left area) picks from the filtered set. Select calls the `RequestSetScenario` intent. Owns no synced state. First slice of splitting the legacy `dialogs/mapselect.lua` god-dialog — map selection only (options / mods / units become their own components). | +| [CustomLobbyMapList.lua](CustomLobbyMapList.lua) | the dialog's scrollable, **virtualised** list: a fixed pool of **text-only** rows (name + `size · players` badge) reused as you scroll, standard scrollbar contract. Mouse-driven (a custom Group list can't take keyboard focus like `ItemList`); `OnSelect` / `OnConfirm`. **No per-row thumbnails** — the engine never frees `MapPreview` textures (see the gotcha below), so only the dialog's single candidate preview renders one. | | [CustomLobbyInterface.lua](CustomLobbyInterface.lua) | composition root (one model subscription for SlotCount); lays out slot rows + the observer strip, and acts as the rows' drag coordinator (`UICustomLobbySlotCoordinator`: hit-test, drop-highlight, drag ghost → `RequestSwapSlots`); `OpenDebug()` / hot-reload. | | [/lua/ui/lobby/lobby.lua](../lobby.lua) | engine entry wrapper (`CreateLobby`/`HostGame`/`JoinGame`) → CustomLobby. Old lobby preserved at `lobby-old.lua`. | @@ -91,3 +92,21 @@ the map-select dialog (searchable list + preview), which sets `ScenarioFile` thr - The controller is the only writer, through the model write-helpers (never mutate a held table in place). - A `Derive` handler must read its own LazyVar (`function(xLazy) self:OnX(xLazy()) end`). - FAF is Lua 5.0 — no `%` operator; use `math.mod`. + +## Layout / init gotchas (learned building the map-select dialog) + +These are the recurring footguns when writing a custom control here — all variations of +"layout isn't a value you can read yet." Read `/lua/ui/CLAUDE.md` § 1–2 first; this is the +lobby-specific checklist. + +| Symptom | Cause | Fix | +|---|---|---| +| `attempt to call method 'SetFunction' (a nil value)` while laying a control out | A `self.X` field name **collides with a Control edge** — `Left` / `Right` / `Top` / `Bottom` / `Width` / `Height` / `Depth` are reserved LazyVars; assigning `self.Top = 0` clobbers the edge. | Name custom fields anything else (`ScrollTop`, not `Top`). | +| `attempt to call method 'AtLeftRightIn' (a nil value)` (or similar) in a layouter chain | Not every `LayoutHelpers.*` function is exposed on the fluent `ReusedLayoutFor` builder. `AtLeftRightIn` is bare-only. | Use the methods other components use — `:AtLeftIn(p):AtRightIn(p)`, `:AnchorToBottom`, `:Fill`, … — or call `LayoutHelpers.Foo(control, …)` directly. | +| `circular dependency in lazy evaluation` when an `Edit`'s font is set, or a list/preview reads its size | Reading a **concrete** layout value (`SetFont`, `ShowItem`, `Width()`) before the control is anchored into a settled parent rect. | Three-phase init: build in `__init`, anchor in `__post_init`, and read geometry only in an `Initialize()` the **opener calls after mounting** (e.g. after `Popup` centres the dialog). For an `Edit`, give it placeholder `:Left(0):Top(0):Width():Height()` before `SetFont`. | +| Cascade of errors after one layout failure (half-built pools, observers firing into a broken control) | A throw mid-`Initialize` left partial state (e.g. `PoolCount` set before the rows existed), and a streaming `Derive`/thread kept calling into it. | Set "ready" counters **after** the thing they describe is fully built; guard paint/refresh paths against nil rows; gate model-observer work on a `self.Ready` flag set in `Initialize`. | +| Memory climbs ~30 MB and never drops — not on re-texture, `ClearTexture`, `Destroy`, or dialog close | `MapPreview:SetTexture` / `SetTextureFromMap` allocate textures the engine **never frees** (no release API on the control or globally). Loading one per list row × hundreds of maps leaks memory the game needs in-match. | Don't put a `MapPreview` per list row. Render at most a **few** previews total (the map-select list is text-only; only the single candidate preview loads a texture, once per selection). The same applies to per-row `Bitmap` thumbnails via `SetNewTexture`. | + +Reference implementation for all four: [CustomLobbyMapSelect.lua](CustomLobbyMapSelect.lua) + +[CustomLobbyMapList.lua](CustomLobbyMapList.lua) (the pooled list's `ScrollTop`, three-phase +`Initialize`, post-loop `PoolCount`, nil-guarded `PaintRow`). diff --git a/lua/ui/lobby/customlobby/CustomLobbyMapCatalog.lua b/lua/ui/lobby/customlobby/CustomLobbyMapCatalog.lua index 9c99fe86120..f5dcd8c218a 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyMapCatalog.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyMapCatalog.lua @@ -20,38 +20,141 @@ --** SOFTWARE. --****************************************************************************************************** --- The map catalog: the custom lobby's cached list of playable skirmish scenarios. +-- The map catalog: the custom lobby's list of playable skirmish maps. -- --- `MapUtil.EnumerateSkirmishScenarios` is expensive — it `doscript`s every `_scenario.lua` --- on disk plus all mod maps — so we read it once and cache it here, rather than re-enumerating --- each time the map-select dialog opens. +-- This is the custom lobby's OWN enumeration — it does not call MapUtil's +-- `EnumerateSkirmishScenarios` (which eagerly loads each map's options + strings and blocks +-- while it reads the whole `/maps` tree). We're slowly moving off MapUtil: the only thing we +-- still borrow is `LoadScenarioInfoFile` — the `doscript` loader for one `_scenario.lua` — and +-- even that is a candidate to inline later. -- --- This is deliberately NOT one of the three lobby models (Launch / Session / Local): it's --- read-only *reference data*, identical on every peer, derived from disk rather than dictated --- by the host. It never goes on the wire — only the host's *choice* (ScenarioFile, in the --- launch model) is synced. See /lua/ui/lobby/customlobby/CLAUDE.md and the --- `customlobby-model-choice` skill. +-- Two differences from the legacy enumerator: +-- * **lighter** — we load only the scenario *info* (name / map / preview / size / +-- Configurations), not its `_options.lua` / `_strings.lua`. The list, preview and info +-- panel need nothing more; the options schema is loaded separately by the options slice. +-- * **async** — maps stream in across frames rather than blocking the UI on open. The list +-- is a `LazyVar` that re-fires as each batch lands, so the dialog can show a live +-- "N maps loaded" count and fill in progressively. -- --- The enumerated entries are full `UILobbyScenarioInfo` tables (name / preview / map / size / --- Configurations / options), so the dialog can render the list, preview and info without any --- further disk reads. +-- This stays *reference data*, never on the wire: the LazyVar gives local reactivity (progress), +-- not host-dictated sync. Only the host's *choice* (`ScenarioFile`, in the launch model) syncs; +-- every peer enumerates its own disk. See CLAUDE.md and the `customlobby-model-choice` skill. -local MapUtil = import("/lua/ui/maputil.lua") +local Create = import("/lua/lazyvar.lua").Create ----@type UILobbyScenarioInfo[] | nil -local Scenarios = nil +-- The one MapUtil function we still lean on: the `doscript` loader for a single scenario-info +-- file (handles the on-disk version backcompat). Everything else is ours. +local LoadScenarioInfoFile = import("/lua/ui/maputil.lua").LoadScenarioInfoFile ---- Returns all playable skirmish scenarios, enumerating + caching on first call. ----@param force? boolean # re-read from disk even if cached (e.g. maps changed on disk) ----@return UILobbyScenarioInfo[] -function GetScenarios(force) - if not Scenarios or force then - Scenarios = MapUtil.EnumerateSkirmishScenarios() +-- maps loaded per frame-slice before yielding — keeps the open responsive on big vaults +local BatchSize = 4 + +--- The growing list of playable skirmish maps. Re-fired (new table ref) as batches land. +---@type LazyVar +local Scenarios = Create({}) + +local Loading = false -- a load thread is currently running +local Loaded = false -- the disk has been fully enumerated + +------------------------------------------------------------------------------- +--#region Enumeration + +--- A scenario is listable if it's a skirmish map with at least one start spot. +---@param scenario UIScenarioInfoFile +---@return boolean +local function IsPlayableSkirmish(scenario) + if scenario.type ~= "skirmish" then + return false + end + local standard = scenario.Configurations and scenario.Configurations.standard + local teams = standard and standard.teams + local first = teams and teams[1] + return first ~= nil and first.armies ~= nil +end + +---@param a UILobbyScenarioInfo +---@param b UILobbyScenarioInfo +---@return boolean +local function SortByName(a, b) + return string.upper(a.name or "") < string.upper(b.name or "") +end + +--- Publishes a fresh (sorted) copy of the accumulator so dependents go dirty. +---@param accumulator UILobbyScenarioInfo[] +local function Publish(accumulator) + local snapshot = table.copy(accumulator) + table.sort(snapshot, SortByName) + Scenarios:Set(snapshot) +end + +--- Enumerates `/maps` across frames, loading each map's info and streaming the playable +--- skirmish maps into the `Scenarios` LazyVar in batches. +--- TODO: mod maps (legacy also scanned `mods.AllSelectableMods()`); skipped for now. +local function LoadThread() + local files = DiskFindFiles('/maps', '*_scenario.lua') + + local accumulator = {} + local seen = 0 + for _, file in files do + local scenario = LoadScenarioInfoFile(file) + if scenario and IsPlayableSkirmish(scenario) then + scenario.file = file + table.insert(accumulator, scenario) + end + + seen = seen + 1 + if math.mod(seen, BatchSize) == 0 then + Publish(accumulator) + WaitFrames(5) + end + end + + Loaded = true + Loading = false + Publish(accumulator) +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Public API + +--- Kicks off the async enumeration if it hasn't run yet. Idempotent — safe to call on every +--- dialog open; once loaded it's a no-op and the cached list stays. +function EnsureLoaded() + if Loading or Loaded then + return end + Loading = true + Scenarios:Set({}) + ForkThread(LoadThread) +end + +--- The maps LazyVar — subscribe to it (via `Derive`) to react as the list streams in. +---@return LazyVar +function GetScenariosVar() return Scenarios end ---- Finds the cached scenario whose file path matches `scenarioFile` (case-insensitive), or nil. +--- The current (possibly partial) list of maps. +---@return UILobbyScenarioInfo[] +function GetScenarios() + return Scenarios() +end + +--- How many maps are currently loaded. +---@return number +function GetCount() + return table.getn(Scenarios()) +end + +--- Whether the disk has been fully enumerated (vs. still streaming). +---@return boolean +function IsLoaded() + return Loaded +end + +--- Finds the loaded map whose file path matches `scenarioFile` (case-insensitive), or nil. ---@param scenarioFile FileName | false ---@return UILobbyScenarioInfo | nil function FindByFile(scenarioFile) @@ -59,7 +162,7 @@ function FindByFile(scenarioFile) return nil end local target = string.lower(scenarioFile) - for _, scenario in GetScenarios() do + for _, scenario in Scenarios() do if string.lower(scenario.file) == target then return scenario end @@ -67,11 +170,15 @@ function FindByFile(scenarioFile) return nil end ---- Drops the cache so the next `GetScenarios` re-reads from disk. +--- Drops everything so the next `EnsureLoaded` re-reads from disk (e.g. maps changed on disk). function Refresh() - Scenarios = nil + Loaded = false + Loading = false + Scenarios:Set({}) end +--#endregion + ------------------------------------------------------------------------------- --#region Debugging diff --git a/lua/ui/lobby/customlobby/CustomLobbyMapList.lua b/lua/ui/lobby/customlobby/CustomLobbyMapList.lua new file mode 100644 index 00000000000..de859685842 --- /dev/null +++ b/lua/ui/lobby/customlobby/CustomLobbyMapList.lua @@ -0,0 +1,335 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- A scrollable, pooled map list for the map-select dialog: each row shows the map name and a +-- `size · players` badge. Replaces the flat `ItemList` so rows can carry richer content (see +-- /lua/ui/CLAUDE.md § 6.1). +-- +-- NOTE: rows used to carry a mini map-preview thumbnail, but the engine never releases the +-- textures `MapPreview:SetTextureFromMap` / `SetTexture` allocate — not on re-texture, not on +-- `ClearTexture`, not on `Destroy`, not on dialog close. Scrolling a vault leaked tens of MB +-- that the game needs in-match, so thumbnails were removed. Rows are now text-only. +-- +-- It is *virtualised*: a fixed pool of row controls (sized to the visible height) is reused as +-- you scroll. The standard scrollbar contract (GetScrollValues / ScrollLines / ScrollPages / +-- ScrollSetTop / IsScrollable / CalcVisible) drives the windowing. +-- +-- Mouse-driven (click selects, double-click confirms, wheel + scrollbar scroll). A custom Group +-- list can't take keyboard focus the way ItemList can, so arrow-key navigation lives with the +-- owner (the dialog wires Enter on its search box / Esc on the popup). + +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 + +local Layouter = LayoutHelpers.ReusedLayoutFor + +local RowHeight = 24 + +local SelectedColor = 'ff2c3e48' +local HoverColor = 'ff1a2630' +local IdleColor = '00000000' + +--- Number of start spots a scenario declares, or 0. +---@param scenario UILobbyScenarioInfo +---@return number +local function ArmyCount(scenario) + local armies = scenario.Configurations + and scenario.Configurations.standard + and scenario.Configurations.standard.teams + and scenario.Configurations.standard.teams[1] + and scenario.Configurations.standard.teams[1].armies + return armies and table.getsize(armies) or 0 +end + +---@class UICustomLobbyMapListRow : Group +---@field Background Bitmap +---@field Name Text +---@field Meta Text +---@field _poolIndex number +---@field _hover boolean + +---@class UICustomLobbyMapList : Group +---@field Trash TrashBag +---@field Items UILobbyScenarioInfo[] +---@field Rows UICustomLobbyMapListRow[] +---@field PoolCount number +---@field ScrollTop number # 0-based scroll offset (first visible = Items[ScrollTop+1]); NOT the `Top` edge LazyVar +---@field Selected number | false # selected item index (1-based) +---@field OnSelect fun(scenario: UILobbyScenarioInfo, index: number) +---@field OnConfirm fun(scenario: UILobbyScenarioInfo) +local CustomLobbyMapList = ClassUI(Group) { + + ---@param self UICustomLobbyMapList + ---@param parent Control + __init = function(self, parent) + Group.__init(self, parent, "CustomLobbyMapList") + + self.Trash = TrashBag() + self.Items = {} + self.Rows = {} + self.PoolCount = 0 + self.ScrollTop = 0 + self.Selected = false + self.OnSelect = nil + self.OnConfirm = nil + end, + + ---@param self UICustomLobbyMapList + __post_init = function(self) + self.HandleEvent = function(control, event) + if event.Type == 'WheelRotation' then + local lines = event.WheelRotation > 0 and -3 or 3 + self:ScrollLines(nil, lines) + return true + end + return false + end + end, + + --- Builds the row pool sized to the (now concrete) height and attaches the scrollbar. + --- Called by the owner after the list is laid out + mounted — the pool count is read from + --- `Height()`, which isn't settled during __post_init (three-phase init, /lua/ui/CLAUDE.md § 1). + ---@param self UICustomLobbyMapList + Initialize = function(self) + if self.PoolCount > 0 then + return + end + + local count = math.floor(self.Height() / LayoutHelpers.ScaleNumber(RowHeight)) + if count < 1 then + count = 1 + end + for i = 1, count do + self.Rows[i] = self:CreateRow(i) + local top = (i - 1) * RowHeight + Layouter(self.Rows[i]) + :AtLeftIn(self) + :AtRightIn(self) + :AtTopIn(self, top) + :Height(RowHeight) + :End() + end + -- set the pool count only once the whole pool exists, so a build error never leaves + -- CalcVisible iterating past the rows that were actually created + self.PoolCount = count + + UIUtil.CreateVertScrollbarFor(self) + self:CalcVisible() + end, + + --- Builds one pooled row (name + size/players badge). Private. + ---@param self UICustomLobbyMapList + ---@param poolIndex number + ---@return UICustomLobbyMapListRow + CreateRow = function(self, poolIndex) + ---@type UICustomLobbyMapListRow + local row = Group(self) + row._poolIndex = poolIndex + row._hover = false + + row.Background = Bitmap(row) + row.Background:SetSolidColor(IdleColor) + + row.Name = UIUtil.CreateText(row, "", 14, UIUtil.bodyFont) + row.Name:DisableHitTest() + + row.Meta = UIUtil.CreateText(row, "", 12, UIUtil.bodyFont) + row.Meta:SetColor('ff9aa0a8') + row.Meta:DisableHitTest() + + Layouter(row.Background):Fill(row):End() + Layouter(row.Name):AtLeftIn(row, 10):AtVerticalCenterIn(row):End() + Layouter(row.Meta):AtRightIn(row, 10):AtVerticalCenterIn(row):End() + + -- the background catches the mouse; children are hit-test-disabled so they don't block it + row.Background.HandleEvent = function(control, event) + local index = self.ScrollTop + poolIndex + local scenario = self.Items[index] + if not scenario then + return false + end + if event.Type == 'ButtonPress' then + self:SetSelection(index) + if self.OnSelect then + self.OnSelect(scenario, index) + end + return true + elseif event.Type == 'ButtonDClick' then + if self.OnConfirm then + self.OnConfirm(scenario) + end + return true + elseif event.Type == 'MouseEnter' then + row._hover = true + self:PaintRow(row, index) + return true + elseif event.Type == 'MouseExit' then + row._hover = false + self:PaintRow(row, index) + return true + end + return false + end + + return row + end, + + --- Replaces the data set and refreshes the window (resets scroll to the top). + ---@param self UICustomLobbyMapList + ---@param items UILobbyScenarioInfo[] + SetItems = function(self, items) + self.Items = items or {} + self.ScrollTop = 0 + self.Selected = false + self:CalcVisible() + end, + + --- Selects an item by index (1-based) and repaints; does not scroll (see ShowItem). + ---@param self UICustomLobbyMapList + ---@param index number | false + SetSelection = function(self, index) + self.Selected = index or false + self:CalcVisible() + end, + + --- The selected scenario, or nil. + ---@param self UICustomLobbyMapList + ---@return UILobbyScenarioInfo | nil + GetSelected = function(self) + return self.Selected and self.Items[self.Selected] or nil + end, + + --- Scrolls so item `index` (1-based) is within the visible window. + ---@param self UICustomLobbyMapList + ---@param index number + ShowItem = function(self, index) + if self.PoolCount == 0 then + return + end + if index <= self.ScrollTop then + self.ScrollTop = index - 1 + elseif index > self.ScrollTop + self.PoolCount then + self.ScrollTop = index - self.PoolCount + end + self:ClampTop() + self:CalcVisible() + end, + + --- Paints a single row to reflect its data + selection/hover state. Private. + ---@param self UICustomLobbyMapList + ---@param row UICustomLobbyMapListRow + ---@param index number + PaintRow = function(self, row, index) + if not row then + return + end + local scenario = self.Items[index] + if not scenario then + row:Hide() + return + end + row:Show() + + local color = IdleColor + if index == self.Selected then + color = SelectedColor + elseif row._hover then + color = HoverColor + end + row.Background:SetSolidColor(color) + + row.Name:SetText(LOC(scenario.name) or "?") + + local players = ArmyCount(scenario) + local size = scenario.size and math.floor(scenario.size[1] / 50) or "?" + row.Meta:SetText(size .. "km · " .. players .. "p") + end, + + --------------------------------------------------------------------------- + --#region Scrollbar contract + + ---@param self UICustomLobbyMapList + CalcVisible = function(self) + for i = 1, self.PoolCount do + self:PaintRow(self.Rows[i], self.ScrollTop + i) + end + end, + + ---@param self UICustomLobbyMapList + ClampTop = function(self) + local maxTop = math.max(0, table.getn(self.Items) - self.PoolCount) + if self.ScrollTop > maxTop then + self.ScrollTop = maxTop + end + if self.ScrollTop < 0 then + self.ScrollTop = 0 + end + end, + + ---@param self UICustomLobbyMapList + GetScrollValues = function(self, axis) + local size = table.getn(self.Items) + return 0, size, self.ScrollTop, math.min(self.ScrollTop + self.PoolCount, size) + end, + + ---@param self UICustomLobbyMapList + ScrollLines = function(self, axis, delta) + self:ScrollSetTop(axis, self.ScrollTop + math.floor(delta)) + end, + + ---@param self UICustomLobbyMapList + ScrollPages = function(self, axis, delta) + self:ScrollSetTop(axis, self.ScrollTop + math.floor(delta) * self.PoolCount) + end, + + ---@param self UICustomLobbyMapList + ScrollSetTop = function(self, axis, top) + top = math.floor(top) + if top == self.ScrollTop then + return + end + self.ScrollTop = top + self:ClampTop() + self:CalcVisible() + end, + + ---@param self UICustomLobbyMapList + IsScrollable = function(self, axis) + return true + end, + + --#endregion + + ---@param self UICustomLobbyMapList + OnDestroy = function(self) + self.Trash:Destroy() + end, +} + +---@param parent Control +---@return UICustomLobbyMapList +Create = function(parent) + return CustomLobbyMapList(parent) +end diff --git a/lua/ui/lobby/customlobby/CustomLobbyMapSelect.lua b/lua/ui/lobby/customlobby/CustomLobbyMapSelect.lua index fbf865f9f42..9a33aa708c1 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyMapSelect.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyMapSelect.lua @@ -20,61 +20,217 @@ --** SOFTWARE. --****************************************************************************************************** --- The map-select dialog: a searchable list of scenarios with a preview + info, and --- Select / Cancel. It is a transient picker, NOT a persistent model component: +-- The map-select dialog: a searchable, filterable list of scenarios with a preview + info, and +-- Random / Select / Cancel. -- --- * it reads the catalog (CustomLobbyMapCatalog) for the scenario list — reference data, --- * it previews the *highlighted candidate*, which is decoupled from the launch model --- (you're browsing, not committing), so it drives the shared MapPreview control directly --- rather than the model-bound CustomLobbyMapPreview, and --- * on Select it calls the controller intent `RequestSetScenario(file)` — the host sets the --- scenario in the launch model and broadcasts it, the same path a `/map ` chat --- command would use. It owns no synced state of its own. +-- Layout is organised into labelled *areas* (Group containers) — title, left (filters + +-- selection + stats), preview (right), and actions (bottom). Flip the module-level `Debug` flag +-- to tint each area so the regions are visible while iterating on layout. -- --- This is the first of the sub-dialogs that the legacy `dialogs/mapselect.lua` god-dialog is --- being split into: it does map selection ONLY. Game options, mods and unit restrictions — --- which the legacy dialog also bundled — become their own components (the options panel loads --- its schema from the selected map + mods; see CLAUDE.md "Next slices"). +-- It is a transient picker, NOT a persistent model component: +-- * it subscribes to the catalog (CustomLobbyMapCatalog), which streams maps in across frames, +-- * it previews the *highlighted candidate* (decoupled from the launch model) via the shared +-- MapPreview control, and +-- * on Select it calls the controller intent `RequestSetScenario(file)` — the same path a +-- `/map ` chat command would use. It owns no synced state. +-- +-- This is the first sub-dialog split out of the legacy `dialogs/mapselect.lua` god-dialog: map +-- selection ONLY (options / mods / units become their own components). +-- +-- The selection list is text-only — per-row map-preview thumbnails leaked GPU/CPU memory the +-- game needs in-match (the engine never frees MapPreview textures), so only the single candidate +-- preview on the right renders one. local UIUtil = import("/lua/ui/uiutil.lua") local LayoutHelpers = import("/lua/maui/layouthelpers.lua") +local Tooltip = import("/lua/ui/game/tooltip.lua") +local Prefs = import("/lua/user/prefs.lua") local Group = import("/lua/maui/group.lua").Group local Bitmap = import("/lua/maui/bitmap.lua").Bitmap local Edit = import("/lua/maui/edit.lua").Edit -local ItemList = import("/lua/maui/itemlist.lua").ItemList local MapPreview = import("/lua/ui/controls/mappreview.lua").MapPreview local Popup = import("/lua/ui/controls/popups/popup.lua").Popup +local Combo = import("/lua/ui/controls/combo.lua").Combo +local TextArea = import("/lua/ui/controls/textarea.lua").TextArea + +-- still borrowed from MapUtil: the save-file loader + start-position extraction (file/geometry +-- primitives, same category as the catalog's LoadScenarioInfoFile) +local MapUtil = import("/lua/ui/maputil.lua") local CustomLobbyMapCatalog = import("/lua/ui/lobby/customlobby/customlobbymapcatalog.lua") +local CustomLobbyMapList = import("/lua/ui/lobby/customlobby/customlobbymaplist.lua") local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") +local LazyVarDerive = import("/lua/lazyvar.lua").Derive + local Layouter = LayoutHelpers.ReusedLayoutFor -local DialogWidth = 740 -local DialogHeight = 520 -local ListWidth = 300 +-- flip to tint each layout area so the regions are visible while iterating +local Debug = false + +local DialogWidth = 720 +local DialogHeight = 620 +local Pad = 12 +local LeftWidth = 300 local PreviewSize = 300 +local TitleHeight = 32 +local ActionHeight = 48 +local FilterHeight = 110 +local StatsHeight = 22 + +-- the same resource/wreck icons the in-lobby map preview uses (CustomLobbyMapPreview) +local MassIcon = "/game/build-ui/icon-mass_bmp.dds" +local EnergyIcon = "/game/build-ui/icon-energy_bmp.dds" +local WreckIcon = "/scx_menu/lan-game-lobby/mappreview/wreckage.dds" + +local PrefsKey = "customlobby_mapselect" + +-- Filter dropdowns. The first entry is the "no filter" option; `value` matches the scenario +-- (size = `scenarioInfo.size[1]` ogrids; players = number of start spots). +local SizeFilters = { + { label = "Any size", value = false }, + { label = "5 km", value = 256 }, + { label = "10 km", value = 512 }, + { label = "20 km", value = 1024 }, + { label = "40 km", value = 2048 }, + { label = "81 km", value = 4096 }, +} + +local PlayerFilters = { { label = "Any", value = false } } +for n = 2, 16 do + table.insert(PlayerFilters, { label = tostring(n), value = n }) +end + +-- comparison operators applied to the size / player filters +local Operators = { "=", ">=", "<=" } + +--- Pulls the `.label` column out of a filter table for `Combo:AddItems`. +---@param filters table[] +---@return string[] +local function FilterLabels(filters) + local labels = {} + for i, filter in filters do + labels[i] = filter.label + end + return labels +end + +--- Number of start spots a scenario declares, or 0. +---@param scenario UILobbyScenarioInfo +---@return number +local function ArmyCount(scenario) + local armies = scenario.Configurations + and scenario.Configurations.standard + and scenario.Configurations.standard.teams + and scenario.Configurations.standard.teams[1] + and scenario.Configurations.standard.teams[1].armies + return armies and table.getsize(armies) or 0 +end + +--- Applies a comparison operator. A nil/false target means "no filter" → always passes. +---@param value number +---@param op string +---@param target number | false +---@return boolean +local function PassesComparison(value, op, target) + if not target then + return true + end + if op == ">=" then + return value >= target + elseif op == "<=" then + return value <= target + end + return value == target +end + +--- Clamps a stored combo index to a table's range (defaults to 1). +---@param index any +---@param options table[] +---@return number +local function ClampIndex(index, options) + if type(index) ~= 'number' or index < 1 or index > table.getn(options) then + return 1 + end + return index +end + +--- Creates a layout area (an invisible Group with an optional debug tint). +---@param parent Control +---@param name string +---@param color string +---@return Group +local function CreateArea(parent, name, color) + local area = Group(parent, name) + local bg = Bitmap(area) + bg:SetSolidColor(color) + bg:SetAlpha(Debug and 0.18 or 0.0) + bg:DisableHitTest() + Layouter(bg):Fill(area):End() + return area +end ---@class UICustomLobbyMapSelect : Group ---@field Trash TrashBag +---@field OverlayTrash TrashBag +---@field TitleArea Group +---@field LeftArea Group +---@field FilterArea Group +---@field SelectionArea Group +---@field StatsArea Group +---@field PreviewArea Group +---@field ActionArea Group ---@field Title Text ---@field Search Edit ----@field MapList ItemList +---@field SizeLabel Text +---@field SizeCombo Combo +---@field SizeOpCombo Combo +---@field PlayersLabel Text +---@field PlayersCombo Combo +---@field PlayersOpCombo Combo +---@field SizeFilter number | false +---@field SizeOp string +---@field PlayerFilter number | false +---@field PlayerOp string +---@field SizeIndex number +---@field SizeOpIndex number +---@field PlayerIndex number +---@field PlayerOpIndex number +---@field MapList UICustomLobbyMapList +---@field EmptyLabel Text +---@field SpawnsToggle Checkbox +---@field ResourcesToggle Checkbox +---@field WrecksToggle Checkbox +---@field ShowSpawns boolean +---@field ShowResources boolean +---@field ShowWrecks boolean +---@field SpawnIcons Control[] +---@field ResourceIcons Control[] +---@field WreckIcons Control[] ---@field Preview MapPreview ---@field PreviewBg Bitmap ----@field InfoTitle Text ----@field InfoName Text ----@field InfoSize Text ----@field InfoPlayers Text +---@field PreviewTitleBar Bitmap +---@field PreviewTitle Text +---@field InfoMeta Text +---@field InfoMarkers Text +---@field Warning Text +---@field Description TextArea +---@field RandomButton Button ---@field SelectButton Button ---@field CancelButton Button +---@field CountLabel Text +---@field Spinner Text ---@field OnConfirmCb fun(scenarioFile: FileName) ---@field OnCancelCb fun() +---@field ScenariosObserver LazyVar ---@field Scenarios UILobbyScenarioInfo[] ----@field Filtered UILobbyScenarioInfo[] # the currently-listed (filtered) subset, 1-based by row ----@field Selected? UILobbyScenarioInfo # the highlighted candidate +---@field Filtered UILobbyScenarioInfo[] +---@field Selected? UILobbyScenarioInfo +---@field LastInspected? UILobbyScenarioInfo +---@field CurrentFile FileName | false +---@field Ready boolean local CustomLobbyMapSelect = ClassUI(Group) { ---@param self UICustomLobbyMapSelect @@ -85,67 +241,175 @@ local CustomLobbyMapSelect = ClassUI(Group) { Group.__init(self, parent, "CustomLobbyMapSelect") self.Trash = TrashBag() + self.OverlayTrash = self.Trash:Add(TrashBag()) self.OnConfirmCb = onConfirm self.OnCancelCb = onCancel - self.Scenarios = CustomLobbyMapCatalog.GetScenarios() + self.Ready = false self.Filtered = {} - -- default the highlight to whatever map is currently set (read-only model peek) - self.Selected = CustomLobbyMapCatalog.FindByFile(CustomLobbyLaunchModel.GetSingleton().ScenarioFile()) - - self.Title = UIUtil.CreateText(self, "Select Map", 22, UIUtil.titleFont) + self.Selected = nil + self.ShowSpawns = true + self.ShowResources = false + self.ShowWrecks = false + self.SpawnIcons = {} + self.ResourceIcons = {} + self.WreckIcons = {} + self.CurrentFile = CustomLobbyLaunchModel.GetSingleton().ScenarioFile() + self.Scenarios = CustomLobbyMapCatalog.GetScenarios() - self.Search = Edit(self) - -- An Edit reads its own bounds when the font is set, which is circular before the - -- control is anchored — give it placeholder dimensions first (see /lua/ui/CLAUDE.md - -- § 1); __post_init lays it out for real. - Layouter(self.Search):Left(0):Top(0):Width(ListWidth):Height(22):End() + -- restore the last-used filters + search (persisted across opens) + local saved = Prefs.GetFromCurrentProfile(PrefsKey) or {} + self.SizeIndex = ClampIndex(saved.sizeIndex, SizeFilters) + self.SizeOpIndex = ClampIndex(saved.sizeOpIndex, Operators) + self.PlayerIndex = ClampIndex(saved.playersIndex, PlayerFilters) + self.PlayerOpIndex = ClampIndex(saved.playersOpIndex, Operators) + self.SizeFilter = SizeFilters[self.SizeIndex].value + self.SizeOp = Operators[self.SizeOpIndex] + self.PlayerFilter = PlayerFilters[self.PlayerIndex].value + self.PlayerOp = Operators[self.PlayerOpIndex] + + -- areas + self.TitleArea = CreateArea(self, "TitleArea", 'ffcc4040') + self.LeftArea = CreateArea(self, "LeftArea", 'ff4060cc') + self.FilterArea = CreateArea(self.LeftArea, "FilterArea", 'ff40cc60') + self.SelectionArea = CreateArea(self.LeftArea, "SelectionArea", 'ffcccc40') + self.StatsArea = CreateArea(self.LeftArea, "StatsArea", 'ff40cccc') + self.PreviewArea = CreateArea(self, "PreviewArea", 'ffcc40cc') + self.ActionArea = CreateArea(self, "ActionArea", 'ff808080') + + self.Title = UIUtil.CreateText(self.TitleArea, "Select Map", 22, UIUtil.titleFont) + + --#region filters (in FilterArea) + self.Search = Edit(self.FilterArea) + Layouter(self.Search):Left(0):Top(0):Width(96):Height(22):End() self.Search:SetFont(UIUtil.bodyFont, 16) self.Search:SetForegroundColor(UIUtil.fontColor) self.Search:ShowBackground(true) self.Search:SetBackgroundColor('77778888') - self.Search:SetText("") + self.Search:SetText(saved.search or "") self.Search.OnTextChanged = function(control, newText, oldText) self:Populate() + self:SavePrefs() end - - -- ItemList is legacy (see /lua/ui/CLAUDE.md §6.1) but fine for a flat string list; - -- swap for a pooled Group list if rows ever need richer content (thumbnails, badges). - self.MapList = ItemList(self, "customlobby:maplist") - self.MapList:SetFont(UIUtil.bodyFont, 14) - self.MapList:SetColors(UIUtil.fontColor, "00000000", "FF000000", UIUtil.highlightColor, "ffbcfffe") - self.MapList:ShowMouseoverItem(true) - self.MapList.OnClick = function(control, row) - self:OnMapHighlighted(row) + self.Search.OnEnterPressed = function(control, text) + self:Confirm() + return true end - self.MapList.OnKeySelect = function(control, row) - self:OnMapHighlighted(row) + Tooltip.AddControlTooltipManual(self.Search, "Search", "Filter the list by map name.") + + self.SizeLabel = UIUtil.CreateText(self.FilterArea, "Size", 13, UIUtil.bodyFont) + self.SizeLabel:SetColor('ff9aa0a8') + self.SizeCombo, self.SizeOpCombo = self:CreateFilterRow( + SizeFilters, self.SizeIndex, self.SizeOpIndex, + function(index) self.SizeIndex = index; self.SizeFilter = SizeFilters[index].value end, + function(index) self.SizeOpIndex = index; self.SizeOp = Operators[index] end, + "Map size", "Filter by map dimensions, with a comparison operator.") + + self.PlayersLabel = UIUtil.CreateText(self.FilterArea, "Players", 13, UIUtil.bodyFont) + self.PlayersLabel:SetColor('ff9aa0a8') + self.PlayersCombo, self.PlayersOpCombo = self:CreateFilterRow( + PlayerFilters, self.PlayerIndex, self.PlayerOpIndex, + function(index) self.PlayerIndex = index; self.PlayerFilter = PlayerFilters[index].value end, + function(index) self.PlayerOpIndex = index; self.PlayerOp = Operators[index] end, + "Player count", "Filter by number of start positions, with a comparison operator.") + --#endregion + + --#region selection list + stats + self.MapList = CustomLobbyMapList.Create(self.SelectionArea) + self.MapList.OnSelect = function(scenario, index) + self:OnMapSelected(scenario) end - self.MapList.OnDoubleClick = function(control, row) - self:OnMapHighlighted(row) + self.MapList.OnConfirm = function(scenario) + self:OnMapSelected(scenario) self:Confirm() end - -- preview of the highlighted candidate (decoupled from the launch model) - self.PreviewBg = Bitmap(self) + self.EmptyLabel = UIUtil.CreateText(self.SelectionArea, "No maps match", 14, UIUtil.bodyFont) + self.EmptyLabel:SetColor('ff8a909a') + self.EmptyLabel:DisableHitTest() + self.EmptyLabel:Hide() + + self.CountLabel = UIUtil.CreateText(self.StatsArea, "", 13, UIUtil.bodyFont) + self.CountLabel:SetColor('ff9aa0a8') + self.Spinner = UIUtil.CreateText(self.StatsArea, "", 13, UIUtil.bodyFont) + self.Spinner:SetColor('ff9aa0a8') + --#endregion + + --#region preview area (toggles, preview, info, description) + self.SpawnsToggle = self:CreateToggle("Spawns", self.ShowSpawns, + function(checked) self.ShowSpawns = checked; self:ApplyOverlayVisibility() end, + "Spawns", "Show the start positions.") + self.ResourcesToggle = self:CreateToggle("Resources", self.ShowResources, + function(checked) self.ShowResources = checked; self:ApplyOverlayVisibility() end, + "Resources", "Show mass and hydrocarbon deposits.") + self.WrecksToggle = self:CreateToggle("Wrecks", self.ShowWrecks, + function(checked) self.ShowWrecks = checked; self:ApplyOverlayVisibility() end, + "Wrecks", "Show prebuilt wreckage (if the map defines any).") + + self.PreviewBg = Bitmap(self.PreviewArea) self.PreviewBg:SetSolidColor('ff000000') self.PreviewBg:DisableHitTest() - self.Preview = MapPreview(self) - - self.InfoTitle = UIUtil.CreateText(self, "Map Info", 16, UIUtil.titleFont) - self.InfoName = UIUtil.CreateText(self, "", 14, UIUtil.bodyFont) - self.InfoSize = UIUtil.CreateText(self, "", 14, UIUtil.bodyFont) - self.InfoPlayers = UIUtil.CreateText(self, "", 14, UIUtil.bodyFont) + self.Preview = MapPreview(self.PreviewArea) + + self.PreviewTitleBar = Bitmap(self.PreviewArea) + self.PreviewTitleBar:SetSolidColor('aa0a0e12') + self.PreviewTitleBar:DisableHitTest() + self.PreviewTitle = UIUtil.CreateText(self.PreviewArea, "", 16, UIUtil.titleFont) + self.PreviewTitle:DisableHitTest() + + self.InfoMeta = UIUtil.CreateText(self.PreviewArea, "", 13, UIUtil.bodyFont) + self.InfoMeta:SetColor('ffc8ccd0') + self.InfoMarkers = UIUtil.CreateText(self.PreviewArea, "", 13, UIUtil.bodyFont) + self.InfoMarkers:SetColor('ff9aa0a8') + self.Warning = UIUtil.CreateText(self.PreviewArea, "", 13, UIUtil.bodyFont) + self.Warning:SetColor('ffff6b6b') + + self.Description = TextArea(self.PreviewArea, 200, 80) + self.Description:SetFont(UIUtil.bodyFont, 12) + self.Description:SetColors('ff8a909a', "00000000", 'ff8a909a', "00000000") + --#endregion + + --#region actions + self.RandomButton = UIUtil.CreateButtonStd(self, '/scx_menu/small-btn/small', "Random", 16, 2) + self.RandomButton.OnClick = function(button, modifiers) + self:PickRandom() + end + Tooltip.AddControlTooltipManual(self.RandomButton, "Random", "Pick a random map from the current filtered list.") - self.SelectButton = UIUtil.CreateButtonStd(self, '/scx_menu/small-btn/small', "Select", 16, 2) + self.SelectButton = UIUtil.CreateButtonStd(self.ActionArea, '/scx_menu/small-btn/small', "Select", 16, 2) self.SelectButton.OnClick = function(button, modifiers) self:Confirm() end - self.CancelButton = UIUtil.CreateButtonStd(self, '/scx_menu/small-btn/small', "Cancel", 16, 2) + self.CancelButton = UIUtil.CreateButtonStd(self.ActionArea, '/scx_menu/small-btn/small', "Cancel", 16, 2) self.CancelButton.OnClick = function(button, modifiers) self.OnCancelCb() end + --#endregion + + self.ScenariosObserver = self.Trash:Add( + LazyVarDerive(CustomLobbyMapCatalog.GetScenariosVar(), function(scenariosLazy) + self:OnScenariosChanged(scenariosLazy()) + end)) + + CustomLobbyMapCatalog.EnsureLoaded() + + -- spin a small throbber beside the count while the catalog is still streaming + self.Trash:Add(ForkThread(function() + local frames = { "|", "/", "-", "\\" } + local i = 1 + while not CustomLobbyMapCatalog.IsLoaded() do + if IsDestroyed(self) then + return + end + self.Spinner:SetText(frames[i]) + i = math.mod(i, 4) + 1 + WaitSeconds(0.12) + end + if not IsDestroyed(self) then + self.Spinner:SetText("") + end + end)) end, ---@param self UICustomLobbyMapSelect @@ -153,134 +417,509 @@ local CustomLobbyMapSelect = ClassUI(Group) { self.Width:Set(LayoutHelpers.ScaleNumber(DialogWidth)) self.Height:Set(LayoutHelpers.ScaleNumber(DialogHeight)) - Layouter(self.Title):AtTopIn(self, 14):AtHorizontalCenterIn(self):End() + --#region areas + Layouter(self.TitleArea):AtLeftIn(self, Pad):AtRightIn(self, Pad):AtTopIn(self, Pad):Height(TitleHeight):End() + Layouter(self.ActionArea):AtLeftIn(self, Pad):AtRightIn(self, Pad):AtBottomIn(self, Pad):Height(ActionHeight):End() + Layouter(self.LeftArea) + :AtLeftIn(self, Pad):Width(LeftWidth) + :AnchorToBottom(self.TitleArea, Pad):AnchorToTop(self.ActionArea, Pad) + :End() + Layouter(self.PreviewArea) + :AnchorToRight(self.LeftArea, Pad):AtRightIn(self, Pad) + :AnchorToBottom(self.TitleArea, Pad):AnchorToTop(self.ActionArea, Pad) + :End() - Layouter(self.Search) - :AtLeftIn(self, 18):AtTopIn(self, 54):Width(ListWidth):Height(22) + Layouter(self.FilterArea):AtLeftIn(self.LeftArea):AtRightIn(self.LeftArea):AtTopIn(self.LeftArea):Height(FilterHeight):End() + Layouter(self.StatsArea):AtLeftIn(self.LeftArea):AtRightIn(self.LeftArea):AtBottomIn(self.LeftArea):Height(StatsHeight):End() + Layouter(self.SelectionArea) + :AtLeftIn(self.LeftArea):AtRightIn(self.LeftArea) + :AnchorToBottom(self.FilterArea, Pad):AnchorToTop(self.StatsArea, Pad) :End() + --#endregion - Layouter(self.CancelButton):AtRightIn(self, 18):AtBottomIn(self, 14):End() - Layouter(self.SelectButton):AnchorToLeft(self.CancelButton, 16):AtVerticalCenterIn(self.CancelButton):End() + Layouter(self.Title):AtHorizontalCenterIn(self.TitleArea):AtVerticalCenterIn(self.TitleArea):End() - -- list fills the left column between the search box and the buttons - Layouter(self.MapList) - :AtLeftIn(self, 18) - :Width(ListWidth - 16) -- leave room for the scrollbar - :AnchorToBottom(self.Search, 10) - :AnchorToTop(self.SelectButton, 14) - :End() - UIUtil.CreateVertScrollbarFor(self.MapList) + --#region filters + Layouter(self.Search):AtLeftIn(self.FilterArea):AtRightIn(self.FilterArea):AtTopIn(self.FilterArea):Height(22):End() + + Layouter(self.SizeCombo):AtLeftIn(self.FilterArea, 56):AnchorToBottom(self.Search, 12):Width(110):End() + Layouter(self.SizeLabel):AtLeftIn(self.FilterArea):AtVerticalCenterIn(self.SizeCombo):End() + Layouter(self.SizeOpCombo):AnchorToRight(self.SizeCombo, 8):AtVerticalCenterIn(self.SizeCombo):Width(56):End() + + Layouter(self.PlayersCombo):AtLeftIn(self.FilterArea, 56):AnchorToBottom(self.SizeCombo, 10):Width(110):End() + Layouter(self.PlayersLabel):AtLeftIn(self.FilterArea):AtVerticalCenterIn(self.PlayersCombo):End() + Layouter(self.PlayersOpCombo):AnchorToRight(self.PlayersCombo, 8):AtVerticalCenterIn(self.PlayersCombo):Width(56):End() + --#endregion + + --#region selection list + stats + Layouter(self.MapList):AtLeftIn(self.SelectionArea):AtTopIn(self.SelectionArea):AtBottomIn(self.SelectionArea):End() + self.MapList.Right:Set(function() return self.SelectionArea.Right() - LayoutHelpers.ScaleNumber(32) end) + Layouter(self.EmptyLabel):AtHorizontalCenterIn(self.SelectionArea):AtVerticalCenterIn(self.SelectionArea):End() + + Layouter(self.CountLabel):AtLeftIn(self.StatsArea):AtVerticalCenterIn(self.StatsArea):End() + Layouter(self.Spinner):AnchorToRight(self.CountLabel, 8):AtVerticalCenterIn(self.StatsArea):End() + --#endregion + + --#region preview area + -- overlay toggles centred above the preview + Layouter(self.ResourcesToggle):AtHorizontalCenterIn(self.PreviewArea):AtTopIn(self.PreviewArea):End() + Layouter(self.SpawnsToggle):AnchorToLeft(self.ResourcesToggle, 16):AtVerticalCenterIn(self.ResourcesToggle):End() + Layouter(self.WrecksToggle):AnchorToRight(self.ResourcesToggle, 16):AtVerticalCenterIn(self.ResourcesToggle):End() - -- preview + info in the right column Layouter(self.Preview) - :AtRightIn(self, 18):AtTopIn(self, 54):Width(PreviewSize):Height(PreviewSize) + :AtHorizontalCenterIn(self.PreviewArea):AnchorToBottom(self.ResourcesToggle, 10) + :Width(PreviewSize):Height(PreviewSize) :End() Layouter(self.PreviewBg):Fill(self.Preview):End() self.PreviewBg.Depth:Set(function() return self.Preview.Depth() - 1 end) - Layouter(self.InfoTitle):AtLeftIn(self.Preview):AnchorToBottom(self.Preview, 14):End() - Layouter(self.InfoName):AtLeftIn(self.Preview):AnchorToBottom(self.InfoTitle, 6):End() - Layouter(self.InfoSize):AtLeftIn(self.Preview):AnchorToBottom(self.InfoName, 4):End() - Layouter(self.InfoPlayers):AtLeftIn(self.Preview):AnchorToBottom(self.InfoSize, 4):End() + -- map name overlaid across the top of the preview + Layouter(self.PreviewTitleBar):AtLeftIn(self.Preview):AtRightIn(self.Preview):AtTopIn(self.Preview):Height(26):End() + self.PreviewTitleBar.Depth:Set(function() return self.Preview.Depth() + 20 end) + Layouter(self.PreviewTitle):AtHorizontalCenterIn(self.Preview):AtVerticalCenterIn(self.PreviewTitleBar):End() + self.PreviewTitle.Depth:Set(function() return self.PreviewTitleBar.Depth() + 1 end) + + -- info centred under the preview + Layouter(self.InfoMeta):AtHorizontalCenterIn(self.Preview):AnchorToBottom(self.Preview, 12):End() + Layouter(self.InfoMarkers):AtHorizontalCenterIn(self.Preview):AnchorToBottom(self.InfoMeta, 4):End() + Layouter(self.Warning):AtHorizontalCenterIn(self.Preview):AnchorToBottom(self.InfoMarkers, 4):End() + + -- description fills the rest of the preview area and scrolls when it overflows + Layouter(self.Description) + :AtLeftIn(self.PreviewArea):AnchorToBottom(self.Warning, 10):AtBottomIn(self.PreviewArea) + :End() + self.Description.Right:Set(function() return self.PreviewArea.Right() - LayoutHelpers.ScaleNumber(32) end) + UIUtil.CreateVertScrollbarFor(self.Description) + --#endregion + + --#region actions + Layouter(self.CancelButton):AtRightIn(self.ActionArea):AtVerticalCenterIn(self.ActionArea):End() + Layouter(self.SelectButton):AnchorToLeft(self.CancelButton, 12):AtVerticalCenterIn(self.ActionArea):End() + -- Random: horizontally centred under the left area, vertically centred in the actions + Layouter(self.RandomButton):AtHorizontalCenterIn(self.LeftArea):AtVerticalCenterIn(self.ActionArea):End() + --#endregion self.MapList:AcquireKeyboardFocus(true) end, - --- Populates the list. The opener calls this AFTER the dialog is mounted + centred by - --- Popup: `Populate` scrolls the list (`ShowItem`), which forces a concrete geometry - --- read, and the dialog's own rect isn't settled during __post_init (Popup re-parents - --- and re-centres it afterwards). This is the three-phase init pattern — see - --- /lua/ui/CLAUDE.md § 1. + --- Builds a value-combo + comparison-operator-combo pair in the filter area. Returns both. + ---@param self UICustomLobbyMapSelect + ---@param values table[] + ---@param valueIndex number + ---@param opIndex number + ---@param onValue fun(index: number) + ---@param onOp fun(index: number) + ---@param tooltipTitle string + ---@param tooltipBody string + ---@return Combo valueCombo + ---@return Combo opCombo + CreateFilterRow = function(self, values, valueIndex, opIndex, onValue, onOp, tooltipTitle, tooltipBody) + local valueCombo = Combo(self.FilterArea, 14, 8, nil, nil, "UI_Tab_Click_01", "UI_Tab_Rollover_01") + valueCombo:AddItems(FilterLabels(values), valueIndex) + valueCombo.OnClick = function(combo, index, text) + onValue(index) + self:Populate() + self:SavePrefs() + end + Tooltip.AddControlTooltipManual(valueCombo, tooltipTitle, tooltipBody) + + local opCombo = Combo(self.FilterArea, 14, 3, nil, nil, "UI_Tab_Click_01", "UI_Tab_Rollover_01") + opCombo:AddItems(Operators, opIndex) + opCombo.OnClick = function(combo, index, text) + onOp(index) + self:Populate() + self:SavePrefs() + end + Tooltip.AddControlTooltipManual(opCombo, tooltipTitle, "Comparison operator (=, at least, at most).") + + return valueCombo, opCombo + end, + + --- Builds a labelled checkbox "switch" wired to `onChange(checked)`, with a tooltip. + ---@param self UICustomLobbyMapSelect + ---@param label string + ---@param initial boolean + ---@param onChange fun(checked: boolean) + ---@param tooltipTitle string + ---@param tooltipBody string + ---@return Checkbox + CreateToggle = function(self, label, initial, onChange, tooltipTitle, tooltipBody) + local checkbox = UIUtil.CreateCheckbox(self.PreviewArea, '/CHECKBOX/', label, true, 13) + checkbox:SetCheck(initial, true) + checkbox.OnCheck = function(control, checked) + onChange(checked) + end + Tooltip.AddControlTooltipManual(checkbox, tooltipTitle, tooltipBody) + return checkbox + end, + + --- Builds the list pool + populates. Called by the opener after the dialog is mounted + + --- centred by Popup (three-phase init, /lua/ui/CLAUDE.md § 1). ---@param self UICustomLobbyMapSelect Initialize = function(self) + self.Ready = true + self.MapList:Initialize() self:Populate() end, - --- Rebuilds the list from the catalog, applying the name filter and keeping the current - --- highlight selected (falling back to the first row). + --- The catalog published a new (possibly larger) map list: recount always, and re-list + --- once we're mounted. ---@param self UICustomLobbyMapSelect - Populate = function(self) - self.MapList:DeleteAllItems() + ---@param scenarios UILobbyScenarioInfo[] + OnScenariosChanged = function(self, scenarios) + self.Scenarios = scenarios + if self.Ready then + self:Populate() + else + self:UpdateCount() + end + end, + + --- Updates the footer: "X of Y maps" when a filter/search narrows it, else "Y maps". + ---@param self UICustomLobbyMapSelect + UpdateCount = function(self) + local total = table.getn(self.Scenarios) + local shown = table.getn(self.Filtered) + if self.Ready and shown < total then + self.CountLabel:SetText(LOCF("%d of %d maps", shown, total)) + else + self.CountLabel:SetText(LOCF("%d maps", total)) + end + end, + --- Persists the current filters + search for next time. + ---@param self UICustomLobbyMapSelect + SavePrefs = function(self) + Prefs.SetToCurrentProfile(PrefsKey, { + sizeIndex = self.SizeIndex, + sizeOpIndex = self.SizeOpIndex, + playersIndex = self.PlayerIndex, + playersOpIndex = self.PlayerOpIndex, + search = self.Search:GetText() or "", + }) + end, + + --- Rebuilds the list from the catalog, applying the name search + size/player filters and + --- keeping the current highlight selected (falling back to the lobby's active map). + ---@param self UICustomLobbyMapSelect + Populate = function(self) local search = string.lower(self.Search:GetText() or "") + local targetFile = (self.Selected and self.Selected.file) or self.CurrentFile + local target = targetFile and string.lower(targetFile) + self.Filtered = {} local selectedRow = 0 for _, scenario in self.Scenarios do - if search == "" or string.find(string.lower(scenario.name), search, 1, true) then + local matchesName = search == "" or string.find(string.lower(scenario.name), search, 1, true) + if matchesName and self:PassesFilters(scenario) then table.insert(self.Filtered, scenario) - self.MapList:AddItem(LOC(scenario.name)) - if self.Selected and string.lower(scenario.file) == string.lower(self.Selected.file) then - selectedRow = table.getn(self.Filtered) - 1 + if target and string.lower(scenario.file) == target then + selectedRow = table.getn(self.Filtered) end end end + self.MapList:SetItems(self.Filtered) + if table.getn(self.Filtered) > 0 then - self.MapList:SetSelection(selectedRow) - self.MapList:ShowItem(selectedRow) - self:OnMapHighlighted(selectedRow) + self.EmptyLabel:Hide() + local row = selectedRow > 0 and selectedRow or 1 + self.MapList:SetSelection(row) + self.MapList:ShowItem(row) + self:OnMapSelected(self.Filtered[row]) else + self.EmptyLabel:Show() self.Selected = nil - self.Preview:ClearTexture() - self:UpdateInfo(nil) + self:ClearDetails() self.SelectButton:Disable() + self.RandomButton:Disable() end + + self:UpdateCount() end, - --- A row was highlighted: make it the candidate and refresh the preview + info. + --- Whether a scenario passes the size + player-count filters (with their comparators). ---@param self UICustomLobbyMapSelect - ---@param row number # 0-based ItemList row - OnMapHighlighted = function(self, row) - local scenario = self.Filtered[row + 1] + ---@param scenario UILobbyScenarioInfo + ---@return boolean + PassesFilters = function(self, scenario) + local size = scenario.size and scenario.size[1] or 0 + if not PassesComparison(size, self.SizeOp, self.SizeFilter) then + return false + end + if not PassesComparison(ArmyCount(scenario), self.PlayerOp, self.PlayerFilter) then + return false + end + return true + end, + + --- Highlights a random map from the current filtered set (leaves confirming to the user). + ---@param self UICustomLobbyMapSelect + PickRandom = function(self) + local count = table.getn(self.Filtered) + if count == 0 then + return + end + local row = math.random(1, count) + self.MapList:SetSelection(row) + self.MapList:ShowItem(row) + self:OnMapSelected(self.Filtered[row]) + end, + + --- A map was selected: make it the candidate and refresh the preview + info. + ---@param self UICustomLobbyMapSelect + ---@param scenario UILobbyScenarioInfo + OnMapSelected = function(self, scenario) if not scenario then return end self.Selected = scenario - self.MapList:SetSelection(row) - self:UpdatePreview(scenario) - self:UpdateInfo(scenario) - self.SelectButton:Enable() + if self.LastInspected ~= scenario then + self.LastInspected = scenario + self:Inspect(scenario) + end end, - --- Sets the preview texture for a scenario (preview image, falling back to the heightmap). + --- Loads what's needed to show a candidate: preview texture + overlays, info, and a + --- file-health check that gates Select. ---@param self UICustomLobbyMapSelect ---@param scenario UILobbyScenarioInfo - UpdatePreview = function(self, scenario) + Inspect = function(self, scenario) + self.OverlayTrash:Destroy() + self.SpawnIcons = {} + self.ResourceIcons = {} + self.WreckIcons = {} + if not self.Preview:SetTexture(scenario.preview) then self.Preview:SetTextureFromMap(scenario.map) end + + local problems = {} + if not DiskGetFileInfo(scenario.map) then + table.insert(problems, "map missing") + end + if not DiskGetFileInfo(scenario.script) then + table.insert(problems, "script missing") + end + + local save = nil + if DiskGetFileInfo(scenario.save) then + save = MapUtil.LoadScenarioSaveFile(scenario.save) + else + table.insert(problems, "save missing") + end + + local hasAiMarkers = false + if save and scenario.size then + self:BuildSpawns(scenario, save) + self:BuildResources(scenario, save) + self:BuildWrecks(scenario, save) + self:ApplyOverlayVisibility() + hasAiMarkers = self:DetectAiMarkers(save) + end + + self:UpdateInfo(scenario, hasAiMarkers) + self.RandomButton:Enable() + + if table.getn(problems) > 0 then + self.Warning:SetText("! " .. table.concat(problems, ", ")) + self.SelectButton:Disable() + else + self.Warning:SetText("") + self.SelectButton:Enable() + end end, - --- Fills the info lines for a scenario (nil clears them). + --- Fills the title overlay + info lines (size · players · version, AI markers, description). ---@param self UICustomLobbyMapSelect - ---@param scenario UILobbyScenarioInfo | nil - UpdateInfo = function(self, scenario) - if not scenario then - self.InfoName:SetText("") - self.InfoSize:SetText("") - self.InfoPlayers:SetText("") + ---@param scenario UILobbyScenarioInfo + ---@param hasAiMarkers boolean + UpdateInfo = function(self, scenario, hasAiMarkers) + self.PreviewTitle:SetText(LOC(scenario.name) or "?") + + local parts = {} + if scenario.size then + table.insert(parts, string.format("%dkm x %dkm", + math.floor(scenario.size[1] / 50), math.floor(scenario.size[2] / 50))) + end + local players = ArmyCount(scenario) + if players > 0 then + table.insert(parts, players .. " players") + end + if scenario.map_version then + table.insert(parts, "v" .. scenario.map_version) + end + self.InfoMeta:SetText(table.concat(parts, " · ")) + + self.InfoMarkers:SetText(hasAiMarkers and "AI markers: Yes" or "AI markers: No") + + self.Description:SetText(scenario.description and LOC(scenario.description) or "") + end, + + --- Places numbered start-spot markers on the preview from the save's start positions. + ---@param self UICustomLobbyMapSelect + ---@param scenario UILobbyScenarioInfo + ---@param save UIScenarioSaveFile + BuildSpawns = function(self, scenario, save) + local positions = MapUtil.GetStartPositionsFromScenario(scenario, save) + if not positions then return end + for index, position in positions do + local dot = self.OverlayTrash:Add(self:CreateSpawnDot(index)) + self:PositionMarker(dot, scenario.size[1], scenario.size[2], position[1], position[2]) + table.insert(self.SpawnIcons, dot) + end + end, - self.InfoName:SetText(LOC(scenario.name) or "?") + --- Places mass + hydrocarbon icons from the save's master-chain markers. + ---@param self UICustomLobbyMapSelect + ---@param scenario UILobbyScenarioInfo + ---@param save UIScenarioSaveFile + BuildResources = function(self, scenario, save) + for _, marker in self:Markers(save) do + local icon = (marker.type == "Mass" and MassIcon) + or (marker.type == "Hydrocarbon" and EnergyIcon) + or false + if icon and marker.position then + local dot = self.OverlayTrash:Add(self:CreateMarkerIcon(icon, 10)) + self:PositionMarker(dot, scenario.size[1], scenario.size[2], marker.position[1], marker.position[3]) + table.insert(self.ResourceIcons, dot) + end + end + end, - if scenario.size then - self.InfoSize:SetText(LOCF("Map Size: %dkm x %dkm", scenario.size[1] / 50, scenario.size[2] / 50)) - else - self.InfoSize:SetText("") + --- Best-effort wreck icons: maps that expose prebuilt wreckage as save markers. + ---@param self UICustomLobbyMapSelect + ---@param scenario UILobbyScenarioInfo + ---@param save UIScenarioSaveFile + BuildWrecks = function(self, scenario, save) + for _, marker in self:Markers(save) do + if marker.type and marker.position and string.find(string.lower(marker.type), 'wreck') then + local dot = self.OverlayTrash:Add(self:CreateMarkerIcon(WreckIcon, 11)) + self:PositionMarker(dot, scenario.size[1], scenario.size[2], marker.position[1], marker.position[3]) + table.insert(self.WreckIcons, dot) + end + end + end, + + --- Shows/hides each overlay group according to its toggle. + ---@param self UICustomLobbyMapSelect + ApplyOverlayVisibility = function(self) + local function setVisible(icons, visible) + for _, icon in icons do + if visible then + icon:Show() + else + icon:Hide() + end + end end + setVisible(self.SpawnIcons, self.ShowSpawns) + setVisible(self.ResourceIcons, self.ShowResources) + setVisible(self.WreckIcons, self.ShowWrecks) + end, - local armies = scenario.Configurations - and scenario.Configurations.standard - and scenario.Configurations.standard.teams - and scenario.Configurations.standard.teams[1] - and scenario.Configurations.standard.teams[1].armies - if armies then - self.InfoPlayers:SetText(LOCF("Max Players: %d", table.getsize(armies))) + --- The save's master-chain markers, or an empty table. + ---@param self UICustomLobbyMapSelect + ---@param save UIScenarioSaveFile + ---@return table + Markers = function(self, save) + local masterChain = save.MasterChain and save.MasterChain['_MASTERCHAIN_'] + return (masterChain and masterChain.Markers) or {} + end, + + --- Builds a small numbered start-spot marker. Private. + ---@param self UICustomLobbyMapSelect + ---@param index number + ---@return Group + CreateSpawnDot = function(self, index) + local dot = Group(self.PreviewArea) + dot:DisableHitTest() + dot.Depth:Set(function() return self.Preview.Depth() + 10 end) + + local bg = Bitmap(dot) + bg:SetSolidColor('cc1c2228') + bg:DisableHitTest() + + local label = UIUtil.CreateText(dot, tostring(index), 12, UIUtil.bodyFont) + label:DisableHitTest() + + Layouter(dot):Width(18):Height(18):End() + Layouter(bg):Fill(dot):End() + Layouter(label):AtCenterIn(dot):End() + return dot + end, + + --- Builds a small textured resource/wreck marker (the in-lobby preview's icons). Private. + ---@param self UICustomLobbyMapSelect + ---@param texture FileName + ---@param size number + ---@return Bitmap + CreateMarkerIcon = function(self, texture, size) + local icon = UIUtil.CreateBitmap(self.PreviewArea, texture) + icon:DisableHitTest() + icon.Depth:Set(function() return self.Preview.Depth() + 5 end) + Layouter(icon):Width(size):Height(size):End() + return icon + end, + + --- Positions a marker over the preview at a map coordinate (mirrors the preview's own + --- aspect-correct placement). Private. + ---@param self UICustomLobbyMapSelect + ---@param icon Control + ---@param mapWidth number + ---@param mapHeight number + ---@param px number + ---@param pz number + PositionMarker = function(self, icon, mapWidth, mapHeight, px, pz) + local size = self.Preview.Width() + local xOffset, xFactor, yOffset, yFactor = 0, 1, 0, 1 + if mapWidth > mapHeight then + local ratio = mapHeight / mapWidth + yOffset = ((size / ratio) - size) / 4 + yFactor = ratio else - self.InfoPlayers:SetText("") + local ratio = mapWidth / mapHeight + xOffset = ((size / ratio) - size) / 4 + xFactor = ratio end + + local x = xOffset + (px / mapWidth) * (size - 2) * xFactor + local z = yOffset + (pz / mapHeight) * (size - 2) * yFactor + + icon.Left:Set(function() return self.Preview.Left() + x - 0.5 * icon.Width() end) + icon.Top:Set(function() return self.Preview.Top() + z - 0.5 * icon.Height() end) + end, + + --- Detects whether the save has AI pathing markers (any marker chained to another). + ---@param self UICustomLobbyMapSelect + ---@param save UIScenarioSaveFile + ---@return boolean + DetectAiMarkers = function(self, save) + for _, data in self:Markers(save) do + if data.adjacentTo and string.find(data.adjacentTo, ' ') then + return true + end + end + return false + end, + + --- Clears the preview + info (no candidate / empty list). + ---@param self UICustomLobbyMapSelect + ClearDetails = function(self) + self.LastInspected = nil + self.OverlayTrash:Destroy() + self.SpawnIcons = {} + self.ResourceIcons = {} + self.WreckIcons = {} + self.Preview:ClearTexture() + self.PreviewTitle:SetText("") + self.InfoMeta:SetText("") + self.InfoMarkers:SetText("") + self.Warning:SetText("") + self.Description:SetText("") end, --- Commits the highlighted candidate via the controller intent. @@ -294,7 +933,6 @@ local CustomLobbyMapSelect = ClassUI(Group) { ---@param self UICustomLobbyMapSelect OnDestroy = function(self) - self.MapList:AbandonKeyboardFocus() self.Trash:Destroy() end, } @@ -337,8 +975,8 @@ function Open(parent) end Instance = popup - -- now that Popup has mounted + centred the content, it's safe to populate (the list - -- scroll reads concrete geometry) + -- now that Popup has mounted + centred the content, it's safe to build the list pool + + -- populate (both read concrete geometry) content:Initialize() end From fdd50755e6aab1e9200e4bd2741eca3583bcc1f3 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Mon, 22 Jun 2026 11:13:47 +0200 Subject: [PATCH 19/98] Reduce memory allocations by sharing common bitmaps --- lua/ui/lobby/customlobby/CLAUDE.md | 2 +- .../customlobby/CustomLobbyMapSelect.lua | 160 ++++++++++++++---- lua/ui/maputil.lua | 1 + 3 files changed, 125 insertions(+), 38 deletions(-) diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index ec91090c279..505a8d29f51 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -52,7 +52,7 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | [CustomLobbyObserversInterface.lua](CustomLobbyObserversInterface.lua) | observer strip; subscribes to the model's `Observers` list and shows the count + names (read-only). | | [CustomLobbyMapPreview.lua](CustomLobbyMapPreview.lua) / [CustomLobbyMapPreviewSpawn.lua](CustomLobbyMapPreviewSpawn.lua) | map preview (copied from the autolobby's, adapted to this model): subscribes to `ScenarioFile` (full render) + each slot (spawn-icon refresh); hidden until a scenario is set. Reuses the shared `/lua/ui/controls/mappreview.lua`. | | [CustomLobbyMapCatalog.lua](CustomLobbyMapCatalog.lua) | the lobby's OWN async enumeration of playable skirmish maps (does *not* use `MapUtil.EnumerateSkirmishScenarios`; borrows only `LoadScenarioInfoFile`, loading map *info* — not options/strings). Streams maps into a `Scenarios` `LazyVar` across frames (`EnsureLoaded`) so the open doesn't block and the count updates live. **Reference data — not a sync model**: the LazyVar is local progress reactivity, never on the wire; only the host's `ScenarioFile` choice syncs. | -| [CustomLobbyMapSelect.lua](CustomLobbyMapSelect.lua) | the map-select dialog (transient `Popup`, host-only). Laid out in **areas** (Group containers — title / left {filters, selection, stats} / preview / actions; flip the `Debug` flag to tint them). Searchable list + size & player-count filters each with a comparison operator (`=` / `>=` / `<=`), persisted to Prefs; "X of Y maps" footer with a loading spinner; candidate preview (name overlaid on top) with centred toggle-able overlays — start spots (numbered), resources (mass/hydro icons), wrecks; info (size / players / version / AI markers) + a **scrollable** description; a file-health check that warns and disables Select for broken maps; double-click / Enter confirms, Esc cancels, Random (centred under the left area) picks from the filtered set. Select calls the `RequestSetScenario` intent. Owns no synced state. First slice of splitting the legacy `dialogs/mapselect.lua` god-dialog — map selection only (options / mods / units become their own components). | +| [CustomLobbyMapSelect.lua](CustomLobbyMapSelect.lua) | the map-select dialog (transient `Popup`, host-only). Laid out in **areas** (Group containers — title / left {filters, selection, stats} / preview / actions; flip the `Debug` flag to tint them). Searchable list + size & player-count filters each with a comparison operator (`=` / `>=` / `<=`), persisted to Prefs; "X of Y maps" footer with a loading spinner; candidate preview (name overlaid on top, plus an "Open page" link to the map's `url` for an allowlisted domain via `OpenURL`) with centred toggle-able overlays — start spots (numbered), resources (mass/hydro icons), wrecks; info (size / players / version) + a **scrollable** description; a file-health check that warns and disables Select for broken maps; double-click / Enter confirms, Esc cancels, Random (centred under the left area) picks from the filtered set. Select calls the `RequestSetScenario` intent. Owns no synced state. First slice of splitting the legacy `dialogs/mapselect.lua` god-dialog — map selection only (options / mods / units become their own components). | | [CustomLobbyMapList.lua](CustomLobbyMapList.lua) | the dialog's scrollable, **virtualised** list: a fixed pool of **text-only** rows (name + `size · players` badge) reused as you scroll, standard scrollbar contract. Mouse-driven (a custom Group list can't take keyboard focus like `ItemList`); `OnSelect` / `OnConfirm`. **No per-row thumbnails** — the engine never frees `MapPreview` textures (see the gotcha below), so only the dialog's single candidate preview renders one. | | [CustomLobbyInterface.lua](CustomLobbyInterface.lua) | composition root (one model subscription for SlotCount); lays out slot rows + the observer strip, and acts as the rows' drag coordinator (`UICustomLobbySlotCoordinator`: hit-test, drop-highlight, drag ghost → `RequestSwapSlots`); `OpenDebug()` / hot-reload. | | [/lua/ui/lobby/lobby.lua](../lobby.lua) | engine entry wrapper (`CreateLobby`/`HostGame`/`JoinGame`) → CustomLobby. Old lobby preserved at `lobby-old.lua`. | diff --git a/lua/ui/lobby/customlobby/CustomLobbyMapSelect.lua b/lua/ui/lobby/customlobby/CustomLobbyMapSelect.lua index 9a33aa708c1..a2dc98586bc 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyMapSelect.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyMapSelect.lua @@ -77,7 +77,7 @@ local LeftWidth = 300 local PreviewSize = 300 local TitleHeight = 32 local ActionHeight = 48 -local FilterHeight = 110 +local FilterHeight = 134 local StatsHeight = 22 -- the same resource/wreck icons the in-lobby map preview uses (CustomLobbyMapPreview) @@ -106,6 +106,43 @@ end -- comparison operators applied to the size / player filters local Operators = { "=", ">=", "<=" } +-- Map web pages we'll open in a browser (some scenarios carry a `url`). Matched against the +-- URL's host — exact or as a subdomain — so "faforever.com" also covers "forums.faforever.com". +-- Add a line to extend. +local AllowedUrlDomains = { + "github.com", + "githubusercontent.com", + "gitlab.com", + "github.io", + "faforever.com", +} + +--- The lowercased host of a URL (between the scheme and the first `/` or `:`), or "". +---@param url string +---@return string +local function UrlHost(url) + local rest = string.gsub(string.lower(url), "^https?://", "") + return (string.gsub(rest, "[/:].*$", "")) +end + +--- Whether `url` is an http(s) link to an allowed domain (or a subdomain of one). Guards +--- against look-alikes ("github.com.evil.com" is rejected) by matching the host suffix. +---@param url any +---@return boolean +local function IsAllowedUrl(url) + if type(url) ~= 'string' or not string.find(string.lower(url), "^https?://") then + return false + end + local host = UrlHost(url) + for _, domain in AllowedUrlDomains do + local escaped = string.gsub(domain, "%.", "%%.") + if host == domain or string.find(host, "%." .. escaped .. "$") then + return true + end + end + return false +end + --- Pulls the `.label` column out of a filter table for `Combo:AddItems`. ---@param filters table[] ---@return string[] @@ -183,6 +220,7 @@ end ---@field PreviewArea Group ---@field ActionArea Group ---@field Title Text +---@field FilterTitle Text ---@field Search Edit ---@field SizeLabel Text ---@field SizeCombo Combo @@ -211,12 +249,16 @@ end ---@field WreckIcons Control[] ---@field Preview MapPreview ---@field PreviewBg Bitmap +---@field MassTemplate Bitmap # hidden; overlay icons share its texture (loaded once) +---@field EnergyTemplate Bitmap +---@field WreckTemplate Bitmap ---@field PreviewTitleBar Bitmap ---@field PreviewTitle Text ---@field InfoMeta Text ----@field InfoMarkers Text ---@field Warning Text ---@field Description TextArea +---@field UrlButton Text +---@field CurrentUrl string | false ---@field RandomButton Button ---@field SelectButton Button ---@field CancelButton Button @@ -277,9 +319,11 @@ local CustomLobbyMapSelect = ClassUI(Group) { self.PreviewArea = CreateArea(self, "PreviewArea", 'ffcc40cc') self.ActionArea = CreateArea(self, "ActionArea", 'ff808080') - self.Title = UIUtil.CreateText(self.TitleArea, "Select Map", 22, UIUtil.titleFont) + self.Title = UIUtil.CreateText(self.TitleArea, "Select scenario", 22, UIUtil.titleFont) --#region filters (in FilterArea) + self.FilterTitle = UIUtil.CreateText(self.FilterArea, "Filter", 14, UIUtil.titleFont) + self.Search = Edit(self.FilterArea) Layouter(self.Search):Left(0):Top(0):Width(96):Height(22):End() self.Search:SetFont(UIUtil.bodyFont, 16) @@ -351,16 +395,44 @@ local CustomLobbyMapSelect = ClassUI(Group) { self.PreviewBg:DisableHitTest() self.Preview = MapPreview(self.PreviewArea) + -- hidden template bitmaps: each overlay texture is loaded ONCE here, then every marker + -- shares it via ShareTextures (see CreateMarkerIcon) rather than re-loading per icon. + -- They still need a concrete (dummy) position — an unanchored control's Left/Right + -- reference each other and trip the circular-evaluation guard (see /lua/ui/CLAUDE.md § 1). + self.MassTemplate = self:CreateTemplateBitmap(MassIcon) + self.EnergyTemplate = self:CreateTemplateBitmap(EnergyIcon) + self.WreckTemplate = self:CreateTemplateBitmap(WreckIcon) + self.PreviewTitleBar = Bitmap(self.PreviewArea) self.PreviewTitleBar:SetSolidColor('aa0a0e12') self.PreviewTitleBar:DisableHitTest() self.PreviewTitle = UIUtil.CreateText(self.PreviewArea, "", 16, UIUtil.titleFont) self.PreviewTitle:DisableHitTest() + -- clickable "open page" link in the title bar; shown only when the map has an allowed url + self.CurrentUrl = false + self.UrlButton = UIUtil.CreateText(self.PreviewArea, "Open page", 12, UIUtil.bodyFont) + self.UrlButton:SetColor('ff7fb3ff') + self.UrlButton:Hide() + self.UrlButton.HandleEvent = function(control, event) + if event.Type == 'ButtonPress' then + if self.CurrentUrl then + OpenURL(self.CurrentUrl) + end + return true + elseif event.Type == 'MouseEnter' then + control:SetColor('ffaecbff') + return true + elseif event.Type == 'MouseExit' then + control:SetColor('ff7fb3ff') + return true + end + return false + end + Tooltip.AddControlTooltipManual(self.UrlButton, "Map page", "Open the map's web page in your browser.") + self.InfoMeta = UIUtil.CreateText(self.PreviewArea, "", 13, UIUtil.bodyFont) self.InfoMeta:SetColor('ffc8ccd0') - self.InfoMarkers = UIUtil.CreateText(self.PreviewArea, "", 13, UIUtil.bodyFont) - self.InfoMarkers:SetColor('ff9aa0a8') self.Warning = UIUtil.CreateText(self.PreviewArea, "", 13, UIUtil.bodyFont) self.Warning:SetColor('ffff6b6b') @@ -479,10 +551,13 @@ local CustomLobbyMapSelect = ClassUI(Group) { Layouter(self.PreviewTitle):AtHorizontalCenterIn(self.Preview):AtVerticalCenterIn(self.PreviewTitleBar):End() self.PreviewTitle.Depth:Set(function() return self.PreviewTitleBar.Depth() + 1 end) + -- url link on the right of the title bar (shown only when the map has an allowed url) + Layouter(self.UrlButton):AtRightIn(self.Preview, 8):AtVerticalCenterIn(self.PreviewTitleBar):End() + self.UrlButton.Depth:Set(function() return self.PreviewTitleBar.Depth() + 2 end) + -- info centred under the preview Layouter(self.InfoMeta):AtHorizontalCenterIn(self.Preview):AnchorToBottom(self.Preview, 12):End() - Layouter(self.InfoMarkers):AtHorizontalCenterIn(self.Preview):AnchorToBottom(self.InfoMeta, 4):End() - Layouter(self.Warning):AtHorizontalCenterIn(self.Preview):AnchorToBottom(self.InfoMarkers, 4):End() + Layouter(self.Warning):AtHorizontalCenterIn(self.Preview):AnchorToBottom(self.InfoMeta, 4):End() -- description fills the rest of the preview area and scrolls when it overflows Layouter(self.Description) @@ -710,16 +785,14 @@ local CustomLobbyMapSelect = ClassUI(Group) { table.insert(problems, "save missing") end - local hasAiMarkers = false if save and scenario.size then self:BuildSpawns(scenario, save) self:BuildResources(scenario, save) self:BuildWrecks(scenario, save) self:ApplyOverlayVisibility() - hasAiMarkers = self:DetectAiMarkers(save) end - self:UpdateInfo(scenario, hasAiMarkers) + self:UpdateInfo(scenario) self.RandomButton:Enable() if table.getn(problems) > 0 then @@ -731,11 +804,11 @@ local CustomLobbyMapSelect = ClassUI(Group) { end end, - --- Fills the title overlay + info lines (size · players · version, AI markers, description). + --- Fills the title overlay + info line (size · players · version), the url link, and the + --- description. ---@param self UICustomLobbyMapSelect ---@param scenario UILobbyScenarioInfo - ---@param hasAiMarkers boolean - UpdateInfo = function(self, scenario, hasAiMarkers) + UpdateInfo = function(self, scenario) self.PreviewTitle:SetText(LOC(scenario.name) or "?") local parts = {} @@ -752,7 +825,14 @@ local CustomLobbyMapSelect = ClassUI(Group) { end self.InfoMeta:SetText(table.concat(parts, " · ")) - self.InfoMarkers:SetText(hasAiMarkers and "AI markers: Yes" or "AI markers: No") + -- some scenarios carry a `url` to their source/page; offer to open allowed ones + if scenario.url and IsAllowedUrl(scenario.url) then + self.CurrentUrl = scenario.url + self.UrlButton:Show() + else + self.CurrentUrl = false + self.UrlButton:Hide() + end self.Description:SetText(scenario.description and LOC(scenario.description) or "") end, @@ -779,11 +859,11 @@ local CustomLobbyMapSelect = ClassUI(Group) { ---@param save UIScenarioSaveFile BuildResources = function(self, scenario, save) for _, marker in self:Markers(save) do - local icon = (marker.type == "Mass" and MassIcon) - or (marker.type == "Hydrocarbon" and EnergyIcon) + local template = (marker.type == "Mass" and self.MassTemplate) + or (marker.type == "Hydrocarbon" and self.EnergyTemplate) or false - if icon and marker.position then - local dot = self.OverlayTrash:Add(self:CreateMarkerIcon(icon, 10)) + if template and marker.position then + local dot = self.OverlayTrash:Add(self:CreateMarkerIcon(template, 10)) self:PositionMarker(dot, scenario.size[1], scenario.size[2], marker.position[1], marker.position[3]) table.insert(self.ResourceIcons, dot) end @@ -797,7 +877,7 @@ local CustomLobbyMapSelect = ClassUI(Group) { BuildWrecks = function(self, scenario, save) for _, marker in self:Markers(save) do if marker.type and marker.position and string.find(string.lower(marker.type), 'wreck') then - local dot = self.OverlayTrash:Add(self:CreateMarkerIcon(WreckIcon, 11)) + local dot = self.OverlayTrash:Add(self:CreateMarkerIcon(self.WreckTemplate, 11)) self:PositionMarker(dot, scenario.size[1], scenario.size[2], marker.position[1], marker.position[3]) table.insert(self.WreckIcons, dot) end @@ -852,16 +932,34 @@ local CustomLobbyMapSelect = ClassUI(Group) { return dot end, - --- Builds a small textured resource/wreck marker (the in-lobby preview's icons). Private. + --- Loads an overlay texture once into a hidden template bitmap that markers share from. + --- Given a dummy position because an unanchored control's Left/Right are circular. Private. ---@param self UICustomLobbyMapSelect ---@param texture FileName + ---@return Bitmap + CreateTemplateBitmap = function(self, texture) + local template = UIUtil.CreateBitmap(self.PreviewArea, texture) + template:DisableHitTest() + Layouter(template):Left(0):Top(0):Width(8):Height(8):End() + template:Hide() + return template + end, + + --- Builds a small resource/wreck marker that SHARES its texture with a hidden template + --- bitmap (allocated once in __init), so the texture is loaded once and reused for every + --- marker — not re-loaded per icon. Private. + ---@param self UICustomLobbyMapSelect + ---@param template Bitmap ---@param size number ---@return Bitmap - CreateMarkerIcon = function(self, texture, size) - local icon = UIUtil.CreateBitmap(self.PreviewArea, texture) + CreateMarkerIcon = function(self, template, size) + local icon = UIUtil.CreateBitmapColor(self.PreviewArea, 'ffffffff') icon:DisableHitTest() + -- pin a full (dummy) rect BEFORE ShareTextures — it reads the bitmap's edges, which are + -- circular on a just-created control; PositionMarker overrides Left/Top afterwards + Layouter(icon):Left(0):Top(0):Width(size):Height(size):End() + icon:ShareTextures(template) icon.Depth:Set(function() return self.Preview.Depth() + 5 end) - Layouter(icon):Width(size):Height(size):End() return icon end, @@ -893,19 +991,6 @@ local CustomLobbyMapSelect = ClassUI(Group) { icon.Top:Set(function() return self.Preview.Top() + z - 0.5 * icon.Height() end) end, - --- Detects whether the save has AI pathing markers (any marker chained to another). - ---@param self UICustomLobbyMapSelect - ---@param save UIScenarioSaveFile - ---@return boolean - DetectAiMarkers = function(self, save) - for _, data in self:Markers(save) do - if data.adjacentTo and string.find(data.adjacentTo, ' ') then - return true - end - end - return false - end, - --- Clears the preview + info (no candidate / empty list). ---@param self UICustomLobbyMapSelect ClearDetails = function(self) @@ -917,9 +1002,10 @@ local CustomLobbyMapSelect = ClassUI(Group) { self.Preview:ClearTexture() self.PreviewTitle:SetText("") self.InfoMeta:SetText("") - self.InfoMarkers:SetText("") self.Warning:SetText("") self.Description:SetText("") + self.CurrentUrl = false + self.UrlButton:Hide() end, --- Commits the highlighted candidate via the controller intent. diff --git a/lua/ui/maputil.lua b/lua/ui/maputil.lua index 7a37f1a7560..15fce41dedf 100644 --- a/lua/ui/maputil.lua +++ b/lua/ui/maputil.lua @@ -95,6 +95,7 @@ ---@field norushoffsetX_ARMY_16? number ---@field norushoffsetY_ARMY_16? number ---@field preview? FileName +---@field url? string # optional link to the map's page (vault / repo); shown in the lobby ---@field save FileName ---@field script FileName ---@field size {[1]: number, [2]: number} From 6964698729128b2380e3747054cf6381f7060656 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Mon, 22 Jun 2026 12:00:16 +0200 Subject: [PATCH 20/98] Tweak and tune the custom lobby map list --- .../skills/customlobby-model-choice/SKILL.md | 2 +- lua/lazyvar.lua | 2 +- lua/ui/lobby/customlobby/CLAUDE.md | 12 ++-- .../customlobby/CustomLobbyInterface.lua | 2 +- lua/ui/lobby/customlobby/mapselect/CLAUDE.md | 60 +++++++++++++++++++ .../{ => mapselect}/CustomLobbyMapCatalog.lua | 0 .../{ => mapselect}/CustomLobbyMapList.lua | 0 .../{ => mapselect}/CustomLobbyMapSelect.lua | 33 ++++++---- 8 files changed, 91 insertions(+), 20 deletions(-) create mode 100644 lua/ui/lobby/customlobby/mapselect/CLAUDE.md rename lua/ui/lobby/customlobby/{ => mapselect}/CustomLobbyMapCatalog.lua (100%) rename lua/ui/lobby/customlobby/{ => mapselect}/CustomLobbyMapList.lua (100%) rename lua/ui/lobby/customlobby/{ => mapselect}/CustomLobbyMapSelect.lua (96%) diff --git a/.claude/skills/customlobby-model-choice/SKILL.md b/.claude/skills/customlobby-model-choice/SKILL.md index 6c590815a81..9ddcf2ce585 100644 --- a/.claude/skills/customlobby-model-choice/SKILL.md +++ b/.claude/skills/customlobby-model-choice/SKILL.md @@ -21,7 +21,7 @@ Everything else host-dictated that isn't launched is **Session**. **Not all state is a model.** *Reference data* — identical on every peer and derived from disk or computed from synced inputs — belongs in **neither**. It's a cached module, not a LazyVar -singleton on the wire. Examples: the **map catalog** ([CustomLobbyMapCatalog.lua](/lua/ui/lobby/customlobby/CustomLobbyMapCatalog.lua), +singleton on the wire. Examples: the **map catalog** ([CustomLobbyMapCatalog.lua](/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapCatalog.lua), enumerated scenarios), and the upcoming **option schema** (derived from `ScenarioFile` + `GameMods`). Only the host's *choice* (the `ScenarioFile` / `GameMods` / `GameOptions` values in Launch) syncs; each peer recomputes the catalog/schema locally. Filter/search state in a picker is per-peer UI diff --git a/lua/lazyvar.lua b/lua/lazyvar.lua index ca7e1fefab3..dae7a9963a7 100644 --- a/lua/lazyvar.lua +++ b/lua/lazyvar.lua @@ -9,7 +9,7 @@ local setmetatable = setmetatable -- Set this true to get tracebacks in error messages. It slows down lazyvars a lot, -- so don't use except when debugging. -local ExtendedErrorMessages = false +local ExtendedErrorMessages = true local EvalContext = nil local WeakKeyMeta = { __mode = 'k' } diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index 505a8d29f51..ea26d013b40 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -51,9 +51,7 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | [CustomLobbySlotInterface.lua](CustomLobbySlotInterface.lua) | one slot row; subscribes to its slot + CPU benchmarks; CPU column shows max units at +0 with a green→red cap-headroom square; left-click an open slot to take it / your own to toggle ready; right-click opens its context menu; the host can drag a row onto another to swap. | | [CustomLobbyObserversInterface.lua](CustomLobbyObserversInterface.lua) | observer strip; subscribes to the model's `Observers` list and shows the count + names (read-only). | | [CustomLobbyMapPreview.lua](CustomLobbyMapPreview.lua) / [CustomLobbyMapPreviewSpawn.lua](CustomLobbyMapPreviewSpawn.lua) | map preview (copied from the autolobby's, adapted to this model): subscribes to `ScenarioFile` (full render) + each slot (spawn-icon refresh); hidden until a scenario is set. Reuses the shared `/lua/ui/controls/mappreview.lua`. | -| [CustomLobbyMapCatalog.lua](CustomLobbyMapCatalog.lua) | the lobby's OWN async enumeration of playable skirmish maps (does *not* use `MapUtil.EnumerateSkirmishScenarios`; borrows only `LoadScenarioInfoFile`, loading map *info* — not options/strings). Streams maps into a `Scenarios` `LazyVar` across frames (`EnsureLoaded`) so the open doesn't block and the count updates live. **Reference data — not a sync model**: the LazyVar is local progress reactivity, never on the wire; only the host's `ScenarioFile` choice syncs. | -| [CustomLobbyMapSelect.lua](CustomLobbyMapSelect.lua) | the map-select dialog (transient `Popup`, host-only). Laid out in **areas** (Group containers — title / left {filters, selection, stats} / preview / actions; flip the `Debug` flag to tint them). Searchable list + size & player-count filters each with a comparison operator (`=` / `>=` / `<=`), persisted to Prefs; "X of Y maps" footer with a loading spinner; candidate preview (name overlaid on top, plus an "Open page" link to the map's `url` for an allowlisted domain via `OpenURL`) with centred toggle-able overlays — start spots (numbered), resources (mass/hydro icons), wrecks; info (size / players / version) + a **scrollable** description; a file-health check that warns and disables Select for broken maps; double-click / Enter confirms, Esc cancels, Random (centred under the left area) picks from the filtered set. Select calls the `RequestSetScenario` intent. Owns no synced state. First slice of splitting the legacy `dialogs/mapselect.lua` god-dialog — map selection only (options / mods / units become their own components). | -| [CustomLobbyMapList.lua](CustomLobbyMapList.lua) | the dialog's scrollable, **virtualised** list: a fixed pool of **text-only** rows (name + `size · players` badge) reused as you scroll, standard scrollbar contract. Mouse-driven (a custom Group list can't take keyboard focus like `ItemList`); `OnSelect` / `OnConfirm`. **No per-row thumbnails** — the engine never frees `MapPreview` textures (see the gotcha below), so only the dialog's single candidate preview renders one. | +| [mapselect/](mapselect/CLAUDE.md) | the **map-select dialog** + its catalog and list, in their own folder (host-only `Popup`: searchable, filterable scenario list → `RequestSetScenario`). Self-contained sub-MVC; see [mapselect/CLAUDE.md](mapselect/CLAUDE.md) — including the **`MapPreview` texture-leak** writeup that shaped its design. | | [CustomLobbyInterface.lua](CustomLobbyInterface.lua) | composition root (one model subscription for SlotCount); lays out slot rows + the observer strip, and acts as the rows' drag coordinator (`UICustomLobbySlotCoordinator`: hit-test, drop-highlight, drag ghost → `RequestSwapSlots`); `OpenDebug()` / hot-reload. | | [/lua/ui/lobby/lobby.lua](../lobby.lua) | engine entry wrapper (`CreateLobby`/`HostGame`/`JoinGame`) → CustomLobby. Old lobby preserved at `lobby-old.lua`. | @@ -105,8 +103,10 @@ lobby-specific checklist. | `attempt to call method 'AtLeftRightIn' (a nil value)` (or similar) in a layouter chain | Not every `LayoutHelpers.*` function is exposed on the fluent `ReusedLayoutFor` builder. `AtLeftRightIn` is bare-only. | Use the methods other components use — `:AtLeftIn(p):AtRightIn(p)`, `:AnchorToBottom`, `:Fill`, … — or call `LayoutHelpers.Foo(control, …)` directly. | | `circular dependency in lazy evaluation` when an `Edit`'s font is set, or a list/preview reads its size | Reading a **concrete** layout value (`SetFont`, `ShowItem`, `Width()`) before the control is anchored into a settled parent rect. | Three-phase init: build in `__init`, anchor in `__post_init`, and read geometry only in an `Initialize()` the **opener calls after mounting** (e.g. after `Popup` centres the dialog). For an `Edit`, give it placeholder `:Left(0):Top(0):Width():Height()` before `SetFont`. | | Cascade of errors after one layout failure (half-built pools, observers firing into a broken control) | A throw mid-`Initialize` left partial state (e.g. `PoolCount` set before the rows existed), and a streaming `Derive`/thread kept calling into it. | Set "ready" counters **after** the thing they describe is fully built; guard paint/refresh paths against nil rows; gate model-observer work on a `self.Ready` flag set in `Initialize`. | +| `circular dependency in lazy evaluation` with **no frame from your files** (fires during the render pass) | A control you created in `__init` is **never anchored** in `__post_init` (or you renamed/moved its layout and forgot it). Its `Left`/`Right`/`Top`/`Bottom` stay at the circular defaults, and it errors the moment it's rendered. | Every visible control needs a layout. To find which one, set `import("/lua/lazyvar.lua").ExtendedErrorMessages = true` — the error then appends the offending control's **creation stack** (file + line). Then anchor it (or hide it). | | Memory climbs ~30 MB and never drops — not on re-texture, `ClearTexture`, `Destroy`, or dialog close | `MapPreview:SetTexture` / `SetTextureFromMap` allocate textures the engine **never frees** (no release API on the control or globally). Loading one per list row × hundreds of maps leaks memory the game needs in-match. | Don't put a `MapPreview` per list row. Render at most a **few** previews total (the map-select list is text-only; only the single candidate preview loads a texture, once per selection). The same applies to per-row `Bitmap` thumbnails via `SetNewTexture`. | -Reference implementation for all four: [CustomLobbyMapSelect.lua](CustomLobbyMapSelect.lua) + -[CustomLobbyMapList.lua](CustomLobbyMapList.lua) (the pooled list's `ScrollTop`, three-phase -`Initialize`, post-loop `PoolCount`, nil-guarded `PaintRow`). +Reference implementation for all four: [mapselect/CustomLobbyMapSelect.lua](mapselect/CustomLobbyMapSelect.lua) ++ [mapselect/CustomLobbyMapList.lua](mapselect/CustomLobbyMapList.lua) (the pooled list's +`ScrollTop`, three-phase `Initialize`, post-loop `PoolCount`, nil-guarded `PaintRow`). The +texture-leak case is documented in depth in [mapselect/CLAUDE.md](mapselect/CLAUDE.md). diff --git a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua index 77f772d6e2c..e34cd6751b6 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua @@ -39,7 +39,7 @@ local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontr local CustomLobbySlotInterface = import("/lua/ui/lobby/customlobby/customlobbyslotinterface.lua") local CustomLobbyObserversInterface = import("/lua/ui/lobby/customlobby/customlobbyobserversinterface.lua") local CustomLobbyMapPreview = import("/lua/ui/lobby/customlobby/customlobbymappreview.lua") -local CustomLobbyMapSelect = import("/lua/ui/lobby/customlobby/customlobbymapselect.lua") +local CustomLobbyMapSelect = import("/lua/ui/lobby/customlobby/mapselect/customlobbymapselect.lua") local LazyVarDerive = import("/lua/lazyvar.lua").Derive diff --git a/lua/ui/lobby/customlobby/mapselect/CLAUDE.md b/lua/ui/lobby/customlobby/mapselect/CLAUDE.md new file mode 100644 index 00000000000..20679ff5cc5 --- /dev/null +++ b/lua/ui/lobby/customlobby/mapselect/CLAUDE.md @@ -0,0 +1,60 @@ +# Map select + +The custom lobby's **map-select dialog** and its supporting pieces, split into their own folder +because the dialog is a self-contained sub-MVC and grew large. Opened by the host's "Change Map" +button in [`../CustomLobbyInterface.lua`](../CustomLobbyInterface.lua). + +> Read [`../CLAUDE.md`](../CLAUDE.md) first (the lobby's MVC + the layout/init gotchas). This doc +> is map-select-specific. + +## Files + +| File | Role | +|------|------| +| [CustomLobbyMapSelect.lua](CustomLobbyMapSelect.lua) | the dialog (transient `Popup`, host-only). Laid out in **areas** (Group containers: title / left {filter, selection, stats} / preview / actions — flip the module `Debug` flag to tint them). Searchable list + size & player filters with comparison operators (`=`/`>=`/`<=`, persisted to Prefs); candidate preview with name overlay, an "Open page" link (allowlisted `url` → `OpenURL`), and toggle-able overlays (spawns / resources / wrecks); scrollable description; file-health check that disables Select for broken maps; Random / Select / Cancel. On Select → `CustomLobbyController.RequestSetScenario(file)`. Owns no synced state. | +| [CustomLobbyMapCatalog.lua](CustomLobbyMapCatalog.lua) | the lobby's **own** async enumeration of playable skirmish maps — *not* `MapUtil.EnumerateSkirmishScenarios`. Borrows only `LoadScenarioInfoFile` (map info, not options/strings) and streams maps into a `Scenarios` `LazyVar` across frames (`EnsureLoaded`). **Reference data, never synced** — local progress reactivity only; the host syncs just its `ScenarioFile` choice. | +| [CustomLobbyMapList.lua](CustomLobbyMapList.lua) | the dialog's scrollable, **virtualised** list: a fixed pool of **text-only** rows (name + `size · players`) reused while scrolling, standard scrollbar contract, mouse-driven (no keyboard focus — a custom Group list can't take it). `OnSelect` / `OnConfirm`. | + +This is the first sub-dialog split out of the legacy `dialogs/mapselect.lua` god-dialog: it does +**map selection only**. Game options, mods and unit restrictions become their own components (the +options panel will derive its schema from the selected map + mods — see [`../CLAUDE.md`](../CLAUDE.md)). + +## The `MapPreview` texture leak (why the list is text-only) + +The single most important constraint in this folder. **The engine never frees the textures a +`MapPreview` loads** — and that memory is needed in-match. + +### Symptom + +With a mini `MapPreview` thumbnail per list row, scrolling a real map vault grew the process by +~30 MB (≈120 → 155 MB) and the memory **never came back** — not while the dialog stayed open, and +not after it closed. + +### What we ruled out + +`MapPreview` exposes only `SetTexture` / `SetTextureFromMap` / `ClearTexture` — no release API, and +there is no global texture-cache flush. We tried, and none of these freed the memory: + +- re-using one preview control per row and re-calling `SetTextureFromMap` (re-texture); +- **destroying and recreating** the `MapPreview` control per scenario; +- calling **`ClearTexture()`** before destroy, and on every row teardown + on dialog close. + +The textures are cached engine-side by name, independent of any control's lifetime. Nothing we can +call from Lua releases them. + +### Resolution + +**Don't put a `MapPreview` in a list row.** The list is text-only; only the dialog's **single +candidate preview** loads a texture — once per selection. Total live previews ≈ 1, so the leak is +bounded to a trickle instead of one-per-row-scrolled. + +The few small overlay icons (mass / hydro / wreck) load their texture **once** each into hidden +template bitmaps in `__init` and every marker shares it via `ShareTextures` — so they don't multiply +either. (Those templates are locked hidden with an `OnHide` returning `true`, the `TexturePool` +trick, so a parent `Show()` can't reveal an unpositioned bitmap.) + +### Rule of thumb + +A `MapPreview` (or any `Bitmap` via `SetNewTexture`) loading a **distinct, per-item** texture is a +leak in disguise. Render at most a handful, total — never one per list row / pool slot. If you need +many thumbnails, you need a different approach than `MapPreview`. diff --git a/lua/ui/lobby/customlobby/CustomLobbyMapCatalog.lua b/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapCatalog.lua similarity index 100% rename from lua/ui/lobby/customlobby/CustomLobbyMapCatalog.lua rename to lua/ui/lobby/customlobby/mapselect/CustomLobbyMapCatalog.lua diff --git a/lua/ui/lobby/customlobby/CustomLobbyMapList.lua b/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapList.lua similarity index 100% rename from lua/ui/lobby/customlobby/CustomLobbyMapList.lua rename to lua/ui/lobby/customlobby/mapselect/CustomLobbyMapList.lua diff --git a/lua/ui/lobby/customlobby/CustomLobbyMapSelect.lua b/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapSelect.lua similarity index 96% rename from lua/ui/lobby/customlobby/CustomLobbyMapSelect.lua rename to lua/ui/lobby/customlobby/mapselect/CustomLobbyMapSelect.lua index a2dc98586bc..7f0d474171e 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyMapSelect.lua +++ b/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapSelect.lua @@ -58,8 +58,8 @@ local TextArea = import("/lua/ui/controls/textarea.lua").TextArea -- primitives, same category as the catalog's LoadScenarioInfoFile) local MapUtil = import("/lua/ui/maputil.lua") -local CustomLobbyMapCatalog = import("/lua/ui/lobby/customlobby/customlobbymapcatalog.lua") -local CustomLobbyMapList = import("/lua/ui/lobby/customlobby/customlobbymaplist.lua") +local CustomLobbyMapCatalog = import("/lua/ui/lobby/customlobby/mapselect/customlobbymapcatalog.lua") +local CustomLobbyMapList = import("/lua/ui/lobby/customlobby/mapselect/customlobbymaplist.lua") local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") @@ -73,6 +73,7 @@ local Debug = false local DialogWidth = 720 local DialogHeight = 620 local Pad = 12 +local ColumnGap = 30 -- whitespace separating the left column from the preview column local LeftWidth = 300 local PreviewSize = 300 local TitleHeight = 32 @@ -206,6 +207,7 @@ local function CreateArea(parent, name, color) bg:SetAlpha(Debug and 0.18 or 0.0) bg:DisableHitTest() Layouter(bg):Fill(area):End() + area.Bg = bg return area end @@ -439,6 +441,7 @@ local CustomLobbyMapSelect = ClassUI(Group) { self.Description = TextArea(self.PreviewArea, 200, 80) self.Description:SetFont(UIUtil.bodyFont, 12) self.Description:SetColors('ff8a909a', "00000000", 'ff8a909a', "00000000") + self.Description:SetTextAlignment(0.5) -- centre each line, matching the info above --#endregion --#region actions @@ -497,7 +500,7 @@ local CustomLobbyMapSelect = ClassUI(Group) { :AnchorToBottom(self.TitleArea, Pad):AnchorToTop(self.ActionArea, Pad) :End() Layouter(self.PreviewArea) - :AnchorToRight(self.LeftArea, Pad):AtRightIn(self, Pad) + :AnchorToRight(self.LeftArea, ColumnGap):AtRightIn(self, Pad) :AnchorToBottom(self.TitleArea, Pad):AnchorToTop(self.ActionArea, Pad) :End() @@ -512,7 +515,8 @@ local CustomLobbyMapSelect = ClassUI(Group) { Layouter(self.Title):AtHorizontalCenterIn(self.TitleArea):AtVerticalCenterIn(self.TitleArea):End() --#region filters - Layouter(self.Search):AtLeftIn(self.FilterArea):AtRightIn(self.FilterArea):AtTopIn(self.FilterArea):Height(22):End() + Layouter(self.FilterTitle):AtLeftIn(self.FilterArea):AtTopIn(self.FilterArea):End() + Layouter(self.Search):AtLeftIn(self.FilterArea):AtRightIn(self.FilterArea):AnchorToBottom(self.FilterTitle, 8):Height(22):End() Layouter(self.SizeCombo):AtLeftIn(self.FilterArea, 56):AnchorToBottom(self.Search, 12):Width(110):End() Layouter(self.SizeLabel):AtLeftIn(self.FilterArea):AtVerticalCenterIn(self.SizeCombo):End() @@ -559,11 +563,12 @@ local CustomLobbyMapSelect = ClassUI(Group) { Layouter(self.InfoMeta):AtHorizontalCenterIn(self.Preview):AnchorToBottom(self.Preview, 12):End() Layouter(self.Warning):AtHorizontalCenterIn(self.Preview):AnchorToBottom(self.InfoMeta, 4):End() - -- description fills the rest of the preview area and scrolls when it overflows + -- description sits under the preview, exactly as wide as the map (so it reads as + -- centred, since the preview is centred in its column); scrolls when it overflows Layouter(self.Description) - :AtLeftIn(self.PreviewArea):AnchorToBottom(self.Warning, 10):AtBottomIn(self.PreviewArea) + :AtLeftIn(self.Preview):AnchorToBottom(self.Warning, 10):AtBottomIn(self.PreviewArea) :End() - self.Description.Right:Set(function() return self.PreviewArea.Right() - LayoutHelpers.ScaleNumber(32) end) + self.Description.Right:Set(function() return self.Preview.Right() end) UIUtil.CreateVertScrollbarFor(self.Description) --#endregion @@ -942,6 +947,12 @@ local CustomLobbyMapSelect = ClassUI(Group) { template:DisableHitTest() Layouter(template):Left(0):Top(0):Width(8):Height(8):End() template:Hide() + -- lock it hidden: a parent Show() (Popup mounting the dialog) would otherwise reveal + -- this never-positioned-for-display bitmap. Same trick TexturePool uses for pooled + -- bitmaps — OnHide returning true keeps it hidden regardless of the parent. + template.OnHide = function(control, hidden) + return true + end return template end, @@ -953,11 +964,11 @@ local CustomLobbyMapSelect = ClassUI(Group) { ---@param size number ---@return Bitmap CreateMarkerIcon = function(self, template, size) - local icon = UIUtil.CreateBitmapColor(self.PreviewArea, 'ffffffff') + local icon = UIUtil.CreateBitmapColor(self.PreviewArea, 'ffffff') icon:DisableHitTest() - -- pin a full (dummy) rect BEFORE ShareTextures — it reads the bitmap's edges, which are - -- circular on a just-created control; PositionMarker overrides Left/Top afterwards - Layouter(icon):Left(0):Top(0):Width(size):Height(size):End() + -- size only (matches CustomLobbyMapPreview's working pattern); PositionMarker pins + -- Left/Top afterwards, so the size must be set so its centring maths has a real Width + Layouter(icon):Width(size):Height(size):End() icon:ShareTextures(template) icon.Depth:Set(function() return self.Preview.Depth() + 5 end) return icon From de37cd0d1fc214b4b9f8777561b464e7b2e0e756 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Mon, 22 Jun 2026 12:13:25 +0200 Subject: [PATCH 21/98] Fix issues with hot reload --- lua/ui/lobby/customlobby/CLAUDE.md | 1 + .../customlobby/CustomLobbyInterface.lua | 27 +++++++++++++--- .../customlobby/CustomLobbyMapPreview.lua | 31 +++++++++++++++++-- 3 files changed, 52 insertions(+), 7 deletions(-) diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index ea26d013b40..26c173408a4 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -104,6 +104,7 @@ lobby-specific checklist. | `circular dependency in lazy evaluation` when an `Edit`'s font is set, or a list/preview reads its size | Reading a **concrete** layout value (`SetFont`, `ShowItem`, `Width()`) before the control is anchored into a settled parent rect. | Three-phase init: build in `__init`, anchor in `__post_init`, and read geometry only in an `Initialize()` the **opener calls after mounting** (e.g. after `Popup` centres the dialog). For an `Edit`, give it placeholder `:Left(0):Top(0):Width():Height()` before `SetFont`. | | Cascade of errors after one layout failure (half-built pools, observers firing into a broken control) | A throw mid-`Initialize` left partial state (e.g. `PoolCount` set before the rows existed), and a streaming `Derive`/thread kept calling into it. | Set "ready" counters **after** the thing they describe is fully built; guard paint/refresh paths against nil rows; gate model-observer work on a `self.Ready` flag set in `Initialize`. | | `circular dependency in lazy evaluation` with **no frame from your files** (fires during the render pass) | A control you created in `__init` is **never anchored** in `__post_init` (or you renamed/moved its layout and forgot it). Its `Left`/`Right`/`Top`/`Bottom` stay at the circular defaults, and it errors the moment it's rendered. | Every visible control needs a layout. To find which one, set `import("/lua/lazyvar.lua").ExtendedErrorMessages = true` — the error then appends the offending control's **creation stack** (file + line). Then anchor it (or hide it). | +| `circular dependency` only **on hot-reload** (or when the model already has state), not on a fresh open | A `Derive` observer fires **immediately on creation** in `__init`, and its handler reads layout geometry (e.g. positions icons via `self.Preview.Width()`). Fresh open: the model field is empty, handler no-ops. Reload: the field already has a value, so it renders before the parent has laid the component out. | Gate the observer handlers behind a `self.Ready` flag (default false). Set `Ready = true` and do the first render **deferred** (`ForkThread` + `WaitFrames(1)` from `__post_init`, or an `Initialize()` the parent calls) — once the parent has actually sized you. See `CustomLobbyMapPreview`. | | Memory climbs ~30 MB and never drops — not on re-texture, `ClearTexture`, `Destroy`, or dialog close | `MapPreview:SetTexture` / `SetTextureFromMap` allocate textures the engine **never frees** (no release API on the control or globally). Loading one per list row × hundreds of maps leaks memory the game needs in-match. | Don't put a `MapPreview` per list row. Render at most a **few** previews total (the map-select list is text-only; only the single candidate preview loads a texture, once per selection). The same applies to per-row `Bitmap` thumbnails via `SetNewTexture`. | Reference implementation for all four: [mapselect/CustomLobbyMapSelect.lua](mapselect/CustomLobbyMapSelect.lua) diff --git a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua index e34cd6751b6..0ace4cfa243 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua @@ -396,18 +396,35 @@ function CloseDebug() Instance = false end ---- Called by the module manager when this module is reloaded. +--- Called by the module manager when this module is reloaded. The rebuild is driven by +--- OnDirty's deferred thread (below), so there's nothing to do here. ---@param newModule any function __moduleinfo.OnReload(newModule) - if Instance then - newModule.SetupSingleton() - end end --- Called by the module manager when this module becomes dirty. +--- +--- Two things matter here: +--- * the re-import is DEFERRED a couple of frames — a synchronous `import` re-enters the +--- module manager mid-reload (e.g. when an edit to a `mapselect/` file cascades up to this +--- importer), the reload never finishes, and the torn-down lobby is left as a black frame; +--- * the rebuild is done HERE (in the deferred thread, against the freshly imported module), +--- not in OnReload — OnReload isn't reliably called for a module reloaded *transitively*, +--- which is exactly the cascade case. We only rebuild if a lobby was actually mounted, so +--- editing a lobby file while in the menu doesn't spawn a phantom lobby. function __moduleinfo.OnDirty() + local wasMounted = Instance ~= false ModuleTrash:Destroy() - import(__moduleinfo.name) + Instance = false + ForkThread( + function() + WaitFrames(2) + local newModule = import(__moduleinfo.name) + if wasMounted then + newModule.SetupSingleton() + end + end + ) end --#endregion diff --git a/lua/ui/lobby/customlobby/CustomLobbyMapPreview.lua b/lua/ui/lobby/customlobby/CustomLobbyMapPreview.lua index 9857479aebb..81df38155fb 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyMapPreview.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyMapPreview.lua @@ -40,6 +40,7 @@ local LazyVarDerive = import("/lua/lazyvar.lua").Derive ---@class UICustomLobbyMapPreview : Group ---@field Trash TrashBag +---@field Ready boolean # true once laid out by the parent; gates geometry-reading renders ---@field Preview MapPreview ---@field Overlay Bitmap ---@field PathToScenarioFile? FileName @@ -75,12 +76,21 @@ local CustomLobbyMapPreview = ClassUI(Group) { self.IconTrash = TrashBag() + -- Geometry-reading renders (spawn-icon placement reads self.Preview.Width()) are gated + -- until we've been laid out by our parent — see __post_init. The Derive observers below + -- fire immediately on creation; without this gate, a reload with a scenario already set + -- would position icons against a not-yet-sized preview and trip the circular guard. + self.Ready = false + local model = CustomLobbyLaunchModel.GetSingleton() -- the scenario file drives the whole preview: render on change, hide when unset self.ScenarioObserver = self.Trash:Add( LazyVarDerive(model.ScenarioFile, function(scenarioFileLazy) - self:OnScenarioFileChanged(scenarioFileLazy()) + local scenarioFile = scenarioFileLazy() + if self.Ready then + self:OnScenarioFileChanged(scenarioFile) + end end)) -- each slot drives only the spawn icons (faction / position) — refreshed against @@ -90,7 +100,9 @@ local CustomLobbyMapPreview = ClassUI(Group) { self.PlayerObservers[slot] = self.Trash:Add( LazyVarDerive(model.Players[slot], function(playerLazy) playerLazy() - self:OnPlayersChanged() + if self.Ready then + self:OnPlayersChanged() + end end)) end end, @@ -151,6 +163,21 @@ local CustomLobbyMapPreview = ClassUI(Group) { LayoutHelpers.ReusedLayoutFor(self.EnergyIcon):Hide():End() LayoutHelpers.ReusedLayoutFor(self.MassIcon):Hide():End() LayoutHelpers.ReusedLayoutFor(self.WreckageIcon):Hide():End() + + -- our PARENT lays us out after this __post_init returns, so self.Preview.Width() isn't + -- concrete yet. Defer the first render one frame, then mark ready so the observers drive + -- subsequent updates. (Without this, a reload with a scenario already set renders here — + -- against an unsized preview — and trips the circular-dependency guard.) + self.Trash:Add(ForkThread( + function() + WaitFrames(1) + if IsDestroyed(self) then + return + end + self.Ready = true + self:OnScenarioFileChanged(CustomLobbyLaunchModel.GetSingleton().ScenarioFile()) + end + )) end, --- Places an icon at a map coordinate. Private. From f182f674528d59785b1cd43ed7d44ab941516be3 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Mon, 22 Jun 2026 12:59:40 +0200 Subject: [PATCH 22/98] Refactor the map preview control --- lua/ui/lobby/customlobby/CLAUDE.md | 3 +- .../customlobby/CustomLobbyMapPreview.lua | 326 +++----------- .../CustomLobbyScenarioPreview.lua | 414 ++++++++++++++++++ lua/ui/lobby/customlobby/mapselect/CLAUDE.md | 4 +- .../mapselect/CustomLobbyMapCatalog.lua | 68 ++- .../mapselect/CustomLobbyMapSelect.lua | 282 ++---------- 6 files changed, 575 insertions(+), 522 deletions(-) create mode 100644 lua/ui/lobby/customlobby/CustomLobbyScenarioPreview.lua diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index 26c173408a4..38bd1a8de37 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -50,7 +50,8 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | [CustomLobbyMenus.lua](CustomLobbyMenus.lua) | declarative menu **definitions**: entry lists with `when(ctx)`/`action(ctx)` filtered by lobby state (`BuildSlotMenu`). Adding/state-gating an item is a one-liner here. | | [CustomLobbySlotInterface.lua](CustomLobbySlotInterface.lua) | one slot row; subscribes to its slot + CPU benchmarks; CPU column shows max units at +0 with a green→red cap-headroom square; left-click an open slot to take it / your own to toggle ready; right-click opens its context menu; the host can drag a row onto another to swap. | | [CustomLobbyObserversInterface.lua](CustomLobbyObserversInterface.lua) | observer strip; subscribes to the model's `Observers` list and shows the count + names (read-only). | -| [CustomLobbyMapPreview.lua](CustomLobbyMapPreview.lua) / [CustomLobbyMapPreviewSpawn.lua](CustomLobbyMapPreviewSpawn.lua) | map preview (copied from the autolobby's, adapted to this model): subscribes to `ScenarioFile` (full render) + each slot (spawn-icon refresh); hidden until a scenario is set. Reuses the shared `/lua/ui/controls/mappreview.lua`. | +| [CustomLobbyScenarioPreview.lua](CustomLobbyScenarioPreview.lua) | **shared** map-preview *surface*: the scenario's map texture + overlays (start spots, resource/wreck markers) with aspect-correct positioning, texture-leak-safe icon sharing, per-group visibility, and three-phase init. Chrome-free; owners wrap it. Spawn appearance is the owner's via a `CreateSpawnIcon` factory. Used by both the in-lobby preview and the map-select dialog so the fiddly bits live once. | +| [CustomLobbyMapPreview.lua](CustomLobbyMapPreview.lua) / [CustomLobbyMapPreviewSpawn.lua](CustomLobbyMapPreviewSpawn.lua) | the in-lobby preview: a thin model-bound wrapper around `CustomLobbyScenarioPreview` (faction-icon spawns) + glow/brackets chrome. Subscribes to `ScenarioFile` (loads via the catalog, hands to the surface, show/hide) + each slot (refreshes the surface's spawn data, no reload). `CustomLobbyMapPreviewSpawn` is the faction spawn icon. | | [mapselect/](mapselect/CLAUDE.md) | the **map-select dialog** + its catalog and list, in their own folder (host-only `Popup`: searchable, filterable scenario list → `RequestSetScenario`). Self-contained sub-MVC; see [mapselect/CLAUDE.md](mapselect/CLAUDE.md) — including the **`MapPreview` texture-leak** writeup that shaped its design. | | [CustomLobbyInterface.lua](CustomLobbyInterface.lua) | composition root (one model subscription for SlotCount); lays out slot rows + the observer strip, and acts as the rows' drag coordinator (`UICustomLobbySlotCoordinator`: hit-test, drop-highlight, drag ghost → `RequestSwapSlots`); `OpenDebug()` / hot-reload. | | [/lua/ui/lobby/lobby.lua](../lobby.lua) | engine entry wrapper (`CreateLobby`/`HostGame`/`JoinGame`) → CustomLobby. Old lobby preserved at `lobby-old.lua`. | diff --git a/lua/ui/lobby/customlobby/CustomLobbyMapPreview.lua b/lua/ui/lobby/customlobby/CustomLobbyMapPreview.lua index 81df38155fb..9397507a308 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyMapPreview.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyMapPreview.lua @@ -20,38 +20,33 @@ --** SOFTWARE. --****************************************************************************************************** --- Map preview for the custom lobby. Copied from the autolobby's AutolobbyMapPreview and --- adapted to this lobby's model: the autolobby observes one `Scenario` bundle, but here --- the scenario file and the players live separately (`ScenarioFile` + per-slot `Players`), --- so we observe the file (full re-render) and each slot (cheap spawn-icon refresh against --- the cached scenario). The rendering itself (preview texture, resource markers, spawn --- icons) is unchanged from the autolobby and uses the shared MapPreview control. +-- The in-lobby map preview: the shared scenario-preview surface (CustomLobbyScenarioPreview) +-- with the lobby's chrome — a glow border + dialog brackets — wrapped around it, bound to the +-- launch model. +-- +-- It owns the model wiring only: it subscribes to `ScenarioFile` (load the scenario, hand it to +-- the surface, show/hide) and to each slot's player (refresh the surface's spawn data, no map +-- reload). The texture / overlay / positioning work all lives in the surface, which the +-- map-select dialog reuses too — see CustomLobbyScenarioPreview.lua. +-- +-- Spawns render as faction icons (CustomLobbyMapPreviewSpawn): the surface calls each icon's +-- :Update(faction) for a seated spot and :Reset() for an empty one, so empty spots stay blank. 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 CustomLobbyScenarioPreview = import("/lua/ui/lobby/customlobby/customlobbyscenariopreview.lua") local CustomLobbyMapPreviewSpawn = import("/lua/ui/lobby/customlobby/customlobbymappreviewspawn.lua") +local CustomLobbyMapCatalog = import("/lua/ui/lobby/customlobby/mapselect/customlobbymapcatalog.lua") local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") local LazyVarDerive = import("/lua/lazyvar.lua").Derive ---@class UICustomLobbyMapPreview : Group ---@field Trash TrashBag ----@field Ready boolean # true once laid out by the parent; gates geometry-reading renders ----@field Preview MapPreview ---@field Overlay Bitmap ----@field PathToScenarioFile? FileName ----@field ScenarioInfo? UILobbyScenarioInfo ----@field ScenarioSave? UIScenarioSaveFile ----@field PlayerOptions? table ----@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 UICustomLobbyMapPreviewSpawn[] +---@field Surface UICustomLobbyScenarioPreview ---@field ScenarioObserver LazyVar ---@field PlayerObservers LazyVar[] local CustomLobbyMapPreview = ClassUI(Group) { @@ -63,306 +58,95 @@ local CustomLobbyMapPreview = ClassUI(Group) { self.Trash = TrashBag() - self.Preview = MapPreview(self) - 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() - - -- Geometry-reading renders (spawn-icon placement reads self.Preview.Width()) are gated - -- until we've been laid out by our parent — see __post_init. The Derive observers below - -- fire immediately on creation; without this gate, a reload with a scenario already set - -- would position icons against a not-yet-sized preview and trip the circular guard. - self.Ready = false + -- the shared surface, with faction icons for spawns + self.Surface = CustomLobbyScenarioPreview.Create(self, { + CreateSpawnIcon = function(surface, index) + return CustomLobbyMapPreviewSpawn.Create(surface) + end, + }) local model = CustomLobbyLaunchModel.GetSingleton() -- the scenario file drives the whole preview: render on change, hide when unset self.ScenarioObserver = self.Trash:Add( LazyVarDerive(model.ScenarioFile, function(scenarioFileLazy) - local scenarioFile = scenarioFileLazy() - if self.Ready then - self:OnScenarioFileChanged(scenarioFile) - end + self:OnScenarioFileChanged(scenarioFileLazy()) end)) - -- each slot drives only the spawn icons (faction / position) — refreshed against - -- the already-loaded scenario, so a take/swap/faction change doesn't reload the map + -- each slot drives only the spawn icons (faction / position) — refreshed against the + -- already-loaded scenario, so a take/swap/faction change doesn't reload the map self.PlayerObservers = {} for slot = 1, CustomLobbyLaunchModel.MaxSlots do self.PlayerObservers[slot] = self.Trash:Add( LazyVarDerive(model.Players[slot], function(playerLazy) playerLazy() - if self.Ready then - self:OnPlayersChanged() - end + self:OnPlayersChanged() end)) end end, ---@param self UICustomLobbyMapPreview - OnDestroy = function(self) - self.Trash:Destroy() - end, - - --- Show + render the preview once a scenario file is set, hide it otherwise. - ---@param self UICustomLobbyMapPreview - ---@param scenarioFile FileName | false - OnScenarioFileChanged = function(self, scenarioFile) - if scenarioFile then - self:Show() - self:UpdateScenario(scenarioFile, self:_GatherPlayerOptions()) - else - self:Hide() - end - end, - - --- A slot changed: refresh just the spawn icons, reusing the loaded scenario. - ---@param self UICustomLobbyMapPreview - OnPlayersChanged = function(self) - if self.ScenarioInfo and self.ScenarioSave then - self.PlayerOptions = self:_GatherPlayerOptions() - self:_UpdateSpawnLocations(self.ScenarioInfo, self.ScenarioSave, self.PlayerOptions) - end - end, - - --- Collects the seated players keyed by start spot (the spawn id the preview uses). - ---@param self UICustomLobbyMapPreview - ---@return table - _GatherPlayerOptions = function(self) - local model = CustomLobbyLaunchModel.GetSingleton() - local options = {} - for slot = 1, CustomLobbyLaunchModel.MaxSlots do - local player = model.Players[slot]() - if player then - options[player.StartSpot or slot] = player - end - end - return options - end, - - ---@param self UICustomLobbyMapPreview - ---@param parent Control - __post_init = function(self, parent) + __post_init = function(self) LayoutHelpers.ReusedLayoutFor(self.Overlay) :Fill(self) :DisableHitTest(true) :End() - LayoutHelpers.ReusedLayoutFor(self.Preview) + LayoutHelpers.ReusedLayoutFor(self.Surface) :FillFixedBorder(self.Overlay, 24) :End() - - LayoutHelpers.ReusedLayoutFor(self.EnergyIcon):Hide():End() - LayoutHelpers.ReusedLayoutFor(self.MassIcon):Hide():End() - LayoutHelpers.ReusedLayoutFor(self.WreckageIcon):Hide():End() - - -- our PARENT lays us out after this __post_init returns, so self.Preview.Width() isn't - -- concrete yet. Defer the first render one frame, then mark ready so the observers drive - -- subsequent updates. (Without this, a reload with a scenario already set renders here — - -- against an unsized preview — and trips the circular-dependency guard.) - self.Trash:Add(ForkThread( - function() - WaitFrames(1) - if IsDestroyed(self) then - return - end - self.Ready = true - self:OnScenarioFileChanged(CustomLobbyLaunchModel.GetSingleton().ScenarioFile()) - end - )) end, - --- Places an icon at a map coordinate. Private. ---@param self UICustomLobbyMapPreview - ---@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 - 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 - - icon.Left:Set(function() return self.Preview.Left() + x - 0.5 * icon.Width() end) - icon.Top:Set(function() return self.Preview.Top() + z - 0.5 * icon.Height() end) - - return icon + OnDestroy = function(self) + self.Trash:Destroy() end, - --- Sets the preview texture. Private. + --- Loads the scenario and hands it to the surface; hides the preview when unset. ---@param self UICustomLobbyMapPreview - ---@param scenarioInfo UILobbyScenarioInfo - _UpdatePreview = function(self, scenarioInfo) - if not self.Preview:SetTexture(scenarioInfo.preview) then - self.Preview:SetTextureFromMap(scenarioInfo.map) + ---@param scenarioFile FileName | false + OnScenarioFileChanged = function(self, scenarioFile) + if not scenarioFile then + self.Surface:Clear() + self:Hide() + return end - end, - - --- Creates icons for resource markers. Private. - ---@param self UICustomLobbyMapPreview - ---@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 + local scenarioInfo = CustomLobbyMapCatalog.LoadInfo(scenarioFile) + if not scenarioInfo then + self.Surface:Clear() + self:Hide() 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 + self.Surface:SetScenario(scenarioInfo, CustomLobbyMapCatalog.LoadSave(scenarioInfo)) + self.Surface:SetSpawnData(self:GatherSpawnData()) + self:Show() end, - --- Creates icons for wreckages. Private. + --- A slot changed: refresh the surface's spawn data (faction by start spot). ---@param self UICustomLobbyMapPreview - ---@param scenarioInfo UILobbyScenarioInfo - ---@param scenarioSave UIScenarioSaveFile - _UpdateWreckages = function(self, scenarioInfo, scenarioSave) - -- TODO + OnPlayersChanged = function(self) + self.Surface:SetSpawnData(self:GatherSpawnData()) end, - --- Creates/updates spawn-location icons. Private. + --- The seated factions keyed by start spot (the spawn id the surface positions by). ---@param self UICustomLobbyMapPreview - ---@param scenarioInfo UILobbyScenarioInfo - ---@param scenarioSave UIScenarioSaveFile - ---@param playerOptions table - _UpdateSpawnLocations = function(self, scenarioInfo, scenarioSave, playerOptions) - local spawnIcons = self.SpawnIcons - local positions = MapUtil.GetStartPositionsFromScenario(scenarioInfo, scenarioSave) - if not positions then - for id, icon in spawnIcons do - icon:Destroy() - end - return - end - - -- clean up icons whose position no longer exists - for id, icon in spawnIcons do - if not positions[id] then - icon:Destroy() - end - end - - for id, position in positions do - local icon = spawnIcons[id] - if not icon then - icon = CustomLobbyMapPreviewSpawn.Create(self) - end - - spawnIcons[id] = icon - - self:PositionIcon( - icon, scenarioInfo.size[1], scenarioInfo.size[2], - position[1], position[2] - ) - - local options = playerOptions[id] - if options then - icon:Update(options.Faction) - else - icon:Reset() + ---@return table + GatherSpawnData = function(self) + local model = CustomLobbyLaunchModel.GetSingleton() + local data = {} + for slot = 1, CustomLobbyLaunchModel.MaxSlots do + local player = model.Players[slot]() + if player then + data[player.StartSpot or slot] = player.Faction end end + return data end, - - --- Renders the preview for a scenario, including resource and spawn icons. - ---@param self UICustomLobbyMapPreview - ---@param pathToScenarioInfo FileName # a reference to a _scenario.lua file - ---@param playerOptions table - UpdateScenario = function(self, pathToScenarioInfo, playerOptions) - self.IconTrash:Destroy() - self.Preview:ClearTexture() - self.PathToScenarioFile = pathToScenarioInfo - - local scenarioInfo = MapUtil.LoadScenario(pathToScenarioInfo) - if not scenarioInfo then - -- TODO: show a default image that indicates something is off - self.ScenarioInfo = nil - self.ScenarioSave = nil - return - end - - self.ScenarioInfo = scenarioInfo - self:_UpdatePreview(scenarioInfo) - - local scenarioSave = MapUtil.LoadScenarioSaveFile(scenarioInfo.save) - if not scenarioSave then - self.ScenarioSave = nil - 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 UICustomLobbyMapPreview - 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 diff --git a/lua/ui/lobby/customlobby/CustomLobbyScenarioPreview.lua b/lua/ui/lobby/customlobby/CustomLobbyScenarioPreview.lua new file mode 100644 index 00000000000..6f932754889 --- /dev/null +++ b/lua/ui/lobby/customlobby/CustomLobbyScenarioPreview.lua @@ -0,0 +1,414 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- A reusable map-preview *surface*: the scenario's map texture plus its overlays — start +-- spots, resource deposits (mass / hydrocarbon) and prebuilt wrecks — placed at the right +-- spots with aspect-correct maths. It is deliberately chrome-free: no title, border, glow or +-- brackets. Owners wrap it and add their own decoration (the in-lobby `CustomLobbyMapPreview` +-- adds a glow + brackets; the map-select dialog adds a title bar + frame). +-- +-- It exists so the two preview consumers share ONE implementation of the fiddly bits — the +-- texture-leak-safe icon sharing, the aspect-correct positioning, the three-phase init — rather +-- than each maintaining a copy that drifts (and re-learns the same engine gotchas). +-- +-- Memory: the resource/wreck icons load their texture ONCE into hidden template bitmaps and +-- every marker shares it via `ShareTextures` (the engine never frees per-bitmap textures — see +-- mapselect/CLAUDE.md). The map texture itself is one `MapPreview`; don't instantiate this +-- control per list row. +-- +-- Spawn appearance is the owner's choice via the `CreateSpawnIcon` option: the in-lobby preview +-- passes faction icons (`CustomLobbyMapPreviewSpawn`), the picker passes numbered dots (the +-- default). A spawn icon may implement `:Update(data)` / `:Reset()`; the surface calls `Update` +-- with `SetSpawnData()[index]` when present, else `Reset` — so faction icons hide on empty +-- spots while numbered dots (no Update/Reset) stay shown. + +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 Bitmap = import("/lua/maui/bitmap.lua").Bitmap +local MapPreview = import("/lua/ui/controls/mappreview.lua").MapPreview + +local Layouter = LayoutHelpers.ReusedLayoutFor + +local MassIcon = "/game/build-ui/icon-mass_bmp.dds" +local EnergyIcon = "/game/build-ui/icon-energy_bmp.dds" +local WreckIcon = "/scx_menu/lan-game-lobby/mappreview/wreckage.dds" + +local ResourceIconSize = 10 +local WreckIconSize = 11 +local SpawnDotSize = 18 + +---@class UICustomLobbyScenarioPreviewOptions +---@field CreateSpawnIcon? fun(surface: Control, index: number): Control # defaults to a numbered dot + +---@class UICustomLobbyScenarioPreview : Group +---@field Trash TrashBag +---@field MarkerTrash TrashBag # resource + wreck icons (rebuilt on SetScenario) +---@field SpawnTrash TrashBag # spawn icons (rebuilt on SetScenario / SetSpawnData) +---@field Preview MapPreview +---@field MassTemplate Bitmap # hidden; resource markers share its texture +---@field EnergyTemplate Bitmap +---@field WreckTemplate Bitmap +---@field SpawnIcons table +---@field ResourceIcons Control[] +---@field WreckIcons Control[] +---@field ShowSpawns boolean +---@field ShowResources boolean +---@field ShowWrecks boolean +---@field ScenarioInfo? UILobbyScenarioInfo +---@field ScenarioSave? UIScenarioSaveFile +---@field SpawnData table # per-start-spot data handed to spawn icons' :Update +---@field CreateSpawnIcon fun(surface: Control, index: number): Control +---@field Ready boolean # true once laid out by the parent; gates geometry reads +local CustomLobbyScenarioPreview = ClassUI(Group) { + + ---@param self UICustomLobbyScenarioPreview + ---@param parent Control + ---@param options? UICustomLobbyScenarioPreviewOptions + __init = function(self, parent, options) + Group.__init(self, parent, "CustomLobbyScenarioPreview") + + options = options or {} + + self.Trash = TrashBag() + self.MarkerTrash = self.Trash:Add(TrashBag()) + self.SpawnTrash = self.Trash:Add(TrashBag()) + + self.SpawnIcons = {} + self.ResourceIcons = {} + self.WreckIcons = {} + + self.ShowSpawns = true + self.ShowResources = true + self.ShowWrecks = true + + self.ScenarioInfo = nil + self.ScenarioSave = nil + self.SpawnData = {} + self.Ready = false + + self.CreateSpawnIcon = options.CreateSpawnIcon or function(surface, index) + return self:CreateNumberedDot(index) + end + + self.Preview = MapPreview(self) + + self.MassTemplate = self:CreateTemplateBitmap(MassIcon) + self.EnergyTemplate = self:CreateTemplateBitmap(EnergyIcon) + self.WreckTemplate = self:CreateTemplateBitmap(WreckIcon) + end, + + ---@param self UICustomLobbyScenarioPreview + __post_init = function(self) + Layouter(self.Preview):Fill(self):End() + + -- our PARENT sizes us after this returns, so self.Preview.Width() isn't concrete yet; + -- defer the first render a frame, then let SetScenario/SetSpawnData drive updates + self.Trash:Add(ForkThread( + function() + WaitFrames(1) + if IsDestroyed(self) then + return + end + self.Ready = true + self:Render() + end + )) + end, + + ---@param self UICustomLobbyScenarioPreview + OnDestroy = function(self) + self.Trash:Destroy() + end, + + --------------------------------------------------------------------------- + --#region Public API + + --- Renders a scenario: map texture + resource/wreck/spawn overlays. Pass already-loaded + --- info + save (the owner / catalog does the disk read). A nil info clears the surface. + ---@param self UICustomLobbyScenarioPreview + ---@param scenarioInfo? UILobbyScenarioInfo + ---@param scenarioSave? UIScenarioSaveFile + SetScenario = function(self, scenarioInfo, scenarioSave) + self.ScenarioInfo = scenarioInfo + self.ScenarioSave = scenarioSave + if self.Ready then + self:Render() + end + end, + + --- Updates the per-start-spot spawn data (e.g. seated players' factions) and refreshes just + --- the spawn icons against the already-loaded scenario — no map reload. + ---@param self UICustomLobbyScenarioPreview + ---@param spawnData table + SetSpawnData = function(self, spawnData) + self.SpawnData = spawnData or {} + if self.Ready and self.ScenarioInfo and self.ScenarioSave then + self:RenderSpawns() + end + end, + + --- Shows/hides an overlay group: 'spawns' | 'resources' | 'wrecks'. + ---@param self UICustomLobbyScenarioPreview + ---@param kind string + ---@param visible boolean + SetOverlayVisible = function(self, kind, visible) + if kind == 'spawns' then + self.ShowSpawns = visible + elseif kind == 'resources' then + self.ShowResources = visible + elseif kind == 'wrecks' then + self.ShowWrecks = visible + end + self:ApplyVisibility() + end, + + --- Clears the texture + all overlays (no scenario shown). + ---@param self UICustomLobbyScenarioPreview + Clear = function(self) + self.ScenarioInfo = nil + self.ScenarioSave = nil + self.MarkerTrash:Destroy() + self.SpawnTrash:Destroy() + self.SpawnIcons = {} + self.ResourceIcons = {} + self.WreckIcons = {} + self.Preview:ClearTexture() + end, + + --#endregion + + --------------------------------------------------------------------------- + --#region Rendering (private) + + ---@param self UICustomLobbyScenarioPreview + Render = function(self) + self.MarkerTrash:Destroy() + self.ResourceIcons = {} + self.WreckIcons = {} + + local info = self.ScenarioInfo + if not info then + self.SpawnTrash:Destroy() + self.SpawnIcons = {} + self.Preview:ClearTexture() + return + end + + if not self.Preview:SetTexture(info.preview) then + self.Preview:SetTextureFromMap(info.map) + end + + if self.ScenarioSave and info.size then + self:BuildResources() + self:BuildWrecks() + end + self:RenderSpawns() + self:ApplyVisibility() + end, + + --- Rebuilds only the spawn icons (resources/wrecks untouched). + ---@param self UICustomLobbyScenarioPreview + RenderSpawns = function(self) + self.SpawnTrash:Destroy() + self.SpawnIcons = {} + + local info = self.ScenarioInfo + if not (info and self.ScenarioSave and info.size) then + return + end + + local positions = MapUtil.GetStartPositionsFromScenario(info, self.ScenarioSave) + if not positions then + return + end + + for index, position in positions do + local icon = self.SpawnTrash:Add(self.CreateSpawnIcon(self, index)) + icon.Depth:Set(function() return self.Preview.Depth() + 10 end) + self:PlaceMarker(icon, info.size[1], info.size[2], position[1], position[2]) + + local data = self.SpawnData[index] + if data ~= nil and icon.Update then + icon:Update(data) + elseif icon.Reset then + icon:Reset() + end + + self.SpawnIcons[index] = icon + end + + if not self.ShowSpawns then + self:ApplyVisibility() + end + end, + + --- Places mass + hydrocarbon icons from the save's master-chain markers. + ---@param self UICustomLobbyScenarioPreview + BuildResources = function(self) + local info = self.ScenarioInfo + for _, marker in self:Markers() do + local template = (marker.type == "Mass" and self.MassTemplate) + or (marker.type == "Hydrocarbon" and self.EnergyTemplate) + or false + if template and marker.position then + local icon = self.MarkerTrash:Add(self:CreateMarkerIcon(template, ResourceIconSize)) + self:PlaceMarker(icon, info.size[1], info.size[2], marker.position[1], marker.position[3]) + table.insert(self.ResourceIcons, icon) + end + end + end, + + --- Best-effort wreck icons: maps that expose prebuilt wreckage as save markers. + ---@param self UICustomLobbyScenarioPreview + BuildWrecks = function(self) + local info = self.ScenarioInfo + for _, marker in self:Markers() do + if marker.type and marker.position and string.find(string.lower(marker.type), 'wreck') then + local icon = self.MarkerTrash:Add(self:CreateMarkerIcon(self.WreckTemplate, WreckIconSize)) + self:PlaceMarker(icon, info.size[1], info.size[2], marker.position[1], marker.position[3]) + table.insert(self.WreckIcons, icon) + end + end + end, + + --- Shows/hides each overlay group per its flag. Spawn icons that override Show (e.g. the + --- faction icon hides itself with no faction) keep their own logic. + ---@param self UICustomLobbyScenarioPreview + ApplyVisibility = function(self) + local function setVisible(icons, visible) + for _, icon in icons do + if visible then + icon:Show() + else + icon:Hide() + end + end + end + setVisible(self.SpawnIcons, self.ShowSpawns) + setVisible(self.ResourceIcons, self.ShowResources) + setVisible(self.WreckIcons, self.ShowWrecks) + end, + + --- The save's master-chain markers, or an empty table. + ---@param self UICustomLobbyScenarioPreview + ---@return table + Markers = function(self) + local masterChain = self.ScenarioSave.MasterChain and self.ScenarioSave.MasterChain['_MASTERCHAIN_'] + return (masterChain and masterChain.Markers) or {} + end, + + --#endregion + + --------------------------------------------------------------------------- + --#region Icon factories (private) + + --- The default spawn icon: a small numbered dot. + ---@param self UICustomLobbyScenarioPreview + ---@param index number + ---@return Group + CreateNumberedDot = function(self, index) + local dot = Group(self) + dot:DisableHitTest() + + local bg = Bitmap(dot) + bg:SetSolidColor('cc1c2228') + bg:DisableHitTest() + + local label = UIUtil.CreateText(dot, tostring(index), 12, UIUtil.bodyFont) + label:DisableHitTest() + + Layouter(dot):Width(SpawnDotSize):Height(SpawnDotSize):End() + Layouter(bg):Fill(dot):End() + Layouter(label):AtCenterIn(dot):End() + return dot + end, + + --- Loads an overlay texture once into a hidden template bitmap that markers share from. + --- Given a dummy position (unanchored Left/Right are circular) and locked hidden so a parent + --- Show() can't reveal it. + ---@param self UICustomLobbyScenarioPreview + ---@param texture FileName + ---@return Bitmap + CreateTemplateBitmap = function(self, texture) + local template = UIUtil.CreateBitmap(self, texture) + template:DisableHitTest() + Layouter(template):Left(0):Top(0):Width(8):Height(8):End() + template:Hide() + template.OnHide = function(control, hidden) + return true + end + return template + end, + + --- A small resource/wreck marker sharing its texture with a template (loaded once). + ---@param self UICustomLobbyScenarioPreview + ---@param template Bitmap + ---@param size number + ---@return Bitmap + CreateMarkerIcon = function(self, template, size) + local icon = UIUtil.CreateBitmapColor(self, 'ffffff') + icon:DisableHitTest() + Layouter(icon):Width(size):Height(size):End() + icon:ShareTextures(template) + icon.Depth:Set(function() return self.Preview.Depth() + 5 end) + return icon + end, + + --- Positions an overlay control over the preview at a map coordinate (aspect-correct). + ---@param self UICustomLobbyScenarioPreview + ---@param icon Control + ---@param mapWidth number + ---@param mapHeight number + ---@param px number + ---@param pz number + PlaceMarker = function(self, icon, mapWidth, mapHeight, px, pz) + local size = self.Preview.Width() + local xOffset, xFactor, yOffset, yFactor = 0, 1, 0, 1 + if mapWidth > mapHeight then + local ratio = mapHeight / mapWidth + yOffset = ((size / ratio) - size) / 4 + yFactor = ratio + else + local ratio = mapWidth / mapHeight + xOffset = ((size / ratio) - size) / 4 + xFactor = ratio + end + + local x = xOffset + (px / mapWidth) * (size - 2) * xFactor + local z = yOffset + (pz / mapHeight) * (size - 2) * yFactor + + icon.Left:Set(function() return self.Preview.Left() + x - 0.5 * icon.Width() end) + icon.Top:Set(function() return self.Preview.Top() + z - 0.5 * icon.Height() end) + end, + + --#endregion +} + +---@param parent Control +---@param options? UICustomLobbyScenarioPreviewOptions +---@return UICustomLobbyScenarioPreview +Create = function(parent, options) + return CustomLobbyScenarioPreview(parent, options) +end diff --git a/lua/ui/lobby/customlobby/mapselect/CLAUDE.md b/lua/ui/lobby/customlobby/mapselect/CLAUDE.md index 20679ff5cc5..b61b680bc8d 100644 --- a/lua/ui/lobby/customlobby/mapselect/CLAUDE.md +++ b/lua/ui/lobby/customlobby/mapselect/CLAUDE.md @@ -11,8 +11,8 @@ button in [`../CustomLobbyInterface.lua`](../CustomLobbyInterface.lua). | File | Role | |------|------| -| [CustomLobbyMapSelect.lua](CustomLobbyMapSelect.lua) | the dialog (transient `Popup`, host-only). Laid out in **areas** (Group containers: title / left {filter, selection, stats} / preview / actions — flip the module `Debug` flag to tint them). Searchable list + size & player filters with comparison operators (`=`/`>=`/`<=`, persisted to Prefs); candidate preview with name overlay, an "Open page" link (allowlisted `url` → `OpenURL`), and toggle-able overlays (spawns / resources / wrecks); scrollable description; file-health check that disables Select for broken maps; Random / Select / Cancel. On Select → `CustomLobbyController.RequestSetScenario(file)`. Owns no synced state. | -| [CustomLobbyMapCatalog.lua](CustomLobbyMapCatalog.lua) | the lobby's **own** async enumeration of playable skirmish maps — *not* `MapUtil.EnumerateSkirmishScenarios`. Borrows only `LoadScenarioInfoFile` (map info, not options/strings) and streams maps into a `Scenarios` `LazyVar` across frames (`EnsureLoaded`). **Reference data, never synced** — local progress reactivity only; the host syncs just its `ScenarioFile` choice. | +| [CustomLobbyMapSelect.lua](CustomLobbyMapSelect.lua) | the dialog (transient `Popup`, host-only). Laid out in **areas** (Group containers: title / left {filter, selection, stats} / preview / actions — flip the module `Debug` flag to tint them). Searchable list + size & player filters with comparison operators (`=`/`>=`/`<=`, persisted to Prefs); candidate preview (the shared `../CustomLobbyScenarioPreview` surface with numbered-dot spawns) with name overlay, an "Open page" link (allowlisted `url` → `OpenURL`), and toggle-able overlays (spawns / resources / wrecks); scrollable description; file-health check that disables Select for broken maps; Random / Select / Cancel. On Select → `CustomLobbyController.RequestSetScenario(file)`. Owns no synced state. | +| [CustomLobbyMapCatalog.lua](CustomLobbyMapCatalog.lua) | the lobby's **own** scenario data layer — and the single point of contact with `MapUtil`'s `doscript` loaders. Async enumeration of playable skirmish maps (streams into a `Scenarios` `LazyVar` across frames via `EnsureLoaded`; *not* `MapUtil.EnumerateSkirmishScenarios`), plus on-demand `LoadInfo` / `LoadSave` (the latter memoised by save path with a FIFO bound — the save `doscript` is expensive and browsing re-loads the same maps). **Reference data, never synced** — local only; the host syncs just its `ScenarioFile` choice. | | [CustomLobbyMapList.lua](CustomLobbyMapList.lua) | the dialog's scrollable, **virtualised** list: a fixed pool of **text-only** rows (name + `size · players`) reused while scrolling, standard scrollbar contract, mouse-driven (no keyboard focus — a custom Group list can't take it). `OnSelect` / `OnConfirm`. | This is the first sub-dialog split out of the legacy `dialogs/mapselect.lua` god-dialog: it does diff --git a/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapCatalog.lua b/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapCatalog.lua index f5dcd8c218a..77bae9a6cd1 100644 --- a/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapCatalog.lua +++ b/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapCatalog.lua @@ -42,9 +42,11 @@ local Create = import("/lua/lazyvar.lua").Create --- The one MapUtil function we still lean on: the `doscript` loader for a single scenario-info --- file (handles the on-disk version backcompat). Everything else is ours. -local LoadScenarioInfoFile = import("/lua/ui/maputil.lua").LoadScenarioInfoFile +-- The catalog is the lobby's single point of contact with MapUtil's `doscript` file loaders +-- (info + save) — so no other custom-lobby module imports MapUtil. Everything above that +-- (enumeration, filtering, caching) is ours. +local MapUtil = import("/lua/ui/maputil.lua") +local LoadScenarioInfoFile = MapUtil.LoadScenarioInfoFile -- maps loaded per frame-slice before yielding — keeps the open responsive on big vaults local BatchSize = 4 @@ -56,6 +58,12 @@ local Scenarios = Create({}) local Loading = false -- a load thread is currently running local Loaded = false -- the disk has been fully enumerated +-- memoised save files (the expensive `doscript`), keyed by lowercased save path; `false` = a +-- known-missing/unreadable save. FIFO-bounded so browsing a big vault doesn't grow unbounded. +local SaveCache = {} +local SaveCacheOrder = {} +local SaveCacheMax = 24 + ------------------------------------------------------------------------------- --#region Enumeration @@ -170,11 +178,65 @@ function FindByFile(scenarioFile) return nil end +--- Loads a scenario's info for `scenarioFile`. Returns the already-enumerated entry if we have +--- it (the streamed list is the info cache), else reads it from disk. nil if unreadable. +---@param scenarioFile FileName | false +---@return UILobbyScenarioInfo | nil +function LoadInfo(scenarioFile) + if not scenarioFile then + return nil + end + local cached = FindByFile(scenarioFile) + if cached then + return cached + end + local info = LoadScenarioInfoFile(scenarioFile) + if not info then + return nil + end + info.file = scenarioFile + return info --[[@as UILobbyScenarioInfo]] +end + +--- Loads (and caches) a scenario's save file — the marker data the preview overlays need. +--- The save `doscript` is expensive, and both the in-lobby preview and the picker re-load the +--- same maps repeatedly, so results are memoised by save path with a small FIFO bound. Returns +--- nil if the save is missing / unreadable (cached as a negative so we don't retry the disk). +---@param scenarioInfo UILobbyScenarioInfo +---@return UIScenarioSaveFile | nil +function LoadSave(scenarioInfo) + if not (scenarioInfo and scenarioInfo.save) then + return nil + end + + local key = string.lower(scenarioInfo.save) + local cached = SaveCache[key] + if cached ~= nil then + return cached or nil -- `false` = known-missing + end + + local save = false + if DiskGetFileInfo(scenarioInfo.save) then + save = MapUtil.LoadScenarioSaveFile(scenarioInfo.save) or false + end + + SaveCache[key] = save + table.insert(SaveCacheOrder, key) + if table.getn(SaveCacheOrder) > SaveCacheMax then + local oldest = table.remove(SaveCacheOrder, 1) + SaveCache[oldest] = nil + end + + return save or nil +end + --- Drops everything so the next `EnsureLoaded` re-reads from disk (e.g. maps changed on disk). function Refresh() Loaded = false Loading = false Scenarios:Set({}) + SaveCache = {} + SaveCacheOrder = {} end --#endregion diff --git a/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapSelect.lua b/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapSelect.lua index 7f0d474171e..d8de39340fe 100644 --- a/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapSelect.lua +++ b/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapSelect.lua @@ -30,7 +30,8 @@ -- It is a transient picker, NOT a persistent model component: -- * it subscribes to the catalog (CustomLobbyMapCatalog), which streams maps in across frames, -- * it previews the *highlighted candidate* (decoupled from the launch model) via the shared --- MapPreview control, and +-- scenario-preview surface (CustomLobbyScenarioPreview, same control the in-lobby preview +-- uses), here with numbered-dot spawns + a title bar / url link layered on top, and -- * on Select it calls the controller intent `RequestSetScenario(file)` — the same path a -- `/map ` chat command would use. It owns no synced state. -- @@ -49,15 +50,11 @@ local Prefs = import("/lua/user/prefs.lua") local Group = import("/lua/maui/group.lua").Group local Bitmap = import("/lua/maui/bitmap.lua").Bitmap local Edit = import("/lua/maui/edit.lua").Edit -local MapPreview = import("/lua/ui/controls/mappreview.lua").MapPreview local Popup = import("/lua/ui/controls/popups/popup.lua").Popup local Combo = import("/lua/ui/controls/combo.lua").Combo local TextArea = import("/lua/ui/controls/textarea.lua").TextArea --- still borrowed from MapUtil: the save-file loader + start-position extraction (file/geometry --- primitives, same category as the catalog's LoadScenarioInfoFile) -local MapUtil = import("/lua/ui/maputil.lua") - +local CustomLobbyScenarioPreview = import("/lua/ui/lobby/customlobby/customlobbyscenariopreview.lua") local CustomLobbyMapCatalog = import("/lua/ui/lobby/customlobby/mapselect/customlobbymapcatalog.lua") local CustomLobbyMapList = import("/lua/ui/lobby/customlobby/mapselect/customlobbymaplist.lua") local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") @@ -81,11 +78,6 @@ local ActionHeight = 48 local FilterHeight = 134 local StatsHeight = 22 --- the same resource/wreck icons the in-lobby map preview uses (CustomLobbyMapPreview) -local MassIcon = "/game/build-ui/icon-mass_bmp.dds" -local EnergyIcon = "/game/build-ui/icon-energy_bmp.dds" -local WreckIcon = "/scx_menu/lan-game-lobby/mappreview/wreckage.dds" - local PrefsKey = "customlobby_mapselect" -- Filter dropdowns. The first entry is the "no filter" option; `value` matches the scenario @@ -213,7 +205,6 @@ end ---@class UICustomLobbyMapSelect : Group ---@field Trash TrashBag ----@field OverlayTrash TrashBag ---@field TitleArea Group ---@field LeftArea Group ---@field FilterArea Group @@ -243,17 +234,8 @@ end ---@field SpawnsToggle Checkbox ---@field ResourcesToggle Checkbox ---@field WrecksToggle Checkbox ----@field ShowSpawns boolean ----@field ShowResources boolean ----@field ShowWrecks boolean ----@field SpawnIcons Control[] ----@field ResourceIcons Control[] ----@field WreckIcons Control[] ----@field Preview MapPreview +---@field Surface UICustomLobbyScenarioPreview ---@field PreviewBg Bitmap ----@field MassTemplate Bitmap # hidden; overlay icons share its texture (loaded once) ----@field EnergyTemplate Bitmap ----@field WreckTemplate Bitmap ---@field PreviewTitleBar Bitmap ---@field PreviewTitle Text ---@field InfoMeta Text @@ -285,19 +267,12 @@ local CustomLobbyMapSelect = ClassUI(Group) { Group.__init(self, parent, "CustomLobbyMapSelect") self.Trash = TrashBag() - self.OverlayTrash = self.Trash:Add(TrashBag()) self.OnConfirmCb = onConfirm self.OnCancelCb = onCancel self.Ready = false self.Filtered = {} self.Selected = nil - self.ShowSpawns = true - self.ShowResources = false - self.ShowWrecks = false - self.SpawnIcons = {} - self.ResourceIcons = {} - self.WreckIcons = {} self.CurrentFile = CustomLobbyLaunchModel.GetSingleton().ScenarioFile() self.Scenarios = CustomLobbyMapCatalog.GetScenarios() @@ -382,28 +357,25 @@ local CustomLobbyMapSelect = ClassUI(Group) { --#endregion --#region preview area (toggles, preview, info, description) - self.SpawnsToggle = self:CreateToggle("Spawns", self.ShowSpawns, - function(checked) self.ShowSpawns = checked; self:ApplyOverlayVisibility() end, - "Spawns", "Show the start positions.") - self.ResourcesToggle = self:CreateToggle("Resources", self.ShowResources, - function(checked) self.ShowResources = checked; self:ApplyOverlayVisibility() end, - "Resources", "Show mass and hydrocarbon deposits.") - self.WrecksToggle = self:CreateToggle("Wrecks", self.ShowWrecks, - function(checked) self.ShowWrecks = checked; self:ApplyOverlayVisibility() end, - "Wrecks", "Show prebuilt wreckage (if the map defines any).") - + -- the candidate preview surface (shared with the in-lobby preview); spawns render as + -- numbered dots (the surface default). Resources/wrecks start hidden — the toggles flip + -- them; spawns start shown. self.PreviewBg = Bitmap(self.PreviewArea) self.PreviewBg:SetSolidColor('ff000000') self.PreviewBg:DisableHitTest() - self.Preview = MapPreview(self.PreviewArea) + self.Surface = CustomLobbyScenarioPreview.Create(self.PreviewArea) + self.Surface:SetOverlayVisible('resources', false) + self.Surface:SetOverlayVisible('wrecks', false) - -- hidden template bitmaps: each overlay texture is loaded ONCE here, then every marker - -- shares it via ShareTextures (see CreateMarkerIcon) rather than re-loading per icon. - -- They still need a concrete (dummy) position — an unanchored control's Left/Right - -- reference each other and trip the circular-evaluation guard (see /lua/ui/CLAUDE.md § 1). - self.MassTemplate = self:CreateTemplateBitmap(MassIcon) - self.EnergyTemplate = self:CreateTemplateBitmap(EnergyIcon) - self.WreckTemplate = self:CreateTemplateBitmap(WreckIcon) + self.SpawnsToggle = self:CreateToggle("Spawns", true, + function(checked) self.Surface:SetOverlayVisible('spawns', checked) end, + "Spawns", "Show the start positions.") + self.ResourcesToggle = self:CreateToggle("Resources", false, + function(checked) self.Surface:SetOverlayVisible('resources', checked) end, + "Resources", "Show mass and hydrocarbon deposits.") + self.WrecksToggle = self:CreateToggle("Wrecks", false, + function(checked) self.Surface:SetOverlayVisible('wrecks', checked) end, + "Wrecks", "Show prebuilt wreckage (if the map defines any).") self.PreviewTitleBar = Bitmap(self.PreviewArea) self.PreviewTitleBar:SetSolidColor('aa0a0e12') @@ -542,33 +514,33 @@ local CustomLobbyMapSelect = ClassUI(Group) { Layouter(self.SpawnsToggle):AnchorToLeft(self.ResourcesToggle, 16):AtVerticalCenterIn(self.ResourcesToggle):End() Layouter(self.WrecksToggle):AnchorToRight(self.ResourcesToggle, 16):AtVerticalCenterIn(self.ResourcesToggle):End() - Layouter(self.Preview) + Layouter(self.Surface) :AtHorizontalCenterIn(self.PreviewArea):AnchorToBottom(self.ResourcesToggle, 10) :Width(PreviewSize):Height(PreviewSize) :End() - Layouter(self.PreviewBg):Fill(self.Preview):End() - self.PreviewBg.Depth:Set(function() return self.Preview.Depth() - 1 end) + Layouter(self.PreviewBg):Fill(self.Surface):End() + self.PreviewBg.Depth:Set(function() return self.Surface.Depth() - 1 end) -- map name overlaid across the top of the preview - Layouter(self.PreviewTitleBar):AtLeftIn(self.Preview):AtRightIn(self.Preview):AtTopIn(self.Preview):Height(26):End() - self.PreviewTitleBar.Depth:Set(function() return self.Preview.Depth() + 20 end) - Layouter(self.PreviewTitle):AtHorizontalCenterIn(self.Preview):AtVerticalCenterIn(self.PreviewTitleBar):End() + Layouter(self.PreviewTitleBar):AtLeftIn(self.Surface):AtRightIn(self.Surface):AtTopIn(self.Surface):Height(26):End() + self.PreviewTitleBar.Depth:Set(function() return self.Surface.Depth() + 20 end) + Layouter(self.PreviewTitle):AtHorizontalCenterIn(self.Surface):AtVerticalCenterIn(self.PreviewTitleBar):End() self.PreviewTitle.Depth:Set(function() return self.PreviewTitleBar.Depth() + 1 end) -- url link on the right of the title bar (shown only when the map has an allowed url) - Layouter(self.UrlButton):AtRightIn(self.Preview, 8):AtVerticalCenterIn(self.PreviewTitleBar):End() + Layouter(self.UrlButton):AtRightIn(self.Surface, 8):AtVerticalCenterIn(self.PreviewTitleBar):End() self.UrlButton.Depth:Set(function() return self.PreviewTitleBar.Depth() + 2 end) -- info centred under the preview - Layouter(self.InfoMeta):AtHorizontalCenterIn(self.Preview):AnchorToBottom(self.Preview, 12):End() - Layouter(self.Warning):AtHorizontalCenterIn(self.Preview):AnchorToBottom(self.InfoMeta, 4):End() + Layouter(self.InfoMeta):AtHorizontalCenterIn(self.Surface):AnchorToBottom(self.Surface, 12):End() + Layouter(self.Warning):AtHorizontalCenterIn(self.Surface):AnchorToBottom(self.InfoMeta, 4):End() -- description sits under the preview, exactly as wide as the map (so it reads as -- centred, since the preview is centred in its column); scrolls when it overflows Layouter(self.Description) - :AtLeftIn(self.Preview):AnchorToBottom(self.Warning, 10):AtBottomIn(self.PreviewArea) + :AtLeftIn(self.Surface):AnchorToBottom(self.Warning, 10):AtBottomIn(self.PreviewArea) :End() - self.Description.Right:Set(function() return self.Preview.Right() end) + self.Description.Right:Set(function() return self.Surface.Right() end) UIUtil.CreateVertScrollbarFor(self.Description) --#endregion @@ -744,7 +716,7 @@ local CustomLobbyMapSelect = ClassUI(Group) { local row = math.random(1, count) self.MapList:SetSelection(row) self.MapList:ShowItem(row) - self:OnMapSelected(self.Filtered[row]) + self:OnMapSelected(self.Filtered[ ow]) end, --- A map was selected: make it the candidate and refresh the preview + info. @@ -761,20 +733,11 @@ local CustomLobbyMapSelect = ClassUI(Group) { end end, - --- Loads what's needed to show a candidate: preview texture + overlays, info, and a + --- Shows a candidate: hands the scenario to the preview surface, fills the info, and runs a --- file-health check that gates Select. ---@param self UICustomLobbyMapSelect ---@param scenario UILobbyScenarioInfo Inspect = function(self, scenario) - self.OverlayTrash:Destroy() - self.SpawnIcons = {} - self.ResourceIcons = {} - self.WreckIcons = {} - - if not self.Preview:SetTexture(scenario.preview) then - self.Preview:SetTextureFromMap(scenario.map) - end - local problems = {} if not DiskGetFileInfo(scenario.map) then table.insert(problems, "map missing") @@ -783,20 +746,13 @@ local CustomLobbyMapSelect = ClassUI(Group) { table.insert(problems, "script missing") end - local save = nil - if DiskGetFileInfo(scenario.save) then - save = MapUtil.LoadScenarioSaveFile(scenario.save) - else + if not DiskGetFileInfo(scenario.save) then table.insert(problems, "save missing") end - if save and scenario.size then - self:BuildSpawns(scenario, save) - self:BuildResources(scenario, save) - self:BuildWrecks(scenario, save) - self:ApplyOverlayVisibility() - end - + -- the catalog owns scenario loading + caching (the save doscript is expensive and we + -- re-inspect the same maps as you browse) + self.Surface:SetScenario(scenario, CustomLobbyMapCatalog.LoadSave(scenario)) self:UpdateInfo(scenario) self.RandomButton:Enable() @@ -842,175 +798,11 @@ local CustomLobbyMapSelect = ClassUI(Group) { self.Description:SetText(scenario.description and LOC(scenario.description) or "") end, - --- Places numbered start-spot markers on the preview from the save's start positions. - ---@param self UICustomLobbyMapSelect - ---@param scenario UILobbyScenarioInfo - ---@param save UIScenarioSaveFile - BuildSpawns = function(self, scenario, save) - local positions = MapUtil.GetStartPositionsFromScenario(scenario, save) - if not positions then - return - end - for index, position in positions do - local dot = self.OverlayTrash:Add(self:CreateSpawnDot(index)) - self:PositionMarker(dot, scenario.size[1], scenario.size[2], position[1], position[2]) - table.insert(self.SpawnIcons, dot) - end - end, - - --- Places mass + hydrocarbon icons from the save's master-chain markers. - ---@param self UICustomLobbyMapSelect - ---@param scenario UILobbyScenarioInfo - ---@param save UIScenarioSaveFile - BuildResources = function(self, scenario, save) - for _, marker in self:Markers(save) do - local template = (marker.type == "Mass" and self.MassTemplate) - or (marker.type == "Hydrocarbon" and self.EnergyTemplate) - or false - if template and marker.position then - local dot = self.OverlayTrash:Add(self:CreateMarkerIcon(template, 10)) - self:PositionMarker(dot, scenario.size[1], scenario.size[2], marker.position[1], marker.position[3]) - table.insert(self.ResourceIcons, dot) - end - end - end, - - --- Best-effort wreck icons: maps that expose prebuilt wreckage as save markers. - ---@param self UICustomLobbyMapSelect - ---@param scenario UILobbyScenarioInfo - ---@param save UIScenarioSaveFile - BuildWrecks = function(self, scenario, save) - for _, marker in self:Markers(save) do - if marker.type and marker.position and string.find(string.lower(marker.type), 'wreck') then - local dot = self.OverlayTrash:Add(self:CreateMarkerIcon(self.WreckTemplate, 11)) - self:PositionMarker(dot, scenario.size[1], scenario.size[2], marker.position[1], marker.position[3]) - table.insert(self.WreckIcons, dot) - end - end - end, - - --- Shows/hides each overlay group according to its toggle. - ---@param self UICustomLobbyMapSelect - ApplyOverlayVisibility = function(self) - local function setVisible(icons, visible) - for _, icon in icons do - if visible then - icon:Show() - else - icon:Hide() - end - end - end - setVisible(self.SpawnIcons, self.ShowSpawns) - setVisible(self.ResourceIcons, self.ShowResources) - setVisible(self.WreckIcons, self.ShowWrecks) - end, - - --- The save's master-chain markers, or an empty table. - ---@param self UICustomLobbyMapSelect - ---@param save UIScenarioSaveFile - ---@return table - Markers = function(self, save) - local masterChain = save.MasterChain and save.MasterChain['_MASTERCHAIN_'] - return (masterChain and masterChain.Markers) or {} - end, - - --- Builds a small numbered start-spot marker. Private. - ---@param self UICustomLobbyMapSelect - ---@param index number - ---@return Group - CreateSpawnDot = function(self, index) - local dot = Group(self.PreviewArea) - dot:DisableHitTest() - dot.Depth:Set(function() return self.Preview.Depth() + 10 end) - - local bg = Bitmap(dot) - bg:SetSolidColor('cc1c2228') - bg:DisableHitTest() - - local label = UIUtil.CreateText(dot, tostring(index), 12, UIUtil.bodyFont) - label:DisableHitTest() - - Layouter(dot):Width(18):Height(18):End() - Layouter(bg):Fill(dot):End() - Layouter(label):AtCenterIn(dot):End() - return dot - end, - - --- Loads an overlay texture once into a hidden template bitmap that markers share from. - --- Given a dummy position because an unanchored control's Left/Right are circular. Private. - ---@param self UICustomLobbyMapSelect - ---@param texture FileName - ---@return Bitmap - CreateTemplateBitmap = function(self, texture) - local template = UIUtil.CreateBitmap(self.PreviewArea, texture) - template:DisableHitTest() - Layouter(template):Left(0):Top(0):Width(8):Height(8):End() - template:Hide() - -- lock it hidden: a parent Show() (Popup mounting the dialog) would otherwise reveal - -- this never-positioned-for-display bitmap. Same trick TexturePool uses for pooled - -- bitmaps — OnHide returning true keeps it hidden regardless of the parent. - template.OnHide = function(control, hidden) - return true - end - return template - end, - - --- Builds a small resource/wreck marker that SHARES its texture with a hidden template - --- bitmap (allocated once in __init), so the texture is loaded once and reused for every - --- marker — not re-loaded per icon. Private. - ---@param self UICustomLobbyMapSelect - ---@param template Bitmap - ---@param size number - ---@return Bitmap - CreateMarkerIcon = function(self, template, size) - local icon = UIUtil.CreateBitmapColor(self.PreviewArea, 'ffffff') - icon:DisableHitTest() - -- size only (matches CustomLobbyMapPreview's working pattern); PositionMarker pins - -- Left/Top afterwards, so the size must be set so its centring maths has a real Width - Layouter(icon):Width(size):Height(size):End() - icon:ShareTextures(template) - icon.Depth:Set(function() return self.Preview.Depth() + 5 end) - return icon - end, - - --- Positions a marker over the preview at a map coordinate (mirrors the preview's own - --- aspect-correct placement). Private. - ---@param self UICustomLobbyMapSelect - ---@param icon Control - ---@param mapWidth number - ---@param mapHeight number - ---@param px number - ---@param pz number - PositionMarker = function(self, icon, mapWidth, mapHeight, px, pz) - local size = self.Preview.Width() - local xOffset, xFactor, yOffset, yFactor = 0, 1, 0, 1 - if mapWidth > mapHeight then - local ratio = mapHeight / mapWidth - yOffset = ((size / ratio) - size) / 4 - yFactor = ratio - else - local ratio = mapWidth / mapHeight - xOffset = ((size / ratio) - size) / 4 - xFactor = ratio - end - - local x = xOffset + (px / mapWidth) * (size - 2) * xFactor - local z = yOffset + (pz / mapHeight) * (size - 2) * yFactor - - icon.Left:Set(function() return self.Preview.Left() + x - 0.5 * icon.Width() end) - icon.Top:Set(function() return self.Preview.Top() + z - 0.5 * icon.Height() end) - end, - --- Clears the preview + info (no candidate / empty list). ---@param self UICustomLobbyMapSelect ClearDetails = function(self) self.LastInspected = nil - self.OverlayTrash:Destroy() - self.SpawnIcons = {} - self.ResourceIcons = {} - self.WreckIcons = {} - self.Preview:ClearTexture() + self.Surface:Clear() self.PreviewTitle:SetText("") self.InfoMeta:SetText("") self.Warning:SetText("") From 2bf52a9683d43224041f64067e0213eadcf805bf Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Mon, 22 Jun 2026 15:13:40 +0200 Subject: [PATCH 23/98] Add support for a mod selection dialog --- lua/ui/lobby/customlobby/CLAUDE.md | 6 +- .../customlobby/CustomLobbyController.lua | 19 + .../customlobby/CustomLobbyInterface.lua | 15 + .../customlobby/CustomLobbyLaunchModel.lua | 8 + .../mapselect/CustomLobbyMapCatalog.lua | 2 +- .../mapselect/CustomLobbyMapList.lua | 18 +- .../mapselect/CustomLobbyMapSelect.lua | 50 +- lua/ui/lobby/customlobby/modselect/CLAUDE.md | 50 + .../modselect/CustomLobbyModCatalog.lua | 204 ++++ .../modselect/CustomLobbyModList.lua | 418 +++++++ .../modselect/CustomLobbyModSelect.lua | 1043 +++++++++++++++++ lua/ui/modutilities.lua | 376 ++++++ 12 files changed, 2201 insertions(+), 8 deletions(-) create mode 100644 lua/ui/lobby/customlobby/modselect/CLAUDE.md create mode 100644 lua/ui/lobby/customlobby/modselect/CustomLobbyModCatalog.lua create mode 100644 lua/ui/lobby/customlobby/modselect/CustomLobbyModList.lua create mode 100644 lua/ui/lobby/customlobby/modselect/CustomLobbyModSelect.lua create mode 100644 lua/ui/modutilities.lua diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index 38bd1a8de37..8f3ca95c0c5 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -53,6 +53,7 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | [CustomLobbyScenarioPreview.lua](CustomLobbyScenarioPreview.lua) | **shared** map-preview *surface*: the scenario's map texture + overlays (start spots, resource/wreck markers) with aspect-correct positioning, texture-leak-safe icon sharing, per-group visibility, and three-phase init. Chrome-free; owners wrap it. Spawn appearance is the owner's via a `CreateSpawnIcon` factory. Used by both the in-lobby preview and the map-select dialog so the fiddly bits live once. | | [CustomLobbyMapPreview.lua](CustomLobbyMapPreview.lua) / [CustomLobbyMapPreviewSpawn.lua](CustomLobbyMapPreviewSpawn.lua) | the in-lobby preview: a thin model-bound wrapper around `CustomLobbyScenarioPreview` (faction-icon spawns) + glow/brackets chrome. Subscribes to `ScenarioFile` (loads via the catalog, hands to the surface, show/hide) + each slot (refreshes the surface's spawn data, no reload). `CustomLobbyMapPreviewSpawn` is the faction spawn icon. | | [mapselect/](mapselect/CLAUDE.md) | the **map-select dialog** + its catalog and list, in their own folder (host-only `Popup`: searchable, filterable scenario list → `RequestSetScenario`). Self-contained sub-MVC; see [mapselect/CLAUDE.md](mapselect/CLAUDE.md) — including the **`MapPreview` texture-leak** writeup that shaped its design. | +| [modselect/](modselect/CLAUDE.md) | the **mod-select dialog** + its catalog and list, built to the map-select shape (checkbox list + type filters + detail panel + **presets**). Returns a uid set; the opener routes it — sim mods → `RequestSetGameMods` (synced), UI mods → local prefs — or persists the lot standalone. Mod domain logic lives in [`/lua/ui/modutilities.lua`](/lua/ui/modutilities.lua) (the `maputil.lua` sibling, fronting `/lua/mods.lua`). See [modselect/CLAUDE.md](modselect/CLAUDE.md). | | [CustomLobbyInterface.lua](CustomLobbyInterface.lua) | composition root (one model subscription for SlotCount); lays out slot rows + the observer strip, and acts as the rows' drag coordinator (`UICustomLobbySlotCoordinator`: hit-test, drop-highlight, drag ghost → `RequestSwapSlots`); `OpenDebug()` / hot-reload. | | [/lua/ui/lobby/lobby.lua](../lobby.lua) | engine entry wrapper (`CreateLobby`/`HostGame`/`JoinGame`) → CustomLobby. Old lobby preserved at `lobby-old.lua`. | @@ -81,7 +82,9 @@ the map-select dialog (searchable list + preview), which sets `ScenarioFile` thr `GameOptions` *values*. When the scenario/mods change, the controller **reconciles** the values (drop stale keys, seed new defaults) — see the `TODO` in `RequestSetScenario`. Merge rule: start with map/mod options only *adding* keys (no overriding base lobby options). -2. Remaining sub-dialogs (mods, units, presets, prefs) as mini-MVC, following the map-select shape. +2. Remaining sub-dialogs (units, prefs) as mini-MVC, following the map-select shape. **Mods** is + done — see [modselect/](modselect/CLAUDE.md); it carries its own presets (replacing the legacy + per-mod "favorites"), so a separate presets dialog may not be needed. 3. Make slot controls interactive (faction/colour/team → controller **intents**). 4. Map-derived `SlotCount`; the GPGNet (`localPort == -1`) FAF-client path. @@ -106,6 +109,7 @@ lobby-specific checklist. | Cascade of errors after one layout failure (half-built pools, observers firing into a broken control) | A throw mid-`Initialize` left partial state (e.g. `PoolCount` set before the rows existed), and a streaming `Derive`/thread kept calling into it. | Set "ready" counters **after** the thing they describe is fully built; guard paint/refresh paths against nil rows; gate model-observer work on a `self.Ready` flag set in `Initialize`. | | `circular dependency in lazy evaluation` with **no frame from your files** (fires during the render pass) | A control you created in `__init` is **never anchored** in `__post_init` (or you renamed/moved its layout and forgot it). Its `Left`/`Right`/`Top`/`Bottom` stay at the circular defaults, and it errors the moment it's rendered. | Every visible control needs a layout. To find which one, set `import("/lua/lazyvar.lua").ExtendedErrorMessages = true` — the error then appends the offending control's **creation stack** (file + line). Then anchor it (or hide it). | | `circular dependency` only **on hot-reload** (or when the model already has state), not on a fresh open | A `Derive` observer fires **immediately on creation** in `__init`, and its handler reads layout geometry (e.g. positions icons via `self.Preview.Width()`). Fresh open: the model field is empty, handler no-ops. Reload: the field already has a value, so it renders before the parent has laid the component out. | Gate the observer handlers behind a `self.Ready` flag (default false). Set `Ready = true` and do the first render **deferred** (`ForkThread` + `WaitFrames(1)` from `__post_init`, or an `Initialize()` the parent calls) — once the parent has actually sized you. See `CustomLobbyMapPreview`. | +| A `TextArea`'s text wraps far too early (~50–60% of the visible box) | `TextArea.__init` pins `Width` to its **constructor** `width` arg via `SetDimensions`, and `ReflowText` wraps to `Width()` — but the fluent `:AtLeftIn():AtRightIn()` set only `Left`/`Right`, never `Width`. So the box renders `Left..Right` wide while the text still wraps at the stale constructor width. | Bind `Width` to the span: `self.X.Width:Set(function() return self.X.Right() - self.X.Left() end)`. **Do this in `Initialize()` (post-mount), not `__post_init`:** `TextArea` hooks `Width.OnDirty → ReflowText`, so the bind *eagerly* reads `Right()/Left()` → the parent geometry, which is still circular until `Popup` mounts the dialog. (Left/Right anchor to the parent, so no self-cycle once mounted.) | | Memory climbs ~30 MB and never drops — not on re-texture, `ClearTexture`, `Destroy`, or dialog close | `MapPreview:SetTexture` / `SetTextureFromMap` allocate textures the engine **never frees** (no release API on the control or globally). Loading one per list row × hundreds of maps leaks memory the game needs in-match. | Don't put a `MapPreview` per list row. Render at most a **few** previews total (the map-select list is text-only; only the single candidate preview loads a texture, once per selection). The same applies to per-row `Bitmap` thumbnails via `SetNewTexture`. | Reference implementation for all four: [mapselect/CustomLobbyMapSelect.lua](mapselect/CustomLobbyMapSelect.lua) diff --git a/lua/ui/lobby/customlobby/CustomLobbyController.lua b/lua/ui/lobby/customlobby/CustomLobbyController.lua index 131395ead49..d6f162ee0f4 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyController.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyController.lua @@ -525,6 +525,25 @@ function RequestSetScenario(scenarioFile) BroadcastLaunchInfo(instance) end +--- The host sets the active sim mods. Host-only — backs the mod-select dialog. Sets `GameMods` +--- in the launch model and broadcasts the launch config so every peer sees the same sim mods. +--- UI mods are per-peer and handled locally by the dialog, never here. +---@param gameMods table +function RequestSetGameMods(gameMods) + local instance = LobbyInstance + if not instance then + return + end + + if not CustomLobbyLocalModel.GetSingleton().IsHost() then + WARN("CustomLobby: only the host can change the game mods") + return + end + + CustomLobbyLaunchModel.SetGameMods(CustomLobbyLaunchModel.GetSingleton(), gameMods) + BroadcastLaunchInfo(instance) +end + --- The host swaps the contents of two slots. Host-only (a client request isn't --- offered) — backs a host-side drag/menu and a `/swap ` chat command. ---@param slotA number diff --git a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua index 0ace4cfa243..e9b2041dc96 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua @@ -40,6 +40,7 @@ local CustomLobbySlotInterface = import("/lua/ui/lobby/customlobby/customlobbysl local CustomLobbyObserversInterface = import("/lua/ui/lobby/customlobby/customlobbyobserversinterface.lua") local CustomLobbyMapPreview = import("/lua/ui/lobby/customlobby/customlobbymappreview.lua") local CustomLobbyMapSelect = import("/lua/ui/lobby/customlobby/mapselect/customlobbymapselect.lua") +local CustomLobbyModSelect = import("/lua/ui/lobby/customlobby/modselect/customlobbymodselect.lua") local LazyVarDerive = import("/lua/lazyvar.lua").Derive @@ -57,6 +58,7 @@ local PanelWidth = 520 ---@field ObserversPanel UICustomLobbyObserversInterface ---@field MapPreview UICustomLobbyMapPreview ---@field MapButton Button +---@field ModsButton Button ---@field LeaveButton Button ---@field SlotCountObserver LazyVar ---@field IsHostObserver LazyVar @@ -97,6 +99,13 @@ local CustomLobbyInterface = Class(Group) { CustomLobbyMapSelect.Open(GetFrame(0)) end + -- everyone can open the mod picker (UI mods are per-player); only the host can change the + -- shared sim mods, which the dialog gates on its own + self.ModsButton = UIUtil.CreateButtonStd(self, '/scx_menu/small-btn/small', "Mods", 16, 2) + self.ModsButton.OnClick = function(button, modifiers) + CustomLobbyModSelect.Open(GetFrame(0)) + end + -- leaving disconnects + returns to the menu via the escape handler that -- lobby.lua registered (one teardown definition, shared with the Esc key) self.LeaveButton = UIUtil.CreateButtonStd(self, '/scx_menu/small-btn/small', "Leave", 16, 2) @@ -145,6 +154,12 @@ local CustomLobbyInterface = Class(Group) { :AtLeftIn(self.MapPreview) :End() + -- mods button beside it (shown to everyone) + Layouter(self.ModsButton) + :AnchorToRight(self.MapButton, 8) + :AtVerticalCenterIn(self.MapButton) + :End() + -- stack the rows top-to-bottom inside the panel via sibling anchoring for slot = 1, CustomLobbyLaunchModel.MaxSlots do local row = self.Slots[slot] diff --git a/lua/ui/lobby/customlobby/CustomLobbyLaunchModel.lua b/lua/ui/lobby/customlobby/CustomLobbyLaunchModel.lua index d222d266b18..bca1b674cab 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyLaunchModel.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyLaunchModel.lua @@ -168,6 +168,14 @@ function SetScenario(model, scenarioFile) model.ScenarioFile:Set(scenarioFile) end +--- Sets the active sim mods (a uid set). UI mods are per-peer and never live here — only sim +--- mods become part of the launch (and must agree across players). +---@param model UICustomLobbyLaunchModel +---@param gameMods table +function SetGameMods(model, gameMods) + model.GameMods:Set(table.copy(gameMods)) +end + --- Appends a player to the observer list (copy-then-Set). ---@param model UICustomLobbyLaunchModel ---@param player UICustomLobbyPlayer diff --git a/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapCatalog.lua b/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapCatalog.lua index 77bae9a6cd1..8097090a10e 100644 --- a/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapCatalog.lua +++ b/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapCatalog.lua @@ -49,7 +49,7 @@ local MapUtil = import("/lua/ui/maputil.lua") local LoadScenarioInfoFile = MapUtil.LoadScenarioInfoFile -- maps loaded per frame-slice before yielding — keeps the open responsive on big vaults -local BatchSize = 4 +local BatchSize = 5 --- The growing list of playable skirmish maps. Re-fired (new table ref) as batches land. ---@type LazyVar diff --git a/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapList.lua b/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapList.lua index de859685842..57253a3db99 100644 --- a/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapList.lua +++ b/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapList.lua @@ -77,6 +77,7 @@ end ---@field PoolCount number ---@field ScrollTop number # 0-based scroll offset (first visible = Items[ScrollTop+1]); NOT the `Top` edge LazyVar ---@field Selected number | false # selected item index (1-based) +---@field Scrollbar Scrollbar | false # hidden while everything fits ---@field OnSelect fun(scenario: UILobbyScenarioInfo, index: number) ---@field OnConfirm fun(scenario: UILobbyScenarioInfo) local CustomLobbyMapList = ClassUI(Group) { @@ -92,6 +93,7 @@ local CustomLobbyMapList = ClassUI(Group) { self.PoolCount = 0 self.ScrollTop = 0 self.Selected = false + self.Scrollbar = false self.OnSelect = nil self.OnConfirm = nil end, @@ -135,7 +137,7 @@ local CustomLobbyMapList = ClassUI(Group) { -- CalcVisible iterating past the rows that were actually created self.PoolCount = count - UIUtil.CreateVertScrollbarFor(self) + self.Scrollbar = UIUtil.CreateVertScrollbarFor(self) self:CalcVisible() end, @@ -275,6 +277,20 @@ local CustomLobbyMapList = ClassUI(Group) { for i = 1, self.PoolCount do self:PaintRow(self.Rows[i], self.ScrollTop + i) end + self:UpdateScrollbar() + end, + + --- Shows the scrollbar only when there are more items than fit the pool. + ---@param self UICustomLobbyMapList + UpdateScrollbar = function(self) + if not self.Scrollbar then + return + end + if table.getn(self.Items) > self.PoolCount then + self.Scrollbar:Show() + else + self.Scrollbar:Hide() + end end, ---@param self UICustomLobbyMapList diff --git a/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapSelect.lua b/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapSelect.lua index d8de39340fe..5fa7a6f5814 100644 --- a/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapSelect.lua +++ b/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapSelect.lua @@ -136,6 +136,27 @@ local function IsAllowedUrl(url) return false end +--- Downgrades an `https://` URL to `http://` — the engine's `OpenURL` only handles `http://`. +---@param url string +---@return string +local function ToOpenableUrl(url) + return (string.gsub(url, "^https://", "http://")) +end + +--- Shows `scrollbar` only when the TextArea's content is taller than the box it sits in. +---@param textArea TextArea +---@param scrollbar Scrollbar | false +local function UpdateTextAreaScrollbar(textArea, scrollbar) + if not scrollbar then + return + end + if textArea:GetTextHeight() > textArea.Height() then + scrollbar:Show() + else + scrollbar:Hide() + end +end + --- Pulls the `.label` column out of a filter table for `Combo:AddItems`. ---@param filters table[] ---@return string[] @@ -214,6 +235,7 @@ end ---@field ActionArea Group ---@field Title Text ---@field FilterTitle Text +---@field SearchLabel Text ---@field Search Edit ---@field SizeLabel Text ---@field SizeCombo Combo @@ -241,6 +263,7 @@ end ---@field InfoMeta Text ---@field Warning Text ---@field Description TextArea +---@field DescriptionScrollbar Scrollbar | false ---@field UrlButton Text ---@field CurrentUrl string | false ---@field RandomButton Button @@ -301,6 +324,9 @@ local CustomLobbyMapSelect = ClassUI(Group) { --#region filters (in FilterArea) self.FilterTitle = UIUtil.CreateText(self.FilterArea, "Filter", 14, UIUtil.titleFont) + self.SearchLabel = UIUtil.CreateText(self.FilterArea, "Search", 13, UIUtil.bodyFont) + self.SearchLabel:SetColor('ff9aa0a8') + self.Search = Edit(self.FilterArea) Layouter(self.Search):Left(0):Top(0):Width(96):Height(22):End() self.Search:SetFont(UIUtil.bodyFont, 16) @@ -316,7 +342,7 @@ local CustomLobbyMapSelect = ClassUI(Group) { self:Confirm() return true end - Tooltip.AddControlTooltipManual(self.Search, "Search", "Filter the list by map name.") + Tooltip.AddControlTooltipManual(self.Search, "Search", "Filter the list by map name or author.") self.SizeLabel = UIUtil.CreateText(self.FilterArea, "Size", 13, UIUtil.bodyFont) self.SizeLabel:SetColor('ff9aa0a8') @@ -391,7 +417,7 @@ local CustomLobbyMapSelect = ClassUI(Group) { self.UrlButton.HandleEvent = function(control, event) if event.Type == 'ButtonPress' then if self.CurrentUrl then - OpenURL(self.CurrentUrl) + OpenURL(ToOpenableUrl(self.CurrentUrl)) end return true elseif event.Type == 'MouseEnter' then @@ -488,7 +514,8 @@ local CustomLobbyMapSelect = ClassUI(Group) { --#region filters Layouter(self.FilterTitle):AtLeftIn(self.FilterArea):AtTopIn(self.FilterArea):End() - Layouter(self.Search):AtLeftIn(self.FilterArea):AtRightIn(self.FilterArea):AnchorToBottom(self.FilterTitle, 8):Height(22):End() + Layouter(self.SearchLabel):AtLeftIn(self.FilterArea):AtVerticalCenterIn(self.Search):End() + Layouter(self.Search):AnchorToRight(self.SearchLabel, 8):AtRightIn(self.FilterArea):AnchorToBottom(self.FilterTitle, 8):Height(22):End() Layouter(self.SizeCombo):AtLeftIn(self.FilterArea, 56):AnchorToBottom(self.Search, 12):Width(110):End() Layouter(self.SizeLabel):AtLeftIn(self.FilterArea):AtVerticalCenterIn(self.SizeCombo):End() @@ -541,7 +568,7 @@ local CustomLobbyMapSelect = ClassUI(Group) { :AtLeftIn(self.Surface):AnchorToBottom(self.Warning, 10):AtBottomIn(self.PreviewArea) :End() self.Description.Right:Set(function() return self.Surface.Right() end) - UIUtil.CreateVertScrollbarFor(self.Description) + self.DescriptionScrollbar = UIUtil.CreateVertScrollbarFor(self.Description) --#endregion --#region actions @@ -610,6 +637,12 @@ local CustomLobbyMapSelect = ClassUI(Group) { ---@param self UICustomLobbyMapSelect Initialize = function(self) self.Ready = true + + -- TextArea pins Width to its constructor value and wraps to Width(), not Left..Right. Bind + -- it to the laid-out (map-wide) span now — not in __post_init: the bind eagerly fires + -- Width.OnDirty → ReflowText, which reads parent geometry that's circular until mounted. + self.Description.Width:Set(function() return self.Description.Right() - self.Description.Left() end) + self.MapList:Initialize() self:Populate() end, @@ -663,7 +696,12 @@ local CustomLobbyMapSelect = ClassUI(Group) { local selectedRow = 0 for _, scenario in self.Scenarios do - local matchesName = search == "" or string.find(string.lower(scenario.name), search, 1, true) + -- search matches the map name or (when the scenario provides one) its author + local haystack = string.lower(scenario.name or "") + if scenario.author then + haystack = haystack .. " " .. string.lower(scenario.author) + end + local matchesName = search == "" or string.find(haystack, search, 1, true) if matchesName and self:PassesFilters(scenario) then table.insert(self.Filtered, scenario) if target and string.lower(scenario.file) == target then @@ -796,6 +834,7 @@ local CustomLobbyMapSelect = ClassUI(Group) { end self.Description:SetText(scenario.description and LOC(scenario.description) or "") + UpdateTextAreaScrollbar(self.Description, self.DescriptionScrollbar) end, --- Clears the preview + info (no candidate / empty list). @@ -809,6 +848,7 @@ local CustomLobbyMapSelect = ClassUI(Group) { self.Description:SetText("") self.CurrentUrl = false self.UrlButton:Hide() + UpdateTextAreaScrollbar(self.Description, self.DescriptionScrollbar) end, --- Commits the highlighted candidate via the controller intent. diff --git a/lua/ui/lobby/customlobby/modselect/CLAUDE.md b/lua/ui/lobby/customlobby/modselect/CLAUDE.md new file mode 100644 index 00000000000..cd695b2adc9 --- /dev/null +++ b/lua/ui/lobby/customlobby/modselect/CLAUDE.md @@ -0,0 +1,50 @@ +# Mod select + +The custom lobby's **mod-select dialog** and its supporting pieces, in their own folder — the +mod-side counterpart to [`../mapselect/`](../mapselect/CLAUDE.md), built to the same shape. Opened +by the "Mods" button in [`../CustomLobbyInterface.lua`](../CustomLobbyInterface.lua), and usable +standalone (the main-menu mod manager use case). + +> Read [`../CLAUDE.md`](../CLAUDE.md) first (the lobby's MVC + the layout/init gotchas), then +> [`../mapselect/CLAUDE.md`](../mapselect/CLAUDE.md) — this dialog mirrors it, including the +> **texture-leak** rule that keeps the list text-only. + +## Files + +| File | Role | +|------|------| +| [CustomLobbyModSelect.lua](CustomLobbyModSelect.lua) | the dialog (transient `Popup`). Same **areas** layout as the map dialog (title / left {filter, list, stats} / detail / actions — flip the module `Debug` flag to tint them). Left: name/author search + **Game / UI / Unavailable** type filters (persisted to Prefs) + a checkbox list; right: the highlighted mod's icon, author/version/type, website/source links (allowlisted → `OpenURL`), scrollable description, and a requires/conflicts/missing summary. Bottom: **presets** (load / save / delete) + OK / Cancel. Owns a *working selection set*, not synced state; on OK it hands the set to an `onConfirm` callback. | +| [CustomLobbyModCatalog.lua](CustomLobbyModCatalog.lua) | the lobby's **own** mod data layer — **reference data, never synced** (each peer enumerates its own disk; only the host's sim-mod *choice* syncs). Async-streams classified, display-ready `UILobbyModInfo` entries into a `LazyVar` (built from `ModUtilities`, which fronts `/lua/mods.lua`), so the dialog shows a live "N mods" count and fills in progressively. `EnsureLoaded` / `GetModsVar` / `FindByUid` / `Refresh`. | +| [CustomLobbyModList.lua](CustomLobbyModList.lua) | the dialog's scrollable, **virtualised** list: a fixed pool of rows reused while scrolling, each a **checkbox + name + type badge**. The list doesn't own the selection — it paints checkboxes from a set the dialog hands it (`SetChecked`) and asks a predicate whether each row may be toggled (`SetCanToggle`). `OnSelect` / `OnToggle` / `OnConfirm`. | + +The mod domain logic lives one level up in [`/lua/ui/modutilities.lua`](/lua/ui/modutilities.lua) — +the UI-facing front door over `/lua/mods.lua` (the sibling of `maputil.lua` for maps): mod +classification, name/version/author formatting (lifted out of the legacy `ModsManager.lua`), +dependency-aware selection edits, **prefs persistence** (so a dialog never touches prefs), and +**presets** (named snapshots, replacing the legacy "favorites"). + +## The MVC boundary (where the selection goes) + +The dialog is decoupled from the lobby models — it just returns a uid set. The **opener** decides +what that means, which is the whole point: + +- **`Open` (in-lobby).** Seeds from the host's sim mods (`launch.GameMods()`) + this peer's UI + mods (prefs). On OK, **sim** mods go through the host-authoritative `RequestSetGameMods` intent + → `GameMods` → broadcast (a non-host sees them read-only via `canEditGameMods`); **UI** mods are + this peer's own choice and persist locally via `ModUtilities.SetSelectedUIMods`. UI mods never + go on the wire — see the `customlobby-model-choice` skill. +- **`OpenStandalone` (no lobby).** Seeds from `ModUtilities.GetSelectedMods()`; on OK the whole + selection persists to the preference file via `ModUtilities.SetSelectedMods`. + +Selection edits (checkbox ticks, preset loads) run through `ModUtilities.ResolveEnable` / +`ResolveDisable`, which pull in requirements and drop conflicts; the dialog repaints the list from +the resulting set. Blacklisted / missing-dependency mods can't be enabled. + +## Texture leak (same as mapselect) + +Rows are **text-only** (checkbox + name + badge) for the same reason the map list is: a mod's +`icon` is a distinct texture per mod, and the engine never frees the textures a `Bitmap`/ +`MapPreview` loads (full writeup in [`../mapselect/CLAUDE.md`](../mapselect/CLAUDE.md)). One icon +per row × a big vault would leak the memory the game needs in-match. The icon is shown **once**, in +the detail panel, re-textured per highlighted mod — the same bounded trickle the map dialog's +single preview accepts. diff --git a/lua/ui/lobby/customlobby/modselect/CustomLobbyModCatalog.lua b/lua/ui/lobby/customlobby/modselect/CustomLobbyModCatalog.lua new file mode 100644 index 00000000000..f7ab77d47db --- /dev/null +++ b/lua/ui/lobby/customlobby/modselect/CustomLobbyModCatalog.lua @@ -0,0 +1,204 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- The mod catalog: the custom lobby's list of selectable mods, the mod-side counterpart to +-- CustomLobbyMapCatalog. +-- +-- Like the map catalog, this is *reference data*, NOT a model — it's identical on every peer +-- (each enumerates its own disk) and never goes on the wire. Only the host's *choice* of sim +-- mods (the launch model's `GameMods`) and each peer's UI-mod choice (prefs) sync/persist; the +-- catalog is just the menu they pick from. See the `customlobby-model-choice` skill. +-- +-- It builds normalized, display-ready `UILobbyModInfo` entries (classified type, formatted +-- name / version / author, resolved dependency sets) from `ModUtilities`, the UI mod helper that +-- fronts `/lua/mods.lua`. Classification runs `GetDependencies` per mod, so we stream the build +-- across frames in batches and re-fire the `Mods` LazyVar as each batch lands — the dialog shows +-- a live "N mods loaded" count and fills in progressively, exactly like the map list. + +local Create = import("/lua/lazyvar.lua").Create + +-- the single point of contact with the mod domain layer (which in turn fronts /lua/mods.lua) +local ModUtilities = import("/lua/ui/modutilities.lua") + +local FallbackIcon = '/textures/ui/common/dialogs/mod-manager/generic-icon_bmp.dds' + +-- mods classified per frame-slice before yielding — keeps the open responsive +local BatchSize = 5 + +--- The growing list of selectable mods. Re-fired (new table ref) as batches land. +---@type LazyVar +local ModList = Create({}) + +local Loading = false -- a load thread is currently running +local Loaded = false -- the disk has been fully enumerated + classified + +------------------------------------------------------------------------------- +--#region Enumeration + +--- Builds the normalized, display-ready entry for one raw `ModInfo`. +---@param mod ModInfo +---@return UILobbyModInfo +local function BuildEntry(mod) + local dependencies = ModUtilities.GetDependencies(mod.uid) + local icon = mod.icon + if not icon or icon == "" or not DiskGetFileInfo(icon) then + icon = FallbackIcon + end + + ---@type UILobbyModInfo + return { + uid = mod.uid, + name = mod.name or mod.uid, + title = ModUtilities.FormatName(mod), + versionText = ModUtilities.FormatVersion(mod), + author = ModUtilities.FormatAuthor(mod), + description = mod.description or "", + copyright = mod.copyright, + icon = icon, + location = mod.location, + ui_only = mod.ui_only and true or false, + type = ModUtilities.Classify(mod), + blacklistReason = ModUtilities.GetBlacklistReason(mod.uid), + url = mod.url, + github = mod.github, + requires = dependencies.requires, + missing = dependencies.missing, + conflicts = dependencies.conflicts, + } +end + +---@param a UILobbyModInfo +---@param b UILobbyModInfo +---@return boolean +local function SortByTitle(a, b) + return string.upper(a.title or "") < string.upper(b.title or "") +end + +--- Publishes a fresh (sorted) copy of the accumulator so dependents go dirty. +---@param accumulator UILobbyModInfo[] +local function Publish(accumulator) + local snapshot = table.copy(accumulator) + table.sort(snapshot, SortByTitle) + ModList:Set(snapshot) +end + +--- Classifies every selectable mod across frames, streaming the entries into `ModList`. +local function LoadThread() + local selectable = ModUtilities.GetSelectableMods() + + local accumulator = {} + local seen = 0 + for _, mod in selectable do + table.insert(accumulator, BuildEntry(mod)) + + seen = seen + 1 + if math.mod(seen, BatchSize) == 0 then + Publish(accumulator) + WaitFrames(1) + end + end + + Loaded = true + Loading = false + Publish(accumulator) +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Public API + +--- Kicks off the enumeration if it hasn't run yet. Idempotent — safe to call on every open; +--- once loaded it's a no-op and the cached list stays. +function EnsureLoaded() + if Loading or Loaded then + return + end + Loading = true + ModList:Set({}) + ForkThread(LoadThread) +end + +--- The mods LazyVar — subscribe to it (via `Derive`) to react as the list streams in. +---@return LazyVar +function GetModsVar() + return ModList +end + +--- The current (possibly partial) list of mods. +---@return UILobbyModInfo[] +function GetMods() + return ModList() +end + +--- How many mods are currently loaded. +---@return number +function GetCount() + return table.getn(ModList()) +end + +--- Whether the disk has been fully enumerated (vs. still streaming). +---@return boolean +function IsLoaded() + return Loaded +end + +--- Finds the loaded mod with the given uid, or nil. +---@param uid string | false +---@return UILobbyModInfo | nil +function FindByUid(uid) + if not uid then + return nil + end + for _, mod in ModList() do + if mod.uid == uid then + return mod + end + end + return nil +end + +--- Drops everything so the next `EnsureLoaded` re-reads from disk (e.g. mods changed on disk). +function Refresh() + Loaded = false + Loading = false + ModUtilities.Refresh() + ModList:Set({}) +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Debugging + +--- Hot-reload hook: re-imports this module after a couple of frames. The list rebuilds on the +--- next access, so dropping the cache is harmless. +function __moduleinfo.OnDirty() + ForkThread( + function() + WaitFrames(2) + import(__moduleinfo.name) + end + ) +end + +--#endregion diff --git a/lua/ui/lobby/customlobby/modselect/CustomLobbyModList.lua b/lua/ui/lobby/customlobby/modselect/CustomLobbyModList.lua new file mode 100644 index 00000000000..f0ac2261212 --- /dev/null +++ b/lua/ui/lobby/customlobby/modselect/CustomLobbyModList.lua @@ -0,0 +1,418 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- A scrollable, pooled mod list for the mod-select dialog. Each row carries a checkbox (select / +-- deselect the mod), the mod name, and a short type badge. The sibling of CustomLobbyMapList — +-- same virtualisation, same scrollbar contract — with selection checkboxes added. +-- +-- Rows are *text-only* (no per-row mod icons) on purpose: a mod's icon is a distinct texture per +-- mod, and the engine never frees the textures a `Bitmap`/`MapPreview` loads (see +-- mapselect/CLAUDE.md). One icon per row × a big vault would leak the memory the game needs +-- in-match, so the icon is shown once, in the dialog's detail panel, for the highlighted mod. +-- +-- Two interactions, two regions: +-- * the checkbox toggles membership in the selection (`OnToggle`); +-- * clicking the rest of the row highlights it for the detail panel (`OnSelect`), and +-- double-clicking confirms (`OnConfirm`). +-- +-- The list does not own the selection — it paints checkboxes from a selection set the dialog +-- hands it (`SetChecked`) and asks the dialog whether each row may be toggled (`SetCanToggle`, +-- e.g. a non-host can't change sim mods, blacklisted mods can't be enabled). After the dialog +-- mutates the selection it calls `Refresh` to repaint. + +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 + +local Layouter = LayoutHelpers.ReusedLayoutFor + +local RowHeight = 26 + +-- mod names are single-line Text (which doesn't clip), so cap them with an ellipsis to stop a +-- long name running under the type badge. Char-based (the engine's Text has no width-measure). +local NameMaxChars = 28 + +--- Truncates `text` to `maxChars`, appending "…" when it had to cut. +---@param text string +---@param maxChars number +---@return string +local function Truncate(text, maxChars) + text = text or "" + if string.len(text) > maxChars then + return string.sub(text, 1, maxChars - 1) .. "…" + end + return text +end + +local SelectedColor = 'ff2c3e48' +local HoverColor = 'ff1a2630' +local IdleColor = '00000000' + +local EnabledNameColor = 'ffe9ece9' +local DisabledNameColor = 'ff6a7078' + +-- short badge text + colour per mod type +local TypeBadges = { + GAME = { text = "GAME", color = 'ffd0a24c' }, + UI = { text = "UI", color = 'ff6db3e2' }, + BLACKLISTED = { text = "BL", color = 'ffb05050' }, + NO_DEPENDENCY = { text = "DEP", color = 'ffb05050' }, + LOCAL = { text = "LCL", color = 'ffb0902c' }, +} + +---@class UICustomLobbyModListRow : Group +---@field Background Bitmap +---@field Check Checkbox +---@field Name Text +---@field Badge Text +---@field _poolIndex number +---@field _hover boolean + +---@class UICustomLobbyModList : Group +---@field Trash TrashBag +---@field Items UILobbyModInfo[] +---@field Rows UICustomLobbyModListRow[] +---@field PoolCount number +---@field ScrollTop number # 0-based scroll offset; NOT the `Top` edge LazyVar +---@field Selected number | false # highlighted item index (1-based) +---@field Scrollbar Scrollbar | false # hidden while everything fits +---@field Checked UIModSelection # the dialog's selection set (read-only here) +---@field CanToggle fun(mod: UILobbyModInfo): boolean +---@field OnSelect fun(mod: UILobbyModInfo, index: number) +---@field OnToggle fun(mod: UILobbyModInfo, checked: boolean) +---@field OnConfirm fun(mod: UILobbyModInfo) +local CustomLobbyModList = ClassUI(Group) { + + ---@param self UICustomLobbyModList + ---@param parent Control + __init = function(self, parent) + Group.__init(self, parent, "CustomLobbyModList") + + self.Trash = TrashBag() + self.Items = {} + self.Rows = {} + self.PoolCount = 0 + self.ScrollTop = 0 + self.Selected = false + self.Scrollbar = false + self.Checked = {} + self.CanToggle = function(mod) return true end + self.OnSelect = nil + self.OnToggle = nil + self.OnConfirm = nil + end, + + ---@param self UICustomLobbyModList + __post_init = function(self) + self.HandleEvent = function(control, event) + if event.Type == 'WheelRotation' then + local lines = event.WheelRotation > 0 and -3 or 3 + self:ScrollLines(nil, lines) + return true + end + return false + end + end, + + --- Builds the row pool sized to the (now concrete) height and attaches the scrollbar. + --- Called by the owner after the list is laid out + mounted (three-phase init, + --- /lua/ui/CLAUDE.md § 1) — the pool count reads `Height()`, unsettled during __post_init. + ---@param self UICustomLobbyModList + Initialize = function(self) + if self.PoolCount > 0 then + return + end + + local count = math.floor(self.Height() / LayoutHelpers.ScaleNumber(RowHeight)) + if count < 1 then + count = 1 + end + for i = 1, count do + self.Rows[i] = self:CreateRow(i) + local top = (i - 1) * RowHeight + Layouter(self.Rows[i]) + :AtLeftIn(self) + :AtRightIn(self) + :AtTopIn(self, top) + :Height(RowHeight) + :End() + end + -- set the pool count only once the whole pool exists, so a build error never leaves + -- CalcVisible iterating past rows that were actually created + self.PoolCount = count + + self.Scrollbar = UIUtil.CreateVertScrollbarFor(self) + self:CalcVisible() + end, + + --- Builds one pooled row (checkbox + name + type badge). Private. + ---@param self UICustomLobbyModList + ---@param poolIndex number + ---@return UICustomLobbyModListRow + CreateRow = function(self, poolIndex) + ---@type UICustomLobbyModListRow + local row = Group(self) + row._poolIndex = poolIndex + row._hover = false + + row.Background = Bitmap(row) + row.Background:SetSolidColor(IdleColor) + + row.Check = UIUtil.CreateCheckbox(row, '/CHECKBOX/', "", false, 11) + row.Check.OnCheck = function(control, checked) + local index = self.ScrollTop + poolIndex + local mod = self.Items[index] + if mod and self.OnToggle then + self.OnToggle(mod, checked) + end + end + + row.Name = UIUtil.CreateText(row, "", 14, UIUtil.bodyFont) + row.Name:DisableHitTest() + + row.Badge = UIUtil.CreateText(row, "", 11, UIUtil.bodyFont) + row.Badge:DisableHitTest() + + Layouter(row.Background):Fill(row):End() + Layouter(row.Check):AtLeftIn(row, 6):AtVerticalCenterIn(row):End() + Layouter(row.Name):AnchorToRight(row.Check, 8):AtVerticalCenterIn(row):End() + Layouter(row.Badge):AtRightIn(row, 10):AtVerticalCenterIn(row):End() + + -- the background catches selection / confirm; the checkbox handles its own clicks, and + -- the text labels are hit-test-disabled so they don't block the background + row.Background.HandleEvent = function(control, event) + local index = self.ScrollTop + poolIndex + local mod = self.Items[index] + if not mod then + return false + end + if event.Type == 'ButtonPress' then + self:SetSelection(index) + if self.OnSelect then + self.OnSelect(mod, index) + end + return true + elseif event.Type == 'ButtonDClick' then + if self.OnConfirm then + self.OnConfirm(mod) + end + return true + elseif event.Type == 'MouseEnter' then + row._hover = true + self:PaintRow(row, index) + return true + elseif event.Type == 'MouseExit' then + row._hover = false + self:PaintRow(row, index) + return true + end + return false + end + + return row + end, + + --- Replaces the data set and refreshes the window (resets scroll to the top). + ---@param self UICustomLobbyModList + ---@param items UILobbyModInfo[] + SetItems = function(self, items) + self.Items = items or {} + self.ScrollTop = 0 + self.Selected = false + self:CalcVisible() + end, + + --- Points the list at the dialog's selection set (held by reference; the dialog mutates it + --- and calls `Refresh`). Drives each row's checkbox state. + ---@param self UICustomLobbyModList + ---@param checked UIModSelection + SetChecked = function(self, checked) + self.Checked = checked or {} + self:CalcVisible() + end, + + --- Sets the predicate deciding whether a row's checkbox is interactive (e.g. a non-host + --- can't toggle sim mods; blacklisted mods can't be enabled). + ---@param self UICustomLobbyModList + ---@param predicate fun(mod: UILobbyModInfo): boolean + SetCanToggle = function(self, predicate) + self.CanToggle = predicate or function(mod) return true end + end, + + --- Repaints the visible window (call after the dialog mutates the selection). + ---@param self UICustomLobbyModList + Refresh = function(self) + self:CalcVisible() + end, + + --- Selects an item by index (1-based) and repaints; does not scroll (see ShowItem). + ---@param self UICustomLobbyModList + ---@param index number | false + SetSelection = function(self, index) + self.Selected = index or false + self:CalcVisible() + end, + + --- The highlighted mod, or nil. + ---@param self UICustomLobbyModList + ---@return UILobbyModInfo | nil + GetSelected = function(self) + return self.Selected and self.Items[self.Selected] or nil + end, + + --- Scrolls so item `index` (1-based) is within the visible window. + ---@param self UICustomLobbyModList + ---@param index number + ShowItem = function(self, index) + if self.PoolCount == 0 then + return + end + if index <= self.ScrollTop then + self.ScrollTop = index - 1 + elseif index > self.ScrollTop + self.PoolCount then + self.ScrollTop = index - self.PoolCount + end + self:ClampTop() + self:CalcVisible() + end, + + --- Paints a single row to reflect its data + selection / hover / checkbox state. Private. + ---@param self UICustomLobbyModList + ---@param row UICustomLobbyModListRow + ---@param index number + PaintRow = function(self, row, index) + if not row then + return + end + local mod = self.Items[index] + if not mod then + row:Hide() + return + end + row:Show() + + local color = IdleColor + if index == self.Selected then + color = SelectedColor + elseif row._hover then + color = HoverColor + end + row.Background:SetSolidColor(color) + + local canToggle = self.CanToggle(mod) + row.Check:SetCheck(self.Checked[mod.uid] and true or false, true) + if canToggle then + row.Check:Enable() + else + row.Check:Disable() + end + + row.Name:SetText(Truncate(mod.title or mod.name or "?", NameMaxChars)) + row.Name:SetColor(canToggle and EnabledNameColor or DisabledNameColor) + + local badge = TypeBadges[mod.type] or TypeBadges.GAME + row.Badge:SetText(badge.text) + row.Badge:SetColor(badge.color) + end, + + --------------------------------------------------------------------------- + --#region Scrollbar contract + + ---@param self UICustomLobbyModList + CalcVisible = function(self) + for i = 1, self.PoolCount do + self:PaintRow(self.Rows[i], self.ScrollTop + i) + end + self:UpdateScrollbar() + end, + + --- Shows the scrollbar only when there are more items than fit the pool. + ---@param self UICustomLobbyModList + UpdateScrollbar = function(self) + if not self.Scrollbar then + return + end + if table.getn(self.Items) > self.PoolCount then + self.Scrollbar:Show() + else + self.Scrollbar:Hide() + end + end, + + ---@param self UICustomLobbyModList + ClampTop = function(self) + local maxTop = math.max(0, table.getn(self.Items) - self.PoolCount) + if self.ScrollTop > maxTop then + self.ScrollTop = maxTop + end + if self.ScrollTop < 0 then + self.ScrollTop = 0 + end + end, + + ---@param self UICustomLobbyModList + GetScrollValues = function(self, axis) + local size = table.getn(self.Items) + return 0, size, self.ScrollTop, math.min(self.ScrollTop + self.PoolCount, size) + end, + + ---@param self UICustomLobbyModList + ScrollLines = function(self, axis, delta) + self:ScrollSetTop(axis, self.ScrollTop + math.floor(delta)) + end, + + ---@param self UICustomLobbyModList + ScrollPages = function(self, axis, delta) + self:ScrollSetTop(axis, self.ScrollTop + math.floor(delta) * self.PoolCount) + end, + + ---@param self UICustomLobbyModList + ScrollSetTop = function(self, axis, top) + top = math.floor(top) + if top == self.ScrollTop then + return + end + self.ScrollTop = top + self:ClampTop() + self:CalcVisible() + end, + + ---@param self UICustomLobbyModList + IsScrollable = function(self, axis) + return true + end, + + --#endregion + + ---@param self UICustomLobbyModList + OnDestroy = function(self) + self.Trash:Destroy() + end, +} + +---@param parent Control +---@return UICustomLobbyModList +Create = function(parent) + return CustomLobbyModList(parent) +end diff --git a/lua/ui/lobby/customlobby/modselect/CustomLobbyModSelect.lua b/lua/ui/lobby/customlobby/modselect/CustomLobbyModSelect.lua new file mode 100644 index 00000000000..712a70e7155 --- /dev/null +++ b/lua/ui/lobby/customlobby/modselect/CustomLobbyModSelect.lua @@ -0,0 +1,1043 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- The mod-select dialog: a searchable, type-filterable list of mods you tick on the left, with +-- the highlighted mod's details on the right, plus named presets. The sibling of the map-select +-- dialog (CustomLobbyMapSelect) — same areas/three-phase-init/Prefs shape — but for mods. +-- +-- It is a transient picker, NOT a model component: it owns no synced state. It works off the +-- CustomLobbyModCatalog (reference data, streamed in) and a *working selection set*, and on OK +-- it just hands that set to an `onConfirm` callback. Where the selection goes is the opener's +-- decision (the MVC boundary): +-- * `Open` (in-lobby) — sim mods route through the host-authoritative `RequestSetGameMods` +-- intent (synced via the launch model's `GameMods`); UI mods are this peer's own choice and +-- persist locally via `ModUtilities.SetSelectedUIMods`. +-- * `OpenStandalone` — no lobby; the whole selection persists to the preference file via +-- `ModUtilities.SetSelectedMods` (the main-menu mod-manager use case). +-- +-- Sim-mod checkboxes are read-only for a non-host (`canEditGameMods`); blacklisted / missing- +-- dependency mods can't be enabled. Selection edits run through `ModUtilities.ResolveEnable` / +-- `ResolveDisable`, which pull in requirements and drop conflicts. +-- +-- Like the map dialog, the list is text-only; only the detail panel shows a mod icon, one at a +-- time (the engine never frees mod-icon textures — see mapselect/CLAUDE.md). + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") +local Tooltip = import("/lua/ui/game/tooltip.lua") +local Prefs = import("/lua/user/prefs.lua") + +local Group = import("/lua/maui/group.lua").Group +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap +local Edit = import("/lua/maui/edit.lua").Edit +local Popup = import("/lua/ui/controls/popups/popup.lua").Popup +local Combo = import("/lua/ui/controls/combo.lua").Combo +local TextArea = import("/lua/ui/controls/textarea.lua").TextArea + +local ModUtilities = import("/lua/ui/modutilities.lua") +local CustomLobbyModCatalog = import("/lua/ui/lobby/customlobby/modselect/customlobbymodcatalog.lua") +local CustomLobbyModList = import("/lua/ui/lobby/customlobby/modselect/customlobbymodlist.lua") +local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") +local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/customlobbylocalmodel.lua") + +local LazyVarDerive = import("/lua/lazyvar.lua").Derive + +local Layouter = LayoutHelpers.ReusedLayoutFor + +-- flip to tint each layout area so the regions are visible while iterating +local Debug = false + +local DialogWidth = 760 +local DialogHeight = 620 +local Pad = 12 +local ColumnGap = 24 +local LeftWidth = 320 +local IconSize = 64 +local TitleHeight = 32 +local ActionHeight = 120 -- two stacked rows: presets on top, OK / Cancel at the bottom +local FilterHeight = 120 +local StatsHeight = 22 + +local PrefsKey = "customlobby_modselect" + +-- Mod web pages we'll open in a browser (mods carry `url` / `github`). Matched against the URL's +-- host — exact or as a subdomain — so look-alikes ("github.com.evil.com") are rejected. Mirrors +-- the map dialog's allowlist; add a line to extend. +local AllowedUrlDomains = { + "github.com", + "githubusercontent.com", + "gitlab.com", + "github.io", + "faforever.com", +} + +--- The lowercased host of a URL (between the scheme and the first `/` or `:`), or "". +---@param url string +---@return string +local function UrlHost(url) + local rest = string.gsub(string.lower(url), "^https?://", "") + return (string.gsub(rest, "[/:].*$", "")) +end + +--- Whether `url` is an http(s) link to an allowed domain (or a subdomain of one). +---@param url any +---@return boolean +local function IsAllowedUrl(url) + if type(url) ~= 'string' or not string.find(string.lower(url), "^https?://") then + return false + end + local host = UrlHost(url) + for _, domain in AllowedUrlDomains do + local escaped = string.gsub(domain, "%.", "%%.") + if host == domain or string.find(host, "%." .. escaped .. "$") then + return true + end + end + return false +end + +--- Downgrades an `https://` URL to `http://` — the engine's `OpenURL` only handles `http://`. +---@param url string +---@return string +local function ToOpenableUrl(url) + return (string.gsub(url, "^https://", "http://")) +end + +-- the detail title is single-line Text (which doesn't clip), so cap it with an ellipsis so a long +-- mod name doesn't run off the panel. Char-based (the engine's Text has no width-measure). +local TitleMaxChars = 32 + +--- Truncates `text` to `maxChars`, appending "…" when it had to cut. +---@param text string +---@param maxChars number +---@return string +local function Truncate(text, maxChars) + text = text or "" + if string.len(text) > maxChars then + return string.sub(text, 1, maxChars - 1) .. "…" + end + return text +end + +--- Shows `scrollbar` only when the TextArea's content is taller than the box it sits in. +---@param textArea TextArea +---@param scrollbar Scrollbar | false +local function UpdateTextAreaScrollbar(textArea, scrollbar) + if not scrollbar then + return + end + if textArea:GetTextHeight() > textArea.Height() then + scrollbar:Show() + else + scrollbar:Hide() + end +end + +--- Joins a uid set's display names (looked up in the catalog) into "A, B, C", or "" if empty. +---@param uidSet table | nil +---@return string +local function NamesOf(uidSet) + if not uidSet then + return "" + end + local names = {} + for uid in uidSet do + local mod = CustomLobbyModCatalog.FindByUid(uid) + table.insert(names, mod and mod.title or uid) + end + table.sort(names) + return table.concat(names, ", ") +end + +--- Creates a layout area (an invisible Group with an optional debug tint). +---@param parent Control +---@param name string +---@param color string +---@return Group +local function CreateArea(parent, name, color) + local area = Group(parent, name) + local bg = Bitmap(area) + bg:SetSolidColor(color) + bg:SetAlpha(Debug and 0.18 or 0.0) + bg:DisableHitTest() + Layouter(bg):Fill(area):End() + area.Bg = bg + return area +end + +---@class UICustomLobbyModSelect : Group +---@field Trash TrashBag +---@field CanEditGameMods boolean +---@field Selection UIModSelection +---@field OnConfirmCb fun(selection: UIModSelection) +---@field OnCancelCb fun() +---@field TitleArea Group +---@field LeftArea Group +---@field FilterArea Group +---@field SelectionArea Group +---@field StatsArea Group +---@field PreviewArea Group +---@field ActionArea Group +---@field Title Text +---@field FilterTitle Text +---@field SearchLabel Text +---@field Search Edit +---@field GameToggle Checkbox +---@field UIToggle Checkbox +---@field UnavailableToggle Checkbox +---@field ShowGame boolean +---@field ShowUI boolean +---@field ShowUnavailable boolean +---@field ModList UICustomLobbyModList +---@field EmptyLabel Text +---@field CountLabel Text +---@field SelectedLabel Text +---@field Spinner Text +---@field Icon Bitmap +---@field DetailTitle Text +---@field DetailMeta Text +---@field UrlButton Text +---@field GithubButton Text +---@field CurrentUrl string | false +---@field CurrentGithub string | false +---@field Description TextArea +---@field DescriptionScrollbar Scrollbar | false +---@field DepsText TextArea +---@field DepsScrollbar Scrollbar | false +---@field Note Text +---@field PresetLabel Text +---@field PresetCombo Combo +---@field SavePresetButton Button +---@field DeletePresetButton Button +---@field SelectButton Button +---@field CancelButton Button +---@field ClearButton Button +---@field PresetNames string[] +---@field ModsObserver LazyVar +---@field Mods UILobbyModInfo[] +---@field Filtered UILobbyModInfo[] +---@field Highlighted? UILobbyModInfo +---@field Ready boolean +local CustomLobbyModSelect = ClassUI(Group) { + + ---@param self UICustomLobbyModSelect + ---@param parent Control + ---@param options { initial: UIModSelection, canEditGameMods: boolean, onConfirm: fun(selection: UIModSelection), onCancel: fun() } + __init = function(self, parent, options) + Group.__init(self, parent, "CustomLobbyModSelect") + + self.Trash = TrashBag() + self.OnConfirmCb = options.onConfirm + self.OnCancelCb = options.onCancel + self.CanEditGameMods = options.canEditGameMods ~= false + self.Selection = table.copy(options.initial or {}) + + self.Ready = false + self.Filtered = {} + self.Highlighted = nil + self.Mods = CustomLobbyModCatalog.GetMods() + self.PresetNames = {} + + -- restore the last-used filters + search (persisted across opens) + local saved = Prefs.GetFromCurrentProfile(PrefsKey) or {} + self.ShowGame = saved.showGame ~= false + self.ShowUI = saved.showUI ~= false + self.ShowUnavailable = saved.showUnavailable == true + + -- areas + self.TitleArea = CreateArea(self, "TitleArea", 'ffcc4040') + self.LeftArea = CreateArea(self, "LeftArea", 'ff4060cc') + self.FilterArea = CreateArea(self.LeftArea, "FilterArea", 'ff40cc60') + self.SelectionArea = CreateArea(self.LeftArea, "SelectionArea", 'ffcccc40') + self.StatsArea = CreateArea(self.LeftArea, "StatsArea", 'ff40cccc') + self.PreviewArea = CreateArea(self, "PreviewArea", 'ffcc40cc') + self.ActionArea = CreateArea(self, "ActionArea", 'ff808080') + + self.Title = UIUtil.CreateText(self.TitleArea, "Select mods", 22, UIUtil.titleFont) + + --#region filters (in FilterArea) + self.FilterTitle = UIUtil.CreateText(self.FilterArea, "Filter", 14, UIUtil.titleFont) + + self.SearchLabel = UIUtil.CreateText(self.FilterArea, "Search", 13, UIUtil.bodyFont) + self.SearchLabel:SetColor('ff9aa0a8') + + self.Search = Edit(self.FilterArea) + Layouter(self.Search):Left(0):Top(0):Width(96):Height(22):End() + self.Search:SetFont(UIUtil.bodyFont, 16) + self.Search:SetForegroundColor(UIUtil.fontColor) + self.Search:ShowBackground(true) + self.Search:SetBackgroundColor('77778888') + self.Search:SetText(saved.search or "") + self.Search.OnTextChanged = function(control, newText, oldText) + self:Populate() + self:SavePrefs() + end + Tooltip.AddControlTooltipManual(self.Search, "Search", "Filter the list by mod name or author.") + + self.GameToggle = self:CreateToggle("Game", self.ShowGame, + function(checked) self.ShowGame = checked; self:Populate(); self:SavePrefs() end, + "Game mods", "Show mods that change the simulation (all players need them).") + self.UIToggle = self:CreateToggle("UI", self.ShowUI, + function(checked) self.ShowUI = checked; self:Populate(); self:SavePrefs() end, + "UI mods", "Show mods that change only your interface (per-player).") + self.UnavailableToggle = self:CreateToggle("Deprecated", self.ShowUnavailable, + function(checked) self.ShowUnavailable = checked; self:Populate(); self:SavePrefs() end, + "Deprecated mods", "Show blacklisted mods and mods missing a dependency.") + --#endregion + + --#region selection list + stats + self.ModList = CustomLobbyModList.Create(self.SelectionArea) + self.ModList.OnSelect = function(mod, index) + self:OnModHighlighted(mod) + end + self.ModList.OnToggle = function(mod, checked) + self:ToggleMod(mod, checked) + end + self.ModList.OnConfirm = function(mod) + self:Confirm() + end + + self.EmptyLabel = UIUtil.CreateText(self.SelectionArea, "No mods match", 14, UIUtil.bodyFont) + self.EmptyLabel:SetColor('ff8a909a') + self.EmptyLabel:DisableHitTest() + self.EmptyLabel:Hide() + + self.CountLabel = UIUtil.CreateText(self.StatsArea, "", 13, UIUtil.bodyFont) + self.CountLabel:SetColor('ff9aa0a8') + self.SelectedLabel = UIUtil.CreateText(self.StatsArea, "", 13, UIUtil.bodyFont) + self.SelectedLabel:SetColor('ff9aa0a8') + self.Spinner = UIUtil.CreateText(self.StatsArea, "", 13, UIUtil.bodyFont) + self.Spinner:SetColor('ff9aa0a8') + --#endregion + + --#region detail panel (right) + self.Icon = Bitmap(self.PreviewArea) + self.Icon:DisableHitTest() + self.Icon:Hide() + + self.DetailTitle = UIUtil.CreateText(self.PreviewArea, "", 18, UIUtil.titleFont) + self.DetailTitle:DisableHitTest() + self.DetailMeta = UIUtil.CreateText(self.PreviewArea, "", 13, UIUtil.bodyFont) + self.DetailMeta:SetColor('ffc8ccd0') + self.DetailMeta:DisableHitTest() + + self.CurrentUrl = false + self.UrlButton = self:CreateLink("Website", "Open the mod's web page in your browser.", + function() return self.CurrentUrl end) + self.CurrentGithub = false + self.GithubButton = self:CreateLink("Source", "Open the mod's source repository in your browser.", + function() return self.CurrentGithub end) + + self.Description = TextArea(self.PreviewArea, 200, 80) + self.Description:SetFont(UIUtil.bodyFont, 12) + self.Description:SetColors('ffc8ccd0', "00000000", 'ffc8ccd0', "00000000") + + self.DepsText = TextArea(self.PreviewArea, 200, 60) + self.DepsText:SetFont(UIUtil.bodyFont, 12) + self.DepsText:SetColors('ff9aa0a8', "00000000", 'ff9aa0a8', "00000000") + + self.Note = UIUtil.CreateText(self.PreviewArea, "", 12, UIUtil.bodyFont) + self.Note:SetColor('ffd0a24c') + self.Note:DisableHitTest() + --#endregion + + --#region actions (presets + ok/cancel) + self.PresetLabel = UIUtil.CreateText(self.ActionArea, "Preset", 13, UIUtil.bodyFont) + self.PresetLabel:SetColor('ff9aa0a8') + + self.PresetCombo = Combo(self.ActionArea, 14, 8, nil, nil, "UI_Tab_Click_01", "UI_Tab_Rollover_01") + self.PresetCombo.OnClick = function(combo, index, text) + self:LoadPreset(text) + end + Tooltip.AddControlTooltipManual(self.PresetCombo, "Presets", "Load a saved selection of mods.") + + self.SavePresetButton = UIUtil.CreateButtonStd(self.ActionArea, '/scx_menu/small-btn/small', "Save", 14, 2) + self.SavePresetButton.OnClick = function(button, modifiers) + self:PromptSavePreset() + end + Tooltip.AddControlTooltipManual(self.SavePresetButton, "Save preset", "Save the current selection as a named preset.") + + self.DeletePresetButton = UIUtil.CreateButtonStd(self.ActionArea, '/scx_menu/small-btn/small', "Delete", 14, 2) + self.DeletePresetButton.OnClick = function(button, modifiers) + self:DeleteSelectedPreset() + end + Tooltip.AddControlTooltipManual(self.DeletePresetButton, "Delete preset", "Delete the selected preset.") + + self.SelectButton = UIUtil.CreateButtonStd(self.ActionArea, '/scx_menu/small-btn/small', "OK", 16, 2) + self.SelectButton.OnClick = function(button, modifiers) + self:Confirm() + end + + self.CancelButton = UIUtil.CreateButtonStd(self.ActionArea, '/scx_menu/small-btn/small', "Cancel", 16, 2) + self.CancelButton.OnClick = function(button, modifiers) + self.OnCancelCb() + end + + self.ClearButton = UIUtil.CreateButtonStd(self.ActionArea, '/scx_menu/small-btn/small', "Clear", 14, 2) + self.ClearButton.OnClick = function(button, modifiers) + self:ClearSelection() + end + Tooltip.AddControlTooltipManual(self.ClearButton, "Clear", + "Deselect every mod you can change (UI mods, plus game mods when you're the host).") + --#endregion + + self.ModsObserver = self.Trash:Add( + LazyVarDerive(CustomLobbyModCatalog.GetModsVar(), function(modsLazy) + self:OnModsChanged(modsLazy()) + end)) + + CustomLobbyModCatalog.EnsureLoaded() + + -- spin a small throbber beside the count while the catalog is still streaming + self.Trash:Add(ForkThread(function() + local frames = { "|", "/", "-", "\\" } + local i = 1 + while not CustomLobbyModCatalog.IsLoaded() do + if IsDestroyed(self) then + return + end + self.Spinner:SetText(frames[i]) + i = math.mod(i, 4) + 1 + WaitSeconds(0.12) + end + if not IsDestroyed(self) then + self.Spinner:SetText("") + end + end)) + end, + + ---@param self UICustomLobbyModSelect + __post_init = function(self) + self.Width:Set(LayoutHelpers.ScaleNumber(DialogWidth)) + self.Height:Set(LayoutHelpers.ScaleNumber(DialogHeight)) + + --#region areas + Layouter(self.TitleArea):AtLeftIn(self, Pad):AtRightIn(self, Pad):AtTopIn(self, Pad):Height(TitleHeight):End() + Layouter(self.ActionArea):AtLeftIn(self, Pad):AtRightIn(self, Pad):AtBottomIn(self, Pad):Height(ActionHeight):End() + Layouter(self.LeftArea) + :AtLeftIn(self, Pad):Width(LeftWidth) + :AnchorToBottom(self.TitleArea, Pad):AnchorToTop(self.ActionArea, Pad) + :End() + Layouter(self.PreviewArea) + :AnchorToRight(self.LeftArea, ColumnGap):AtRightIn(self, Pad) + :AnchorToBottom(self.TitleArea, Pad):AnchorToTop(self.ActionArea, Pad) + :End() + + Layouter(self.FilterArea):AtLeftIn(self.LeftArea):AtRightIn(self.LeftArea):AtTopIn(self.LeftArea):Height(FilterHeight):End() + Layouter(self.StatsArea):AtLeftIn(self.LeftArea):AtRightIn(self.LeftArea):AtBottomIn(self.LeftArea):Height(StatsHeight):End() + Layouter(self.SelectionArea) + :AtLeftIn(self.LeftArea):AtRightIn(self.LeftArea) + :AnchorToBottom(self.FilterArea, Pad):AnchorToTop(self.StatsArea, Pad) + :End() + --#endregion + + Layouter(self.Title):AtHorizontalCenterIn(self.TitleArea):AtVerticalCenterIn(self.TitleArea):End() + + --#region filters + Layouter(self.FilterTitle):AtLeftIn(self.FilterArea):AtTopIn(self.FilterArea):End() + Layouter(self.SearchLabel):AtLeftIn(self.FilterArea):AtVerticalCenterIn(self.Search):End() + Layouter(self.Search):AnchorToRight(self.SearchLabel, 8):AtRightIn(self.FilterArea):AnchorToBottom(self.FilterTitle, 8):Height(22):End() + Layouter(self.GameToggle):AtLeftIn(self.FilterArea):AnchorToBottom(self.Search, 12):End() + Layouter(self.UIToggle):AnchorToRight(self.GameToggle, 16):AtVerticalCenterIn(self.GameToggle):End() + Layouter(self.UnavailableToggle):AnchorToRight(self.UIToggle, 16):AtVerticalCenterIn(self.UIToggle):End() + --#endregion + + --#region selection list + stats + Layouter(self.ModList):AtLeftIn(self.SelectionArea):AtTopIn(self.SelectionArea):AtBottomIn(self.SelectionArea):End() + self.ModList.Right:Set(function() return self.SelectionArea.Right() - LayoutHelpers.ScaleNumber(32) end) + Layouter(self.EmptyLabel):AtHorizontalCenterIn(self.SelectionArea):AtVerticalCenterIn(self.SelectionArea):End() + + Layouter(self.CountLabel):AtLeftIn(self.StatsArea):AtVerticalCenterIn(self.StatsArea):End() + Layouter(self.Spinner):AnchorToRight(self.CountLabel, 8):AtVerticalCenterIn(self.StatsArea):End() + Layouter(self.SelectedLabel):AtRightIn(self.StatsArea):AtVerticalCenterIn(self.StatsArea):End() + --#endregion + + --#region detail panel + Layouter(self.Icon):AtLeftIn(self.PreviewArea):AtTopIn(self.PreviewArea):Width(IconSize):Height(IconSize):End() + Layouter(self.DetailTitle):AnchorToRight(self.Icon, 12):AtTopIn(self.PreviewArea, 2):End() + Layouter(self.DetailMeta):AnchorToRight(self.Icon, 12):AnchorToBottom(self.DetailTitle, 6):End() + Layouter(self.UrlButton):AnchorToRight(self.Icon, 12):AnchorToBottom(self.DetailMeta, 8):End() + Layouter(self.GithubButton):AnchorToRight(self.UrlButton, 16):AtVerticalCenterIn(self.UrlButton):End() + + Layouter(self.Description) + :AtLeftIn(self.PreviewArea):AtRightIn(self.PreviewArea, 32) + :AnchorToBottom(self.Icon, 14):Height(160) + :End() + self.DescriptionScrollbar = UIUtil.CreateVertScrollbarFor(self.Description) + + Layouter(self.Note):AtLeftIn(self.PreviewArea):AtBottomIn(self.PreviewArea):End() + + -- dependency summary fills the gap between the description and the note line; scrolls if a + -- mod lists many requirements / conflicts + Layouter(self.DepsText) + :AtLeftIn(self.PreviewArea):AtRightIn(self.PreviewArea, 32) + :AnchorToBottom(self.Description, 12):AnchorToTop(self.Note, 8) + :End() + self.DepsScrollbar = UIUtil.CreateVertScrollbarFor(self.DepsText) + --#endregion + + --#region actions + -- presets on the top row, OK / Cancel on the bottom row — the gap between them is the + -- vertical space the tall action area buys + -- top row: pin the (tall) combo + buttons to the area's top edge, and centre the short + -- label on the combo — centring the tall controls on a top-aligned label would clip them + Layouter(self.PresetCombo):AtLeftIn(self.ActionArea, 48):AtTopIn(self.ActionArea):Width(150):End() + Layouter(self.PresetLabel):AtLeftIn(self.ActionArea):AtVerticalCenterIn(self.PresetCombo):End() + Layouter(self.SavePresetButton):AnchorToRight(self.PresetCombo, 8):AtVerticalCenterIn(self.PresetCombo):End() + Layouter(self.DeletePresetButton):AnchorToRight(self.SavePresetButton, 4):AtVerticalCenterIn(self.PresetCombo):End() + + -- bottom row: Clear on the left, Cancel / OK on the right + Layouter(self.SelectButton):AtRightIn(self.ActionArea):AtBottomIn(self.ActionArea):End() + Layouter(self.CancelButton):AnchorToLeft(self.SelectButton, 12):AtVerticalCenterIn(self.SelectButton):End() + Layouter(self.ClearButton):AtLeftIn(self.ActionArea):AtVerticalCenterIn(self.SelectButton):End() + --#endregion + end, + + --- Builds a labelled checkbox filter wired to `onChange(checked)`, with a tooltip. + ---@param self UICustomLobbyModSelect + ---@param label string + ---@param initial boolean + ---@param onChange fun(checked: boolean) + ---@param tooltipTitle string + ---@param tooltipBody string + ---@return Checkbox + CreateToggle = function(self, label, initial, onChange, tooltipTitle, tooltipBody) + local checkbox = UIUtil.CreateCheckbox(self.FilterArea, '/CHECKBOX/', label, true, 13) + checkbox:SetCheck(initial, true) + checkbox.OnCheck = function(control, checked) + onChange(checked) + end + Tooltip.AddControlTooltipManual(checkbox, tooltipTitle, tooltipBody) + return checkbox + end, + + --- Builds a clickable text link whose target comes from `getUrl()` (so it can change as the + --- highlighted mod changes). Hidden until shown by UpdateDetail. + ---@param self UICustomLobbyModSelect + ---@param label string + ---@param tooltipBody string + ---@param getUrl fun(): string | false + ---@return Text + CreateLink = function(self, label, tooltipBody, getUrl) + local link = UIUtil.CreateText(self.PreviewArea, label, 12, UIUtil.bodyFont) + link:SetColor('ff7fb3ff') + link:Hide() + link.HandleEvent = function(control, event) + if event.Type == 'ButtonPress' then + local url = getUrl() + if url then + OpenURL(ToOpenableUrl(url)) + end + return true + elseif event.Type == 'MouseEnter' then + control:SetColor('ffaecbff') + return true + elseif event.Type == 'MouseExit' then + control:SetColor('ff7fb3ff') + return true + end + return false + end + Tooltip.AddControlTooltipManual(link, "Open link", tooltipBody) + return link + end, + + --- Builds the list pool + populates + wires the selection. Called by the opener after the + --- dialog is mounted + centred by Popup (three-phase init, /lua/ui/CLAUDE.md § 1). + ---@param self UICustomLobbyModSelect + Initialize = function(self) + self.Ready = true + + -- TextArea pins Width to its constructor value and wraps to Width(), not Left..Right. + -- Bind it to the laid-out span so text wraps at the real panel width — but only now (the + -- opener calls Initialize after Popup mounts): the bind eagerly fires Width.OnDirty → + -- ReflowText, which reads the parent geometry, circular until we're mounted. + self.Description.Width:Set(function() return self.Description.Right() - self.Description.Left() end) + self.DepsText.Width:Set(function() return self.DepsText.Right() - self.DepsText.Left() end) + + self.ModList:Initialize() + self.ModList:SetCanToggle(function(mod) + if mod.type == 'BLACKLISTED' or mod.type == 'NO_DEPENDENCY' then + return false + end + if not mod.ui_only and not self.CanEditGameMods then + return false + end + return true + end) + self.ModList:SetChecked(self.Selection) + self:RefreshPresets() + self:Populate() + end, + + --- The catalog published a new (possibly larger) mod list: recount always, re-list once + --- we're mounted. + ---@param self UICustomLobbyModSelect + ---@param mods UILobbyModInfo[] + OnModsChanged = function(self, mods) + self.Mods = mods + if self.Ready then + self:Populate() + else + self:UpdateStats() + end + end, + + --- Persists the current filters + search for next time. + ---@param self UICustomLobbyModSelect + SavePrefs = function(self) + Prefs.SetToCurrentProfile(PrefsKey, { + showGame = self.ShowGame, + showUI = self.ShowUI, + showUnavailable = self.ShowUnavailable, + search = self.Search:GetText() or "", + }) + end, + + --- Rebuilds the list from the catalog, applying the name/author search + type filters, and + --- keeps the current highlight. + ---@param self UICustomLobbyModSelect + Populate = function(self) + local search = string.lower(self.Search:GetText() or "") + local targetUid = self.Highlighted and self.Highlighted.uid + + self.Filtered = {} + local highlightRow = 0 + + for _, mod in self.Mods do + if self:PassesFilters(mod, search) then + table.insert(self.Filtered, mod) + if targetUid and mod.uid == targetUid then + highlightRow = table.getn(self.Filtered) + end + end + end + + self.ModList:SetItems(self.Filtered) + self.ModList:SetChecked(self.Selection) + + if table.getn(self.Filtered) > 0 then + self.EmptyLabel:Hide() + local row = highlightRow > 0 and highlightRow or 1 + self.ModList:SetSelection(row) + self.ModList:ShowItem(row) + self:OnModHighlighted(self.Filtered[row]) + else + self.EmptyLabel:Show() + self.Highlighted = nil + self:ClearDetail() + end + + self:UpdateStats() + end, + + --- Whether a mod passes the type filters + name/author search. + ---@param self UICustomLobbyModSelect + ---@param mod UILobbyModInfo + ---@param search string # already lowercased + ---@return boolean + PassesFilters = function(self, mod, search) + local unavailable = mod.type == 'BLACKLISTED' or mod.type == 'NO_DEPENDENCY' + if unavailable then + if not self.ShowUnavailable then + return false + end + elseif mod.ui_only then + if not self.ShowUI then + return false + end + else + if not self.ShowGame then + return false + end + end + + if search ~= "" then + local haystack = string.lower((mod.title or "") .. " " .. (mod.author or "")) + if not string.find(haystack, search, 1, true) then + return false + end + end + return true + end, + + --- A mod was highlighted (row click): show its details. + ---@param self UICustomLobbyModSelect + ---@param mod UILobbyModInfo + OnModHighlighted = function(self, mod) + if not mod then + return + end + self.Highlighted = mod + self:UpdateDetail(mod) + end, + + --- Toggles a mod's membership in the working selection (with dependency / conflict + --- resolution), repaints the list, and refreshes the stats. + ---@param self UICustomLobbyModSelect + ---@param mod UILobbyModInfo + ---@param checked boolean + ToggleMod = function(self, mod, checked) + local disabled = {} + if checked then + self.Selection, disabled = ModUtilities.ResolveEnable(self.Selection, mod.uid) + else + self.Selection = ModUtilities.ResolveDisable(self.Selection, mod.uid) + end + + self.ModList:SetChecked(self.Selection) + self:UpdateStats() + + if table.getn(disabled) > 0 then + local conflictSet = {} + for _, uid in disabled do + conflictSet[uid] = true + end + self.Note:SetText("Disabled conflicting: " .. NamesOf(conflictSet)) + else + self.Note:SetText("") + end + end, + + --- Fills the detail panel for `mod`: icon, title, author/version/type, links, description, + --- and the dependency summary. + ---@param self UICustomLobbyModSelect + ---@param mod UILobbyModInfo + UpdateDetail = function(self, mod) + -- one re-textured icon for the whole dialog (never one per row — see the header note) + self.Icon:SetTexture(mod.icon) + self.Icon:Show() + + self.DetailTitle:SetText(Truncate(mod.title or mod.name or "?", TitleMaxChars)) + + local parts = {} + table.insert(parts, "by " .. (mod.author or "UNKNOWN")) + if mod.versionText ~= "" then + table.insert(parts, mod.versionText) + end + table.insert(parts, mod.ui_only and "UI mod" or "Game mod") + self.DetailMeta:SetText(table.concat(parts, " · ")) + + self.CurrentUrl = IsAllowedUrl(mod.url) and mod.url or false + if self.CurrentUrl then self.UrlButton:Show() else self.UrlButton:Hide() end + self.CurrentGithub = IsAllowedUrl(mod.github) and mod.github or false + if self.CurrentGithub then self.GithubButton:Show() else self.GithubButton:Hide() end + + self.Description:SetText(mod.description and LOC(mod.description) or "") + + -- dependency summary, then the minor identity fields (uid / copyright / location) + local deps = self:DependencySummary(mod) + local info = self:ModInfoLines(mod) + if deps ~= "" and info ~= "" then + self.DepsText:SetText(deps .. "\n\n" .. info) + else + self.DepsText:SetText(deps ~= "" and deps or info) + end + self.Note:SetText("") + + UpdateTextAreaScrollbar(self.Description, self.DescriptionScrollbar) + UpdateTextAreaScrollbar(self.DepsText, self.DepsScrollbar) + end, + + --- The minor identity fields shown at the bottom of the detail block: copyright, uid, and the + --- mod's location on disk. + ---@param self UICustomLobbyModSelect + ---@param mod UILobbyModInfo + ---@return string + ModInfoLines = function(self, mod) + -- always show all three labels; a missing field reads "omitted" rather than vanishing + local function value(v) + return (v and v ~= "") and v or "omitted" + end + return table.concat({ + "Copyright: " .. value(mod.copyright), + "UID: " .. value(mod.uid), + "Location: " .. value(mod.location), + }, "\n") + end, + + --- A one-or-more-line summary of a mod's requirements / conflicts / missing dependencies. + ---@param self UICustomLobbyModSelect + ---@param mod UILobbyModInfo + ---@return string + DependencySummary = function(self, mod) + local lines = {} + if mod.blacklistReason then + table.insert(lines, "Blacklisted: " .. LOC(mod.blacklistReason)) + end + if mod.missing then + table.insert(lines, "Missing dependencies: " .. NamesOf(mod.missing)) + end + if mod.requires then + table.insert(lines, "Requires: " .. NamesOf(mod.requires)) + end + if mod.conflicts then + table.insert(lines, "Conflicts with: " .. NamesOf(mod.conflicts)) + end + return table.concat(lines, "\n") + end, + + --- Clears the detail panel (no highlight / empty list). + ---@param self UICustomLobbyModSelect + ClearDetail = function(self) + self.Icon:Hide() + self.DetailTitle:SetText("") + self.DetailMeta:SetText("") + self.Description:SetText("") + self.DepsText:SetText("") + self.Note:SetText("") + self.CurrentUrl = false + self.CurrentGithub = false + self.UrlButton:Hide() + self.GithubButton:Hide() + UpdateTextAreaScrollbar(self.Description, self.DescriptionScrollbar) + UpdateTextAreaScrollbar(self.DepsText, self.DepsScrollbar) + end, + + --- Updates the footer: "X of Y mods" + the selected breakdown ("G game · U UI"), so the host + --- can see what actually syncs (game) vs. stays local (UI). + ---@param self UICustomLobbyModSelect + UpdateStats = function(self) + local total = table.getn(self.Mods) + local shown = table.getn(self.Filtered) + if self.Ready and shown < total then + self.CountLabel:SetText(LOCF("%d of %d mods", shown, total)) + else + self.CountLabel:SetText(LOCF("%d mods", total)) + end + + local game = table.getsize(ModUtilities.FilterSimMods(self.Selection)) + local ui = table.getsize(ModUtilities.FilterUIMods(self.Selection)) + self.SelectedLabel:SetText(LOCF("%d game · %d UI", game, ui)) + end, + + --------------------------------------------------------------------------- + --#region Presets + + --- Reloads the preset dropdown from stored presets. + ---@param self UICustomLobbyModSelect + RefreshPresets = function(self) + self.PresetNames = {} + for _, preset in ModUtilities.GetPresets() do + table.insert(self.PresetNames, preset.Name) + end + self.PresetCombo:ClearItems() + if table.getn(self.PresetNames) > 0 then + self.PresetCombo:AddItems(self.PresetNames) + self.DeletePresetButton:Enable() + else + self.PresetCombo:AddItems({ "(no presets)" }) + self.DeletePresetButton:Disable() + end + end, + + --- Loads a preset's selection into the working set (pruned to installed mods; sim mods are + --- dropped for a non-host who can't change them). + ---@param self UICustomLobbyModSelect + ---@param name string + LoadPreset = function(self, name) + local stored = ModUtilities.GetPreset(name) + if not stored then + return + end + local selection = ModUtilities.PruneMissing(stored) + if not self.CanEditGameMods then + selection = ModUtilities.FilterUIMods(selection) + end + self.Selection = selection + self.ModList:SetChecked(self.Selection) + self:UpdateStats() + self.Note:SetText("Loaded preset '" .. name .. "'") + end, + + --- Prompts for a name and saves the current selection as a preset. + ---@param self UICustomLobbyModSelect + PromptSavePreset = function(self) + UIUtil.CreateInputDialog(GetFrame(0), "Name this preset", function(dialog, name) + if not name or name == "" then + return + end + ModUtilities.SavePreset(name, self.Selection) + self:RefreshPresets() + self.Note:SetText("Saved preset '" .. name .. "'") + end) + end, + + --- Deletes the preset currently shown in the dropdown. + ---@param self UICustomLobbyModSelect + DeleteSelectedPreset = function(self) + local index = self.PresetCombo:GetItem() + local name = self.PresetNames[index] + if not name then + return + end + ModUtilities.DeletePreset(name) + self:RefreshPresets() + self.Note:SetText("Deleted preset '" .. name .. "'") + end, + + --#endregion + + --- Deselects everything the user is allowed to change: all mods for the host, only UI mods for + --- a non-host (their sim mods are the host's choice and stay put). + ---@param self UICustomLobbyModSelect + ClearSelection = function(self) + if self.CanEditGameMods then + self.Selection = {} + else + self.Selection = ModUtilities.FilterSimMods(self.Selection) + end + self.ModList:SetChecked(self.Selection) + self:UpdateStats() + self.Note:SetText("Cleared selection") + end, + + --- Commits the working selection via the opener's callback. + ---@param self UICustomLobbyModSelect + Confirm = function(self) + self.OnConfirmCb(self.Selection) + end, + + ---@param self UICustomLobbyModSelect + OnDestroy = function(self) + self.Trash:Destroy() + end, +} + +------------------------------------------------------------------------------- +--#region Singleton + open / close + +---@type Popup | false +local Instance = false + +--- Internal: builds the dialog over `parent` with the given options and wires the Popup. +---@param parent Control +---@param options { initial: UIModSelection, canEditGameMods: boolean, onConfirm: fun(selection: UIModSelection) } +local function OpenWith(parent, options) + if Instance then + Instance:Close() + end + + local popup + local content = CustomLobbyModSelect(parent, { + initial = options.initial, + canEditGameMods = options.canEditGameMods, + onConfirm = function(selection) + options.onConfirm(selection) + if popup then + popup:Close() + end + end, + onCancel = function() + if popup then + popup:Close() + end + end, + }) + + popup = Popup(parent, content) + local baseOnClosed = popup.OnClosed + popup.OnClosed = function(self) + baseOnClosed(self) + Instance = false + end + Instance = popup + + -- now that Popup has mounted + centred the content, it's safe to build the list pool + + -- populate (both read concrete geometry) + content:Initialize() +end + +--- Opens the mod-select dialog for the **lobby**. Sim mods route through the host-authoritative +--- `RequestSetGameMods` intent (a non-host sees them read-only); UI mods persist locally. Starts +--- from the launch model's sim mods + this peer's UI mods. +---@param parent? Control +function Open(parent) + parent = parent or GetFrame(0) + + local launch = CustomLobbyLaunchModel.GetSingleton() + local isHost = CustomLobbyLocalModel.GetSingleton().IsHost() + + -- seed: host-dictated sim mods (synced) + this peer's own UI mods (local) + local initial = table.copy(launch.GameMods() or {}) + for uid in ModUtilities.GetSelectedUIMods() do + initial[uid] = true + end + + OpenWith(parent, { + initial = initial, + canEditGameMods = isHost, + onConfirm = function(selection) + ModUtilities.SetSelectedUIMods(selection) -- this peer's UI mods, applied + persisted + if isHost then + CustomLobbyController.RequestSetGameMods(ModUtilities.FilterSimMods(selection)) + end + end, + }) +end + +--- Opens the mod-select dialog **standalone** (no lobby — the main-menu mod manager). The whole +--- selection persists to the preference file. `onClosed` (optional) runs after it closes. +---@param parent? Control +---@param onClosed? fun() +function OpenStandalone(parent, onClosed) + parent = parent or GetFrame(0) + + OpenWith(parent, { + initial = ModUtilities.GetSelectedMods(), + canEditGameMods = true, + onConfirm = function(selection) + ModUtilities.SetSelectedMods(selection) + end, + }) + + if onClosed then + local popup = Instance + if popup then + local baseOnClosed = popup.OnClosed + popup.OnClosed = function(self) + baseOnClosed(self) + onClosed() + end + end + end +end + +--- Closes the dialog if open. +function Close() + if Instance then + Instance:Close() + Instance = false + end +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Debugging + +function __moduleinfo.OnDirty() + Close() +end + +--#endregion diff --git a/lua/ui/modutilities.lua b/lua/ui/modutilities.lua new file mode 100644 index 00000000000..655cc39d777 --- /dev/null +++ b/lua/ui/modutilities.lua @@ -0,0 +1,376 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- Mod utilities: the UI-facing front door for working with mods, the sibling of `maputil.lua` +-- for maps. It is a thin, *pure* helper layer on top of `/lua/mods.lua` (the engine-facing +-- global that the sim, the blueprint loader and ~30 other modules use, so it stays put). +-- +-- We're slowly moving UI mod logic here, the same way the map work moved off `MapUtil`: +-- * classification (`Classify`) and display formatting (`FormatName` / `FormatVersion` / +-- `FormatAuthor`) — lifted out of the legacy `ModsManager.lua`, which interleaved them +-- with its layout code; +-- * dependency-aware selection edits (`ResolveEnable` / `ResolveDisable`) — the bits of the +-- legacy `ActivateMod` / `DeactivateMod` that are *just set arithmetic*, with the UI side +-- effects removed; +-- * persistence: writing the selection back to the preference file (`SetSelectedMods` and the +-- UI-only / sim-only variants), so a dialog never touches prefs directly; and +-- * **presets** — named selection snapshots stored in prefs, replacing the old "favorites". +-- +-- A picker (e.g. the custom lobby's mod-select dialog) decides *what* the selection is and hands +-- it here to persist; it never reads or writes prefs itself. See +-- `/lua/ui/lobby/customlobby/modselect/`. + +local Mods = import("/lua/mods.lua") +local Prefs = import("/lua/user/prefs.lua") +local SetUtils = import("/lua/system/setutils.lua") +local Blacklist = import("/etc/faf/blacklist.lua").Blacklist + +--- A mod uid set: `{ [uid] = true }`. The shape `mods.lua` and the launch model use. +---@alias UIModSelection table + +--- A normalized, display-ready view of a `ModInfo`. The catalog builds these from +--- `Mods.AllSelectableMods()` + the formatting / classification helpers below. +---@class UILobbyModInfo +---@field uid string +---@field name string # raw mod name (unformatted) +---@field title string # display name (version stripped, title-cased) +---@field versionText string # e.g. "v4", or "" when unversioned +---@field author string # cleaned author (first author, no underscores) +---@field description string +---@field copyright? string +---@field icon FileName +---@field location FileName +---@field ui_only boolean +---@field type ModType # UI | GAME | BLACKLISTED | NO_DEPENDENCY | LOCAL +---@field blacklistReason? LocalizedString +---@field url? string +---@field github? string +---@field requires? UIModSelection # installed uids this mod pulls in +---@field missing? UIModSelection # required uids that aren't installed +---@field conflicts? UIModSelection# installed uids that can't be active alongside this one + +local PresetsPrefsKey = "customlobby_mod_presets" + +------------------------------------------------------------------------------- +--#region Enumeration (thin pass-throughs to mods.lua) + +--- All selectable mods on disk, keyed by uid. Cached by `mods.lua`. +---@return table +function GetSelectableMods() + return Mods.AllSelectableMods() +end + +--- Forces the next `GetSelectableMods` to re-read the disk (mods changed while open). +function Refresh() + Mods.ClearCache() +end + +--- Whether a mod uid is on the FAF blacklist; returns the (localized) reason, or nil. +---@param uid string +---@return LocalizedString | nil +function GetBlacklistReason(uid) + return Blacklist[uid] +end + +--- The installed/missing/conflicting dependency sets for a mod. Pass-through to `mods.lua`. +---@param uid string +---@return {requires: UIModSelection?, missing: UIModSelection?, conflicts: UIModSelection?} +function GetDependencies(uid) + return Mods.GetDependencies(uid) +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Classification + formatting (lifted from ModsManager.lua) + +--- Classifies a mod into the type the picker filters/badges by: BLACKLISTED (on the FAF +--- blacklist or disabled), NO_DEPENDENCY (requires a missing or blacklisted mod), else UI / GAME. +--- (The legacy LOCAL "players missing this mod" type needs host-side peer availability — left +--- for the host slice; this is per-peer reference data.) +---@param mod ModInfo +---@return ModType +function Classify(mod) + if Blacklist[mod.uid] or mod.enabled == false then + return "BLACKLISTED" + end + + local dependencies = Mods.GetDependencies(mod.uid) + if dependencies.missing then + return "NO_DEPENDENCY" + end + if dependencies.requires then + for required in dependencies.requires do + if Blacklist[required] then + return "NO_DEPENDENCY" + end + end + end + + if mod.ui_only then + return "UI" + end + return "GAME" +end + +--- Strips an embedded version tag from a mod name and title-cases it — so "my-mod v2" +--- displays as "My Mod". Mirrors the legacy `GetModNameVersion` name handling. +---@param mod ModInfo +---@return string +function FormatName(mod) + local name = mod.name or "" + name = string.gsub(name, '[%[%<%{%(%s]+[vV]+%s*%d+[%.%d]*[%]%>%}%)%s]*', '') + name = StringCapitalize(name) + name = string.gsub(name, "-", "", 1) + return name +end + +--- A short version string ("v4"), or "" when the mod declares no integer version. +---@param mod ModInfo +---@return string +function FormatVersion(mod) + if type(mod.version) == 'number' then + return "v" .. tostring(mod.version) + elseif type(mod.version) == 'string' and mod.version ~= "" then + return "v" .. mod.version + end + return "" +end + +--- The first credited author, cleaned up, or "UNKNOWN". Mirrors the legacy `GetModAuthor`. +---@param mod ModInfo +---@return string +function FormatAuthor(mod) + local author = mod.author + if not author or author == "" then + return "UNKNOWN" + end + if string.len(author) >= 20 then + if string.find(author, ",") then + author = StringSplit(author, ',')[1] + elseif string.find(author, " ") then + author = StringSplit(author, ' ')[1] + end + end + return (string.gsub(author, "_", "", 1)) +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Dependency-aware selection edits +-- +-- Pure set arithmetic — given a selection and a uid, return the NEW selection. The legacy +-- ActivateMod/DeactivateMod did this inline with UI prompts and counters; the picker keeps the +-- UI concerns (a conflict confirmation, repaint) and calls these for the set maths. + +--- Adds `uid` to a copy of `selection`, pulling in everything it requires (recursively) and +--- removing any installed mods it conflicts with. Returns the new selection and the conflicts +--- that were turned off (so the caller can surface them). +---@param selection UIModSelection +---@param uid string +---@return UIModSelection selection +---@return string[] disabledConflicts +function ResolveEnable(selection, uid) + local next = table.copy(selection) + local disabled = {} + + local function enable(target) + if next[target] then + return + end + next[target] = true + + local dependencies = Mods.GetDependencies(target) + if dependencies.conflicts then + for conflict in dependencies.conflicts do + if next[conflict] then + next[conflict] = nil + table.insert(disabled, conflict) + end + end + end + if dependencies.requires then + for required in dependencies.requires do + enable(required) + end + end + end + + enable(uid) + return next, disabled +end + +--- Removes `uid` from a copy of `selection`, and removes any selected mods that require it +--- (recursively). Returns the new selection. +---@param selection UIModSelection +---@param uid string +---@return UIModSelection +function ResolveDisable(selection, uid) + local next = table.copy(selection) + + local function disable(target) + if not next[target] then + return + end + next[target] = nil + -- drop anything still selected that requires the mod we're turning off + for selected in next do + local dependencies = Mods.GetDependencies(selected) + if dependencies.requires and dependencies.requires[target] then + disable(selected) + end + end + end + + disable(uid) + return next +end + +--- Drops uids that aren't installed any more (e.g. loading an old preset after uninstalling). +---@param selection UIModSelection +---@return UIModSelection +function PruneMissing(selection) + local installed = Mods.AllMods() + return SetUtils.PredicateFilter(selection, function(uid) + return installed[uid] ~= nil + end) +end + +--- The sim-mod (`not ui_only`) subset of a selection. +---@param selection UIModSelection +---@return UIModSelection +function FilterSimMods(selection) + local installed = Mods.AllMods() + return SetUtils.PredicateFilter(selection, function(uid) + return installed[uid] ~= nil and not installed[uid].ui_only + end) +end + +--- The UI-mod (`ui_only`) subset of a selection. +---@param selection UIModSelection +---@return UIModSelection +function FilterUIMods(selection) + local installed = Mods.AllMods() + return SetUtils.PredicateFilter(selection, function(uid) + return installed[uid] ~= nil and installed[uid].ui_only + end) +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Persistence (the "updates the preference file" responsibility) + +--- The player's currently selected mods (sim + UI), from the preference file. +---@return UIModSelection +function GetSelectedMods() + return Mods.GetSelectedMods() +end + +--- The selected sim mods (the set that becomes the launch model's `GameMods`). +---@return UIModSelection +function GetSelectedSimMods() + return Mods.GetSelectedSimMods() +end + +--- The selected UI mods (applied per-player, never on the wire). +---@return UIModSelection +function GetSelectedUIMods() + return Mods.GetSelectedUIMods() +end + +--- Persists the full selection (sim + UI) to prefs and re-applies the active UI mods. Used by +--- the standalone (main-menu) path, where there's no host to dictate sim mods. +---@param selection UIModSelection +function SetSelectedMods(selection) + Mods.SetSelectedMods(selection) +end + +--- Persists only the UI-mod portion of `uiSelection`, keeping the existing sim selection in +--- prefs untouched, then re-applies active UI mods. Used by the lobby path, where sim mods are +--- host-dictated (synced separately) and only UI mods are this player's own choice. +---@param uiSelection UIModSelection +function SetSelectedUIMods(uiSelection) + local merged = GetSelectedSimMods() -- keep my current sim mods as-is + for uid in FilterUIMods(uiSelection) do + merged[uid] = true + end + Mods.SetSelectedMods(merged) +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Presets (named selection snapshots — replacing "favorites") +-- +-- A preset is `{ Name = string, Mods = UIModSelection }`. Stored as an array under one prefs +-- key so the order the user created them in is preserved. + +--- All saved presets, in creation order. +---@return { Name: string, Mods: UIModSelection }[] +function GetPresets() + return Prefs.GetFromCurrentProfile(PresetsPrefsKey) or {} +end + +--- Finds a preset's selection by name, or nil. +---@param name string +---@return UIModSelection | nil +function GetPreset(name) + for _, preset in GetPresets() do + if preset.Name == name then + return preset.Mods + end + end + return nil +end + +--- Saves `selection` under `name`, overwriting an existing preset with the same name. +---@param name string +---@param selection UIModSelection +function SavePreset(name, selection) + local presets = GetPresets() + for _, preset in presets do + if preset.Name == name then + preset.Mods = table.copy(selection) + Prefs.SetToCurrentProfile(PresetsPrefsKey, presets) + return + end + end + table.insert(presets, { Name = name, Mods = table.copy(selection) }) + Prefs.SetToCurrentProfile(PresetsPrefsKey, presets) +end + +--- Removes the preset with the given name (no-op if absent). +---@param name string +function DeletePreset(name) + local presets = GetPresets() + local kept = {} + for _, preset in presets do + if preset.Name ~= name then + table.insert(kept, preset) + end + end + Prefs.SetToCurrentProfile(PresetsPrefsKey, kept) +end + +--#endregion From 55e606da8694a004f75863ed53ee68460f6805c8 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Mon, 22 Jun 2026 15:42:29 +0200 Subject: [PATCH 24/98] Add an options dialog --- lua/ui/lobby/customlobby/CLAUDE.md | 20 +- .../customlobby/CustomLobbyController.lua | 19 + .../customlobby/CustomLobbyInterface.lua | 19 +- .../customlobby/CustomLobbyLaunchModel.lua | 7 + .../lobby/customlobby/optionselect/CLAUDE.md | 54 +++ .../optionselect/CustomLobbyOptionColumn.lua | 273 +++++++++++++ .../optionselect/CustomLobbyOptionSelect.lua | 369 ++++++++++++++++++ lua/ui/optionutil.lua | 228 +++++++++++ 8 files changed, 978 insertions(+), 11 deletions(-) create mode 100644 lua/ui/lobby/customlobby/optionselect/CLAUDE.md create mode 100644 lua/ui/lobby/customlobby/optionselect/CustomLobbyOptionColumn.lua create mode 100644 lua/ui/lobby/customlobby/optionselect/CustomLobbyOptionSelect.lua create mode 100644 lua/ui/optionutil.lua diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index 8f3ca95c0c5..13ed74cdd7e 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -54,6 +54,7 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | [CustomLobbyMapPreview.lua](CustomLobbyMapPreview.lua) / [CustomLobbyMapPreviewSpawn.lua](CustomLobbyMapPreviewSpawn.lua) | the in-lobby preview: a thin model-bound wrapper around `CustomLobbyScenarioPreview` (faction-icon spawns) + glow/brackets chrome. Subscribes to `ScenarioFile` (loads via the catalog, hands to the surface, show/hide) + each slot (refreshes the surface's spawn data, no reload). `CustomLobbyMapPreviewSpawn` is the faction spawn icon. | | [mapselect/](mapselect/CLAUDE.md) | the **map-select dialog** + its catalog and list, in their own folder (host-only `Popup`: searchable, filterable scenario list → `RequestSetScenario`). Self-contained sub-MVC; see [mapselect/CLAUDE.md](mapselect/CLAUDE.md) — including the **`MapPreview` texture-leak** writeup that shaped its design. | | [modselect/](modselect/CLAUDE.md) | the **mod-select dialog** + its catalog and list, built to the map-select shape (checkbox list + type filters + detail panel + **presets**). Returns a uid set; the opener routes it — sim mods → `RequestSetGameMods` (synced), UI mods → local prefs — or persists the lot standalone. Mod domain logic lives in [`/lua/ui/modutilities.lua`](/lua/ui/modutilities.lua) (the `maputil.lua` sibling, fronting `/lua/mods.lua`). See [modselect/CLAUDE.md](modselect/CLAUDE.md). | +| [optionselect/](optionselect/CLAUDE.md) | the **options dialog**: three columns (lobby / scenario / mod options) over the selected scenario + mods, with search + hide-defaults filters; non-default options are marked. Derives the option *schema* per-peer (reference data) via [`/lua/ui/optionutil.lua`](/lua/ui/optionutil.lua); edits a working copy of the *values* and on OK routes the reconciled set through `RequestSetGameOptions` (synced via `GameOptions`). Host-only. See [optionselect/CLAUDE.md](optionselect/CLAUDE.md). | | [CustomLobbyInterface.lua](CustomLobbyInterface.lua) | composition root (one model subscription for SlotCount); lays out slot rows + the observer strip, and acts as the rows' drag coordinator (`UICustomLobbySlotCoordinator`: hit-test, drop-highlight, drag ghost → `RequestSwapSlots`); `OpenDebug()` / hot-reload. | | [/lua/ui/lobby/lobby.lua](../lobby.lua) | engine entry wrapper (`CreateLobby`/`HostGame`/`JoinGame`) → CustomLobby. Old lobby preserved at `lobby-old.lua`. | @@ -75,16 +76,15 @@ the map-select dialog (searchable list + preview), which sets `ScenarioFile` thr ## Next slices (per TARGET_ARCHITECTURE.md) -1. **Options panel** — the next slice. The legacy `dialogs/mapselect.lua` bundled game options - into the map dialog; we're splitting them out. Model the **option schema as a derivation** of - `ScenarioFile` + `GameMods` (static lobby options ∪ the map's `_options.lua` ∪ mod options — - computed per peer, *not* synced, like the map catalog), rendered against the synced - `GameOptions` *values*. When the scenario/mods change, the controller **reconciles** the values - (drop stale keys, seed new defaults) — see the `TODO` in `RequestSetScenario`. Merge rule: - start with map/mod options only *adding* keys (no overriding base lobby options). -2. Remaining sub-dialogs (units, prefs) as mini-MVC, following the map-select shape. **Mods** is - done — see [modselect/](modselect/CLAUDE.md); it carries its own presets (replacing the legacy - per-mod "favorites"), so a separate presets dialog may not be needed. +1. **Options panel** — done; see [optionselect/](optionselect/CLAUDE.md). The schema is derived + per-peer from `ScenarioFile` + `GameMods` (lobby ∪ scenario `_options.lua` ∪ mod options) via + [`/lua/ui/optionutil.lua`](/lua/ui/optionutil.lua); the dialog edits the synced `GameOptions` + *values* and reconciles on confirm (seed defaults, drop stale keys) → `RequestSetGameOptions`. + Still TODO: reconcile in the **controller** on scenario/mod change too (the `TODO` in + `RequestSetScenario`), so values stay sane even without opening the dialog. +2. Remaining sub-dialogs (units, prefs) as mini-MVC, following the map-select shape. **Mods** and + **options** are done — see [modselect/](modselect/CLAUDE.md) (carries its own presets, replacing + the legacy per-mod "favorites") and [optionselect/](optionselect/CLAUDE.md). 3. Make slot controls interactive (faction/colour/team → controller **intents**). 4. Map-derived `SlotCount`; the GPGNet (`localPort == -1`) FAF-client path. diff --git a/lua/ui/lobby/customlobby/CustomLobbyController.lua b/lua/ui/lobby/customlobby/CustomLobbyController.lua index d6f162ee0f4..b49c0bc0cfe 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyController.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyController.lua @@ -544,6 +544,25 @@ function RequestSetGameMods(gameMods) BroadcastLaunchInfo(instance) end +--- The host sets the game options. Host-only — backs the options dialog. Replaces the whole +--- `GameOptions` value table in the launch model and broadcasts so every peer sees the same +--- options. The dialog already seeds defaults + drops stale keys, so this is the reconciled set. +---@param options table +function RequestSetGameOptions(options) + local instance = LobbyInstance + if not instance then + return + end + + if not CustomLobbyLocalModel.GetSingleton().IsHost() then + WARN("CustomLobby: only the host can change the game options") + return + end + + CustomLobbyLaunchModel.SetGameOptions(CustomLobbyLaunchModel.GetSingleton(), options) + BroadcastLaunchInfo(instance) +end + --- The host swaps the contents of two slots. Host-only (a client request isn't --- offered) — backs a host-side drag/menu and a `/swap ` chat command. ---@param slotA number diff --git a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua index e9b2041dc96..15c6fd6c324 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua @@ -41,6 +41,7 @@ local CustomLobbyObserversInterface = import("/lua/ui/lobby/customlobby/customlo local CustomLobbyMapPreview = import("/lua/ui/lobby/customlobby/customlobbymappreview.lua") local CustomLobbyMapSelect = import("/lua/ui/lobby/customlobby/mapselect/customlobbymapselect.lua") local CustomLobbyModSelect = import("/lua/ui/lobby/customlobby/modselect/customlobbymodselect.lua") +local CustomLobbyOptionSelect = import("/lua/ui/lobby/customlobby/optionselect/customlobbyoptionselect.lua") local LazyVarDerive = import("/lua/lazyvar.lua").Derive @@ -59,6 +60,7 @@ local PanelWidth = 520 ---@field MapPreview UICustomLobbyMapPreview ---@field MapButton Button ---@field ModsButton Button +---@field OptionsButton Button ---@field LeaveButton Button ---@field SlotCountObserver LazyVar ---@field IsHostObserver LazyVar @@ -106,6 +108,13 @@ local CustomLobbyInterface = Class(Group) { CustomLobbyModSelect.Open(GetFrame(0)) end + -- options are host-dictated + synced, so only the host edits them (button hidden for + -- others; the RequestSetGameOptions intent is host-gated regardless) + self.OptionsButton = UIUtil.CreateButtonStd(self, '/scx_menu/small-btn/small', "Options", 16, 2) + self.OptionsButton.OnClick = function(button, modifiers) + CustomLobbyOptionSelect.Open(GetFrame(0)) + end + -- leaving disconnects + returns to the menu via the escape handler that -- lobby.lua registered (one teardown definition, shared with the Esc key) self.LeaveButton = UIUtil.CreateButtonStd(self, '/scx_menu/small-btn/small', "Leave", 16, 2) @@ -160,6 +169,12 @@ local CustomLobbyInterface = Class(Group) { :AtVerticalCenterIn(self.MapButton) :End() + -- options button beside mods (host-only; visibility set by OnIsHostChanged) + Layouter(self.OptionsButton) + :AnchorToRight(self.ModsButton, 8) + :AtVerticalCenterIn(self.ModsButton) + :End() + -- stack the rows top-to-bottom inside the panel via sibling anchoring for slot = 1, CustomLobbyLaunchModel.MaxSlots do local row = self.Slots[slot] @@ -194,14 +209,16 @@ local CustomLobbyInterface = Class(Group) { end end, - --- Shows the host-only controls (the map-change button) only to the host. + --- Shows the host-only controls (map-change + options buttons) only to the host. ---@param self UICustomLobbyInterface ---@param isHost boolean OnIsHostChanged = function(self, isHost) if isHost then self.MapButton:Show() + self.OptionsButton:Show() else self.MapButton:Hide() + self.OptionsButton:Hide() end end, diff --git a/lua/ui/lobby/customlobby/CustomLobbyLaunchModel.lua b/lua/ui/lobby/customlobby/CustomLobbyLaunchModel.lua index bca1b674cab..2e09fae8c1a 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyLaunchModel.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyLaunchModel.lua @@ -161,6 +161,13 @@ function SetGameOption(model, key, value) model.GameOptions:Set(options) end +--- Replaces the whole game-options value table (copy-then-Set). +---@param model UICustomLobbyLaunchModel +---@param options table +function SetGameOptions(model, options) + model.GameOptions:Set(table.copy(options)) +end + --- Sets the scenario file. ---@param model UICustomLobbyLaunchModel ---@param scenarioFile FileName | false diff --git a/lua/ui/lobby/customlobby/optionselect/CLAUDE.md b/lua/ui/lobby/customlobby/optionselect/CLAUDE.md new file mode 100644 index 00000000000..eee7851bf74 --- /dev/null +++ b/lua/ui/lobby/customlobby/optionselect/CLAUDE.md @@ -0,0 +1,54 @@ +# Option select + +The custom lobby's **options dialog** — three columns (lobby / scenario / mod options) over the +selected scenario + mods. The third sibling of [`../mapselect/`](../mapselect/CLAUDE.md) and +[`../modselect/`](../modselect/CLAUDE.md), built to the same shape. Opened by the host-only +"Options" button in [`../CustomLobbyInterface.lua`](../CustomLobbyInterface.lua). + +> Read [`../CLAUDE.md`](../CLAUDE.md) first (the lobby's MVC + the layout/init gotchas), then the +> map/mod dialog docs — this mirrors them. + +## Files + +| File | Role | +|------|------| +| [CustomLobbyOptionSelect.lua](CustomLobbyOptionSelect.lua) | the dialog (transient `Popup`). Three fixed columns + a top filter bar (search by label + a **Hide defaults** toggle) + Reset / OK / Cancel. Owns a *working copy* of the option values; on OK it seeds defaults for every untouched option and hands the complete set to `onConfirm`. The in-lobby opener routes that through the host-authoritative `RequestSetGameOptions` intent. Owns no synced state. | +| [CustomLobbyOptionColumn.lua](CustomLobbyOptionColumn.lua) | one column: header (title + shown count), a native **`Grid`** of option rows, an auto-hiding scrollbar, and an **empty state**. Each row is `[marker] label … [value dropdown]`; the marker + a tinted label flag an option that's **not at its default**. Reads/writes the shared values table and reads the schema via `optionutil`. | + +The option domain logic lives one level up in [`/lua/ui/optionutil.lua`](/lua/ui/optionutil.lua) — +the `maputil.lua` / `modutilities.lua` sibling (inspired by Penguin5's `optionutil.lua`): it +**gathers** the schema from the three sources, and **interprets** options + values (default value, +current value, is-default, value index, display text, seed-defaults). + +## The schema (reference data) vs. the values (synced) + +The option **schema** is derived per-peer from the synced `ScenarioFile` + `GameMods` — it is +*reference data*, not a model (like the map/mod catalogs). The three sources: + +- **lobby** — the static base options (team ∪ global ∪ AI) from [`../../lobbyOptions.lua`](../../lobbyOptions.lua); +- **scenario** — the selected map's `_options.lua`, via `MapUtil.LoadScenarioOptionsFile` + (the `_options.lua` name mirrors `_scenario.lua` / `mod_info.lua`); +- **mods** — each selected sim mod's `/lua/AI/LobbyOptions/lobbyoptions.lua` (`AIOpts`). + +Only the option **values** sync: the host edits them here and they ride in the launch model's +`GameOptions` (broadcast via `BroadcastLaunchInfo`, applied in `ProcessSentLaunchInfo`). The dialog +seeds defaults + drops keys absent from the current schema, so confirming is also the +**reconciliation** the [`../CLAUDE.md`](../CLAUDE.md) options-slice note called for. + +## Why a `Grid`, not the pooled list + +The map/mod lists are text-only and virtualise by hand. Option rows hold a live **`Combo`** each, +which is painful to repaint on every scroll tick. The native `Grid` hides off-screen rows itself, +so the combos are created once (per filter rebuild) with no clipping tricks and no per-scroll +churn. Rows are rebuilt only when the **filter** (search / hide-defaults) changes — never on a +value edit — so editing never disturbs an open dropdown. + +## Known gaps (deliberate, for a later pass) + +- **No per-column show/hide toggles.** The `Grid` destroys its items on `SetDimensions`, so + reflowing to fewer/wider columns means a rebuild; the three columns are fixed for now. The top + bar is search + hide-defaults only. +- **Host-only.** Options are all host-dictated + synced, so the dialog is host-only (the button + hides for clients; the intent is host-gated regardless). No read-only client view yet. +- **No live cross-mod/scenario key-clash handling.** `optionutil` de-dups within a source by key; + it doesn't resolve a scenario option colliding with a base lobby key (rare). diff --git a/lua/ui/lobby/customlobby/optionselect/CustomLobbyOptionColumn.lua b/lua/ui/lobby/customlobby/optionselect/CustomLobbyOptionColumn.lua new file mode 100644 index 00000000000..5253b4ef397 --- /dev/null +++ b/lua/ui/lobby/customlobby/optionselect/CustomLobbyOptionColumn.lua @@ -0,0 +1,273 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- One column of the options dialog (lobby / scenario / mod). A header (title + shown count), a +-- scrollable `Grid` of option rows, an auto-hiding scrollbar, and an empty-state label. +-- +-- Each row is `[marker] label …………… [value dropdown]`. The dropdown picks among the option's +-- values; the marker (a dot) lights up when the option is *not* at its default, so changed +-- options stand out. The column doesn't own the values — it reads/writes a shared values table +-- (handed in via `SetData`) and calls `onChange` so the dialog can react (re-count, etc.). +-- +-- The list uses the native `Grid`, which hides off-screen rows itself, so the value dropdowns are +-- real `Combo`s created once (no per-scroll rebuild, no clipping tricks). Rows are rebuilt only +-- when the filter (search / hide-defaults) changes — not on a value edit, so editing never +-- disturbs an open dropdown. + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") +local Tooltip = import("/lua/ui/game/tooltip.lua") + +local Group = import("/lua/maui/group.lua").Group +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap +local Grid = import("/lua/maui/grid.lua").Grid +local Combo = import("/lua/ui/controls/combo.lua").Combo + +local OptionUtil = import("/lua/ui/optionutil.lua") + +local Layouter = LayoutHelpers.ReusedLayoutFor + +local HeaderHeight = 24 +local RowHeight = 30 +local ScrollbarGap = 20 -- space reserved on the column's right for the scrollbar +local ComboWidth = 116 +local MarkerWidth = 14 + +local ModifiedColor = 'ffd0a24c' -- label + marker colour for a non-default option +local DefaultColor = 'ffc8ccd0' + +local LabelMaxChars = 22 -- single-line Text doesn't clip; cap with an ellipsis + +--- Truncates `text` to `maxChars`, appending "…" when it had to cut. +---@param text string +---@param maxChars number +---@return string +local function Truncate(text, maxChars) + text = text or "" + if string.len(text) > maxChars then + return string.sub(text, 1, maxChars - 1) .. "…" + end + return text +end + +---@class UICustomLobbyOptionColumn : Group +---@field Trash TrashBag +---@field ContentWidth number +---@field Header Text +---@field Count Text +---@field Grid Grid +---@field Scrollbar Scrollbar | false +---@field Empty Text +---@field Options ScenarioOption[] +---@field Values table +---@field OnChange fun() +---@field Shown number # options currently displayed (after filtering) +local CustomLobbyOptionColumn = ClassUI(Group) { + + ---@param self UICustomLobbyOptionColumn + ---@param parent Control + ---@param title string + ---@param contentWidth number # unscaled width of the grid / rows (excludes the scrollbar gap) + __init = function(self, parent, title, contentWidth) + Group.__init(self, parent, "CustomLobbyOptionColumn") + + self.Trash = TrashBag() + self.ContentWidth = contentWidth + self.Options = {} + self.Values = {} + self.OnChange = nil + self.Scrollbar = false + self.Shown = 0 + + self.Header = UIUtil.CreateText(self, title, 16, UIUtil.titleFont) + self.Header:DisableHitTest() + + self.Count = UIUtil.CreateText(self, "", 12, UIUtil.bodyFont) + self.Count:SetColor('ff9aa0a8') + self.Count:DisableHitTest() + + self.Grid = Grid(self, contentWidth, RowHeight) + + self.Empty = UIUtil.CreateText(self, "No options", 13, UIUtil.bodyFont) + self.Empty:SetColor('ff8a909a') + self.Empty:DisableHitTest() + self.Empty:Hide() + end, + + ---@param self UICustomLobbyOptionColumn + __post_init = function(self) + Layouter(self.Header):AtLeftIn(self):AtTopIn(self):End() + Layouter(self.Count):AtRightIn(self, ScrollbarGap):AtVerticalCenterIn(self.Header):End() + Layouter(self.Grid) + :AtLeftIn(self):AnchorToBottom(self.Header, 6):AtBottomIn(self) + :Width(self.ContentWidth) + :End() + Layouter(self.Empty):AtHorizontalCenterIn(self.Grid):AtTopIn(self.Grid, 12):End() + end, + + --- Points the column at its options + the shared values table, and the change callback. The + --- title can be refreshed too. Does not build rows yet (see Refresh). + ---@param self UICustomLobbyOptionColumn + ---@param options ScenarioOption[] + ---@param values table + ---@param onChange fun() + SetData = function(self, options, values, onChange) + self.Options = options or {} + self.Values = values or {} + self.OnChange = onChange + end, + + --- Builds the scrollbar; called by the dialog after mount (the Grid needs a concrete height). + ---@param self UICustomLobbyOptionColumn + Initialize = function(self) + if self.Scrollbar then + return + end + self.Scrollbar = UIUtil.CreateVertScrollbarFor(self.Grid) + end, + + --- Rebuilds the visible rows applying the search + hide-defaults filter, updates the count and + --- the empty state, and shows the scrollbar only when it's needed. + ---@param self UICustomLobbyOptionColumn + ---@param search string # already lowercased; "" = no search + ---@param hideDefaults boolean + Refresh = function(self, search, hideDefaults) + self.Grid:DeleteAndDestroyAll(true) + + local filtered = {} + for _, option in self.Options do + if self:Passes(option, search, hideDefaults) then + table.insert(filtered, option) + end + end + self.Shown = table.getn(filtered) + + if self.Shown > 0 then + self.Empty:Hide() + self.Grid:AppendCols(1, true) + self.Grid:AppendRows(self.Shown, true) + for row, option in filtered do + self.Grid:SetItem(self:CreateRow(option), 1, row, true) + end + self.Grid:EndBatch() + else + self.Empty:Show() + end + + self.Count:SetText(tostring(self.Shown)) + self:UpdateScrollbar() + end, + + --- Whether an option survives the current filter. + ---@param self UICustomLobbyOptionColumn + ---@param option ScenarioOption + ---@param search string + ---@param hideDefaults boolean + ---@return boolean + Passes = function(self, option, search, hideDefaults) + if hideDefaults and OptionUtil.IsDefault(option, self.Values) then + return false + end + if search ~= "" then + if not string.find(string.lower(LOC(option.label) or ""), search, 1, true) then + return false + end + end + return true + end, + + --- Builds one option row: a non-default marker, the label, and a value dropdown. Private. + ---@param self UICustomLobbyOptionColumn + ---@param option ScenarioOption + ---@return Group + CreateRow = function(self, option) + local row = Group(self.Grid) + LayoutHelpers.SetDimensions(row, self.ContentWidth, RowHeight) + + local marker = Bitmap(row) + marker:SetSolidColor(ModifiedColor) + marker:DisableHitTest() + + local label = UIUtil.CreateText(row, Truncate(LOC(option.label) or option.key, LabelMaxChars), 14, UIUtil.bodyFont) + label:DisableHitTest() + + local combo = Combo(row, 14, 10, nil, nil, "UI_Tab_Click_01", "UI_Tab_Rollover_01") + combo:AddItems(OptionUtil.ValueLabels(option), OptionUtil.FindValueIndex(option, OptionUtil.GetCurrentValueKey(option, self.Values))) + combo.OnClick = function(control, index, text) + self.Values[option.key] = OptionUtil.ValueKeyOf(option.values[index]) + self:PaintMarker(marker, label, option) + if self.OnChange then + self.OnChange() + end + end + + Layouter(marker):AtLeftIn(row):AtVerticalCenterIn(row):Width(6):Height(6):End() + Layouter(combo):AtRightIn(row, 4):AtVerticalCenterIn(row):Width(ComboWidth):End() + Layouter(label):AtLeftIn(row, MarkerWidth):AtVerticalCenterIn(row):End() + + Tooltip.AddControlTooltipManual(label, LOC(option.label) or option.key, LOC(option.help) or "") + self:PaintMarker(marker, label, option) + + return row + end, + + --- Lights the marker + tints the label when the option is off its default, dims both when not. + ---@param self UICustomLobbyOptionColumn + ---@param marker Bitmap + ---@param label Text + ---@param option ScenarioOption + PaintMarker = function(self, marker, label, option) + if OptionUtil.IsDefault(option, self.Values) then + marker:Hide() + label:SetColor(DefaultColor) + else + marker:Show() + label:SetColor(ModifiedColor) + end + end, + + --- Shows the scrollbar only when the grid actually overflows. + ---@param self UICustomLobbyOptionColumn + UpdateScrollbar = function(self) + if not self.Scrollbar then + return + end + if self.Grid:IsScrollable("Vert") then + self.Scrollbar:Show() + else + self.Scrollbar:Hide() + end + end, + + ---@param self UICustomLobbyOptionColumn + OnDestroy = function(self) + self.Trash:Destroy() + end, +} + +---@param parent Control +---@param title string +---@param contentWidth number +---@return UICustomLobbyOptionColumn +Create = function(parent, title, contentWidth) + return CustomLobbyOptionColumn(parent, title, contentWidth) +end diff --git a/lua/ui/lobby/customlobby/optionselect/CustomLobbyOptionSelect.lua b/lua/ui/lobby/customlobby/optionselect/CustomLobbyOptionSelect.lua new file mode 100644 index 00000000000..6612481a650 --- /dev/null +++ b/lua/ui/lobby/customlobby/optionselect/CustomLobbyOptionSelect.lua @@ -0,0 +1,369 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- The options dialog: three columns — lobby, scenario and mod options — over the currently +-- selected scenario + mods. The third sibling of the map- and mod-select dialogs. +-- +-- It is a transient picker, NOT a model component: it owns no synced state. Its inputs are the +-- selected `ScenarioFile`, the selected sim mods (`GameMods`) and the existing option *values*; +-- it derives the option *schema* per column via `optionutil` (lobby = lobbyOptions.lua, scenario = +-- the map's `_options.lua`, mods = each sim mod's lobbyoptions). Only options from the selected +-- scenario / mods are shown, so a column is empty (with an empty state) when that source has none. +-- +-- It edits a working copy of the values and, on OK, hands the complete value set (defaults seeded +-- for everything untouched) to an `onConfirm` callback. The in-lobby opener routes that through +-- the host-authoritative `RequestSetGameOptions` intent (game options are synced). An option that +-- is *not* at its default is marked per row (a dot + tinted label) by the column. +-- +-- Top bar: a search filter (by option label) and a "Hide defaults" toggle. (Per-column show/hide +-- toggles would need the columns to reflow, which the Grid can't do without rebuilding — left for +-- later; the three columns are fixed for now.) + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") +local Tooltip = import("/lua/ui/game/tooltip.lua") + +local Group = import("/lua/maui/group.lua").Group +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap +local Edit = import("/lua/maui/edit.lua").Edit +local Popup = import("/lua/ui/controls/popups/popup.lua").Popup + +local OptionUtil = import("/lua/ui/optionutil.lua") +local CustomLobbyOptionColumn = import("/lua/ui/lobby/customlobby/optionselect/customlobbyoptioncolumn.lua") +local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") + +local Layouter = LayoutHelpers.ReusedLayoutFor + +-- flip to tint each layout area so the regions are visible while iterating +local Debug = false + +local DialogWidth = 980 +local DialogHeight = 620 +local Pad = 12 +local ColGap = 16 +local TitleHeight = 32 +local FilterHeight = 28 +local ActionHeight = 48 + +-- three equal columns spanning the inner width, each reserving room on its right for a scrollbar +local ColTotalWidth = math.floor((DialogWidth - 2 * Pad - 2 * ColGap) / 3) +local ColContentWidth = ColTotalWidth - 20 + +local PrefsKey = "customlobby_optionselect" + +--- Concatenates the three columns' option lists into one (for seeding defaults across all of them). +---@param lobby ScenarioOption[] +---@param scenario ScenarioOption[] +---@param mods ScenarioOption[] +---@return ScenarioOption[] +local function ConcatOptions(lobby, scenario, mods) + local all = {} + for _, list in { lobby, scenario, mods } do + for _, option in list do + table.insert(all, option) + end + end + return all +end + +--- Creates a layout area (an invisible Group with an optional debug tint). +---@param parent Control +---@param name string +---@param color string +---@return Group +local function CreateArea(parent, name, color) + local area = Group(parent, name) + local bg = Bitmap(area) + bg:SetSolidColor(color) + bg:SetAlpha(Debug and 0.18 or 0.0) + bg:DisableHitTest() + Layouter(bg):Fill(area):End() + return area +end + +---@class UICustomLobbyOptionSelect : Group +---@field Trash TrashBag +---@field Values table +---@field LobbyOptions ScenarioOption[] +---@field ScenarioOptions ScenarioOption[] +---@field ModOptions ScenarioOption[] +---@field OnConfirmCb fun(values: table) +---@field OnCancelCb fun() +---@field HideDefaults boolean +---@field TitleArea Group +---@field ActionArea Group +---@field Title Text +---@field SearchLabel Text +---@field Search Edit +---@field HideDefaultsToggle Checkbox +---@field LobbyColumn UICustomLobbyOptionColumn +---@field ScenarioColumn UICustomLobbyOptionColumn +---@field ModColumn UICustomLobbyOptionColumn +---@field ResetButton Button +---@field SelectButton Button +---@field CancelButton Button +---@field Ready boolean +local CustomLobbyOptionSelect = ClassUI(Group) { + + ---@param self UICustomLobbyOptionSelect + ---@param parent Control + ---@param options { scenarioFile: FileName|false, gameMods: table, values: table, onConfirm: fun(values: table), onCancel: fun() } + __init = function(self, parent, options) + Group.__init(self, parent, "CustomLobbyOptionSelect") + + self.Trash = TrashBag() + self.OnConfirmCb = options.onConfirm + self.OnCancelCb = options.onCancel + self.Ready = false + + -- working copy of the values; the columns read + write this same table + self.Values = table.copy(options.values or {}) + + -- the schema, derived per-column from the selected scenario + mods (reference data) + self.LobbyOptions = OptionUtil.GetLobbyOptions() + self.ScenarioOptions = OptionUtil.GetScenarioOptions(options.scenarioFile) + self.ModOptions = OptionUtil.GetModOptions(options.gameMods) + + local saved = import("/lua/user/prefs.lua").GetFromCurrentProfile(PrefsKey) or {} + self.HideDefaults = saved.hideDefaults == true + + self.TitleArea = CreateArea(self, "TitleArea", 'ffcc4040') + self.ActionArea = CreateArea(self, "ActionArea", 'ff808080') + + self.Title = UIUtil.CreateText(self.TitleArea, "Game options", 22, UIUtil.titleFont) + + --#region top filter bar + self.SearchLabel = UIUtil.CreateText(self, "Search", 13, UIUtil.bodyFont) + self.SearchLabel:SetColor('ff9aa0a8') + + self.Search = Edit(self) + Layouter(self.Search):Left(0):Top(0):Width(96):Height(22):End() + self.Search:SetFont(UIUtil.bodyFont, 16) + self.Search:SetForegroundColor(UIUtil.fontColor) + self.Search:ShowBackground(true) + self.Search:SetBackgroundColor('77778888') + self.Search:SetText(saved.search or "") + self.Search.OnTextChanged = function(control, newText, oldText) + self:RefreshColumns() + self:SavePrefs() + end + Tooltip.AddControlTooltipManual(self.Search, "Search", "Filter options by name across all three columns.") + + self.HideDefaultsToggle = UIUtil.CreateCheckbox(self, '/CHECKBOX/', "Hide defaults", true, 13) + self.HideDefaultsToggle:SetCheck(self.HideDefaults, true) + self.HideDefaultsToggle.OnCheck = function(control, checked) + self.HideDefaults = checked + self:RefreshColumns() + self:SavePrefs() + end + Tooltip.AddControlTooltipManual(self.HideDefaultsToggle, "Hide defaults", + "Show only the options that have been changed from their default value.") + --#endregion + + --#region columns + self.LobbyColumn = CustomLobbyOptionColumn.Create(self, "Lobby", ColContentWidth) + self.ScenarioColumn = CustomLobbyOptionColumn.Create(self, "Scenario", ColContentWidth) + self.ModColumn = CustomLobbyOptionColumn.Create(self, "Mods", ColContentWidth) + + self.LobbyColumn:SetData(self.LobbyOptions, self.Values) + self.ScenarioColumn:SetData(self.ScenarioOptions, self.Values) + self.ModColumn:SetData(self.ModOptions, self.Values) + --#endregion + + --#region actions + self.ResetButton = UIUtil.CreateButtonStd(self.ActionArea, '/scx_menu/small-btn/small', "Reset", 16, 2) + self.ResetButton.OnClick = function(button, modifiers) + self:ResetToDefaults() + end + Tooltip.AddControlTooltipManual(self.ResetButton, "Reset", "Reset every option to its default value.") + + self.SelectButton = UIUtil.CreateButtonStd(self.ActionArea, '/scx_menu/small-btn/small', "OK", 16, 2) + self.SelectButton.OnClick = function(button, modifiers) + self:Confirm() + end + + self.CancelButton = UIUtil.CreateButtonStd(self.ActionArea, '/scx_menu/small-btn/small', "Cancel", 16, 2) + self.CancelButton.OnClick = function(button, modifiers) + self.OnCancelCb() + end + --#endregion + end, + + ---@param self UICustomLobbyOptionSelect + __post_init = function(self) + self.Width:Set(LayoutHelpers.ScaleNumber(DialogWidth)) + self.Height:Set(LayoutHelpers.ScaleNumber(DialogHeight)) + + Layouter(self.TitleArea):AtLeftIn(self, Pad):AtRightIn(self, Pad):AtTopIn(self, Pad):Height(TitleHeight):End() + Layouter(self.ActionArea):AtLeftIn(self, Pad):AtRightIn(self, Pad):AtBottomIn(self, Pad):Height(ActionHeight):End() + Layouter(self.Title):AtHorizontalCenterIn(self.TitleArea):AtVerticalCenterIn(self.TitleArea):End() + + --#region top filter bar + Layouter(self.SearchLabel):AtLeftIn(self, Pad):AnchorToBottom(self.TitleArea, 10):End() + Layouter(self.Search):AnchorToRight(self.SearchLabel, 8):AtVerticalCenterIn(self.SearchLabel):Width(220):Height(FilterHeight - 6):End() + Layouter(self.HideDefaultsToggle):AnchorToRight(self.Search, 24):AtVerticalCenterIn(self.Search):End() + --#endregion + + --#region columns (three fixed equal columns between the filter bar and the actions) + Layouter(self.LobbyColumn) + :AtLeftIn(self, Pad):Width(ColTotalWidth) + :AnchorToBottom(self.Search, 12):AnchorToTop(self.ActionArea, 10) + :End() + Layouter(self.ScenarioColumn) + :AnchorToRight(self.LobbyColumn, ColGap):Width(ColTotalWidth) + :AnchorToBottom(self.Search, 12):AnchorToTop(self.ActionArea, 10) + :End() + Layouter(self.ModColumn) + :AnchorToRight(self.ScenarioColumn, ColGap):Width(ColTotalWidth) + :AnchorToBottom(self.Search, 12):AnchorToTop(self.ActionArea, 10) + :End() + --#endregion + + --#region actions + Layouter(self.SelectButton):AtRightIn(self.ActionArea):AtVerticalCenterIn(self.ActionArea):End() + Layouter(self.CancelButton):AnchorToLeft(self.SelectButton, 12):AtVerticalCenterIn(self.ActionArea):End() + Layouter(self.ResetButton):AtLeftIn(self.ActionArea):AtVerticalCenterIn(self.ActionArea):End() + --#endregion + end, + + --- Builds the columns' scrollbars + first render. Called by the opener after Popup mounts (the + --- Grids need a concrete height — three-phase init, /lua/ui/CLAUDE.md § 1). + ---@param self UICustomLobbyOptionSelect + Initialize = function(self) + self.Ready = true + self.LobbyColumn:Initialize() + self.ScenarioColumn:Initialize() + self.ModColumn:Initialize() + self:RefreshColumns() + end, + + --- Re-applies the search + hide-defaults filter to every column. + ---@param self UICustomLobbyOptionSelect + RefreshColumns = function(self) + if not self.Ready then + return + end + local search = string.lower(self.Search:GetText() or "") + self.LobbyColumn:Refresh(search, self.HideDefaults) + self.ScenarioColumn:Refresh(search, self.HideDefaults) + self.ModColumn:Refresh(search, self.HideDefaults) + end, + + --- Resets every option to its default by clearing the working values in place (the columns + --- share the table by reference), then re-rendering. + ---@param self UICustomLobbyOptionSelect + ResetToDefaults = function(self) + for key in self.Values do + self.Values[key] = nil + end + self:RefreshColumns() + end, + + --- Persists the search + hide-defaults filter for next time. + ---@param self UICustomLobbyOptionSelect + SavePrefs = function(self) + import("/lua/user/prefs.lua").SetToCurrentProfile(PrefsKey, { + hideDefaults = self.HideDefaults, + search = self.Search:GetText() or "", + }) + end, + + --- Commits the complete value set (defaults seeded for every untouched option) via the opener. + ---@param self UICustomLobbyOptionSelect + Confirm = function(self) + local all = ConcatOptions(self.LobbyOptions, self.ScenarioOptions, self.ModOptions) + self.OnConfirmCb(OptionUtil.SeedDefaults(all, self.Values)) + end, + + ---@param self UICustomLobbyOptionSelect + OnDestroy = function(self) + self.Trash:Destroy() + end, +} + +------------------------------------------------------------------------------- +--#region Singleton + open / close + +---@type Popup | false +local Instance = false + +--- Opens the options dialog over `parent`, seeded from the launch model's scenario / mods / +--- option values. Confirming routes the new values through the host-authoritative +--- `RequestSetGameOptions` intent (game options are synced). Replaces any dialog already open. +---@param parent? Control +function Open(parent) + parent = parent or GetFrame(0) + if Instance then + Instance:Close() + end + + local launch = CustomLobbyLaunchModel.GetSingleton() + + local popup + local content = CustomLobbyOptionSelect(parent, { + scenarioFile = launch.ScenarioFile(), + gameMods = launch.GameMods(), + values = launch.GameOptions(), + onConfirm = function(values) + CustomLobbyController.RequestSetGameOptions(values) + if popup then + popup:Close() + end + end, + onCancel = function() + if popup then + popup:Close() + end + end, + }) + + popup = Popup(parent, content) + local baseOnClosed = popup.OnClosed + popup.OnClosed = function(self) + baseOnClosed(self) + Instance = false + end + Instance = popup + + -- Popup has mounted + centred the content; safe to build the grids' scrollbars + render + content:Initialize() +end + +--- Closes the dialog if open. +function Close() + if Instance then + Instance:Close() + Instance = false + end +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Debugging + +function __moduleinfo.OnDirty() + Close() +end + +--#endregion diff --git a/lua/ui/optionutil.lua b/lua/ui/optionutil.lua new file mode 100644 index 00000000000..557ca797999 --- /dev/null +++ b/lua/ui/optionutil.lua @@ -0,0 +1,228 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- Option utilities: the UI-facing helper for game options, the sibling of `maputil.lua` (maps) +-- and `modutilities.lua` (mods). Inspired by Penguin5's `optionutil.lua`. +-- +-- A game option is a `ScenarioOption` (see lobbyOptions.lua): `{ key, label, help, default, +-- mponly?, values, value_text?, value_help? }`, where `values` is a list of either +-- `{ key, text, help }` tables or bare values formatted through `value_text`. `default` is a +-- 1-based index into `values`. The *selected* value of an option is stored, by its `key`, in the +-- game-options value table (the launch model's `GameOptions`) as the chosen value's `key`. +-- +-- Options come from three sources — the three columns of the options dialog: +-- * **lobby** — the static base options (team / global / AI) from `lobbyOptions.lua`; +-- * **scenario** — the selected map's `_options.lua` (loaded via MapUtil); +-- * **mods** — each selected sim mod's `/lua/AI/LobbyOptions/lobbyoptions.lua` (`AIOpts`). +-- +-- This module only *gathers and interprets* the schema + values (pure, no UI). Which option +-- value is live, the per-option default, and how a value reads on screen all come from here so the +-- dialog and the controller agree. The schema is reference data (each peer derives it from the +-- synced `ScenarioFile` + `GameMods`); only the *values* are synced. + +local LobbyOptions = import("/lua/ui/lobby/lobbyOptions.lua") +local MapUtil = import("/lua/ui/maputil.lua") +local Mods = import("/lua/mods.lua") + +-- where a sim mod declares its lobby options, relative to the mod's location +local ModOptionsFile = "/lua/AI/LobbyOptions/lobbyoptions.lua" + +------------------------------------------------------------------------------- +--#region Gathering the schema + +--- Appends every entry of `source` to `target` whose `key` isn't already present, so later +--- sources can't silently shadow an earlier option. Returns `target`. +---@param target ScenarioOption[] +---@param source ScenarioOption[] +---@return ScenarioOption[] +local function AppendUniqueByKey(target, source) + local seen = {} + for _, option in target do + seen[option.key] = true + end + for _, option in source do + if option.key and not seen[option.key] then + seen[option.key] = true + table.insert(target, option) + end + end + return target +end + +--- The static lobby options (team + global + AI), as one fresh list. +---@return ScenarioOption[] +function GetLobbyOptions() + local options = {} + AppendUniqueByKey(options, LobbyOptions.teamOptions or {}) + AppendUniqueByKey(options, LobbyOptions.globalOpts or {}) + AppendUniqueByKey(options, LobbyOptions.AIOpts or {}) + return options +end + +--- The selected scenario's own options (its `_options.lua`), validated, or an empty list. +---@param scenarioFile FileName | false +---@return ScenarioOption[] +function GetScenarioOptions(scenarioFile) + if not scenarioFile then + return {} + end + local path = MapUtil.GetPathToScenarioOptions(scenarioFile) + local ok, options = pcall(MapUtil.LoadScenarioOptionsFile, path) + if not ok or not options then + return {} + end + -- ValidateScenarioOptions repairs bad `default` indices *in place* (and returns false when it + -- had to), so we call it for that side-effect and keep the now-sane options either way. It's + -- pcall'd because it indexes `option.values` — untrusted disk data may have a malformed option. + pcall(MapUtil.ValidateScenarioOptions, options) + return options +end + +--- The options contributed by the selected sim mods — each mod's `lobbyoptions.lua` `AIOpts`, +--- merged and de-duplicated by key. +---@param gameMods table # selected sim-mod uid set (the launch model's GameMods) +---@return ScenarioOption[] +function GetModOptions(gameMods) + if not gameMods then + return {} + end + local allMods = Mods.AllMods() + local options = {} + for uid in gameMods do + local mod = allMods[uid] + if mod and mod.location then + local path = mod.location .. ModOptionsFile + if DiskGetFileInfo(path) then + local ok, module = pcall(import, path) + if ok and module and module.AIOpts then + AppendUniqueByKey(options, module.AIOpts) + end + end + end + end + -- repair any bad `default` indices in place (same contract as the scenario options); pcall'd + -- because mod-supplied options are untrusted disk data + pcall(MapUtil.ValidateScenarioOptions, options) + return options +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Interpreting options + values + +--- The `key` an option value resolves to (a `{key=...}` table's key, or a bare value itself). +---@param value any | ScenarioOptionValue +---@return any +function ValueKeyOf(value) + if type(value) == 'table' then + return value.key + end + return value +end + +--- How an option value reads on screen: a `{text=...}` value's localized text, else the bare +--- value run through the option's `value_text` formatter (defaulting to the value itself). +---@param option ScenarioOption +---@param value any | ScenarioOptionValue +---@return string +function ValueDisplay(option, value) + if type(value) == 'table' then + return LOC(value.text) or tostring(value.key) + end + if option.value_text then + -- pcall: a malformed mod-supplied `value_text` (bad format spec) would otherwise throw + local ok, formatted = pcall(string.format, LOC(option.value_text), tostring(value)) + return ok and formatted or tostring(value) + end + return tostring(value) +end + +--- The default value-key of an option (`values[default]`, resolved through `ValueKeyOf`). +---@param option ScenarioOption +---@return any +function GetDefaultValueKey(option) + -- clamp against a garbage `default` index (untrusted options that skipped validation) + return ValueKeyOf(option.values[option.default] or option.values[1]) +end + +--- The currently selected value-key for an option: the stored value if present, else the default. +---@param option ScenarioOption +---@param values table # key -> selected value-key +---@return any +function GetCurrentValueKey(option, values) + local current = values[option.key] + if current == nil then + return GetDefaultValueKey(option) + end + return current +end + +--- Whether an option is currently at its default value. +---@param option ScenarioOption +---@param values table +---@return boolean +function IsDefault(option, values) + return GetCurrentValueKey(option, values) == GetDefaultValueKey(option) +end + +--- The 1-based index into `option.values` of the given value-key, or the option's default index +--- when it doesn't match any value. +---@param option ScenarioOption +---@param valueKey any +---@return number +function FindValueIndex(option, valueKey) + for i, value in option.values do + if ValueKeyOf(value) == valueKey then + return i + end + end + return option.default +end + +--- The display labels for an option's values, in order (for a dropdown). +---@param option ScenarioOption +---@return string[] +function ValueLabels(option) + local labels = {} + for i, value in option.values do + labels[i] = ValueDisplay(option, value) + end + return labels +end + +--- Fills `values` (a copy) with the default for every option in `options` that has no value yet, +--- so the result is a complete set ready to launch with. Returns the copy. +---@param options ScenarioOption[] +---@param values table +---@return table +function SeedDefaults(options, values) + local seeded = table.copy(values or {}) + for _, option in options do + if seeded[option.key] == nil then + seeded[option.key] = GetDefaultValueKey(option) + end + end + return seeded +end + +--#endregion From 450e5bfecde561b0374ce42e8108375ce201645e Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Mon, 22 Jun 2026 15:51:34 +0200 Subject: [PATCH 25/98] Only update preferences upon destroying dialog --- lua/lazyvar.lua | 2 +- .../lobby/customlobby/mapselect/CustomLobbyMapSelect.lua | 4 +--- .../lobby/customlobby/modselect/CustomLobbyModSelect.lua | 8 ++++---- .../customlobby/optionselect/CustomLobbyOptionSelect.lua | 6 +++--- 4 files changed, 9 insertions(+), 11 deletions(-) diff --git a/lua/lazyvar.lua b/lua/lazyvar.lua index dae7a9963a7..ca7e1fefab3 100644 --- a/lua/lazyvar.lua +++ b/lua/lazyvar.lua @@ -9,7 +9,7 @@ local setmetatable = setmetatable -- Set this true to get tracebacks in error messages. It slows down lazyvars a lot, -- so don't use except when debugging. -local ExtendedErrorMessages = true +local ExtendedErrorMessages = false local EvalContext = nil local WeakKeyMeta = { __mode = 'k' } diff --git a/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapSelect.lua b/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapSelect.lua index 5fa7a6f5814..fa6fcdd8aae 100644 --- a/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapSelect.lua +++ b/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapSelect.lua @@ -336,7 +336,6 @@ local CustomLobbyMapSelect = ClassUI(Group) { self.Search:SetText(saved.search or "") self.Search.OnTextChanged = function(control, newText, oldText) self:Populate() - self:SavePrefs() end self.Search.OnEnterPressed = function(control, text) self:Confirm() @@ -598,7 +597,6 @@ local CustomLobbyMapSelect = ClassUI(Group) { valueCombo.OnClick = function(combo, index, text) onValue(index) self:Populate() - self:SavePrefs() end Tooltip.AddControlTooltipManual(valueCombo, tooltipTitle, tooltipBody) @@ -607,7 +605,6 @@ local CustomLobbyMapSelect = ClassUI(Group) { opCombo.OnClick = function(combo, index, text) onOp(index) self:Populate() - self:SavePrefs() end Tooltip.AddControlTooltipManual(opCombo, tooltipTitle, "Comparison operator (=, at least, at most).") @@ -862,6 +859,7 @@ local CustomLobbyMapSelect = ClassUI(Group) { ---@param self UICustomLobbyMapSelect OnDestroy = function(self) + self:SavePrefs() self.Trash:Destroy() end, } diff --git a/lua/ui/lobby/customlobby/modselect/CustomLobbyModSelect.lua b/lua/ui/lobby/customlobby/modselect/CustomLobbyModSelect.lua index 712a70e7155..9e462dc1d38 100644 --- a/lua/ui/lobby/customlobby/modselect/CustomLobbyModSelect.lua +++ b/lua/ui/lobby/customlobby/modselect/CustomLobbyModSelect.lua @@ -290,18 +290,17 @@ local CustomLobbyModSelect = ClassUI(Group) { self.Search:SetText(saved.search or "") self.Search.OnTextChanged = function(control, newText, oldText) self:Populate() - self:SavePrefs() end Tooltip.AddControlTooltipManual(self.Search, "Search", "Filter the list by mod name or author.") self.GameToggle = self:CreateToggle("Game", self.ShowGame, - function(checked) self.ShowGame = checked; self:Populate(); self:SavePrefs() end, + function(checked) self.ShowGame = checked; self:Populate() end, "Game mods", "Show mods that change the simulation (all players need them).") self.UIToggle = self:CreateToggle("UI", self.ShowUI, - function(checked) self.ShowUI = checked; self:Populate(); self:SavePrefs() end, + function(checked) self.ShowUI = checked; self:Populate() end, "UI mods", "Show mods that change only your interface (per-player).") self.UnavailableToggle = self:CreateToggle("Deprecated", self.ShowUnavailable, - function(checked) self.ShowUnavailable = checked; self:Populate(); self:SavePrefs() end, + function(checked) self.ShowUnavailable = checked; self:Populate() end, "Deprecated mods", "Show blacklisted mods and mods missing a dependency.") --#endregion @@ -920,6 +919,7 @@ local CustomLobbyModSelect = ClassUI(Group) { ---@param self UICustomLobbyModSelect OnDestroy = function(self) + self:SavePrefs() self.Trash:Destroy() end, } diff --git a/lua/ui/lobby/customlobby/optionselect/CustomLobbyOptionSelect.lua b/lua/ui/lobby/customlobby/optionselect/CustomLobbyOptionSelect.lua index 6612481a650..ae89f3b2422 100644 --- a/lua/ui/lobby/customlobby/optionselect/CustomLobbyOptionSelect.lua +++ b/lua/ui/lobby/customlobby/optionselect/CustomLobbyOptionSelect.lua @@ -165,7 +165,6 @@ local CustomLobbyOptionSelect = ClassUI(Group) { self.Search:SetText(saved.search or "") self.Search.OnTextChanged = function(control, newText, oldText) self:RefreshColumns() - self:SavePrefs() end Tooltip.AddControlTooltipManual(self.Search, "Search", "Filter options by name across all three columns.") @@ -174,7 +173,6 @@ local CustomLobbyOptionSelect = ClassUI(Group) { self.HideDefaultsToggle.OnCheck = function(control, checked) self.HideDefaults = checked self:RefreshColumns() - self:SavePrefs() end Tooltip.AddControlTooltipManual(self.HideDefaultsToggle, "Hide defaults", "Show only the options that have been changed from their default value.") @@ -279,7 +277,8 @@ local CustomLobbyOptionSelect = ClassUI(Group) { self:RefreshColumns() end, - --- Persists the search + hide-defaults filter for next time. + --- Persists the search + hide-defaults filter for next time. Called once on close (not per + --- interaction — `SetToCurrentProfile` writes the profile, too costly to fire per keystroke). ---@param self UICustomLobbyOptionSelect SavePrefs = function(self) import("/lua/user/prefs.lua").SetToCurrentProfile(PrefsKey, { @@ -297,6 +296,7 @@ local CustomLobbyOptionSelect = ClassUI(Group) { ---@param self UICustomLobbyOptionSelect OnDestroy = function(self) + self:SavePrefs() self.Trash:Destroy() end, } From 1882395d8f265a119be1141cda10a53cdd25119f Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Mon, 22 Jun 2026 15:56:56 +0200 Subject: [PATCH 26/98] Keep track of progress --- lua/ui/lobby/USER_STORIES.md | 191 ++++++++++++++++++----------------- 1 file changed, 98 insertions(+), 93 deletions(-) diff --git a/lua/ui/lobby/USER_STORIES.md b/lua/ui/lobby/USER_STORIES.md index 29402d8143c..193e0df3a9e 100644 --- a/lua/ui/lobby/USER_STORIES.md +++ b/lua/ui/lobby/USER_STORIES.md @@ -7,148 +7,153 @@ that ``** — with *when/given* conditions where they matter. Roles: **Host**, **Player** (human in a slot), **Observer**, **Joining client** (connecting peer), **System** (automatic / engine-driven). +> **Progress** (updated as the [`customlobby/`](customlobby/CLAUDE.md) rebuild lands slices): +> ✅ done · 🟡 partial / works with gaps · ⬜ not started. Markers reflect the reactive-MVC +> rebuild, not the legacy `lobby.lua`. Launch itself isn't wired yet, so anything that only +> resolves *at launch* is ⬜ even when its inputs are configurable. + --- ## A. Lifecycle & entry -- As a **player**, I want to browse discovered games (map, name, player count), so that I can pick one to join. -- As a **host**, I want to create a game with a name and a starting map, so that others can find and join it. *(Given a valid nickname, 2–32 non-space chars.)* -- As a **player**, I want to join by direct IP:port, so that I can connect when discovery doesn't list the game. -- As a **joining client**, I want to receive the full lobby state on connect, so that I immediately see the current map, slots, options and mods. -- As a **player**, I want to leave the lobby with a confirmation prompt, so that I don't exit by accident; *and when launched via `/gpgnet`*, leaving quits to desktop instead of the menu. -- As a **host**, I want to rehost my last game, so that the previous map/options/mods and player slots are restored automatically. -- As a **player**, I want my lobby UI to tear down cleanly when the game launches, so that no stale threads or windows linger. +- ⬜ As a **player**, I want to browse discovered games (map, name, player count), so that I can pick one to join. +- 🟡 As a **host**, I want to create a game with a name and a starting map, so that others can find and join it. *(Given a valid nickname, 2–32 non-space chars.)* — *host/client connect + sync work; the create-game entry UI (name, nickname validation) isn't rebuilt.* +- 🟡 As a **player**, I want to join by direct IP:port, so that I can connect when discovery doesn't list the game. *(Engine join path works in testing; no direct-IP entry UI yet.)* +- ✅ As a **joining client**, I want to receive the full lobby state on connect, so that I immediately see the current map, slots, options and mods. *(`BroadcastPlayers` + `BroadcastLaunchInfo` + `BroadcastSessionState` on join.)* +- 🟡 As a **player**, I want to leave the lobby with a confirmation prompt, so that I don't exit by accident; *and when launched via `/gpgnet`*, leaving quits to desktop instead of the menu. — *Leave/Esc → menu works; no confirm prompt, no gpgnet quit-to-desktop.* +- ⬜ As a **host**, I want to rehost my last game, so that the previous map/options/mods and player slots are restored automatically. +- ⬜ As a **player**, I want my lobby UI to tear down cleanly when the game launches, so that no stale threads or windows linger. ## B. Slots & players -- As a **host**, I want to open or close an empty slot, so that I control how many players can join. -- As a **host on an adaptive map**, I want to close a slot as "spawn-mex", so that the position yields mass instead of an ACU. -- As a **player**, I want to occupy an empty open slot, so that I can join the game; *when* I'm not already marked ready. -- As a **host**, I want to move a player to another slot or swap two players, so that I can arrange start positions and teams. -- As a **host**, I want to kick a player with an optional message, so that I can remove someone; *and* the message is remembered for next time. -- As a **player**, I want each slot row to show name, rating, country, faction, colour, team, ping, CPU and ready state, so that I can assess the lobby at a glance. -- As a **player**, I want a slot's controls to reflect who owns it (me / another player / host / AI) via colour and an appropriate right-click menu, so that I only see actions I'm allowed to take. +- 🟡 As a **host**, I want to open or close an empty slot, so that I control how many players can join. *(Closed-slot state is modelled + synced; the open/close control isn't wired.)* +- ⬜ As a **host on an adaptive map**, I want to close a slot as "spawn-mex", so that the position yields mass instead of an ACU. +- ✅ As a **player**, I want to occupy an empty open slot, so that I can join the game; *when* I'm not already marked ready. +- ✅ As a **host**, I want to move a player to another slot or swap two players, so that I can arrange start positions and teams. *(Drag a row onto another → `RequestSwapSlots`.)* +- 🟡 As a **host**, I want to kick a player with an optional message, so that I can remove someone; *and* the message is remembered for next time. *(Eject works; no message / recall.)* +- 🟡 As a **player**, I want each slot row to show name, rating, country, faction, colour, team, ping, CPU and ready state, so that I can assess the lobby at a glance. *(Name, CPU/headroom, ready shown; rating/country/faction/colour/team/ping not yet.)* +- ✅ As a **player**, I want a slot's controls to reflect who owns it (me / another player / host / AI) via colour and an appropriate right-click menu, so that I only see actions I'm allowed to take. ## C. Per-player settings -- As a **player**, I want to choose my faction (including Random), so that I play what I want; *given* the map allows it and I'm not ready. -- As a **player**, I want to pick a colour, so that I'm visually distinct; *when* the colour is free — taken colours are hidden and the host reverts conflicts. -- As a **player**, I want to set my team, so that I'm allied correctly; *when* AutoTeams is off and I'm not ready. -- As a **player**, I want to toggle Ready, so that I signal I'm set; *and when* ready, my own controls lock until I unready. -- As a **host**, I want to force a player not-ready, so that I can change settings that affect them. -- As a **player**, I want to pick my start position on the map preview, so that I control where I spawn; *when* spawn is fixed (host gets swap/assign tools otherwise). -- As a **player**, I want my last faction/colour remembered, so that I don't re-pick every lobby. +- ⬜ As a **player**, I want to choose my faction (including Random), so that I play what I want; *given* the map allows it and I'm not ready. +- ⬜ As a **player**, I want to pick a colour, so that I'm visually distinct; *when* the colour is free — taken colours are hidden and the host reverts conflicts. +- ⬜ As a **player**, I want to set my team, so that I'm allied correctly; *when* AutoTeams is off and I'm not ready. +- ✅ As a **player**, I want to toggle Ready, so that I signal I'm set; *and when* ready, my own controls lock until I unready. +- ⬜ As a **host**, I want to force a player not-ready, so that I can change settings that affect them. +- ⬜ As a **player**, I want to pick my start position on the map preview, so that I control where I spawn; *when* spawn is fixed (host gets swap/assign tools otherwise). +- ⬜ As a **player**, I want my last faction/colour remembered, so that I don't re-pick every lobby. ## D. AI management (host) -- As a **host**, I want to add an AI of a chosen personality to a slot, so that I can fill the game; *and* the AI is auto-ready. -- As a **host**, I want the AI personality list to include built-in and mod-provided AIs, so that all installed AIs are selectable. -- As a **host**, I want to remove an AI from a slot, so that I can free it. -- As a **host**, I want AIs to get a computed rating from cheat/build/map multipliers, so that game quality estimates stay meaningful. -- As a **host**, I want to set an AI's faction/colour/team and move/swap it like a player, so that I can arrange AI-inclusive setups. -- As a **host**, I want a quick "fill slots" action, so that I can populate remaining slots with AIs at once. +- ⬜ As a **host**, I want to add an AI of a chosen personality to a slot, so that I can fill the game; *and* the AI is auto-ready. +- ⬜ As a **host**, I want the AI personality list to include built-in and mod-provided AIs, so that all installed AIs are selectable. +- ⬜ As a **host**, I want to remove an AI from a slot, so that I can free it. +- ⬜ As a **host**, I want AIs to get a computed rating from cheat/build/map multipliers, so that game quality estimates stay meaningful. +- ⬜ As a **host**, I want to set an AI's faction/colour/team and move/swap it like a player, so that I can arrange AI-inclusive setups. +- ⬜ As a **host**, I want a quick "fill slots" action, so that I can populate remaining slots with AIs at once. ## E. Observers -- As a **joining client**, I want to enter as an observer when no slot is free or when I request it, so that I can still watch. -- As a **player**, I want to become an observer (or request it from the host), so that I can step out of the game without leaving. -- As an **observer**, I want to become a player / request a specific slot, so that I can join in; *given* a slot is available (host may evict an AI). -- As a **player**, I want to see the observer list with rating, ping and CPU, so that I know who's spectating. -- As a **host**, I want to kick an observer, so that I can manage spectators; *and* observers are auto-kicked at launch if observers are disallowed. -- As an **observer**, I want to use public and private chat (shown greyed), so that I can still communicate. +- 🟡 As a **joining client**, I want to enter as an observer when no slot is free or when I request it, so that I can still watch. *(Observers are modelled/synced; auto-enter-as-observer on a full lobby isn't wired.)* +- 🟡 As a **player**, I want to become an observer (or request it from the host), so that I can step out of the game without leaving. *(Host moves a player to observers via the context menu; self-request not yet.)* +- ✅ As an **observer**, I want to become a player / request a specific slot, so that I can join in; *given* a slot is available (host may evict an AI). *(Right-click → "Play this slot".)* +- 🟡 As a **player**, I want to see the observer list with rating, ping and CPU, so that I know who's spectating. *(Observer strip shows count + names; no rating/ping/CPU.)* +- ⬜ As a **host**, I want to kick an observer, so that I can manage spectators; *and* observers are auto-kicked at launch if observers are disallowed. +- ⬜ As an **observer**, I want to use public and private chat (shown greyed), so that I can still communicate. ## F. Host authority & validation -- As a **host**, I want to be the single source of truth, so that all clients converge on one authoritative state. -- As a **player**, I want my own-slot changes applied optimistically and then confirmed by the host, so that the UI feels responsive but stays consistent. -- As a **host**, I want option-changing buttons disabled while any human is not ready, so that settings can't shift after people commit. -- As a **host**, I want the launch button enabled only when everyone is ready (or single-player / I'm observing), so that I can't start prematurely. -- As a **system**, I want to ignore a move request if the player became ready in the meantime, so that races don't corrupt slot state. +- ✅ As a **host**, I want to be the single source of truth, so that all clients converge on one authoritative state. *(Host-authoritative: request → validate → broadcast snapshot.)* +- 🟡 As a **player**, I want my own-slot changes applied optimistically and then confirmed by the host, so that the UI feels responsive but stays consistent. *(Round-trips through the host; not yet optimistic.)* +- ⬜ As a **host**, I want option-changing buttons disabled while any human is not ready, so that settings can't shift after people commit. +- ⬜ As a **host**, I want the launch button enabled only when everyone is ready (or single-player / I'm observing), so that I can't start prematurely. +- ⬜ As a **system**, I want to ignore a move request if the player became ready in the meantime, so that races don't corrupt slot state. ## G. Game options (host) -- As a **host**, I want to set the victory condition (assassination / decapitation / supremacy / annihilation / sandbox), so that the win rule fits the game. -- As a **host**, I want to configure share conditions (full / until-death / partial / transfer-to-killer / defectors / civilian-deserter) and share-unit-cap, so that resource/unit transfer on death matches our preference. -- As a **host**, I want to set unit cap, fog of war, game speed, no-rush timer, prebuilt units, civilians, score and cheats, so that the match rules are tuned. -- As a **host**, I want to mark the game unranked, so that it doesn't affect ratings. -- As a **host (multiplayer)**, I want to set timeouts and disconnection delay, so that disconnect handling fits the context (tournament/quick/regular). -- As a **host**, I want to set AI multipliers (cheat/build), omni, TML randomization and expansion limits, so that AIx difficulty is tuned. -- As a **host**, I want a "reset to defaults" action, so that I can clear all options at once (which also unreadies everyone). -- As a **host**, I want map-specific and mod-provided options surfaced alongside the standard ones, so that nothing is hidden. -- As a **player**, I want to see the active (and optionally only the changed) options, so that I understand the ruleset. +- ✅ As a **host**, I want to set the victory condition (assassination / decapitation / supremacy / annihilation / sandbox), so that the win rule fits the game. *(Options dialog.)* +- ✅ As a **host**, I want to configure share conditions (full / until-death / partial / transfer-to-killer / defectors / civilian-deserter) and share-unit-cap, so that resource/unit transfer on death matches our preference. +- ✅ As a **host**, I want to set unit cap, fog of war, game speed, no-rush timer, prebuilt units, civilians, score and cheats, so that the match rules are tuned. +- ✅ As a **host**, I want to mark the game unranked, so that it doesn't affect ratings. +- ✅ As a **host (multiplayer)**, I want to set timeouts and disconnection delay, so that disconnect handling fits the context (tournament/quick/regular). +- ✅ As a **host**, I want to set AI multipliers (cheat/build), omni, TML randomization and expansion limits, so that AIx difficulty is tuned. *(AI column of the options dialog.)* +- 🟡 As a **host**, I want a "reset to defaults" action, so that I can clear all options at once (which also unreadies everyone). *(Reset button clears to defaults; the auto-unready side-effect isn't wired.)* +- ✅ As a **host**, I want map-specific and mod-provided options surfaced alongside the standard ones, so that nothing is hidden. *(Scenario + Mods columns.)* +- 🟡 As a **player**, I want to see the active (and optionally only the changed) options, so that I understand the ruleset. *(Hide-defaults + non-default marking exist, but the dialog is host-only — no read-only client view.)* ## H. Auto-teams & spawn -- As a **host**, I want AutoTeams modes (top/bottom, left/right, odd/even, manual), so that teams are assigned by position without manual fiddling. -- As a **host**, I want manual AutoTeams by clicking map markers, so that I can hand-place teams on random spawn. -- As a **host**, I want spawn variants (fixed, random, balanced/flex/reveal, penguin-autobalance), so that start placement matches the desired fairness/secrecy. -- As a **host on an adaptive map**, I want per-slot spawn-mex, so that closed positions still contribute economy. -- As a **system**, I want random factions/start spots/AI names and the ratings/clan tables resolved at launch, so that the final config is complete and fair. +- 🟡 As a **host**, I want AutoTeams modes (top/bottom, left/right, odd/even, manual), so that teams are assigned by position without manual fiddling. *(Selectable as a lobby option; the actual auto-teaming/launch resolution isn't wired.)* +- ⬜ As a **host**, I want manual AutoTeams by clicking map markers, so that I can hand-place teams on random spawn. +- 🟡 As a **host**, I want spawn variants (fixed, random, balanced/flex/reveal, penguin-autobalance), so that start placement matches the desired fairness/secrecy. *(Selectable as a lobby option; placement is resolved at launch, which isn't wired.)* +- ⬜ As a **host on an adaptive map**, I want per-slot spawn-mex, so that closed positions still contribute economy. +- ⬜ As a **system**, I want random factions/start spots/AI names and the ratings/clan tables resolved at launch, so that the final config is complete and fair. ## I. Map selection (host) -- As a **host**, I want to browse maps with a preview, so that I can choose a map informedly. -- As a **host**, I want to filter maps by player count, size, type (official/custom), AI markers and hide-obsolete, and search by name, so that I find the right map quickly; *and* my filters persist. -- As a **host**, I want a resource-aware preview (mass/hydro, water/cliff/buildable masks, start spots), so that I understand the map layout. -- As a **host**, I want to pick a random map, so that I can play something fresh. -- As a **player**, I want clear warnings when a map's files are missing, so that I'm not stuck on an unplayable map. +- ✅ As a **host**, I want to browse maps with a preview, so that I can choose a map informedly. +- 🟡 As a **host**, I want to filter maps by player count, size, type (official/custom), AI markers and hide-obsolete, and search by name, so that I find the right map quickly; *and* my filters persist. *(Size, player count, name search + persistence done; type/AI-markers/hide-obsolete filters not.)* +- 🟡 As a **host**, I want a resource-aware preview (mass/hydro, water/cliff/buildable masks, start spots), so that I understand the map layout. *(Start spots, mass/hydro, wrecks done; terrain masks not.)* +- ✅ As a **host**, I want to pick a random map, so that I can play something fresh. +- ✅ As a **player**, I want clear warnings when a map's files are missing, so that I'm not stuck on an unplayable map. *(File-health check disables Select.)* ## J. Mods manager -- As a **host**, I want to enable/disable sim mods, so that gameplay-affecting mods apply to everyone; *and* doing so disables ranking. -- As a **player**, I want to enable UI mods locally, so that my client UI changes without affecting others. -- As a **player**, I want to browse, sort, search, expand/collapse and favorite mods, so that I can manage a large mod list; *and* my preferences persist. -- As a **player**, I want "activate favorites" in one click, so that I quickly enable my usual set (host: sim+UI, client: UI only). -- As a **player**, I want mod dependencies and conflicts handled automatically, so that I don't end up with a broken set. -- As a **host**, I want to see which peers lack a sim mod, so that missing-mod peers are flagged/auto-disabled or kicked rather than causing desyncs. +- 🟡 As a **host**, I want to enable/disable sim mods, so that gameplay-affecting mods apply to everyone; *and* doing so disables ranking. *(Enable/disable + sync done via `RequestSetGameMods`; the ranking-disable side-effect isn't wired.)* +- ✅ As a **player**, I want to enable UI mods locally, so that my client UI changes without affecting others. +- 🟡 As a **player**, I want to browse, sort, search, expand/collapse and favorite mods, so that I can manage a large mod list; *and* my preferences persist. *(Browse, search, type filters, persistence done; sort + expand/collapse not; favorites replaced by presets — see next.)* +- 🟡 As a **player**, I want "activate favorites" in one click, so that I quickly enable my usual set (host: sim+UI, client: UI only). *(Replaced by named **presets** — load a saved selection; the host/client sim-vs-UI split is honoured.)* +- ✅ As a **player**, I want mod dependencies and conflicts handled automatically, so that I don't end up with a broken set. *(`ResolveEnable`/`ResolveDisable`.)* +- ⬜ As a **host**, I want to see which peers lack a sim mod, so that missing-mod peers are flagged/auto-disabled or kicked rather than causing desyncs. ## K. Unit restrictions (host) -- As a **host**, I want to apply unit-restriction presets (tech levels, types, spam/snipe/anti-air/etc.), so that I can shape allowed units fast. -- As a **host**, I want to toggle individual units per faction, so that I can fine-tune beyond presets. -- As a **player**, I want a read-only view of the restrictions, so that I know what's banned. -- As a **host**, I want restrictions saved with presets and reflected in rating eligibility, so that custom rulesets are reusable and correctly rated. +- ⬜ As a **host**, I want to apply unit-restriction presets (tech levels, types, spam/snipe/anti-air/etc.), so that I can shape allowed units fast. +- ⬜ As a **host**, I want to toggle individual units per faction, so that I can fine-tune beyond presets. +- ⬜ As a **player**, I want a read-only view of the restrictions, so that I know what's banned. +- ⬜ As a **host**, I want restrictions saved with presets and reflected in rating eligibility, so that custom rulesets are reusable and correctly rated. ## L. Chat -- As a **player/observer**, I want to send public chat, so that everyone in the lobby sees it. -- As a **player/observer**, I want to whisper via `/whisper` `/pm` `/w` `/private`, so that I can message one person privately. -- As a **player**, I want clear feedback on an unknown command or an invalid whisper target, so that I know it didn't send. -- As a **player**, I want join/leave/connection notices in chat, so that I'm aware of lobby changes. -- As a **player**, I want to click a name in chat to prefill a whisper, so that replying is quick. -- As a **player**, I want command history (up/down) and auto-scroll with a "new message" indicator, so that chatting is comfortable. -- As a **player**, I want ratings shown alongside names, so that I gauge the lobby's skill. +- ⬜ As a **player/observer**, I want to send public chat, so that everyone in the lobby sees it. +- ⬜ As a **player/observer**, I want to whisper via `/whisper` `/pm` `/w` `/private`, so that I can message one person privately. +- ⬜ As a **player**, I want clear feedback on an unknown command or an invalid whisper target, so that I know it didn't send. +- ⬜ As a **player**, I want join/leave/connection notices in chat, so that I'm aware of lobby changes. +- ⬜ As a **player**, I want to click a name in chat to prefill a whisper, so that replying is quick. +- ⬜ As a **player**, I want command history (up/down) and auto-scroll with a "new message" indicator, so that chatting is comfortable. +- ⬜ As a **player**, I want ratings shown alongside names, so that I gauge the lobby's skill. ## M. Connectivity & health -- As a **player**, I want my CPU benchmarked (auto on connect, and on demand), so that others can see my performance; *and* the result is cached and re-broadcast by the host. -- As a **player**, I want CPU and ping bars per slot (ping shown when poor), so that I can spot weak links. -- As a **system**, I want to track per-peer connection status, so that the UI reflects who is fully connected. -- As a **host**, I want launch blocked until every important peer is connected to every other, so that the game doesn't start with broken links. -- As a **player**, I want a peer's disconnect to clear its slot and notify chat, so that the lobby stays accurate. -- As a **client**, I want a "host timed out — keep trying / give up" prompt, so that I can decide what to do on a stalled connection. +- 🟡 As a **player**, I want my CPU benchmarked (auto on connect, and on demand), so that others can see my performance; *and* the result is cached and re-broadcast by the host. *(Stored benchmark is shared + re-broadcast; no live/on-demand stress test.)* +- 🟡 As a **player**, I want CPU and ping bars per slot (ping shown when poor), so that I can spot weak links. *(CPU column with cap-headroom done; ping not.)* +- 🟡 As a **system**, I want to track per-peer connection status, so that the UI reflects who is fully connected. *(`EstablishedPeers`/`OnPeerDisconnected` tracked; not fully surfaced.)* +- ⬜ As a **host**, I want launch blocked until every important peer is connected to every other, so that the game doesn't start with broken links. +- 🟡 As a **player**, I want a peer's disconnect to clear its slot and notify chat, so that the lobby stays accurate. *(Disconnect frees the slot for everyone; no chat notice — chat isn't in the lobby yet.)* +- ⬜ As a **client**, I want a "host timed out — keep trying / give up" prompt, so that I can decide what to do on a stalled connection. ## N. Compatibility & launch validation -- As a **host**, I want to reject peers on a different game version/type/commit, so that desyncs are prevented (with a clear ejection reason). -- As a **player**, I want missing-map situations flagged (and a fallback used), so that the lobby communicates the problem instead of silently breaking. -- As a **host**, I want peers' available mods exchanged and validated, so that everyone has the required sim mods before launch. -- As a **host**, I want launch blocked with a clear reason (no players, host-as-observer when disallowed, observers present, missing connections), so that I know exactly what to fix. +- ⬜ As a **host**, I want to reject peers on a different game version/type/commit, so that desyncs are prevented (with a clear ejection reason). +- 🟡 As a **player**, I want missing-map situations flagged (and a fallback used), so that the lobby communicates the problem instead of silently breaking. *(The map dialog flags missing files; in-lobby fallback not.)* +- ⬜ As a **host**, I want peers' available mods exchanged and validated, so that everyone has the required sim mods before launch. +- ⬜ As a **host**, I want launch blocked with a clear reason (no players, host-as-observer when disallowed, observers present, missing connections), so that I know exactly what to fix. ## O. Presets & rehost -- As a **host**, I want to save the current setup (map, options, mods, restrictions, players) as a named preset, so that I can reuse it. -- As a **host**, I want to load/delete/rename presets, so that I can manage reusable configurations. -- As a **system**, I want the last game auto-saved as a preset at launch, so that rehost can restore it. -- As a **host**, I want rehost to reseat returning players to their prior slots (displacing AIs/others as needed), so that a replay setup reconstitutes quickly. +- ⬜ As a **host**, I want to save the current setup (map, options, mods, restrictions, players) as a named preset, so that I can reuse it. *(Only **mod** selections have presets so far — not the full setup.)* +- ⬜ As a **host**, I want to load/delete/rename presets, so that I can manage reusable configurations. +- ⬜ As a **system**, I want the last game auto-saved as a preset at launch, so that rehost can restore it. +- ⬜ As a **host**, I want rehost to reseat returning players to their prior slots (displacing AIs/others as needed), so that a replay setup reconstitutes quickly. ## P. Lobby preferences (per user) -- As a **player**, I want to choose the lobby background mode, chat font size, snowflake count, windowed mode, background stretch and chat colours, so that the lobby looks how I like; *and* changes apply immediately and persist. +- ⬜ As a **player**, I want to choose the lobby background mode, chat font size, snowflake count, windowed mode, background stretch and chat colours, so that the lobby looks how I like; *and* changes apply immediately and persist. ## Q. Other dialogs -- As a **host**, I want a briefing dialog for scenarios that have one, so that players see the operation context. -- As a **player**, I want a large map preview (with terrain/resource masks), so that I can study the map closely. -- As a **host (skirmish)**, I want a save/load dialog, so that I can resume saved games. -- As a **host**, I want confirmation prompts for destructive actions (kick, reset options), so that I don't trigger them by mistake. +- ⬜ As a **host**, I want a briefing dialog for scenarios that have one, so that players see the operation context. +- ⬜ As a **player**, I want a large map preview (with terrain/resource masks), so that I can study the map closely. +- ⬜ As a **host (skirmish)**, I want a save/load dialog, so that I can resume saved games. +- ⬜ As a **host**, I want confirmation prompts for destructive actions (kick, reset options), so that I don't trigger them by mistake. From 2f6c2ed24c4193007f0f2474752216d950545046 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Mon, 22 Jun 2026 17:09:28 +0200 Subject: [PATCH 27/98] Separate the configuration side into a new class --- lua/ui/lobby/customlobby/CLAUDE.md | 3 +- .../CustomLobbyConfigInterface.lua | 331 ++++++++++++++++++ .../customlobby/CustomLobbyController.lua | 32 ++ .../customlobby/CustomLobbyInterface.lua | 267 +++++++++----- 4 files changed, 552 insertions(+), 81 deletions(-) create mode 100644 lua/ui/lobby/customlobby/CustomLobbyConfigInterface.lua diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index 13ed74cdd7e..20aa7cf9bf8 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -55,7 +55,8 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | [mapselect/](mapselect/CLAUDE.md) | the **map-select dialog** + its catalog and list, in their own folder (host-only `Popup`: searchable, filterable scenario list → `RequestSetScenario`). Self-contained sub-MVC; see [mapselect/CLAUDE.md](mapselect/CLAUDE.md) — including the **`MapPreview` texture-leak** writeup that shaped its design. | | [modselect/](modselect/CLAUDE.md) | the **mod-select dialog** + its catalog and list, built to the map-select shape (checkbox list + type filters + detail panel + **presets**). Returns a uid set; the opener routes it — sim mods → `RequestSetGameMods` (synced), UI mods → local prefs — or persists the lot standalone. Mod domain logic lives in [`/lua/ui/modutilities.lua`](/lua/ui/modutilities.lua) (the `maputil.lua` sibling, fronting `/lua/mods.lua`). See [modselect/CLAUDE.md](modselect/CLAUDE.md). | | [optionselect/](optionselect/CLAUDE.md) | the **options dialog**: three columns (lobby / scenario / mod options) over the selected scenario + mods, with search + hide-defaults filters; non-default options are marked. Derives the option *schema* per-peer (reference data) via [`/lua/ui/optionutil.lua`](/lua/ui/optionutil.lua); edits a working copy of the *values* and on OK routes the reconciled set through `RequestSetGameOptions` (synced via `GameOptions`). Host-only. See [optionselect/CLAUDE.md](optionselect/CLAUDE.md). | -| [CustomLobbyInterface.lua](CustomLobbyInterface.lua) | composition root (one model subscription for SlotCount); lays out slot rows + the observer strip, and acts as the rows' drag coordinator (`UICustomLobbySlotCoordinator`: hit-test, drop-highlight, drag ghost → `RequestSwapSlots`); `OpenDebug()` / hot-reload. | +| [CustomLobbyInterface.lua](CustomLobbyInterface.lua) | composition root, laid out in **areas** (title / left {slots, observers, chat} / right = the config component / actions — flip the module `Debug` flag to tint them), sized for the **1024×768** floor. The slots area sizes to the active `SlotCount` (1..MaxSlots) so observers + chat reflow on smaller maps. Holds two presentation observers (SlotCount, IsHost); basic buttons wired: Leave (Esc handler), **Observe** (`RequestMoveToObserver` on the local slot); Launch is a host-only disabled stub (no launch flow yet). Also the rows' drag coordinator (`UICustomLobbySlotCoordinator`: hit-test, drop-highlight, drag ghost → `RequestSwapSlots`); `OpenDebug()` / hot-reload. | +| [CustomLobbyConfigInterface.lua](CustomLobbyConfigInterface.lua) | the lobby's right column: a **tab strip** (Map / Options / Mods / Units) over a content area. The map preview lives under the **Map tab only** (not pinned); the others show a one-line summary + a button into the popup dialog. Self-subscribes to `IsHost` and gates its host-only buttons via `ApplyHostVisibility` (re-applied after a tab switch, since a panel `Show()` cascades). Buttons: Change map / Options (open dialogs), **Reset options** (`RequestResetGameOptions`), Manage mods (everyone). | | [/lua/ui/lobby/lobby.lua](../lobby.lua) | engine entry wrapper (`CreateLobby`/`HostGame`/`JoinGame`) → CustomLobby. Old lobby preserved at `lobby-old.lua`. | Working today: host + clients see each other (host-authoritative player sync), the diff --git a/lua/ui/lobby/customlobby/CustomLobbyConfigInterface.lua b/lua/ui/lobby/customlobby/CustomLobbyConfigInterface.lua new file mode 100644 index 00000000000..490e57a90c0 --- /dev/null +++ b/lua/ui/lobby/customlobby/CustomLobbyConfigInterface.lua @@ -0,0 +1,331 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- The lobby's right-hand **config panel**: a tab strip (Map / Options / Mods / Units) over a +-- content area. Extracted from CustomLobbyInterface so the interface stays a composition root — +-- this component owns the tabs and self-subscribes to `IsHost` for its host-only buttons (the same +-- "each child subscribes itself" rule the slot rows follow). +-- +-- The tabs are a read/launch surface: the **Map** tab shows the live map preview (it is NOT +-- pinned — switching tabs hides it); **Options** / **Mods** / **Units** show a one-line summary and +-- a button into the corresponding popup dialog. Editing happens in those dialogs; only the host's +-- buttons (change map / options / reset) are shown to the host. +-- +-- Laid out by its parent (filled into the lobby's right column); internally it splits into a tab +-- strip area + a content area — flip the module `Debug` flag to tint them. + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") +local Tooltip = import("/lua/ui/game/tooltip.lua") + +local Group = import("/lua/maui/group.lua").Group +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap +local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/customlobbylocalmodel.lua") +local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") +local CustomLobbyMapPreview = import("/lua/ui/lobby/customlobby/customlobbymappreview.lua") +local CustomLobbyMapSelect = import("/lua/ui/lobby/customlobby/mapselect/customlobbymapselect.lua") +local CustomLobbyModSelect = import("/lua/ui/lobby/customlobby/modselect/customlobbymodselect.lua") +local CustomLobbyOptionSelect = import("/lua/ui/lobby/customlobby/optionselect/customlobbyoptionselect.lua") + +local LazyVarDerive = import("/lua/lazyvar.lua").Derive + +local Layouter = LayoutHelpers.ReusedLayoutFor + +-- flip to tint the tab strip / content areas while iterating +local Debug = false + +local TabHeight = 28 +local TabWidth = 88 +local PreviewWidth = 320 +local PreviewHeight = 300 + +local TabIdleColor = 'ff141a20' +local TabHoverColor = 'ff1f262e' +local TabActiveColor = 'ff2c3e48' + +-- the four tabs, in order +local TabMap = 1 +local TabOptions = 2 +local TabMods = 3 +local TabUnits = 4 + +--- Creates a layout area (an invisible Group with an optional debug tint). +---@param parent Control +---@param name string +---@param color string +---@return Group +local function CreateArea(parent, name, color) + local area = Group(parent, name) + local bg = Bitmap(area) + bg:SetSolidColor(color) + bg:SetAlpha(Debug and 0.18 or 0.0) + bg:DisableHitTest() + Layouter(bg):Fill(area):End() + area.Bg = bg + return area +end + +---@class UICustomLobbyTab +---@field Button Group +---@field Panel Group + +---@class UICustomLobbyConfigInterface : Group +---@field Trash TrashBag +---@field TabStripArea Group +---@field TabContentArea Group +---@field Tabs UICustomLobbyTab[] +---@field ActiveTab number +---@field MapPanel Group +---@field MapPreview UICustomLobbyMapPreview +---@field MapButton Button +---@field OptionsPanel Group +---@field OptionsInfo Text +---@field EditOptionsButton Button +---@field ResetOptionsButton Button +---@field ModsPanel Group +---@field ModsInfo Text +---@field ModsButton Button +---@field UnitsPanel Group +---@field UnitsInfo Text +---@field IsHost boolean +---@field IsHostObserver LazyVar +local CustomLobbyConfigInterface = ClassUI(Group) { + + ---@param self UICustomLobbyConfigInterface + ---@param parent Control + __init = function(self, parent) + Group.__init(self, parent, "CustomLobbyConfigInterface") + + self.Trash = TrashBag() + self.IsHost = false + self.ActiveTab = TabMap + + self.TabStripArea = CreateArea(self, "TabStripArea", 'ffcc8040') + self.TabContentArea = CreateArea(self, "TabContentArea", 'ff8040cc') + + self:BuildTabs() + + local localModel = CustomLobbyLocalModel.GetSingleton() + self.IsHostObserver = self.Trash:Add( + LazyVarDerive(localModel.IsHost, function(isHostLazy) + self:OnIsHostChanged(isHostLazy()) + end)) + end, + + ---@param self UICustomLobbyConfigInterface + __post_init = function(self) + Layouter(self.TabStripArea):AtLeftIn(self):AtRightIn(self):AtTopIn(self):Height(TabHeight):End() + Layouter(self.TabContentArea) + :AtLeftIn(self):AtRightIn(self) + :AnchorToBottom(self.TabStripArea, 6):AtBottomIn(self) + :End() + + self:LayoutTabs() + self:SelectTab(self.ActiveTab) + end, + + --------------------------------------------------------------------------- + --#region Tabs + + --- Builds the four tabs (buttons + content panels). Private. + ---@param self UICustomLobbyConfigInterface + BuildTabs = function(self) + --#region map tab + self.MapPanel = Group(self.TabContentArea, "CustomLobbyMapPanel") + self.MapPreview = CustomLobbyMapPreview.Create(self.MapPanel) + self.MapButton = UIUtil.CreateButtonWithDropshadow(self.MapPanel, '/BUTTON/medium/', "Change map") + self.MapButton.OnClick = function(button, modifiers) + CustomLobbyMapSelect.Open(GetFrame(0)) + end + --#endregion + + --#region options tab + self.OptionsPanel = Group(self.TabContentArea, "CustomLobbyOptionsPanel") + self.OptionsInfo = UIUtil.CreateText(self.OptionsPanel, "Game, scenario and mod options.", 14, UIUtil.bodyFont) + self.OptionsInfo:SetColor('ffc8ccd0') + self.OptionsInfo:DisableHitTest() + self.EditOptionsButton = UIUtil.CreateButtonWithDropshadow(self.OptionsPanel, '/BUTTON/medium/', "Options") + self.EditOptionsButton.OnClick = function(button, modifiers) + CustomLobbyOptionSelect.Open(GetFrame(0)) + end + self.ResetOptionsButton = UIUtil.CreateButtonWithDropshadow(self.OptionsPanel, '/BUTTON/medium/', "Reset options") + self.ResetOptionsButton.OnClick = function(button, modifiers) + CustomLobbyController.RequestResetGameOptions() + end + Tooltip.AddControlTooltipManual(self.ResetOptionsButton, "Reset options", "Reset every option to its default value.") + --#endregion + + --#region mods tab + self.ModsPanel = Group(self.TabContentArea, "CustomLobbyModsPanel") + self.ModsInfo = UIUtil.CreateText(self.ModsPanel, "Sim mods are shared; UI mods are yours.", 14, UIUtil.bodyFont) + self.ModsInfo:SetColor('ffc8ccd0') + self.ModsInfo:DisableHitTest() + self.ModsButton = UIUtil.CreateButtonWithDropshadow(self.ModsPanel, '/BUTTON/medium/', "Manage mods") + self.ModsButton.OnClick = function(button, modifiers) + CustomLobbyModSelect.Open(GetFrame(0)) + end + --#endregion + + --#region units tab + self.UnitsPanel = Group(self.TabContentArea, "CustomLobbyUnitsPanel") + self.UnitsInfo = UIUtil.CreateText(self.UnitsPanel, "Unit restrictions — coming soon.", 14, UIUtil.bodyFont) + self.UnitsInfo:SetColor('ff8a909a') + self.UnitsInfo:DisableHitTest() + --#endregion + + self.Tabs = { + [TabMap] = { Button = self:CreateTabButton("Map", TabMap), Panel = self.MapPanel }, + [TabOptions] = { Button = self:CreateTabButton("Options", TabOptions), Panel = self.OptionsPanel }, + [TabMods] = { Button = self:CreateTabButton("Mods", TabMods), Panel = self.ModsPanel }, + [TabUnits] = { Button = self:CreateTabButton("Units", TabUnits), Panel = self.UnitsPanel }, + } + end, + + --- Builds one clickable tab button (a tinted group + label). Private. + ---@param self UICustomLobbyConfigInterface + ---@param label string + ---@param index number + ---@return Group + CreateTabButton = function(self, label, index) + local tab = Group(self.TabStripArea, "CustomLobbyTab") + + -- the background catches the click; the label is hit-disabled so it doesn't block it + tab.Bg = Bitmap(tab) + tab.Bg:SetSolidColor(TabIdleColor) + + tab.Label = UIUtil.CreateText(tab, label, 14, UIUtil.titleFont) + tab.Label:SetColor('ffc8ccd0') + tab.Label:DisableHitTest() + + tab.Bg.HandleEvent = function(control, event) + if event.Type == 'ButtonPress' then + self:SelectTab(index) + return true + elseif event.Type == 'MouseEnter' then + if self.ActiveTab ~= index then + tab.Bg:SetSolidColor(TabHoverColor) + end + return true + elseif event.Type == 'MouseExit' then + if self.ActiveTab ~= index then + tab.Bg:SetSolidColor(TabIdleColor) + end + return true + end + return false + end + + return tab + end, + + --- Lays out the tab buttons across the strip + fills each content panel. Private. + ---@param self UICustomLobbyConfigInterface + LayoutTabs = function(self) + for index = 1, table.getn(self.Tabs) do + local tab = self.Tabs[index] + local builder = Layouter(tab.Button):AtTopIn(self.TabStripArea):Width(TabWidth):Height(TabHeight) + if index == 1 then + builder:AtLeftIn(self.TabStripArea) + else + builder:AnchorToRight(self.Tabs[index - 1].Button, 2) + end + builder:End() + Layouter(tab.Button.Bg):Fill(tab.Button):End() + Layouter(tab.Button.Label):AtHorizontalCenterIn(tab.Button):AtVerticalCenterIn(tab.Button):End() + Layouter(tab.Panel):Fill(self.TabContentArea):End() + end + + -- map tab content + Layouter(self.MapPreview) + :AtHorizontalCenterIn(self.MapPanel):AtTopIn(self.MapPanel, 8) + :Width(PreviewWidth):Height(PreviewHeight) + :End() + Layouter(self.MapButton):AtHorizontalCenterIn(self.MapPanel):AnchorToBottom(self.MapPreview, 12):End() + + -- options tab content + Layouter(self.OptionsInfo):AtHorizontalCenterIn(self.OptionsPanel):AtTopIn(self.OptionsPanel, 16):End() + Layouter(self.EditOptionsButton):AtHorizontalCenterIn(self.OptionsPanel):AnchorToBottom(self.OptionsInfo, 16):End() + Layouter(self.ResetOptionsButton):AtHorizontalCenterIn(self.OptionsPanel):AnchorToBottom(self.EditOptionsButton, 10):End() + + -- mods tab content + Layouter(self.ModsInfo):AtHorizontalCenterIn(self.ModsPanel):AtTopIn(self.ModsPanel, 16):End() + Layouter(self.ModsButton):AtHorizontalCenterIn(self.ModsPanel):AnchorToBottom(self.ModsInfo, 16):End() + + -- units tab content + Layouter(self.UnitsInfo):AtHorizontalCenterIn(self.UnitsPanel):AtTopIn(self.UnitsPanel, 16):End() + end, + + --- Shows the selected tab's panel, hides the rest, recolours the buttons, and re-applies the + --- host-only button visibility (a panel `Show()` cascades to its children, so host-gated + --- buttons must be re-hidden after). + ---@param self UICustomLobbyConfigInterface + ---@param index number + SelectTab = function(self, index) + self.ActiveTab = index + for i = 1, table.getn(self.Tabs) do + local tab = self.Tabs[i] + if i == index then + tab.Button.Bg:SetSolidColor(TabActiveColor) + tab.Panel:Show() + else + tab.Button.Bg:SetSolidColor(TabIdleColor) + tab.Panel:Hide() + end + end + self:ApplyHostVisibility() + end, + + --#endregion + + --- Tracks host status and re-applies the host-only button visibility. + ---@param self UICustomLobbyConfigInterface + ---@param isHost boolean + OnIsHostChanged = function(self, isHost) + self.IsHost = isHost + self:ApplyHostVisibility() + end, + + --- Host-only buttons (change map / edit + reset options) are shown only to the host. Called + --- after both an is-host change and a tab switch (a panel Show() cascades visibility). + ---@param self UICustomLobbyConfigInterface + ApplyHostVisibility = function(self) + local show = self.IsHost + for _, button in { self.MapButton, self.EditOptionsButton, self.ResetOptionsButton } do + if show then + button:Show() + else + button:Hide() + end + end + end, + + ---@param self UICustomLobbyConfigInterface + OnDestroy = function(self) + self.Trash:Destroy() + end, +} + +---@param parent Control +---@return UICustomLobbyConfigInterface +Create = function(parent) + return CustomLobbyConfigInterface(parent) +end diff --git a/lua/ui/lobby/customlobby/CustomLobbyController.lua b/lua/ui/lobby/customlobby/CustomLobbyController.lua index b49c0bc0cfe..792c87ef0fd 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyController.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyController.lua @@ -563,6 +563,38 @@ function RequestSetGameOptions(options) BroadcastLaunchInfo(instance) end +--- The host resets every game option to its default. Host-only — backs the lobby's "Reset +--- options" button. Derives the current option schema (lobby + scenario + mods) and seeds the +--- defaults, then broadcasts, so it reconciles to exactly the options the current map/mods define. +function RequestResetGameOptions() + local instance = LobbyInstance + if not instance then + return + end + + if not CustomLobbyLocalModel.GetSingleton().IsHost() then + WARN("CustomLobby: only the host can reset the game options") + return + end + + local OptionUtil = import("/lua/ui/optionutil.lua") + local launch = CustomLobbyLaunchModel.GetSingleton() + + local options = {} + for _, option in OptionUtil.GetLobbyOptions() do + table.insert(options, option) + end + for _, option in OptionUtil.GetScenarioOptions(launch.ScenarioFile()) do + table.insert(options, option) + end + for _, option in OptionUtil.GetModOptions(launch.GameMods()) do + table.insert(options, option) + end + + CustomLobbyLaunchModel.SetGameOptions(launch, OptionUtil.SeedDefaults(options, {})) + BroadcastLaunchInfo(instance) +end + --- The host swaps the contents of two slots. Host-only (a client request isn't --- offered) — backs a host-side drag/menu and a `/swap ` chat command. ---@param slotA number diff --git a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua index 15c6fd6c324..1587d8a5f4e 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua @@ -20,14 +20,25 @@ --** SOFTWARE. --****************************************************************************************************** --- Composition root for the custom-games lobby. It builds and lays out the children --- and holds NO model subscriptions of its own — each child subscribes to the model --- itself (see /lua/ui/lobby/TARGET_ARCHITECTURE.md). For now it lays out the slot --- rows; the options panel, map preview, observer list, footer and chat are added in --- later slices. +-- Composition root for the custom-games lobby. It builds and lays out the children and holds only +-- the two presentation observers it needs (slot count, is-host); each child subscribes to the +-- model itself (see /lua/ui/lobby/TARGET_ARCHITECTURE.md). +-- +-- Layout is organised into labelled *areas* (Group containers), like the dialogs — flip the +-- module-level `Debug` flag to tint each so the regions are visible while iterating. Targeted at +-- the 1024x768 minimum resolution: +-- +-- ┌ TitleArea ─────────────────────────────────────────────┐ +-- │ LeftArea (slots / observers / chat) │ ConfigInterface │ +-- └ ActionArea ────────────────────────────────────────────┘ +-- +-- The right column is the CustomLobbyConfigInterface component (the Map / Options / Mods / Units +-- tab panel); it owns its own tabs + host-gating. The interface keeps the slot grid, observer +-- strip, chat, and the action bar (status + launch). local UIUtil = import("/lua/ui/uiutil.lua") local LayoutHelpers = import("/lua/maui/layouthelpers.lua") +local Tooltip = import("/lua/ui/game/tooltip.lua") local EscapeHandler = import("/lua/ui/dialogs/eschandler.lua") local Group = import("/lua/maui/group.lua").Group @@ -38,30 +49,58 @@ local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/customlobbylocal local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") local CustomLobbySlotInterface = import("/lua/ui/lobby/customlobby/customlobbyslotinterface.lua") local CustomLobbyObserversInterface = import("/lua/ui/lobby/customlobby/customlobbyobserversinterface.lua") -local CustomLobbyMapPreview = import("/lua/ui/lobby/customlobby/customlobbymappreview.lua") -local CustomLobbyMapSelect = import("/lua/ui/lobby/customlobby/mapselect/customlobbymapselect.lua") -local CustomLobbyModSelect = import("/lua/ui/lobby/customlobby/modselect/customlobbymodselect.lua") -local CustomLobbyOptionSelect = import("/lua/ui/lobby/customlobby/optionselect/customlobbyoptionselect.lua") +local CustomLobbyConfigInterface = import("/lua/ui/lobby/customlobby/customlobbyconfiginterface.lua") local LazyVarDerive = import("/lua/lazyvar.lua").Derive local Layouter = LayoutHelpers.ReusedLayoutFor +-- flip to tint each layout area so the regions are visible while iterating +local Debug = false + +local Pad = 8 local SlotHeight = 24 -local PanelWidth = 520 +local RightWidth = 360 +local TitleHeight = 44 +local ActionHeight = 60 +local ObserverHeight = 44 + +--- Creates a layout area (an invisible Group with an optional debug tint). +---@param parent Control +---@param name string +---@param color string +---@return Group +local function CreateArea(parent, name, color) + local area = Group(parent, name) + local bg = Bitmap(area) + bg:SetSolidColor(color) + bg:SetAlpha(Debug and 0.18 or 0.0) + bg:DisableHitTest() + Layouter(bg):Fill(area):End() + area.Bg = bg + return area +end ---@class UICustomLobbyInterface : Group, UICustomLobbySlotCoordinator ---@field Trash TrashBag ---@field Background Bitmap +---@field TitleArea Group ---@field Title Text +---@field LeaveButton Button +---@field LeftArea Group +---@field SlotsArea Group +---@field SlotsHeader Text ---@field SlotsPanel Group ---@field Slots UICustomLobbySlotInterface[] +---@field ObserversArea Group ---@field ObserversPanel UICustomLobbyObserversInterface ----@field MapPreview UICustomLobbyMapPreview ----@field MapButton Button ----@field ModsButton Button ----@field OptionsButton Button ----@field LeaveButton Button +---@field SpectateButton Button +---@field ChatArea Group +---@field ChatPlaceholder Text +---@field Config UICustomLobbyConfigInterface +---@field ActionArea Group +---@field StatusLabel Text +---@field LaunchButton Button ---@field SlotCountObserver LazyVar ---@field IsHostObserver LazyVar ---@field HighlightedSlot number | false # slot currently shown as a drop target @@ -81,46 +120,77 @@ local CustomLobbyInterface = Class(Group) { self.Background:SetSolidColor('ff0a0a0a') self.Background:DisableHitTest() - self.Title = UIUtil.CreateText(self, "Custom Lobby (barebones)", 20, UIUtil.titleFont) + --#region areas + self.TitleArea = CreateArea(self, "TitleArea", 'ffcc4040') + self.ActionArea = CreateArea(self, "ActionArea", 'ff808080') + self.LeftArea = CreateArea(self, "LeftArea", 'ff4060cc') + self.SlotsArea = CreateArea(self.LeftArea, "SlotsArea", 'ffcccc40') + self.ObserversArea = CreateArea(self.LeftArea, "ObserversArea", 'ff40cccc') + self.ChatArea = CreateArea(self.LeftArea, "ChatArea", 'ff40cc60') + --#endregion + + -- the right column: the Map / Options / Mods / Units tab panel (owns its own tabs + gating) + self.Config = CustomLobbyConfigInterface.Create(self) + + --#region title bar + self.Title = UIUtil.CreateText(self.TitleArea, "Custom game", 20, UIUtil.titleFont) + self.Title:DisableHitTest() + + self.LeaveButton = UIUtil.CreateButtonWithDropshadow(self.TitleArea, '/BUTTON/medium/', "Leave") + self.LeaveButton.OnClick = function(button, modifiers) + -- leaving disconnects + returns to the menu via the escape handler lobby.lua + -- registered (one teardown, shared with the Esc key) + EscapeHandler.HandleEsc(false) + end + --#endregion + + --#region slots + self.SlotsHeader = UIUtil.CreateText(self.SlotsArea, "Players", 14, UIUtil.titleFont) + self.SlotsHeader:SetColor('ff9aa0a8') + self.SlotsHeader:DisableHitTest() - self.SlotsPanel = Group(self, "CustomLobbySlotsPanel") + self.SlotsPanel = Group(self.SlotsArea, "CustomLobbySlotsPanel") -- One row per possible slot; the SlotCount observer reveals the active ones. self.Slots = {} for slot = 1, CustomLobbyLaunchModel.MaxSlots do self.Slots[slot] = CustomLobbySlotInterface.Create(self.SlotsPanel, slot, self) end - - self.ObserversPanel = CustomLobbyObserversInterface.Create(self) - self.MapPreview = CustomLobbyMapPreview.Create(self) - - -- only the host picks the map; the button hides for everyone else (and the - -- RequestSetScenario intent is host-gated regardless) - self.MapButton = UIUtil.CreateButtonStd(self, '/scx_menu/small-btn/small', "Change Map", 16, 2) - self.MapButton.OnClick = function(button, modifiers) - CustomLobbyMapSelect.Open(GetFrame(0)) - end - - -- everyone can open the mod picker (UI mods are per-player); only the host can change the - -- shared sim mods, which the dialog gates on its own - self.ModsButton = UIUtil.CreateButtonStd(self, '/scx_menu/small-btn/small', "Mods", 16, 2) - self.ModsButton.OnClick = function(button, modifiers) - CustomLobbyModSelect.Open(GetFrame(0)) + --#endregion + + --#region observers + self.ObserversPanel = CustomLobbyObserversInterface.Create(self.ObserversArea) + + -- everyone can drop to the observers; the move is host-authoritative (a client's click + -- asks the host through the intent) + self.SpectateButton = UIUtil.CreateButtonWithDropshadow(self.ObserversArea, '/BUTTON/medium/', "Observe") + self.SpectateButton.OnClick = function(button, modifiers) + local slot = self:FindLocalSlot() + if slot then + CustomLobbyController.RequestMoveToObserver(slot) + end end - - -- options are host-dictated + synced, so only the host edits them (button hidden for - -- others; the RequestSetGameOptions intent is host-gated regardless) - self.OptionsButton = UIUtil.CreateButtonStd(self, '/scx_menu/small-btn/small', "Options", 16, 2) - self.OptionsButton.OnClick = function(button, modifiers) - CustomLobbyOptionSelect.Open(GetFrame(0)) - end - - -- leaving disconnects + returns to the menu via the escape handler that - -- lobby.lua registered (one teardown definition, shared with the Esc key) - self.LeaveButton = UIUtil.CreateButtonStd(self, '/scx_menu/small-btn/small', "Leave", 16, 2) - self.LeaveButton.OnClick = function(button, modifiers) - EscapeHandler.HandleEsc(false) + Tooltip.AddControlTooltipManual(self.SpectateButton, "Become observer", "Leave your slot and watch as an observer.") + --#endregion + + --#region chat (placeholder until the lobby-chat slice lands) + self.ChatPlaceholder = UIUtil.CreateText(self.ChatArea, "Chat — coming soon", 14, UIUtil.bodyFont) + self.ChatPlaceholder:SetColor('ff5a606a') + self.ChatPlaceholder:DisableHitTest() + --#endregion + + --#region action bar + self.StatusLabel = UIUtil.CreateText(self.ActionArea, "", 14, UIUtil.bodyFont) + self.StatusLabel:SetColor('ff9aa0a8') + self.StatusLabel:DisableHitTest() + + self.LaunchButton = UIUtil.CreateButtonWithDropshadow(self.ActionArea, '/BUTTON/large/', "Launch") + self.LaunchButton.OnClick = function(button, modifiers) + -- launch flow isn't wired up yet end + self.LaunchButton:Disable() + Tooltip.AddControlTooltipManual(self.LaunchButton, "Launch", "Launching isn't wired up yet.") + --#endregion local session = CustomLobbySessionModel.GetSingleton() self.SlotCountObserver = self.Trash:Add( @@ -141,38 +211,46 @@ local CustomLobbyInterface = Class(Group) { Layouter(self):Fill(parent):End() Layouter(self.Background):Fill(self):End() - Layouter(self.Title):AtLeftTopIn(self, 40, 36):End() - - Layouter(self.SlotsPanel) - :AtLeftTopIn(self, 40, 80) - :Width(PanelWidth) - :Height(CustomLobbyLaunchModel.MaxSlots * SlotHeight) + --#region areas + Layouter(self.TitleArea):AtLeftIn(self, Pad):AtRightIn(self, Pad):AtTopIn(self, Pad):Height(TitleHeight):End() + Layouter(self.ActionArea):AtLeftIn(self, Pad):AtRightIn(self, Pad):AtBottomIn(self, Pad):Height(ActionHeight):End() + Layouter(self.Config) + :AtRightIn(self, Pad):Width(RightWidth) + :AnchorToBottom(self.TitleArea, Pad):AnchorToTop(self.ActionArea, Pad) :End() - - -- map preview to the right of the slots; hides itself until a scenario is set - Layouter(self.MapPreview) - :AnchorToRight(self.SlotsPanel, 24) - :AtTopIn(self, 80) - :Width(320) - :Height(320) + Layouter(self.LeftArea) + :AtLeftIn(self, Pad):AnchorToLeft(self.Config, Pad) + :AnchorToBottom(self.TitleArea, Pad):AnchorToTop(self.ActionArea, Pad) :End() - -- map-change button under the preview (host-only; visibility set by OnIsHostChanged) - Layouter(self.MapButton) - :AnchorToBottom(self.MapPreview, 12) - :AtLeftIn(self.MapPreview) + -- the slots area sizes to the *active* slot count (map-derived, 1..MaxSlots) so the + -- observers + chat below it reflow up on smaller maps; OnSlotCountChanged keeps it in sync + local slotCount = CustomLobbySessionModel.GetSingleton().SlotCount() + Layouter(self.SlotsArea) + :AtLeftIn(self.LeftArea):AtRightIn(self.LeftArea):AtTopIn(self.LeftArea) + :Height(20 + slotCount * SlotHeight) :End() - - -- mods button beside it (shown to everyone) - Layouter(self.ModsButton) - :AnchorToRight(self.MapButton, 8) - :AtVerticalCenterIn(self.MapButton) + Layouter(self.ObserversArea) + :AtLeftIn(self.LeftArea):AtRightIn(self.LeftArea) + :AnchorToBottom(self.SlotsArea, Pad):Height(ObserverHeight) :End() + Layouter(self.ChatArea) + :AtLeftIn(self.LeftArea):AtRightIn(self.LeftArea) + :AnchorToBottom(self.ObserversArea, Pad):AtBottomIn(self.LeftArea) + :End() + --#endregion + + --#region title bar + Layouter(self.Title):AtLeftIn(self.TitleArea, 8):AtVerticalCenterIn(self.TitleArea):End() + Layouter(self.LeaveButton):AtRightIn(self.TitleArea):AtVerticalCenterIn(self.TitleArea):End() + --#endregion - -- options button beside mods (host-only; visibility set by OnIsHostChanged) - Layouter(self.OptionsButton) - :AnchorToRight(self.ModsButton, 8) - :AtVerticalCenterIn(self.ModsButton) + --#region slots + Layouter(self.SlotsHeader):AtLeftIn(self.SlotsArea, 4):AtTopIn(self.SlotsArea):End() + Layouter(self.SlotsPanel) + :AtLeftIn(self.SlotsArea):AtRightIn(self.SlotsArea) + :AnchorToBottom(self.SlotsHeader, 4) + :Height(slotCount * SlotHeight) :End() -- stack the rows top-to-bottom inside the panel via sibling anchoring @@ -189,14 +267,26 @@ local CustomLobbyInterface = Class(Group) { end builder:End() end + --#endregion + --#region observers + Layouter(self.SpectateButton):AtRightIn(self.ObserversArea):AtVerticalCenterIn(self.ObserversArea):End() Layouter(self.ObserversPanel) - :AtLeftIn(self, 40):AnchorToBottom(self.SlotsPanel, 16):Width(PanelWidth):Height(44) + :AtLeftIn(self.ObserversArea):AnchorToLeft(self.SpectateButton, Pad) + :AtTopIn(self.ObserversArea):AtBottomIn(self.ObserversArea) :End() - Layouter(self.LeaveButton):AtLeftIn(self, 40):AnchorToBottom(self.ObserversPanel, 16):End() + --#endregion + + Layouter(self.ChatPlaceholder):AtHorizontalCenterIn(self.ChatArea):AtVerticalCenterIn(self.ChatArea):End() + + --#region action bar + Layouter(self.StatusLabel):AtLeftIn(self.ActionArea, 8):AtVerticalCenterIn(self.ActionArea):End() + Layouter(self.LaunchButton):AtRightIn(self.ActionArea):AtVerticalCenterIn(self.ActionArea):End() + --#endregion end, - --- Shows the active slots (1..count) and hides the rest. + --- Shows the active slots (1..count), hides the rest, and resizes the slots area to fit them + --- (so the observers + chat below reflow to the map's actual slot count, up to MaxSlots). ---@param self UICustomLobbyInterface ---@param count number OnSlotCountChanged = function(self, count) @@ -207,21 +297,38 @@ local CustomLobbyInterface = Class(Group) { self.Slots[slot]:Hide() end end + self.SlotsPanel.Height:Set(LayoutHelpers.ScaleNumber(count * SlotHeight)) + self.SlotsArea.Height:Set(LayoutHelpers.ScaleNumber(20 + count * SlotHeight)) end, - --- Shows the host-only controls (map-change + options buttons) only to the host. + --- Tracks host status: updates the status line and shows the launch button only to the host. + --- (The right-column config panel gates its own host-only buttons.) ---@param self UICustomLobbyInterface ---@param isHost boolean OnIsHostChanged = function(self, isHost) + self.StatusLabel:SetText(isHost and "You are the host." or "The host controls the game.") if isHost then - self.MapButton:Show() - self.OptionsButton:Show() + self.LaunchButton:Show() else - self.MapButton:Hide() - self.OptionsButton:Hide() + self.LaunchButton:Hide() end end, + --- The local player's slot (the one this peer owns), or nil if they're an observer / unseated. + ---@param self UICustomLobbyInterface + ---@return number | nil + FindLocalSlot = function(self) + local launch = CustomLobbyLaunchModel.GetSingleton() + local localId = CustomLobbyLocalModel.GetSingleton().LocalPeerId() + for slot = 1, CustomLobbyLaunchModel.MaxSlots do + local player = launch.Players[slot]() + if player and player.OwnerID == localId then + return slot + end + end + return nil + end, + --------------------------------------------------------------------------- --#region Slot drag coordination (UICustomLobbySlotCoordinator) -- From 43ebef13510eee995fda173b7805999dd7daca93 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Mon, 22 Jun 2026 17:10:14 +0200 Subject: [PATCH 28/98] Limit resolution to 1024x768 --- .../customlobby/CustomLobbyInterface.lua | 32 ++++++++++++++----- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua index 1587d8a5f4e..97376c099da 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua @@ -58,6 +58,12 @@ local Layouter = LayoutHelpers.ReusedLayoutFor -- flip to tint each layout area so the regions are visible while iterating local Debug = false +-- the lobby content is designed for the 1024x768 floor; the root fills the frame (full-screen +-- backdrop) but the content is centered and capped to this size, so it never stretches on a +-- larger screen +local LobbyWidth = 1024 +local LobbyHeight = 768 + local Pad = 8 local SlotHeight = 24 local RightWidth = 360 @@ -84,6 +90,7 @@ end ---@class UICustomLobbyInterface : Group, UICustomLobbySlotCoordinator ---@field Trash TrashBag ---@field Background Bitmap +---@field Content Group ---@field TitleArea Group ---@field Title Text ---@field LeaveButton Button @@ -120,17 +127,20 @@ local CustomLobbyInterface = Class(Group) { self.Background:SetSolidColor('ff0a0a0a') self.Background:DisableHitTest() + -- everything below lives in a centered, size-capped content group (see __post_init) + self.Content = Group(self, "CustomLobbyContent") + --#region areas - self.TitleArea = CreateArea(self, "TitleArea", 'ffcc4040') - self.ActionArea = CreateArea(self, "ActionArea", 'ff808080') - self.LeftArea = CreateArea(self, "LeftArea", 'ff4060cc') + self.TitleArea = CreateArea(self.Content, "TitleArea", 'ffcc4040') + self.ActionArea = CreateArea(self.Content, "ActionArea", 'ff808080') + self.LeftArea = CreateArea(self.Content, "LeftArea", 'ff4060cc') self.SlotsArea = CreateArea(self.LeftArea, "SlotsArea", 'ffcccc40') self.ObserversArea = CreateArea(self.LeftArea, "ObserversArea", 'ff40cccc') self.ChatArea = CreateArea(self.LeftArea, "ChatArea", 'ff40cc60') --#endregion -- the right column: the Map / Options / Mods / Units tab panel (owns its own tabs + gating) - self.Config = CustomLobbyConfigInterface.Create(self) + self.Config = CustomLobbyConfigInterface.Create(self.Content) --#region title bar self.Title = UIUtil.CreateText(self.TitleArea, "Custom game", 20, UIUtil.titleFont) @@ -211,15 +221,21 @@ local CustomLobbyInterface = Class(Group) { Layouter(self):Fill(parent):End() Layouter(self.Background):Fill(self):End() + -- centred content, capped at the 1024x768 design size (fills the frame at the minimum + -- resolution; centred with a backdrop border on anything larger) + self.Content.Width:Set(function() return math.min(self.Width(), LayoutHelpers.ScaleNumber(LobbyWidth)) end) + self.Content.Height:Set(function() return math.min(self.Height(), LayoutHelpers.ScaleNumber(LobbyHeight)) end) + Layouter(self.Content):AtCenterIn(self):End() + --#region areas - Layouter(self.TitleArea):AtLeftIn(self, Pad):AtRightIn(self, Pad):AtTopIn(self, Pad):Height(TitleHeight):End() - Layouter(self.ActionArea):AtLeftIn(self, Pad):AtRightIn(self, Pad):AtBottomIn(self, Pad):Height(ActionHeight):End() + Layouter(self.TitleArea):AtLeftIn(self.Content, Pad):AtRightIn(self.Content, Pad):AtTopIn(self.Content, Pad):Height(TitleHeight):End() + Layouter(self.ActionArea):AtLeftIn(self.Content, Pad):AtRightIn(self.Content, Pad):AtBottomIn(self.Content, Pad):Height(ActionHeight):End() Layouter(self.Config) - :AtRightIn(self, Pad):Width(RightWidth) + :AtRightIn(self.Content, Pad):Width(RightWidth) :AnchorToBottom(self.TitleArea, Pad):AnchorToTop(self.ActionArea, Pad) :End() Layouter(self.LeftArea) - :AtLeftIn(self, Pad):AnchorToLeft(self.Config, Pad) + :AtLeftIn(self.Content, Pad):AnchorToLeft(self.Config, Pad) :AnchorToBottom(self.TitleArea, Pad):AnchorToTop(self.ActionArea, Pad) :End() From 34e7ce4ded03e7c7303f6c61a584aa5c856496d3 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Mon, 22 Jun 2026 18:12:09 +0200 Subject: [PATCH 29/98] Introduce configuration panel --- lua/ui/lobby/customlobby/CLAUDE.md | 3 +- .../CustomLobbyConfigInterface.lua | 331 ----------------- .../customlobby/CustomLobbyInterface.lua | 8 +- .../config/CustomLobbyConfigInterface.lua | 209 +++++++++++ .../config/CustomLobbyMapPanel.lua | 196 ++++++++++ .../config/CustomLobbyModsPanel.lua | 250 +++++++++++++ .../config/CustomLobbyOptionsPanel.lua | 347 ++++++++++++++++++ .../config/CustomLobbyUnitsPanel.lua | 73 ++++ lua/ui/optionutil.lua | 55 ++- 9 files changed, 1126 insertions(+), 346 deletions(-) delete mode 100644 lua/ui/lobby/customlobby/CustomLobbyConfigInterface.lua create mode 100644 lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua create mode 100644 lua/ui/lobby/customlobby/config/CustomLobbyMapPanel.lua create mode 100644 lua/ui/lobby/customlobby/config/CustomLobbyModsPanel.lua create mode 100644 lua/ui/lobby/customlobby/config/CustomLobbyOptionsPanel.lua create mode 100644 lua/ui/lobby/customlobby/config/CustomLobbyUnitsPanel.lua diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index 20aa7cf9bf8..f29592a947b 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -56,7 +56,7 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | [modselect/](modselect/CLAUDE.md) | the **mod-select dialog** + its catalog and list, built to the map-select shape (checkbox list + type filters + detail panel + **presets**). Returns a uid set; the opener routes it — sim mods → `RequestSetGameMods` (synced), UI mods → local prefs — or persists the lot standalone. Mod domain logic lives in [`/lua/ui/modutilities.lua`](/lua/ui/modutilities.lua) (the `maputil.lua` sibling, fronting `/lua/mods.lua`). See [modselect/CLAUDE.md](modselect/CLAUDE.md). | | [optionselect/](optionselect/CLAUDE.md) | the **options dialog**: three columns (lobby / scenario / mod options) over the selected scenario + mods, with search + hide-defaults filters; non-default options are marked. Derives the option *schema* per-peer (reference data) via [`/lua/ui/optionutil.lua`](/lua/ui/optionutil.lua); edits a working copy of the *values* and on OK routes the reconciled set through `RequestSetGameOptions` (synced via `GameOptions`). Host-only. See [optionselect/CLAUDE.md](optionselect/CLAUDE.md). | | [CustomLobbyInterface.lua](CustomLobbyInterface.lua) | composition root, laid out in **areas** (title / left {slots, observers, chat} / right = the config component / actions — flip the module `Debug` flag to tint them), sized for the **1024×768** floor. The slots area sizes to the active `SlotCount` (1..MaxSlots) so observers + chat reflow on smaller maps. Holds two presentation observers (SlotCount, IsHost); basic buttons wired: Leave (Esc handler), **Observe** (`RequestMoveToObserver` on the local slot); Launch is a host-only disabled stub (no launch flow yet). Also the rows' drag coordinator (`UICustomLobbySlotCoordinator`: hit-test, drop-highlight, drag ghost → `RequestSwapSlots`); `OpenDebug()` / hot-reload. | -| [CustomLobbyConfigInterface.lua](CustomLobbyConfigInterface.lua) | the lobby's right column: a **tab strip** (Map / Options / Mods / Units) over a content area. The map preview lives under the **Map tab only** (not pinned); the others show a one-line summary + a button into the popup dialog. Self-subscribes to `IsHost` and gates its host-only buttons via `ApplyHostVisibility` (re-applied after a tab switch, since a panel `Show()` cascades). Buttons: Change map / Options (open dialogs), **Reset options** (`RequestResetGameOptions`), Manage mods (everyone). | +| [config/](config/) | the lobby's right column — a **tab strip** (Map / Options / Mods / Units) host + one component per tab. [`CustomLobbyConfigInterface.lua`](config/CustomLobbyConfigInterface.lua) owns the strip and switches tabs by calling `panel:SetActive(isActive)`; each panel self-subscribes to the model and **only builds + shows its content while active** (re-hiding after a refresh if inactive), which is how a hidden tab is kept from rendering over the active one (MAUI's `Hide()` only cascades to children that exist at call time — see the gotcha table). Panels: [`CustomLobbyMapPanel`](config/CustomLobbyMapPanel.lua) (live preview + name/size/players + Change map), [`CustomLobbyOptionsPanel`](config/CustomLobbyOptionsPanel.lua) (read-only options grouped **Lobby / Scenario / Mods** with a hide-defaults toggle; map/mod options flagged with a gold marker whose tooltip names the origin — schema via [`/lua/ui/optionutil.lua`](/lua/ui/optionutil.lua)), [`CustomLobbyModsPanel`](config/CustomLobbyModsPanel.lua) (enabled mods in **Game mods / UI mods** sections; UI mods are prefs so they refresh on tab-activate), [`CustomLobbyUnitsPanel`](config/CustomLobbyUnitsPanel.lua) (placeholder). Host-only buttons gate per panel. Three-phase: the parent calls `Initialize()` (panels build their grid scrollbars) after sizing it. | | [/lua/ui/lobby/lobby.lua](../lobby.lua) | engine entry wrapper (`CreateLobby`/`HostGame`/`JoinGame`) → CustomLobby. Old lobby preserved at `lobby-old.lua`. | Working today: host + clients see each other (host-authoritative player sync), the @@ -110,6 +110,7 @@ lobby-specific checklist. | Cascade of errors after one layout failure (half-built pools, observers firing into a broken control) | A throw mid-`Initialize` left partial state (e.g. `PoolCount` set before the rows existed), and a streaming `Derive`/thread kept calling into it. | Set "ready" counters **after** the thing they describe is fully built; guard paint/refresh paths against nil rows; gate model-observer work on a `self.Ready` flag set in `Initialize`. | | `circular dependency in lazy evaluation` with **no frame from your files** (fires during the render pass) | A control you created in `__init` is **never anchored** in `__post_init` (or you renamed/moved its layout and forgot it). Its `Left`/`Right`/`Top`/`Bottom` stay at the circular defaults, and it errors the moment it's rendered. | Every visible control needs a layout. To find which one, set `import("/lua/lazyvar.lua").ExtendedErrorMessages = true` — the error then appends the offending control's **creation stack** (file + line). Then anchor it (or hide it). | | `circular dependency` only **on hot-reload** (or when the model already has state), not on a fresh open | A `Derive` observer fires **immediately on creation** in `__init`, and its handler reads layout geometry (e.g. positions icons via `self.Preview.Width()`). Fresh open: the model field is empty, handler no-ops. Reload: the field already has a value, so it renders before the parent has laid the component out. | Gate the observer handlers behind a `self.Ready` flag (default false). Set `Ready = true` and do the first render **deferred** (`ForkThread` + `WaitFrames(1)` from `__post_init`, or an `Initialize()` the parent calls) — once the parent has actually sized you. See `CustomLobbyMapPreview`. | +| Controls in a hidden container still render — a hidden tab's list/buttons bleed over the active one | MAUI's `Hide()`/`Show()` cascade the hidden flag to the children that exist **at call time**; there's no render-time ancestor check (the `OnHide` "return true to stop the cascade" contract in [`/lua/maui/grid.lua`](/lua/maui/grid.lua) confirms it). So anything created or `Show()`-n in a panel *after* it was hidden (a rebuilt grid row, a button you re-showed) renders, because it missed the cascade. | Re-assert visibility *after* any content rebuild / button-visibility change: show the active container, then **re-`Hide()` the inactive ones** so the hide re-cascades over the new children. Don't populate or `Show()` into a container and assume an earlier `Hide()` still covers it. See `CustomLobbyConfigInterface.ReapplyVisibility`. | | A `TextArea`'s text wraps far too early (~50–60% of the visible box) | `TextArea.__init` pins `Width` to its **constructor** `width` arg via `SetDimensions`, and `ReflowText` wraps to `Width()` — but the fluent `:AtLeftIn():AtRightIn()` set only `Left`/`Right`, never `Width`. So the box renders `Left..Right` wide while the text still wraps at the stale constructor width. | Bind `Width` to the span: `self.X.Width:Set(function() return self.X.Right() - self.X.Left() end)`. **Do this in `Initialize()` (post-mount), not `__post_init`:** `TextArea` hooks `Width.OnDirty → ReflowText`, so the bind *eagerly* reads `Right()/Left()` → the parent geometry, which is still circular until `Popup` mounts the dialog. (Left/Right anchor to the parent, so no self-cycle once mounted.) | | Memory climbs ~30 MB and never drops — not on re-texture, `ClearTexture`, `Destroy`, or dialog close | `MapPreview:SetTexture` / `SetTextureFromMap` allocate textures the engine **never frees** (no release API on the control or globally). Loading one per list row × hundreds of maps leaks memory the game needs in-match. | Don't put a `MapPreview` per list row. Render at most a **few** previews total (the map-select list is text-only; only the single candidate preview loads a texture, once per selection). The same applies to per-row `Bitmap` thumbnails via `SetNewTexture`. | diff --git a/lua/ui/lobby/customlobby/CustomLobbyConfigInterface.lua b/lua/ui/lobby/customlobby/CustomLobbyConfigInterface.lua deleted file mode 100644 index 490e57a90c0..00000000000 --- a/lua/ui/lobby/customlobby/CustomLobbyConfigInterface.lua +++ /dev/null @@ -1,331 +0,0 @@ ---****************************************************************************************************** ---** Copyright (c) 2026 FAForever ---** ---** 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. ---****************************************************************************************************** - --- The lobby's right-hand **config panel**: a tab strip (Map / Options / Mods / Units) over a --- content area. Extracted from CustomLobbyInterface so the interface stays a composition root — --- this component owns the tabs and self-subscribes to `IsHost` for its host-only buttons (the same --- "each child subscribes itself" rule the slot rows follow). --- --- The tabs are a read/launch surface: the **Map** tab shows the live map preview (it is NOT --- pinned — switching tabs hides it); **Options** / **Mods** / **Units** show a one-line summary and --- a button into the corresponding popup dialog. Editing happens in those dialogs; only the host's --- buttons (change map / options / reset) are shown to the host. --- --- Laid out by its parent (filled into the lobby's right column); internally it splits into a tab --- strip area + a content area — flip the module `Debug` flag to tint them. - -local UIUtil = import("/lua/ui/uiutil.lua") -local LayoutHelpers = import("/lua/maui/layouthelpers.lua") -local Tooltip = import("/lua/ui/game/tooltip.lua") - -local Group = import("/lua/maui/group.lua").Group -local Bitmap = import("/lua/maui/bitmap.lua").Bitmap -local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/customlobbylocalmodel.lua") -local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") -local CustomLobbyMapPreview = import("/lua/ui/lobby/customlobby/customlobbymappreview.lua") -local CustomLobbyMapSelect = import("/lua/ui/lobby/customlobby/mapselect/customlobbymapselect.lua") -local CustomLobbyModSelect = import("/lua/ui/lobby/customlobby/modselect/customlobbymodselect.lua") -local CustomLobbyOptionSelect = import("/lua/ui/lobby/customlobby/optionselect/customlobbyoptionselect.lua") - -local LazyVarDerive = import("/lua/lazyvar.lua").Derive - -local Layouter = LayoutHelpers.ReusedLayoutFor - --- flip to tint the tab strip / content areas while iterating -local Debug = false - -local TabHeight = 28 -local TabWidth = 88 -local PreviewWidth = 320 -local PreviewHeight = 300 - -local TabIdleColor = 'ff141a20' -local TabHoverColor = 'ff1f262e' -local TabActiveColor = 'ff2c3e48' - --- the four tabs, in order -local TabMap = 1 -local TabOptions = 2 -local TabMods = 3 -local TabUnits = 4 - ---- Creates a layout area (an invisible Group with an optional debug tint). ----@param parent Control ----@param name string ----@param color string ----@return Group -local function CreateArea(parent, name, color) - local area = Group(parent, name) - local bg = Bitmap(area) - bg:SetSolidColor(color) - bg:SetAlpha(Debug and 0.18 or 0.0) - bg:DisableHitTest() - Layouter(bg):Fill(area):End() - area.Bg = bg - return area -end - ----@class UICustomLobbyTab ----@field Button Group ----@field Panel Group - ----@class UICustomLobbyConfigInterface : Group ----@field Trash TrashBag ----@field TabStripArea Group ----@field TabContentArea Group ----@field Tabs UICustomLobbyTab[] ----@field ActiveTab number ----@field MapPanel Group ----@field MapPreview UICustomLobbyMapPreview ----@field MapButton Button ----@field OptionsPanel Group ----@field OptionsInfo Text ----@field EditOptionsButton Button ----@field ResetOptionsButton Button ----@field ModsPanel Group ----@field ModsInfo Text ----@field ModsButton Button ----@field UnitsPanel Group ----@field UnitsInfo Text ----@field IsHost boolean ----@field IsHostObserver LazyVar -local CustomLobbyConfigInterface = ClassUI(Group) { - - ---@param self UICustomLobbyConfigInterface - ---@param parent Control - __init = function(self, parent) - Group.__init(self, parent, "CustomLobbyConfigInterface") - - self.Trash = TrashBag() - self.IsHost = false - self.ActiveTab = TabMap - - self.TabStripArea = CreateArea(self, "TabStripArea", 'ffcc8040') - self.TabContentArea = CreateArea(self, "TabContentArea", 'ff8040cc') - - self:BuildTabs() - - local localModel = CustomLobbyLocalModel.GetSingleton() - self.IsHostObserver = self.Trash:Add( - LazyVarDerive(localModel.IsHost, function(isHostLazy) - self:OnIsHostChanged(isHostLazy()) - end)) - end, - - ---@param self UICustomLobbyConfigInterface - __post_init = function(self) - Layouter(self.TabStripArea):AtLeftIn(self):AtRightIn(self):AtTopIn(self):Height(TabHeight):End() - Layouter(self.TabContentArea) - :AtLeftIn(self):AtRightIn(self) - :AnchorToBottom(self.TabStripArea, 6):AtBottomIn(self) - :End() - - self:LayoutTabs() - self:SelectTab(self.ActiveTab) - end, - - --------------------------------------------------------------------------- - --#region Tabs - - --- Builds the four tabs (buttons + content panels). Private. - ---@param self UICustomLobbyConfigInterface - BuildTabs = function(self) - --#region map tab - self.MapPanel = Group(self.TabContentArea, "CustomLobbyMapPanel") - self.MapPreview = CustomLobbyMapPreview.Create(self.MapPanel) - self.MapButton = UIUtil.CreateButtonWithDropshadow(self.MapPanel, '/BUTTON/medium/', "Change map") - self.MapButton.OnClick = function(button, modifiers) - CustomLobbyMapSelect.Open(GetFrame(0)) - end - --#endregion - - --#region options tab - self.OptionsPanel = Group(self.TabContentArea, "CustomLobbyOptionsPanel") - self.OptionsInfo = UIUtil.CreateText(self.OptionsPanel, "Game, scenario and mod options.", 14, UIUtil.bodyFont) - self.OptionsInfo:SetColor('ffc8ccd0') - self.OptionsInfo:DisableHitTest() - self.EditOptionsButton = UIUtil.CreateButtonWithDropshadow(self.OptionsPanel, '/BUTTON/medium/', "Options") - self.EditOptionsButton.OnClick = function(button, modifiers) - CustomLobbyOptionSelect.Open(GetFrame(0)) - end - self.ResetOptionsButton = UIUtil.CreateButtonWithDropshadow(self.OptionsPanel, '/BUTTON/medium/', "Reset options") - self.ResetOptionsButton.OnClick = function(button, modifiers) - CustomLobbyController.RequestResetGameOptions() - end - Tooltip.AddControlTooltipManual(self.ResetOptionsButton, "Reset options", "Reset every option to its default value.") - --#endregion - - --#region mods tab - self.ModsPanel = Group(self.TabContentArea, "CustomLobbyModsPanel") - self.ModsInfo = UIUtil.CreateText(self.ModsPanel, "Sim mods are shared; UI mods are yours.", 14, UIUtil.bodyFont) - self.ModsInfo:SetColor('ffc8ccd0') - self.ModsInfo:DisableHitTest() - self.ModsButton = UIUtil.CreateButtonWithDropshadow(self.ModsPanel, '/BUTTON/medium/', "Manage mods") - self.ModsButton.OnClick = function(button, modifiers) - CustomLobbyModSelect.Open(GetFrame(0)) - end - --#endregion - - --#region units tab - self.UnitsPanel = Group(self.TabContentArea, "CustomLobbyUnitsPanel") - self.UnitsInfo = UIUtil.CreateText(self.UnitsPanel, "Unit restrictions — coming soon.", 14, UIUtil.bodyFont) - self.UnitsInfo:SetColor('ff8a909a') - self.UnitsInfo:DisableHitTest() - --#endregion - - self.Tabs = { - [TabMap] = { Button = self:CreateTabButton("Map", TabMap), Panel = self.MapPanel }, - [TabOptions] = { Button = self:CreateTabButton("Options", TabOptions), Panel = self.OptionsPanel }, - [TabMods] = { Button = self:CreateTabButton("Mods", TabMods), Panel = self.ModsPanel }, - [TabUnits] = { Button = self:CreateTabButton("Units", TabUnits), Panel = self.UnitsPanel }, - } - end, - - --- Builds one clickable tab button (a tinted group + label). Private. - ---@param self UICustomLobbyConfigInterface - ---@param label string - ---@param index number - ---@return Group - CreateTabButton = function(self, label, index) - local tab = Group(self.TabStripArea, "CustomLobbyTab") - - -- the background catches the click; the label is hit-disabled so it doesn't block it - tab.Bg = Bitmap(tab) - tab.Bg:SetSolidColor(TabIdleColor) - - tab.Label = UIUtil.CreateText(tab, label, 14, UIUtil.titleFont) - tab.Label:SetColor('ffc8ccd0') - tab.Label:DisableHitTest() - - tab.Bg.HandleEvent = function(control, event) - if event.Type == 'ButtonPress' then - self:SelectTab(index) - return true - elseif event.Type == 'MouseEnter' then - if self.ActiveTab ~= index then - tab.Bg:SetSolidColor(TabHoverColor) - end - return true - elseif event.Type == 'MouseExit' then - if self.ActiveTab ~= index then - tab.Bg:SetSolidColor(TabIdleColor) - end - return true - end - return false - end - - return tab - end, - - --- Lays out the tab buttons across the strip + fills each content panel. Private. - ---@param self UICustomLobbyConfigInterface - LayoutTabs = function(self) - for index = 1, table.getn(self.Tabs) do - local tab = self.Tabs[index] - local builder = Layouter(tab.Button):AtTopIn(self.TabStripArea):Width(TabWidth):Height(TabHeight) - if index == 1 then - builder:AtLeftIn(self.TabStripArea) - else - builder:AnchorToRight(self.Tabs[index - 1].Button, 2) - end - builder:End() - Layouter(tab.Button.Bg):Fill(tab.Button):End() - Layouter(tab.Button.Label):AtHorizontalCenterIn(tab.Button):AtVerticalCenterIn(tab.Button):End() - Layouter(tab.Panel):Fill(self.TabContentArea):End() - end - - -- map tab content - Layouter(self.MapPreview) - :AtHorizontalCenterIn(self.MapPanel):AtTopIn(self.MapPanel, 8) - :Width(PreviewWidth):Height(PreviewHeight) - :End() - Layouter(self.MapButton):AtHorizontalCenterIn(self.MapPanel):AnchorToBottom(self.MapPreview, 12):End() - - -- options tab content - Layouter(self.OptionsInfo):AtHorizontalCenterIn(self.OptionsPanel):AtTopIn(self.OptionsPanel, 16):End() - Layouter(self.EditOptionsButton):AtHorizontalCenterIn(self.OptionsPanel):AnchorToBottom(self.OptionsInfo, 16):End() - Layouter(self.ResetOptionsButton):AtHorizontalCenterIn(self.OptionsPanel):AnchorToBottom(self.EditOptionsButton, 10):End() - - -- mods tab content - Layouter(self.ModsInfo):AtHorizontalCenterIn(self.ModsPanel):AtTopIn(self.ModsPanel, 16):End() - Layouter(self.ModsButton):AtHorizontalCenterIn(self.ModsPanel):AnchorToBottom(self.ModsInfo, 16):End() - - -- units tab content - Layouter(self.UnitsInfo):AtHorizontalCenterIn(self.UnitsPanel):AtTopIn(self.UnitsPanel, 16):End() - end, - - --- Shows the selected tab's panel, hides the rest, recolours the buttons, and re-applies the - --- host-only button visibility (a panel `Show()` cascades to its children, so host-gated - --- buttons must be re-hidden after). - ---@param self UICustomLobbyConfigInterface - ---@param index number - SelectTab = function(self, index) - self.ActiveTab = index - for i = 1, table.getn(self.Tabs) do - local tab = self.Tabs[i] - if i == index then - tab.Button.Bg:SetSolidColor(TabActiveColor) - tab.Panel:Show() - else - tab.Button.Bg:SetSolidColor(TabIdleColor) - tab.Panel:Hide() - end - end - self:ApplyHostVisibility() - end, - - --#endregion - - --- Tracks host status and re-applies the host-only button visibility. - ---@param self UICustomLobbyConfigInterface - ---@param isHost boolean - OnIsHostChanged = function(self, isHost) - self.IsHost = isHost - self:ApplyHostVisibility() - end, - - --- Host-only buttons (change map / edit + reset options) are shown only to the host. Called - --- after both an is-host change and a tab switch (a panel Show() cascades visibility). - ---@param self UICustomLobbyConfigInterface - ApplyHostVisibility = function(self) - local show = self.IsHost - for _, button in { self.MapButton, self.EditOptionsButton, self.ResetOptionsButton } do - if show then - button:Show() - else - button:Hide() - end - end - end, - - ---@param self UICustomLobbyConfigInterface - OnDestroy = function(self) - self.Trash:Destroy() - end, -} - ----@param parent Control ----@return UICustomLobbyConfigInterface -Create = function(parent) - return CustomLobbyConfigInterface(parent) -end diff --git a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua index 97376c099da..df61b82df4e 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua @@ -49,14 +49,14 @@ local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/customlobbylocal local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") local CustomLobbySlotInterface = import("/lua/ui/lobby/customlobby/customlobbyslotinterface.lua") local CustomLobbyObserversInterface = import("/lua/ui/lobby/customlobby/customlobbyobserversinterface.lua") -local CustomLobbyConfigInterface = import("/lua/ui/lobby/customlobby/customlobbyconfiginterface.lua") +local CustomLobbyConfigInterface = import("/lua/ui/lobby/customlobby/config/customlobbyconfiginterface.lua") local LazyVarDerive = import("/lua/lazyvar.lua").Derive local Layouter = LayoutHelpers.ReusedLayoutFor -- flip to tint each layout area so the regions are visible while iterating -local Debug = false +local Debug = true -- the lobby content is designed for the 1024x768 floor; the root fills the frame (full-screen -- backdrop) but the content is centered and capped to this size, so it never stretches on a @@ -299,6 +299,10 @@ local CustomLobbyInterface = Class(Group) { Layouter(self.StatusLabel):AtLeftIn(self.ActionArea, 8):AtVerticalCenterIn(self.ActionArea):End() Layouter(self.LaunchButton):AtRightIn(self.ActionArea):AtVerticalCenterIn(self.ActionArea):End() --#endregion + + -- the config panel is now sized; let it build its grids' scrollbars + first render + -- (three-phase init — its Grids need a concrete height) + self.Config:Initialize() end, --- Shows the active slots (1..count), hides the rest, and resizes the slots area to fit them diff --git a/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua b/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua new file mode 100644 index 00000000000..3e6bb9824cc --- /dev/null +++ b/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua @@ -0,0 +1,209 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- The lobby's right-hand config panel: a tab strip (Map / Options / Mods / Units) over a content +-- area. This is just the **tab host** — each tab's content is its own component in this folder +-- (CustomLobbyMapPanel / OptionsPanel / ModsPanel / UnitsPanel), and each self-subscribes to the +-- model and manages its own host-gating. +-- +-- Visibility rule: exactly one panel is active. The host calls `panel:SetActive(isActive)` on a +-- tab switch; a panel only shows + (re)builds its content while active, and stays hidden +-- otherwise. That's what prevents a hidden tab's content from rendering over the active one — +-- MAUI's `Hide()` only cascades the hidden flag to children that exist at call time, so a panel +-- that builds content while hidden would leak; building only-while-active sidesteps it entirely. +-- +-- Laid out by its parent (filled into the lobby's right column); internally it splits into a tab +-- strip area + a content area — flip the module `Debug` flag to tint them. + +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 +local CustomLobbyMapPanel = import("/lua/ui/lobby/customlobby/config/customlobbymappanel.lua") +local CustomLobbyOptionsPanel = import("/lua/ui/lobby/customlobby/config/customlobbyoptionspanel.lua") +local CustomLobbyModsPanel = import("/lua/ui/lobby/customlobby/config/customlobbymodspanel.lua") +local CustomLobbyUnitsPanel = import("/lua/ui/lobby/customlobby/config/customlobbyunitspanel.lua") + +local Layouter = LayoutHelpers.ReusedLayoutFor + +-- flip to tint the tab strip / content areas while iterating +local Debug = false + +local TabHeight = 28 +local TabWidth = 88 + +local TabIdleColor = 'ff141a20' +local TabHoverColor = 'ff1f262e' +local TabActiveColor = 'ff2c3e48' + +-- the four tabs, in order +local TabMap = 1 +local TabOptions = 2 +local TabMods = 3 +local TabUnits = 4 + +--- Creates a layout area (an invisible Group with an optional debug tint). +---@param parent Control +---@param name string +---@param color string +---@return Group +local function CreateArea(parent, name, color) + local area = Group(parent, name) + local bg = Bitmap(area) + bg:SetSolidColor(color) + bg:SetAlpha(Debug and 0.18 or 0.0) + bg:DisableHitTest() + Layouter(bg):Fill(area):End() + area.Bg = bg + return area +end + +---@class UICustomLobbyConfigTab +---@field Button Group +---@field Panel Control # one of the tab-panel components (implements SetActive/Initialize) + +---@class UICustomLobbyConfigInterface : Group +---@field Trash TrashBag +---@field TabStripArea Group +---@field TabContentArea Group +---@field Tabs UICustomLobbyConfigTab[] +---@field ActiveTab number +local CustomLobbyConfigInterface = ClassUI(Group) { + + ---@param self UICustomLobbyConfigInterface + ---@param parent Control + __init = function(self, parent) + Group.__init(self, parent, "CustomLobbyConfigInterface") + + self.Trash = TrashBag() + self.ActiveTab = TabMap + + self.TabStripArea = CreateArea(self, "TabStripArea", 'ffcc8040') + self.TabContentArea = CreateArea(self, "TabContentArea", 'ff8040cc') + + self.Tabs = { + [TabMap] = { Button = self:CreateTabButton("Map", TabMap), Panel = CustomLobbyMapPanel.Create(self.TabContentArea) }, + [TabOptions] = { Button = self:CreateTabButton("Options", TabOptions), Panel = CustomLobbyOptionsPanel.Create(self.TabContentArea) }, + [TabMods] = { Button = self:CreateTabButton("Mods", TabMods), Panel = CustomLobbyModsPanel.Create(self.TabContentArea) }, + [TabUnits] = { Button = self:CreateTabButton("Units", TabUnits), Panel = CustomLobbyUnitsPanel.Create(self.TabContentArea) }, + } + end, + + ---@param self UICustomLobbyConfigInterface + __post_init = function(self) + Layouter(self.TabStripArea):AtLeftIn(self):AtRightIn(self):AtTopIn(self):Height(TabHeight):End() + Layouter(self.TabContentArea) + :AtLeftIn(self):AtRightIn(self) + :AnchorToBottom(self.TabStripArea, 6):AtBottomIn(self) + :End() + + for index = 1, table.getn(self.Tabs) do + local tab = self.Tabs[index] + local builder = Layouter(tab.Button):AtTopIn(self.TabStripArea):Width(TabWidth):Height(TabHeight) + if index == 1 then + builder:AtLeftIn(self.TabStripArea) + else + builder:AnchorToRight(self.Tabs[index - 1].Button, 2) + end + builder:End() + Layouter(tab.Button.Bg):Fill(tab.Button):End() + Layouter(tab.Button.Label):AtHorizontalCenterIn(tab.Button):AtVerticalCenterIn(tab.Button):End() + Layouter(tab.Panel):Fill(self.TabContentArea):End() + + -- visuals only until the parent calls Initialize; panels build content when activated + tab.Button.Bg:SetSolidColor(index == self.ActiveTab and TabActiveColor or TabIdleColor) + tab.Panel:Hide() + end + end, + + --- Builds the panels' deferred bits (grids need a concrete height) + activates the first tab. + --- Called by the parent after it has laid this component out (three-phase init). + ---@param self UICustomLobbyConfigInterface + Initialize = function(self) + for index = 1, table.getn(self.Tabs) do + self.Tabs[index].Panel:Initialize() + end + self:SelectTab(self.ActiveTab) + end, + + --- Builds one clickable tab button (a tinted group + label). Private. + ---@param self UICustomLobbyConfigInterface + ---@param label string + ---@param index number + ---@return Group + CreateTabButton = function(self, label, index) + local tab = Group(self.TabStripArea, "CustomLobbyConfigTabButton") + + -- the background catches the click; the label is hit-disabled so it doesn't block it + tab.Bg = Bitmap(tab) + tab.Bg:SetSolidColor(TabIdleColor) + + tab.Label = UIUtil.CreateText(tab, label, 14, UIUtil.titleFont) + tab.Label:SetColor('ffc8ccd0') + tab.Label:DisableHitTest() + + tab.Bg.HandleEvent = function(control, event) + if event.Type == 'ButtonPress' then + self:SelectTab(index) + return true + elseif event.Type == 'MouseEnter' then + if self.ActiveTab ~= index then + tab.Bg:SetSolidColor(TabHoverColor) + end + return true + elseif event.Type == 'MouseExit' then + if self.ActiveTab ~= index then + tab.Bg:SetSolidColor(TabIdleColor) + end + return true + end + return false + end + + return tab + end, + + --- Switches tabs: recolours the buttons and activates the chosen panel (which shows + refreshes + --- itself) while deactivating (hiding) the rest. + ---@param self UICustomLobbyConfigInterface + ---@param index number + SelectTab = function(self, index) + self.ActiveTab = index + for i = 1, table.getn(self.Tabs) do + local tab = self.Tabs[i] + tab.Button.Bg:SetSolidColor(i == index and TabActiveColor or TabIdleColor) + tab.Panel:SetActive(i == index) + end + end, + + ---@param self UICustomLobbyConfigInterface + OnDestroy = function(self) + self.Trash:Destroy() + end, +} + +---@param parent Control +---@return UICustomLobbyConfigInterface +Create = function(parent) + return CustomLobbyConfigInterface(parent) +end diff --git a/lua/ui/lobby/customlobby/config/CustomLobbyMapPanel.lua b/lua/ui/lobby/customlobby/config/CustomLobbyMapPanel.lua new file mode 100644 index 00000000000..082fd2d3841 --- /dev/null +++ b/lua/ui/lobby/customlobby/config/CustomLobbyMapPanel.lua @@ -0,0 +1,196 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- The Map tab panel of the config interface: the live map preview + the scenario's name and +-- size/players, and a host-only "Change map" button. +-- +-- It is one of the config interface's tab panels (see CustomLobbyConfigInterface). The host drives +-- it through `SetActive` — a panel only shows + refreshes while it's the active tab, and stays +-- hidden otherwise, which is what keeps a hidden tab's content from rendering over the active one +-- (MAUI's `Hide()` only cascades to children that exist at call time). `Initialize` exists for +-- uniformity with the grid panels (this one has nothing deferred). + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") + +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") +local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/customlobbylocalmodel.lua") +local CustomLobbyMapPreview = import("/lua/ui/lobby/customlobby/customlobbymappreview.lua") +local CustomLobbyMapCatalog = import("/lua/ui/lobby/customlobby/mapselect/customlobbymapcatalog.lua") +local CustomLobbyMapSelect = import("/lua/ui/lobby/customlobby/mapselect/customlobbymapselect.lua") + +local Group = import("/lua/maui/group.lua").Group +local LazyVarDerive = import("/lua/lazyvar.lua").Derive +local Layouter = LayoutHelpers.ReusedLayoutFor + +local PreviewWidth = 320 +local PreviewHeight = 280 +local ValueColor = 'ff9aa0a8' + +--- Number of start spots a scenario declares, or 0. +---@param scenario UILobbyScenarioInfo +---@return number +local function ArmyCount(scenario) + local armies = scenario.Configurations + and scenario.Configurations.standard + and scenario.Configurations.standard.teams + and scenario.Configurations.standard.teams[1] + and scenario.Configurations.standard.teams[1].armies + return armies and table.getsize(armies) or 0 +end + +---@class UICustomLobbyMapPanel : Group +---@field Trash TrashBag +---@field Active boolean +---@field IsHost boolean +---@field Preview UICustomLobbyMapPreview +---@field Name Text +---@field Info Text +---@field ChangeButton Button +---@field ScenarioObserver LazyVar +---@field IsHostObserver LazyVar +local CustomLobbyMapPanel = ClassUI(Group) { + + ---@param self UICustomLobbyMapPanel + ---@param parent Control + __init = function(self, parent) + Group.__init(self, parent, "CustomLobbyMapPanel") + + self.Trash = TrashBag() + self.Active = false + self.IsHost = false + + self.Preview = CustomLobbyMapPreview.Create(self) + + self.Name = UIUtil.CreateText(self, "", 16, UIUtil.titleFont) + self.Name:DisableHitTest() + self.Info = UIUtil.CreateText(self, "", 13, UIUtil.bodyFont) + self.Info:SetColor(ValueColor) + self.Info:DisableHitTest() + + self.ChangeButton = UIUtil.CreateButtonWithDropshadow(self, '/BUTTON/medium/', "Change map") + self.ChangeButton.OnClick = function(button, modifiers) + CustomLobbyMapSelect.Open(GetFrame(0)) + end + + local launch = CustomLobbyLaunchModel.GetSingleton() + self.ScenarioObserver = self.Trash:Add( + LazyVarDerive(launch.ScenarioFile, function(scenarioFileLazy) + scenarioFileLazy() + self:Refresh() + -- the preview self-updates from the model; if we're hidden, re-hide so its newly + -- built markers don't render over the active tab + if not self.Active then + self:Hide() + end + end)) + + local localModel = CustomLobbyLocalModel.GetSingleton() + self.IsHostObserver = self.Trash:Add( + LazyVarDerive(localModel.IsHost, function(isHostLazy) + self.IsHost = isHostLazy() + self:ApplyHostVisibility() + if not self.Active then + self:Hide() + end + end)) + end, + + ---@param self UICustomLobbyMapPanel + __post_init = function(self) + Layouter(self.Preview) + :AtHorizontalCenterIn(self):AtTopIn(self, 8) + :Width(PreviewWidth):Height(PreviewHeight) + :End() + Layouter(self.Name):AtHorizontalCenterIn(self):AnchorToBottom(self.Preview, 8):End() + Layouter(self.Info):AtHorizontalCenterIn(self):AnchorToBottom(self.Name, 4):End() + Layouter(self.ChangeButton):AtHorizontalCenterIn(self):AtBottomIn(self, 6):End() + end, + + --- No deferred work (no grid); kept for a uniform panel interface. + ---@param self UICustomLobbyMapPanel + Initialize = function(self) + end, + + --- Shows + refreshes the panel when it becomes the active tab; hides it otherwise. + ---@param self UICustomLobbyMapPanel + ---@param active boolean + SetActive = function(self, active) + self.Active = active + if active then + self:Show() + self:Refresh() + self:ApplyHostVisibility() + else + self:Hide() + end + end, + + --- Updates the name + size/players line from the current scenario. + ---@param self UICustomLobbyMapPanel + Refresh = function(self) + local scenarioFile = CustomLobbyLaunchModel.GetSingleton().ScenarioFile() + if not scenarioFile then + self.Name:SetText("No map selected") + self.Info:SetText("") + return + end + local info = CustomLobbyMapCatalog.LoadInfo(scenarioFile) + if not info then + self.Name:SetText("Unknown map") + self.Info:SetText("") + return + end + self.Name:SetText(LOC(info.name) or "?") + + local parts = {} + if info.size then + table.insert(parts, string.format("%dkm", math.floor(info.size[1] / 50))) + end + local players = ArmyCount(info) + if players > 0 then + table.insert(parts, players .. " players") + end + self.Info:SetText(table.concat(parts, " · ")) + end, + + --- The change-map button is host-only. + ---@param self UICustomLobbyMapPanel + ApplyHostVisibility = function(self) + if self.IsHost then + self.ChangeButton:Show() + else + self.ChangeButton:Hide() + end + end, + + ---@param self UICustomLobbyMapPanel + OnDestroy = function(self) + self.Trash:Destroy() + end, +} + +---@param parent Control +---@return UICustomLobbyMapPanel +Create = function(parent) + return CustomLobbyMapPanel(parent) +end diff --git a/lua/ui/lobby/customlobby/config/CustomLobbyModsPanel.lua b/lua/ui/lobby/customlobby/config/CustomLobbyModsPanel.lua new file mode 100644 index 00000000000..d75da492ba7 --- /dev/null +++ b/lua/ui/lobby/customlobby/config/CustomLobbyModsPanel.lua @@ -0,0 +1,250 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- The Mods tab panel of the config interface: the enabled mods, grouped into Game mods (the +-- shared sim mods from the launch model) and UI mods (this peer's local choice from prefs), plus +-- a "Manage mods" button (available to everyone — UI mods are per-player). +-- +-- It is a config-interface tab panel driven by the host via `SetActive`. UI mods are prefs, not a +-- reactive model field, so they're picked up on activation (`SetActive(true)` refreshes) as well +-- as when the synced sim mods change. + +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 +local Grid = import("/lua/maui/grid.lua").Grid + +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") +local CustomLobbyModSelect = import("/lua/ui/lobby/customlobby/modselect/customlobbymodselect.lua") +local ModUtilities = import("/lua/ui/modutilities.lua") +local Mods = import("/lua/mods.lua") + +local LazyVarDerive = import("/lua/lazyvar.lua").Derive +local Layouter = LayoutHelpers.ReusedLayoutFor + +local RowHeight = 22 +local ScrollGap = 18 +local GridContentWidth = 360 - 6 - ScrollGap +local LabelMaxChars = 30 +local NormalColor = 'ffc8ccd0' + +--- Truncates `text` to `maxChars`, appending "…" when it had to cut. +---@param text string +---@param maxChars number +---@return string +local function Truncate(text, maxChars) + text = text or "" + if string.len(text) > maxChars then + return string.sub(text, 1, maxChars - 1) .. "…" + end + return text +end + +---@class UICustomLobbyModsPanel : Group +---@field Trash TrashBag +---@field Ready boolean +---@field Active boolean +---@field ModsGrid Grid +---@field Scrollbar Scrollbar | false +---@field Empty Text +---@field ManageButton Button +---@field ModsObserver LazyVar +local CustomLobbyModsPanel = ClassUI(Group) { + + ---@param self UICustomLobbyModsPanel + ---@param parent Control + __init = function(self, parent) + Group.__init(self, parent, "CustomLobbyModsPanel") + + self.Trash = TrashBag() + self.Ready = false + self.Active = false + self.Scrollbar = false + + self.ModsGrid = Grid(self, GridContentWidth, RowHeight) + self.Empty = UIUtil.CreateText(self, "No mods enabled", 13, UIUtil.bodyFont) + self.Empty:SetColor('ff8a909a') + self.Empty:DisableHitTest() + self.Empty:Hide() + + self.ManageButton = UIUtil.CreateButtonWithDropshadow(self, '/BUTTON/medium/', "Manage mods") + self.ManageButton.OnClick = function(button, modifiers) + CustomLobbyModSelect.Open(GetFrame(0)) + end + + -- only the shared sim mods are a model field; UI mods (prefs) are refreshed on activation + self.ModsObserver = self.Trash:Add( + LazyVarDerive(CustomLobbyLaunchModel.GetSingleton().GameMods, function(lazy) + lazy() + self:Refresh() + if not self.Active then + self:Hide() + end + end)) + end, + + ---@param self UICustomLobbyModsPanel + __post_init = function(self) + Layouter(self.ManageButton):AtHorizontalCenterIn(self):AtBottomIn(self, 6):End() + Layouter(self.ModsGrid) + :AtLeftIn(self, 6):Width(GridContentWidth) + :AtTopIn(self, 6):AnchorToTop(self.ManageButton, 8) + :End() + Layouter(self.Empty):AtHorizontalCenterIn(self.ModsGrid):AtTopIn(self.ModsGrid, 8):End() + end, + + --- Builds the grid's scrollbar; called by the host after the panel has a concrete height. + ---@param self UICustomLobbyModsPanel + Initialize = function(self) + self.Ready = true + self.Scrollbar = UIUtil.CreateVertScrollbarFor(self.ModsGrid) + end, + + --- Shows + refreshes the panel when it becomes the active tab (also picks up UI-mod prefs + --- changed while it was inactive); hides it otherwise. + ---@param self UICustomLobbyModsPanel + ---@param active boolean + SetActive = function(self, active) + self.Active = active + if active then + self:Show() + self:Refresh() + else + self:Hide() + end + end, + + --- Rebuilds the enabled-mods grid: a Game mods section (shared sim mods) + a UI mods section + --- (this peer's local mods), each name resolved + sorted. + ---@param self UICustomLobbyModsPanel + Refresh = function(self) + if not self.Ready then + return + end + local allMods = Mods.AllMods() + + local function names(uidSet) + local list = {} + for uid in uidSet do + local mod = allMods[uid] + if mod then + table.insert(list, mod.name or uid) + end + end + table.sort(list) + return list + end + + local sections = { + { Title = "Game mods", Names = names(CustomLobbyLaunchModel.GetSingleton().GameMods()) }, + { Title = "UI mods", Names = names(ModUtilities.GetSelectedUIMods()) }, + } + + local rows = {} + for _, section in sections do + if table.getn(section.Names) > 0 then + table.insert(rows, { Header = section.Title }) + for _, name in section.Names do + table.insert(rows, { Name = name }) + end + end + end + + self.ModsGrid:DeleteAndDestroyAll(true) + if table.getn(rows) > 0 then + self.Empty:Hide() + self.ModsGrid:AppendCols(1, true) + self.ModsGrid:AppendRows(table.getn(rows), true) + for index, row in rows do + local control = row.Header and self:CreateSectionHeader(row.Header) or self:CreateModRow(row.Name) + self.ModsGrid:SetItem(control, 1, index, true) + end + self.ModsGrid:EndBatch() + else + self.Empty:Show() + end + self:UpdateScrollbar() + end, + + --- Builds a section header row (Game mods / UI mods) with a thin underline. + ---@param self UICustomLobbyModsPanel + ---@param title string + ---@return Group + CreateSectionHeader = function(self, title) + local row = Group(self.ModsGrid) + LayoutHelpers.SetDimensions(row, GridContentWidth, RowHeight) + + local label = UIUtil.CreateText(row, string.upper(title), 12, UIUtil.titleFont) + label:SetColor('ff8a909a') + label:DisableHitTest() + Layouter(label):AtLeftIn(row, 2):AtVerticalCenterIn(row):End() + + local line = Bitmap(row) + line:SetSolidColor('ff3a4048') + line:DisableHitTest() + Layouter(line):AtLeftIn(row, 2):AtRightIn(row, 2):AtBottomIn(row):Height(1):End() + + return row + end, + + --- Builds one enabled-mod row (name only — the section header conveys sim vs. UI). + ---@param self UICustomLobbyModsPanel + ---@param name string + ---@return Group + CreateModRow = function(self, name) + local row = Group(self.ModsGrid) + LayoutHelpers.SetDimensions(row, GridContentWidth, RowHeight) + + local label = UIUtil.CreateText(row, Truncate(name, LabelMaxChars), 13, UIUtil.bodyFont) + label:SetColor(NormalColor) + label:DisableHitTest() + Layouter(label):AtLeftIn(row, 4):AtVerticalCenterIn(row):End() + + return row + end, + + --- Shows the scrollbar only when the grid overflows. + ---@param self UICustomLobbyModsPanel + UpdateScrollbar = function(self) + if not self.Scrollbar then + return + end + if self.ModsGrid:IsScrollable("Vert") then + self.Scrollbar:Show() + else + self.Scrollbar:Hide() + end + end, + + ---@param self UICustomLobbyModsPanel + OnDestroy = function(self) + self.Trash:Destroy() + end, +} + +---@param parent Control +---@return UICustomLobbyModsPanel +Create = function(parent) + return CustomLobbyModsPanel(parent) +end diff --git a/lua/ui/lobby/customlobby/config/CustomLobbyOptionsPanel.lua b/lua/ui/lobby/customlobby/config/CustomLobbyOptionsPanel.lua new file mode 100644 index 00000000000..567fbdb6ff9 --- /dev/null +++ b/lua/ui/lobby/customlobby/config/CustomLobbyOptionsPanel.lua @@ -0,0 +1,347 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- The Options tab panel of the config interface: a read-only view of the current option values, +-- grouped into Lobby / Scenario / Mods sections, with a hide-defaults toggle, and host-only +-- "Options" (open the editor) + "Reset options" buttons. +-- +-- Options that come from the map or a mod are flagged with a gold marker + tinted label; the +-- marker's tooltip names the precise origin (`Map: …` / `Mod: …`). The schema is derived per-peer +-- from the synced scenario + mods via `optionutil`; the values are the synced `GameOptions`. +-- +-- It is a config-interface tab panel — the host drives it via `SetActive`. It only builds + shows +-- its grid while active (and re-hides after a refresh if it isn't), so a hidden tab never renders +-- over the active one. `Initialize` builds the scrollbar once the panel has a concrete height. + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") +local Tooltip = import("/lua/ui/game/tooltip.lua") + +local Group = import("/lua/maui/group.lua").Group +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap +local Grid = import("/lua/maui/grid.lua").Grid + +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") +local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/customlobbylocalmodel.lua") +local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") +local CustomLobbyMapCatalog = import("/lua/ui/lobby/customlobby/mapselect/customlobbymapcatalog.lua") +local CustomLobbyOptionSelect = import("/lua/ui/lobby/customlobby/optionselect/customlobbyoptionselect.lua") +local OptionUtil = import("/lua/ui/optionutil.lua") + +local LazyVarDerive = import("/lua/lazyvar.lua").Derive +local Layouter = LayoutHelpers.ReusedLayoutFor + +local RowHeight = 22 +local ScrollGap = 18 +local GridContentWidth = 360 - 6 - ScrollGap +local LabelMaxChars = 26 + +local SpecialColor = 'ffd0a24c' -- marker + label tint for a map/mod option +local NormalColor = 'ffc8ccd0' +local ValueColor = 'ff9aa0a8' + +--- Truncates `text` to `maxChars`, appending "…" when it had to cut. +---@param text string +---@param maxChars number +---@return string +local function Truncate(text, maxChars) + text = text or "" + if string.len(text) > maxChars then + return string.sub(text, 1, maxChars - 1) .. "…" + end + return text +end + +---@class UICustomLobbyOptionsPanel : Group +---@field Trash TrashBag +---@field Ready boolean +---@field Active boolean +---@field IsHost boolean +---@field HideDefaults boolean +---@field HideDefaultsToggle Checkbox +---@field OptionsGrid Grid +---@field Scrollbar Scrollbar | false +---@field Empty Text +---@field EditButton Button +---@field ResetButton Button +---@field ScenarioObserver LazyVar +---@field OptionsObserver LazyVar +---@field ModsObserver LazyVar +---@field IsHostObserver LazyVar +local CustomLobbyOptionsPanel = ClassUI(Group) { + + ---@param self UICustomLobbyOptionsPanel + ---@param parent Control + __init = function(self, parent) + Group.__init(self, parent, "CustomLobbyOptionsPanel") + + self.Trash = TrashBag() + self.Ready = false + self.Active = false + self.IsHost = false + self.HideDefaults = true + self.Scrollbar = false + + self.HideDefaultsToggle = UIUtil.CreateCheckbox(self, '/CHECKBOX/', "Hide defaults", true, 13) + self.HideDefaultsToggle:SetCheck(self.HideDefaults, true) + self.HideDefaultsToggle.OnCheck = function(control, checked) + self.HideDefaults = checked + self:Refresh() + end + Tooltip.AddControlTooltipManual(self.HideDefaultsToggle, "Hide defaults", + "Show only the options that have been changed from their default value.") + + self.OptionsGrid = Grid(self, GridContentWidth, RowHeight) + self.Empty = UIUtil.CreateText(self, "All options at default", 13, UIUtil.bodyFont) + self.Empty:SetColor('ff8a909a') + self.Empty:DisableHitTest() + self.Empty:Hide() + + self.EditButton = UIUtil.CreateButtonWithDropshadow(self, '/BUTTON/medium/', "Options") + self.EditButton.OnClick = function(button, modifiers) + CustomLobbyOptionSelect.Open(GetFrame(0)) + end + self.ResetButton = UIUtil.CreateButtonWithDropshadow(self, '/BUTTON/medium/', "Reset options") + self.ResetButton.OnClick = function(button, modifiers) + CustomLobbyController.RequestResetGameOptions() + end + Tooltip.AddControlTooltipManual(self.ResetButton, "Reset options", "Reset every option to its default value.") + + local launch = CustomLobbyLaunchModel.GetSingleton() + self.ScenarioObserver = self.Trash:Add( + LazyVarDerive(launch.ScenarioFile, function(lazy) lazy(); self:OnModelChanged() end)) + self.OptionsObserver = self.Trash:Add( + LazyVarDerive(launch.GameOptions, function(lazy) lazy(); self:OnModelChanged() end)) + self.ModsObserver = self.Trash:Add( + LazyVarDerive(launch.GameMods, function(lazy) lazy(); self:OnModelChanged() end)) + + local localModel = CustomLobbyLocalModel.GetSingleton() + self.IsHostObserver = self.Trash:Add( + LazyVarDerive(localModel.IsHost, function(isHostLazy) + self.IsHost = isHostLazy() + self:ApplyHostVisibility() + if not self.Active then + self:Hide() + end + end)) + end, + + ---@param self UICustomLobbyOptionsPanel + __post_init = function(self) + Layouter(self.HideDefaultsToggle):AtLeftIn(self, 6):AtTopIn(self, 4):End() + Layouter(self.ResetButton):AtHorizontalCenterIn(self):AtBottomIn(self, 6):End() + Layouter(self.EditButton):AtHorizontalCenterIn(self):AnchorToTop(self.ResetButton, 6):End() + Layouter(self.OptionsGrid) + :AtLeftIn(self, 6):Width(GridContentWidth) + :AnchorToBottom(self.HideDefaultsToggle, 6):AnchorToTop(self.EditButton, 8) + :End() + Layouter(self.Empty):AtHorizontalCenterIn(self.OptionsGrid):AtTopIn(self.OptionsGrid, 8):End() + end, + + --- Builds the grid's scrollbar; called by the host after the panel has a concrete height. + ---@param self UICustomLobbyOptionsPanel + Initialize = function(self) + self.Ready = true + self.Scrollbar = UIUtil.CreateVertScrollbarFor(self.OptionsGrid) + end, + + --- A relevant model field changed: rebuild, and if we're not the active tab re-hide so the + --- freshly built rows don't render over the active one. + ---@param self UICustomLobbyOptionsPanel + OnModelChanged = function(self) + self:Refresh() + if not self.Active then + self:Hide() + end + end, + + --- Shows + refreshes the panel when it becomes the active tab; hides it otherwise. + ---@param self UICustomLobbyOptionsPanel + ---@param active boolean + SetActive = function(self, active) + self.Active = active + if active then + self:Show() + self:Refresh() + self:ApplyHostVisibility() + else + self:Hide() + end + end, + + --- Rebuilds the read-only options grid, grouped into Lobby / Scenario / Mods sections with the + --- hide-defaults filter applied. + ---@param self UICustomLobbyOptionsPanel + Refresh = function(self) + if not self.Ready then + return + end + local launch = CustomLobbyLaunchModel.GetSingleton() + local scenarioFile = launch.ScenarioFile() + local gameMods = launch.GameMods() + local values = launch.GameOptions() + + -- visible (hide-defaults-filtered) entries for one source + local function collect(options, origin) + local entries = {} + for _, option in options do + if not (self.HideDefaults and OptionUtil.IsDefault(option, values)) then + table.insert(entries, { + Option = option, + Origin = origin, + ValueKey = OptionUtil.GetCurrentValueKey(option, values), + }) + end + end + return entries + end + + local info = scenarioFile and CustomLobbyMapCatalog.LoadInfo(scenarioFile) + local mapName = (info and LOC(info.name)) or "the map" + + local modEntries = {} + for _, group in OptionUtil.GetModOptionsByMod(gameMods) do + for _, entry in collect(group.options, "Mod: " .. group.name) do + table.insert(modEntries, entry) + end + end + + local sections = { + { Title = "Lobby", Entries = collect(OptionUtil.GetLobbyOptions(), false) }, + { Title = "Scenario", Entries = collect(OptionUtil.GetScenarioOptions(scenarioFile), "Map: " .. mapName) }, + { Title = "Mods", Entries = modEntries }, + } + + local rows = {} + for _, section in sections do + if table.getn(section.Entries) > 0 then + table.insert(rows, { Header = section.Title }) + for _, entry in section.Entries do + table.insert(rows, { Entry = entry }) + end + end + end + + self.OptionsGrid:DeleteAndDestroyAll(true) + if table.getn(rows) > 0 then + self.Empty:Hide() + self.OptionsGrid:AppendCols(1, true) + self.OptionsGrid:AppendRows(table.getn(rows), true) + for index, row in rows do + local control = row.Header and self:CreateSectionHeader(row.Header) or self:CreateOptionRow(row.Entry) + self.OptionsGrid:SetItem(control, 1, index, true) + end + self.OptionsGrid:EndBatch() + else + self.Empty:Show() + end + self:UpdateScrollbar() + end, + + --- Builds a section header row (Lobby / Scenario / Mods) with a thin underline. + ---@param self UICustomLobbyOptionsPanel + ---@param title string + ---@return Group + CreateSectionHeader = function(self, title) + local row = Group(self.OptionsGrid) + LayoutHelpers.SetDimensions(row, GridContentWidth, RowHeight) + + local label = UIUtil.CreateText(row, string.upper(title), 12, UIUtil.titleFont) + label:SetColor('ff8a909a') + label:DisableHitTest() + Layouter(label):AtLeftIn(row, 2):AtVerticalCenterIn(row):End() + + local line = Bitmap(row) + line:SetSolidColor('ff3a4048') + line:DisableHitTest() + Layouter(line):AtLeftIn(row, 2):AtRightIn(row, 2):AtBottomIn(row):Height(1):End() + + return row + end, + + --- Builds one read-only option row: origin marker (special only) + label + current value. + ---@param self UICustomLobbyOptionsPanel + ---@param entry { Option: ScenarioOption, Origin: string|false, ValueKey: any } + ---@return Group + CreateOptionRow = function(self, entry) + local option = entry.Option + local row = Group(self.OptionsGrid) + LayoutHelpers.SetDimensions(row, GridContentWidth, RowHeight) + + local labelLeft = 4 + if entry.Origin then + local marker = Bitmap(row) + marker:SetSolidColor(SpecialColor) + Layouter(marker):AtLeftIn(row, 4):AtVerticalCenterIn(row):Width(8):Height(8):End() + Tooltip.AddControlTooltipManual(marker, "Source", entry.Origin) + labelLeft = 18 + end + + local label = UIUtil.CreateText(row, Truncate(LOC(option.label) or option.key, LabelMaxChars), 13, UIUtil.bodyFont) + label:SetColor(entry.Origin and SpecialColor or NormalColor) + label:DisableHitTest() + Layouter(label):AtLeftIn(row, labelLeft):AtVerticalCenterIn(row):End() + + local value = UIUtil.CreateText(row, OptionUtil.ValueDisplay(option, entry.ValueKey), 13, UIUtil.bodyFont) + value:SetColor(ValueColor) + value:DisableHitTest() + Layouter(value):AtRightIn(row, 4):AtVerticalCenterIn(row):End() + + return row + end, + + --- The Options + Reset buttons are host-only. + ---@param self UICustomLobbyOptionsPanel + ApplyHostVisibility = function(self) + for _, button in { self.EditButton, self.ResetButton } do + if self.IsHost then + button:Show() + else + button:Hide() + end + end + end, + + --- Shows the scrollbar only when the grid overflows. + ---@param self UICustomLobbyOptionsPanel + UpdateScrollbar = function(self) + if not self.Scrollbar then + return + end + if self.OptionsGrid:IsScrollable("Vert") then + self.Scrollbar:Show() + else + self.Scrollbar:Hide() + end + end, + + ---@param self UICustomLobbyOptionsPanel + OnDestroy = function(self) + self.Trash:Destroy() + end, +} + +---@param parent Control +---@return UICustomLobbyOptionsPanel +Create = function(parent) + return CustomLobbyOptionsPanel(parent) +end diff --git a/lua/ui/lobby/customlobby/config/CustomLobbyUnitsPanel.lua b/lua/ui/lobby/customlobby/config/CustomLobbyUnitsPanel.lua new file mode 100644 index 00000000000..306b871fd66 --- /dev/null +++ b/lua/ui/lobby/customlobby/config/CustomLobbyUnitsPanel.lua @@ -0,0 +1,73 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- The Units tab panel of the config interface: a placeholder until the unit-restrictions slice +-- lands. A config-interface tab panel driven by the host via `SetActive`; it implements the same +-- panel interface (`Initialize` / `SetActive`) as the others so the host can treat them uniformly. + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") + +local Group = import("/lua/maui/group.lua").Group +local Layouter = LayoutHelpers.ReusedLayoutFor + +---@class UICustomLobbyUnitsPanel : Group +---@field Info Text +local CustomLobbyUnitsPanel = ClassUI(Group) { + + ---@param self UICustomLobbyUnitsPanel + ---@param parent Control + __init = function(self, parent) + Group.__init(self, parent, "CustomLobbyUnitsPanel") + + self.Info = UIUtil.CreateText(self, "Unit restrictions — coming soon.", 14, UIUtil.bodyFont) + self.Info:SetColor('ff8a909a') + self.Info:DisableHitTest() + end, + + ---@param self UICustomLobbyUnitsPanel + __post_init = function(self) + Layouter(self.Info):AtHorizontalCenterIn(self):AtTopIn(self, 16):End() + end, + + --- Nothing deferred; kept for a uniform panel interface. + ---@param self UICustomLobbyUnitsPanel + Initialize = function(self) + end, + + --- Shows or hides the panel. + ---@param self UICustomLobbyUnitsPanel + ---@param active boolean + SetActive = function(self, active) + if active then + self:Show() + else + self:Hide() + end + end, +} + +---@param parent Control +---@return UICustomLobbyUnitsPanel +Create = function(parent) + return CustomLobbyUnitsPanel(parent) +end diff --git a/lua/ui/optionutil.lua b/lua/ui/optionutil.lua index 557ca797999..cef6daa8b03 100644 --- a/lua/ui/optionutil.lua +++ b/lua/ui/optionutil.lua @@ -97,6 +97,26 @@ function GetScenarioOptions(scenarioFile) return options end +--- Loads a single mod's lobby options (its `lobbyoptions.lua` `AIOpts`), or nil. Untrusted disk +--- data, so the import + the in-place default repair are both pcall'd. +---@param mod ModInfo +---@return ScenarioOption[] | nil +local function LoadModOptions(mod) + if not (mod and mod.location) then + return nil + end + local path = mod.location .. ModOptionsFile + if not DiskGetFileInfo(path) then + return nil + end + local ok, module = pcall(import, path) + if not (ok and module and module.AIOpts) then + return nil + end + pcall(MapUtil.ValidateScenarioOptions, module.AIOpts) + return module.AIOpts +end + --- The options contributed by the selected sim mods — each mod's `lobbyoptions.lua` `AIOpts`, --- merged and de-duplicated by key. ---@param gameMods table # selected sim-mod uid set (the launch model's GameMods) @@ -108,23 +128,34 @@ function GetModOptions(gameMods) local allMods = Mods.AllMods() local options = {} for uid in gameMods do - local mod = allMods[uid] - if mod and mod.location then - local path = mod.location .. ModOptionsFile - if DiskGetFileInfo(path) then - local ok, module = pcall(import, path) - if ok and module and module.AIOpts then - AppendUniqueByKey(options, module.AIOpts) - end - end + local opts = LoadModOptions(allMods[uid]) + if opts then + AppendUniqueByKey(options, opts) end end - -- repair any bad `default` indices in place (same contract as the scenario options); pcall'd - -- because mod-supplied options are untrusted disk data - pcall(MapUtil.ValidateScenarioOptions, options) return options end +--- The selected sim mods' options grouped by mod, for showing each option's origin. Only mods +--- that actually contribute options appear. +---@param gameMods table +---@return { uid: string, name: string, options: ScenarioOption[] }[] +function GetModOptionsByMod(gameMods) + if not gameMods then + return {} + end + local allMods = Mods.AllMods() + local groups = {} + for uid in gameMods do + local mod = allMods[uid] + local opts = LoadModOptions(mod) + if opts and table.getn(opts) > 0 then + table.insert(groups, { uid = uid, name = mod.name or uid, options = opts }) + end + end + return groups +end + --#endregion ------------------------------------------------------------------------------- From 32dce10738322c51c3b28a5826eac078b5ab15f2 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Mon, 22 Jun 2026 19:11:26 +0200 Subject: [PATCH 30/98] Refine configuration panels --- lua/lazyvar.lua | 2 +- lua/ui/lobby/customlobby/CLAUDE.md | 2 +- .../customlobby/CustomLobbyMapPreview.lua | 4 +- .../config/CustomLobbyConfigInterface.lua | 232 ++++++++--- .../config/CustomLobbyMapPanel.lua | 362 +++++++++++++----- .../config/CustomLobbyModsPanel.lua | 45 +-- .../config/CustomLobbyOptionsPanel.lua | 64 ++-- .../config/CustomLobbyUnitsPanel.lua | 17 +- lua/ui/maputil.lua | 1 + 9 files changed, 494 insertions(+), 235 deletions(-) diff --git a/lua/lazyvar.lua b/lua/lazyvar.lua index ca7e1fefab3..dae7a9963a7 100644 --- a/lua/lazyvar.lua +++ b/lua/lazyvar.lua @@ -9,7 +9,7 @@ local setmetatable = setmetatable -- Set this true to get tracebacks in error messages. It slows down lazyvars a lot, -- so don't use except when debugging. -local ExtendedErrorMessages = false +local ExtendedErrorMessages = true local EvalContext = nil local WeakKeyMeta = { __mode = 'k' } diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index f29592a947b..c1acfd7aa89 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -56,7 +56,7 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | [modselect/](modselect/CLAUDE.md) | the **mod-select dialog** + its catalog and list, built to the map-select shape (checkbox list + type filters + detail panel + **presets**). Returns a uid set; the opener routes it — sim mods → `RequestSetGameMods` (synced), UI mods → local prefs — or persists the lot standalone. Mod domain logic lives in [`/lua/ui/modutilities.lua`](/lua/ui/modutilities.lua) (the `maputil.lua` sibling, fronting `/lua/mods.lua`). See [modselect/CLAUDE.md](modselect/CLAUDE.md). | | [optionselect/](optionselect/CLAUDE.md) | the **options dialog**: three columns (lobby / scenario / mod options) over the selected scenario + mods, with search + hide-defaults filters; non-default options are marked. Derives the option *schema* per-peer (reference data) via [`/lua/ui/optionutil.lua`](/lua/ui/optionutil.lua); edits a working copy of the *values* and on OK routes the reconciled set through `RequestSetGameOptions` (synced via `GameOptions`). Host-only. See [optionselect/CLAUDE.md](optionselect/CLAUDE.md). | | [CustomLobbyInterface.lua](CustomLobbyInterface.lua) | composition root, laid out in **areas** (title / left {slots, observers, chat} / right = the config component / actions — flip the module `Debug` flag to tint them), sized for the **1024×768** floor. The slots area sizes to the active `SlotCount` (1..MaxSlots) so observers + chat reflow on smaller maps. Holds two presentation observers (SlotCount, IsHost); basic buttons wired: Leave (Esc handler), **Observe** (`RequestMoveToObserver` on the local slot); Launch is a host-only disabled stub (no launch flow yet). Also the rows' drag coordinator (`UICustomLobbySlotCoordinator`: hit-test, drop-highlight, drag ghost → `RequestSwapSlots`); `OpenDebug()` / hot-reload. | -| [config/](config/) | the lobby's right column — a **tab strip** (Map / Options / Mods / Units) host + one component per tab. [`CustomLobbyConfigInterface.lua`](config/CustomLobbyConfigInterface.lua) owns the strip and switches tabs by calling `panel:SetActive(isActive)`; each panel self-subscribes to the model and **only builds + shows its content while active** (re-hiding after a refresh if inactive), which is how a hidden tab is kept from rendering over the active one (MAUI's `Hide()` only cascades to children that exist at call time — see the gotcha table). Panels: [`CustomLobbyMapPanel`](config/CustomLobbyMapPanel.lua) (live preview + name/size/players + Change map), [`CustomLobbyOptionsPanel`](config/CustomLobbyOptionsPanel.lua) (read-only options grouped **Lobby / Scenario / Mods** with a hide-defaults toggle; map/mod options flagged with a gold marker whose tooltip names the origin — schema via [`/lua/ui/optionutil.lua`](/lua/ui/optionutil.lua)), [`CustomLobbyModsPanel`](config/CustomLobbyModsPanel.lua) (enabled mods in **Game mods / UI mods** sections; UI mods are prefs so they refresh on tab-activate), [`CustomLobbyUnitsPanel`](config/CustomLobbyUnitsPanel.lua) (placeholder). Host-only buttons gate per panel. Three-phase: the parent calls `Initialize()` (panels build their grid scrollbars) after sizing it. | +| [config/](config/) | the lobby's right column. [`CustomLobbyConfigInterface.lua`](config/CustomLobbyConfigInterface.lua) **pins the map block** (preview + name + size/players) at the top — built once, never destroyed, because the engine never frees the preview's textures (see [mapselect/CLAUDE.md](mapselect/CLAUDE.md)) — over a **tab strip** (Map / Options / Mods / Units). The active tab's content component is **created when its tab is selected and destroyed on switch** (`Tabs[i].Create` factory; only one panel alive at a time), so churn is cheap (text/grids, no leaking textures) and there's no hidden-panel bleed. Panels: [`CustomLobbyMapPanel`](config/CustomLobbyMapPanel.lua) (Change map — preview/name now pinned in the host), [`CustomLobbyOptionsPanel`](config/CustomLobbyOptionsPanel.lua) (read-only options grouped **Lobby / Scenario / Mods** + hide-defaults toggle; map/mod options flagged with a gold marker whose tooltip names the origin — schema via [`/lua/ui/optionutil.lua`](/lua/ui/optionutil.lua)), [`CustomLobbyModsPanel`](config/CustomLobbyModsPanel.lua) (enabled mods in **Game / UI** sections; UI mods are prefs, read on first render), [`CustomLobbyUnitsPanel`](config/CustomLobbyUnitsPanel.lua) (placeholder). Each panel self-subscribes to the model + `IsHost` for its own content/host-gating, and exposes `Initialize()` (the host calls it after sizing the panel — grids need a concrete height) + `Create(parent)`. | | [/lua/ui/lobby/lobby.lua](../lobby.lua) | engine entry wrapper (`CreateLobby`/`HostGame`/`JoinGame`) → CustomLobby. Old lobby preserved at `lobby-old.lua`. | Working today: host + clients see each other (host-authoritative player sync), the diff --git a/lua/ui/lobby/customlobby/CustomLobbyMapPreview.lua b/lua/ui/lobby/customlobby/CustomLobbyMapPreview.lua index 9397507a308..208f726166b 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyMapPreview.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyMapPreview.lua @@ -21,7 +21,8 @@ --****************************************************************************************************** -- The in-lobby map preview: the shared scenario-preview surface (CustomLobbyScenarioPreview) --- with the lobby's chrome — a glow border + dialog brackets — wrapped around it, bound to the +-- with the lobby's chrome — a glow border (no dialog brackets; they don't suit a preview this +-- small) — wrapped around it, bound to the -- launch model. -- -- It owns the model wiring only: it subscribes to `ScenarioFile` (load the scenario, hand it to @@ -59,7 +60,6 @@ local CustomLobbyMapPreview = ClassUI(Group) { self.Trash = TrashBag() self.Overlay = UIUtil.CreateBitmap(self, '/scx_menu/gameselect/map-panel-glow_bmp.dds') - UIUtil.CreateDialogBrackets(self, 30, 24, 30, 24) -- the shared surface, with faction icons for spawns self.Surface = CustomLobbyScenarioPreview.Create(self, { diff --git a/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua b/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua index 3e6bb9824cc..eded10c0d57 100644 --- a/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua +++ b/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua @@ -20,47 +20,88 @@ --** SOFTWARE. --****************************************************************************************************** --- The lobby's right-hand config panel: a tab strip (Map / Options / Mods / Units) over a content --- area. This is just the **tab host** — each tab's content is its own component in this folder --- (CustomLobbyMapPanel / OptionsPanel / ModsPanel / UnitsPanel), and each self-subscribes to the --- model and manages its own host-gating. +-- The lobby's right-hand config panel: -- --- Visibility rule: exactly one panel is active. The host calls `panel:SetActive(isActive)` on a --- tab switch; a panel only shows + (re)builds its content while active, and stays hidden --- otherwise. That's what prevents a hidden tab's content from rendering over the active one — --- MAUI's `Hide()` only cascades the hidden flag to children that exist at call time, so a panel --- that builds content while hidden would leak; building only-while-active sidesteps it entirely. +-- ┌───────────────────────────┐ +-- │ map preview │ ← pinned, persistent +-- │ Name · 20km · 8p │ +-- ├───────────────────────────┤ +-- │ Map | Options | Mods | Un │ ← tab strip +-- ├───────────────────────────┤ +-- │ active tab's content │ ← created on select, destroyed on switch +-- └───────────────────────────┘ -- --- Laid out by its parent (filled into the lobby's right column); internally it splits into a tab --- strip area + a content area — flip the module `Debug` flag to tint them. +-- The **map block is pinned** at the top — the preview plus the scenario name, size and player +-- count — and built exactly once: the engine never frees the textures the preview loads (see +-- mapselect/CLAUDE.md), so it must NOT be destroyed/recreated. Everything below is a **tab** whose +-- content component (CustomLobby*Panel in this folder) is **created when its tab is selected and +-- destroyed when you switch away** — only one tab panel exists at a time. Their content is +-- text/grids (no leaking textures), so churning them is cheap, and because only the live panel +-- exists there's no hidden-panel bleed to manage. +-- +-- Laid out by its parent (filled into the lobby's right column); flip the module `Debug` flag to +-- tint the areas. 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 +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") +local CustomLobbyMapPreview = import("/lua/ui/lobby/customlobby/customlobbymappreview.lua") +local CustomLobbyMapCatalog = import("/lua/ui/lobby/customlobby/mapselect/customlobbymapcatalog.lua") local CustomLobbyMapPanel = import("/lua/ui/lobby/customlobby/config/customlobbymappanel.lua") local CustomLobbyOptionsPanel = import("/lua/ui/lobby/customlobby/config/customlobbyoptionspanel.lua") local CustomLobbyModsPanel = import("/lua/ui/lobby/customlobby/config/customlobbymodspanel.lua") local CustomLobbyUnitsPanel = import("/lua/ui/lobby/customlobby/config/customlobbyunitspanel.lua") +local LazyVarDerive = import("/lua/lazyvar.lua").Derive local Layouter = LayoutHelpers.ReusedLayoutFor --- flip to tint the tab strip / content areas while iterating -local Debug = false +-- flip to tint the areas while iterating +local Debug = true +local PreviewSize = 225 -- the preview is square (FAF maps are square) +local PreviewBlockHeight = PreviewSize + 46 -- preview + name + size/players line local TabHeight = 28 local TabWidth = 88 +local NameMaxChars = 32 local TabIdleColor = 'ff141a20' local TabHoverColor = 'ff1f262e' local TabActiveColor = 'ff2c3e48' --- the four tabs, in order -local TabMap = 1 -local TabOptions = 2 -local TabMods = 3 -local TabUnits = 4 +-- the four tabs, in order, each with the factory that builds its content component on demand +local Tabs = { + { Label = "Map", Create = CustomLobbyMapPanel.Create }, + { Label = "Options", Create = CustomLobbyOptionsPanel.Create }, + { Label = "Mods", Create = CustomLobbyModsPanel.Create }, + { Label = "Units", Create = CustomLobbyUnitsPanel.Create }, +} + +--- Truncates `text` to `maxChars`, appending "…" when it had to cut. +---@param text string +---@param maxChars number +---@return string +local function Truncate(text, maxChars) + text = text or "" + if string.len(text) > maxChars then + return string.sub(text, 1, maxChars - 1) .. "…" + end + return text +end + +--- Number of start spots a scenario declares, or 0. +---@param scenario UILobbyScenarioInfo +---@return number +local function ArmyCount(scenario) + local armies = scenario.Configurations + and scenario.Configurations.standard + and scenario.Configurations.standard.teams + and scenario.Configurations.standard.teams[1] + and scenario.Configurations.standard.teams[1].armies + return armies and table.getsize(armies) or 0 +end --- Creates a layout area (an invisible Group with an optional debug tint). ---@param parent Control @@ -78,16 +119,18 @@ local function CreateArea(parent, name, color) return area end ----@class UICustomLobbyConfigTab ----@field Button Group ----@field Panel Control # one of the tab-panel components (implements SetActive/Initialize) - ---@class UICustomLobbyConfigInterface : Group ---@field Trash TrashBag +---@field PreviewArea Group +---@field Preview UICustomLobbyMapPreview +---@field Name Text +---@field Info Text ---@field TabStripArea Group ---@field TabContentArea Group ----@field Tabs UICustomLobbyConfigTab[] +---@field TabButtons Group[] ---@field ActiveTab number +---@field CurrentPanel Control | false # the live tab content component (others are destroyed) +---@field ScenarioObserver LazyVar local CustomLobbyConfigInterface = ClassUI(Group) { ---@param self UICustomLobbyConfigInterface @@ -96,108 +139,171 @@ local CustomLobbyConfigInterface = ClassUI(Group) { Group.__init(self, parent, "CustomLobbyConfigInterface") self.Trash = TrashBag() - self.ActiveTab = TabMap + self.ActiveTab = 1 + self.CurrentPanel = false + self.PreviewArea = CreateArea(self, "PreviewArea", 'ffcc40cc') self.TabStripArea = CreateArea(self, "TabStripArea", 'ffcc8040') self.TabContentArea = CreateArea(self, "TabContentArea", 'ff8040cc') - self.Tabs = { - [TabMap] = { Button = self:CreateTabButton("Map", TabMap), Panel = CustomLobbyMapPanel.Create(self.TabContentArea) }, - [TabOptions] = { Button = self:CreateTabButton("Options", TabOptions), Panel = CustomLobbyOptionsPanel.Create(self.TabContentArea) }, - [TabMods] = { Button = self:CreateTabButton("Mods", TabMods), Panel = CustomLobbyModsPanel.Create(self.TabContentArea) }, - [TabUnits] = { Button = self:CreateTabButton("Units", TabUnits), Panel = CustomLobbyUnitsPanel.Create(self.TabContentArea) }, - } + -- pinned, persistent map block — never destroyed (the preview's textures aren't freed) + self.Preview = CustomLobbyMapPreview.Create(self.PreviewArea) + self.Name = UIUtil.CreateText(self.PreviewArea, "", 16, UIUtil.titleFont) + self.Name:DisableHitTest() + self.Info = UIUtil.CreateText(self.PreviewArea, "", 13, UIUtil.bodyFont) + self.Info:SetColor('ff9aa0a8') + self.Info:DisableHitTest() + + self.ScenarioObserver = self.Trash:Add( + LazyVarDerive(CustomLobbyLaunchModel.GetSingleton().ScenarioFile, function(scenarioFileLazy) + self:RefreshMapInfo(scenarioFileLazy()) + end)) + + self.TabButtons = {} + for index = 1, table.getn(Tabs) do + self.TabButtons[index] = self:CreateTabButton(Tabs[index].Label, index) + end end, ---@param self UICustomLobbyConfigInterface __post_init = function(self) - Layouter(self.TabStripArea):AtLeftIn(self):AtRightIn(self):AtTopIn(self):Height(TabHeight):End() + Layouter(self.PreviewArea):AtLeftIn(self):AtRightIn(self):AtTopIn(self):Height(PreviewBlockHeight):End() + Layouter(self.TabStripArea):AtLeftIn(self):AtRightIn(self):AnchorToBottom(self.PreviewArea, 8):Height(TabHeight):End() Layouter(self.TabContentArea) :AtLeftIn(self):AtRightIn(self) :AnchorToBottom(self.TabStripArea, 6):AtBottomIn(self) :End() - for index = 1, table.getn(self.Tabs) do - local tab = self.Tabs[index] - local builder = Layouter(tab.Button):AtTopIn(self.TabStripArea):Width(TabWidth):Height(TabHeight) + Layouter(self.Preview) + :AtHorizontalCenterIn(self.PreviewArea):AtTopIn(self.PreviewArea, 4) + :Width(PreviewSize):Height(PreviewSize) + :End() + Layouter(self.Name):AtHorizontalCenterIn(self.PreviewArea):AnchorToBottom(self.Preview, 6):End() + Layouter(self.Info):AtHorizontalCenterIn(self.PreviewArea):AnchorToBottom(self.Name, 2):End() + + for index = 1, table.getn(self.TabButtons) do + local button = self.TabButtons[index] + local builder = Layouter(button):AtTopIn(self.TabStripArea):Width(TabWidth):Height(TabHeight) if index == 1 then builder:AtLeftIn(self.TabStripArea) else - builder:AnchorToRight(self.Tabs[index - 1].Button, 2) + builder:AnchorToRight(self.TabButtons[index - 1], 2) end builder:End() - Layouter(tab.Button.Bg):Fill(tab.Button):End() - Layouter(tab.Button.Label):AtHorizontalCenterIn(tab.Button):AtVerticalCenterIn(tab.Button):End() - Layouter(tab.Panel):Fill(self.TabContentArea):End() - - -- visuals only until the parent calls Initialize; panels build content when activated - tab.Button.Bg:SetSolidColor(index == self.ActiveTab and TabActiveColor or TabIdleColor) - tab.Panel:Hide() + Layouter(button.Bg):Fill(button):End() + Layouter(button.Label):AtHorizontalCenterIn(button):AtVerticalCenterIn(button):End() + button.Bg:SetSolidColor(index == self.ActiveTab and TabActiveColor or TabIdleColor) end end, - --- Builds the panels' deferred bits (grids need a concrete height) + activates the first tab. - --- Called by the parent after it has laid this component out (three-phase init). + --- Opens the initial tab. Called by the parent after it has laid this component out — the tab + --- content (grids) needs a concrete height, which it now has. ---@param self UICustomLobbyConfigInterface Initialize = function(self) - for index = 1, table.getn(self.Tabs) do - self.Tabs[index].Panel:Initialize() - end + self:RefreshMapInfo(CustomLobbyLaunchModel.GetSingleton().ScenarioFile()) self:SelectTab(self.ActiveTab) end, + --- Updates the pinned name + size/players line (the preview self-updates from the model). + ---@param self UICustomLobbyConfigInterface + ---@param scenarioFile FileName | false + RefreshMapInfo = function(self, scenarioFile) + if not scenarioFile then + self.Name:SetText("No map selected") + self.Info:SetText("") + return + end + local info = CustomLobbyMapCatalog.LoadInfo(scenarioFile) + if not info then + self.Name:SetText("Unknown map") + self.Info:SetText("") + return + end + self.Name:SetText(Truncate(LOC(info.name) or "?", NameMaxChars)) + + local parts = {} + if info.size then + table.insert(parts, string.format("%dkm", math.floor(info.size[1] / 50))) + end + local players = ArmyCount(info) + if players > 0 then + table.insert(parts, players .. " players") + end + if info.map_version then + table.insert(parts, "v" .. tostring(info.map_version)) + end + self.Info:SetText(table.concat(parts, " · ")) + end, + --- Builds one clickable tab button (a tinted group + label). Private. ---@param self UICustomLobbyConfigInterface ---@param label string ---@param index number ---@return Group CreateTabButton = function(self, label, index) - local tab = Group(self.TabStripArea, "CustomLobbyConfigTabButton") + local button = Group(self.TabStripArea, "CustomLobbyConfigTabButton") -- the background catches the click; the label is hit-disabled so it doesn't block it - tab.Bg = Bitmap(tab) - tab.Bg:SetSolidColor(TabIdleColor) + button.Bg = Bitmap(button) + button.Bg:SetSolidColor(TabIdleColor) - tab.Label = UIUtil.CreateText(tab, label, 14, UIUtil.titleFont) - tab.Label:SetColor('ffc8ccd0') - tab.Label:DisableHitTest() + button.Label = UIUtil.CreateText(button, label, 14, UIUtil.titleFont) + button.Label:SetColor('ffc8ccd0') + button.Label:DisableHitTest() - tab.Bg.HandleEvent = function(control, event) + button.Bg.HandleEvent = function(control, event) if event.Type == 'ButtonPress' then self:SelectTab(index) return true elseif event.Type == 'MouseEnter' then if self.ActiveTab ~= index then - tab.Bg:SetSolidColor(TabHoverColor) + button.Bg:SetSolidColor(TabHoverColor) end return true elseif event.Type == 'MouseExit' then if self.ActiveTab ~= index then - tab.Bg:SetSolidColor(TabIdleColor) + button.Bg:SetSolidColor(TabIdleColor) end return true end return false end - return tab + return button end, - --- Switches tabs: recolours the buttons and activates the chosen panel (which shows + refreshes - --- itself) while deactivating (hiding) the rest. + --- Switches tabs: destroys the current content component, builds the chosen one into the + --- content area, and recolours the buttons. Clicking the active tab again is a no-op. ---@param self UICustomLobbyConfigInterface ---@param index number SelectTab = function(self, index) + if self.ActiveTab == index and self.CurrentPanel then + return + end self.ActiveTab = index - for i = 1, table.getn(self.Tabs) do - local tab = self.Tabs[i] - tab.Button.Bg:SetSolidColor(i == index and TabActiveColor or TabIdleColor) - tab.Panel:SetActive(i == index) + + for i = 1, table.getn(self.TabButtons) do + self.TabButtons[i].Bg:SetSolidColor(i == index and TabActiveColor or TabIdleColor) end + + if self.CurrentPanel then + self.CurrentPanel:Destroy() + self.CurrentPanel = false + end + + -- build the new tab's content, size it, then let it read its (now concrete) geometry + local panel = Tabs[index].Create(self.TabContentArea) + Layouter(panel):Fill(self.TabContentArea):End() + panel:Initialize() + self.CurrentPanel = panel end, ---@param self UICustomLobbyConfigInterface OnDestroy = function(self) + if self.CurrentPanel then + self.CurrentPanel:Destroy() + self.CurrentPanel = false + end self.Trash:Destroy() end, } diff --git a/lua/ui/lobby/customlobby/config/CustomLobbyMapPanel.lua b/lua/ui/lobby/customlobby/config/CustomLobbyMapPanel.lua index 082fd2d3841..ebed2910da1 100644 --- a/lua/ui/lobby/customlobby/config/CustomLobbyMapPanel.lua +++ b/lua/ui/lobby/customlobby/config/CustomLobbyMapPanel.lua @@ -20,51 +20,121 @@ --** SOFTWARE. --****************************************************************************************************** --- The Map tab panel of the config interface: the live map preview + the scenario's name and --- size/players, and a host-only "Change map" button. +-- The Map tab's content: the selected scenario's details as stacked, labelled sections — -- --- It is one of the config interface's tab panels (see CustomLobbyConfigInterface). The host drives --- it through `SetActive` — a panel only shows + refreshes while it's the active tab, and stays --- hidden otherwise, which is what keeps a hidden tab's content from rendering over the active one --- (MAUI's `Hide()` only cascades to children that exist at call time). `Initialize` exists for --- uniformity with the grid panels (this one has nothing deferred). +-- Author +-- Jip Willem Wijnia +-- +-- Reclaim +-- 1.0M [mass] · 120k [energy] +-- +-- Description +-- +-- +-- Each optional section (author, reclaim) collapses when the map doesn't declare it, so the +-- description floats up. The bottom action sub-area holds the "Open page" link (secondary, left, +-- when the map has an allowed url) and the host-only "Change map" button (primary, right). The +-- preview + name + size + players + version are pinned above the tab strip by the config +-- interface (the preview's textures aren't freed, so it must not be destroyed). +-- +-- A config-interface tab panel: the host creates it when the Map tab is selected and destroys it +-- on switch, so it's the live/visible panel for its lifetime. `Initialize` (called by the host +-- after sizing it) binds the description's width + builds its scrollbar and renders. local UIUtil = import("/lua/ui/uiutil.lua") local LayoutHelpers = import("/lua/maui/layouthelpers.lua") +local Tooltip = import("/lua/ui/game/tooltip.lua") +local Group = import("/lua/maui/group.lua").Group +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap +local TextArea = import("/lua/ui/controls/textarea.lua").TextArea local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/customlobbylocalmodel.lua") -local CustomLobbyMapPreview = import("/lua/ui/lobby/customlobby/customlobbymappreview.lua") local CustomLobbyMapCatalog = import("/lua/ui/lobby/customlobby/mapselect/customlobbymapcatalog.lua") local CustomLobbyMapSelect = import("/lua/ui/lobby/customlobby/mapselect/customlobbymapselect.lua") -local Group = import("/lua/maui/group.lua").Group local LazyVarDerive = import("/lua/lazyvar.lua").Derive local Layouter = LayoutHelpers.ReusedLayoutFor -local PreviewWidth = 320 -local PreviewHeight = 280 -local ValueColor = 'ff9aa0a8' - ---- Number of start spots a scenario declares, or 0. ----@param scenario UILobbyScenarioInfo ----@return number -local function ArmyCount(scenario) - local armies = scenario.Configurations - and scenario.Configurations.standard - and scenario.Configurations.standard.teams - and scenario.Configurations.standard.teams[1] - and scenario.Configurations.standard.teams[1].armies - return armies and table.getsize(armies) or 0 +local ActionHeight = 40 +local IconSize = 14 +local LabelColor = 'ff8a909a' -- the section labels (Author / Reclaim / Description) +local ValueColor = 'ffc8ccd0' +local MassIcon = "/game/build-ui/icon-mass_bmp.dds" +local EnergyIcon = "/game/build-ui/icon-energy_bmp.dds" + +-- map pages we'll open in a browser, matched against the URL host (exact or as a subdomain). +-- Mirrors the map/mod dialogs; add a line to extend. +local AllowedUrlDomains = { + "github.com", + "githubusercontent.com", + "gitlab.com", + "github.io", + "faforever.com", +} + +--- The lowercased host of a URL (between the scheme and the first `/` or `:`), or "". +---@param url string +---@return string +local function UrlHost(url) + local rest = string.gsub(string.lower(url), "^https?://", "") + return (string.gsub(rest, "[/:].*$", "")) +end + +--- Whether `url` is an http(s) link to an allowed domain (or a subdomain of one). +---@param url any +---@return boolean +local function IsAllowedUrl(url) + if type(url) ~= 'string' or not string.find(string.lower(url), "^https?://") then + return false + end + local host = UrlHost(url) + for _, domain in AllowedUrlDomains do + local escaped = string.gsub(domain, "%.", "%%.") + if host == domain or string.find(host, "%." .. escaped .. "$") then + return true + end + end + return false +end + +--- Downgrades an `https://` URL to `http://` — the engine's `OpenURL` only handles `http://`. +---@param url string +---@return string +local function ToOpenableUrl(url) + return (string.gsub(url, "^https://", "http://")) +end + +--- Formats a resource amount compactly: 1016424 -> "1.0M", 119858 -> "120k", 950 -> "950". +---@param amount number +---@return string +local function FormatAmount(amount) + if amount >= 1000000 then + return string.format("%.1fM", amount / 1000000) + elseif amount >= 1000 then + return string.format("%.0fk", amount / 1000) + end + return string.format("%d", amount) end ---@class UICustomLobbyMapPanel : Group ---@field Trash TrashBag ----@field Active boolean +---@field Ready boolean ---@field IsHost boolean ----@field Preview UICustomLobbyMapPreview ----@field Name Text ----@field Info Text +---@field CurrentUrl string | false +---@field AuthorLabel Text +---@field AuthorValue Text +---@field ReclaimLabel Text +---@field ReclaimValue Group +---@field ReclaimMass Text +---@field ReclaimMassIcon Bitmap +---@field ReclaimEnergy Text +---@field ReclaimEnergyIcon Bitmap +---@field DescriptionLabel Text +---@field Description TextArea +---@field DescriptionScrollbar Scrollbar | false +---@field ActionArea Group +---@field UrlButton Text ---@field ChangeButton Button ---@field ScenarioObserver LazyVar ---@field IsHostObserver LazyVar @@ -76,101 +146,206 @@ local CustomLobbyMapPanel = ClassUI(Group) { Group.__init(self, parent, "CustomLobbyMapPanel") self.Trash = TrashBag() - self.Active = false + self.Ready = false self.IsHost = false + self.CurrentUrl = false + self.DescriptionScrollbar = false + + --#region Author section + self.AuthorLabel = self:CreateSectionLabel("Author") + self.AuthorValue = UIUtil.CreateText(self, "", 13, UIUtil.bodyFont) + self.AuthorValue:SetColor(ValueColor) + self.AuthorValue:DisableHitTest() + --#endregion + + --#region Reclaim section (amount + mass/energy icon, amount + energy icon) + self.ReclaimLabel = self:CreateSectionLabel("Reclaim") + self.ReclaimValue = Group(self, "CustomLobbyMapReclaim") + self.ReclaimValue:DisableHitTest() + self.ReclaimMass = UIUtil.CreateText(self.ReclaimValue, "", 13, UIUtil.bodyFont) + self.ReclaimMass:SetColor(ValueColor) + self.ReclaimMass:DisableHitTest() + self.ReclaimMassIcon = Bitmap(self.ReclaimValue, MassIcon) + self.ReclaimMassIcon:DisableHitTest() + self.ReclaimEnergy = UIUtil.CreateText(self.ReclaimValue, "", 13, UIUtil.bodyFont) + self.ReclaimEnergy:SetColor(ValueColor) + self.ReclaimEnergy:DisableHitTest() + self.ReclaimEnergyIcon = Bitmap(self.ReclaimValue, EnergyIcon) + self.ReclaimEnergyIcon:DisableHitTest() + --#endregion - self.Preview = CustomLobbyMapPreview.Create(self) + --#region Description section + self.DescriptionLabel = self:CreateSectionLabel("Description") + self.Description = TextArea(self, 200, 80) + self.Description:SetFont(UIUtil.bodyFont, 12) + self.Description:SetColors(ValueColor, "00000000", ValueColor, "00000000") + --#endregion - self.Name = UIUtil.CreateText(self, "", 16, UIUtil.titleFont) - self.Name:DisableHitTest() - self.Info = UIUtil.CreateText(self, "", 13, UIUtil.bodyFont) - self.Info:SetColor(ValueColor) - self.Info:DisableHitTest() + --#region actions + self.ActionArea = Group(self, "CustomLobbyMapActions") + + self.UrlButton = UIUtil.CreateText(self.ActionArea, "Open page", 12, UIUtil.bodyFont) + self.UrlButton:SetColor('ff7fb3ff') + self.UrlButton:Hide() + self.UrlButton.HandleEvent = function(control, event) + if event.Type == 'ButtonPress' then + if self.CurrentUrl then + OpenURL(ToOpenableUrl(self.CurrentUrl)) + end + return true + elseif event.Type == 'MouseEnter' then + control:SetColor('ffaecbff') + return true + elseif event.Type == 'MouseExit' then + control:SetColor('ff7fb3ff') + return true + end + return false + end + Tooltip.AddControlTooltipManual(self.UrlButton, "Map page", "Open the map's web page in your browser.") - self.ChangeButton = UIUtil.CreateButtonWithDropshadow(self, '/BUTTON/medium/', "Change map") + self.ChangeButton = UIUtil.CreateButtonWithDropshadow(self.ActionArea, '/BUTTON/medium/', "Change map") self.ChangeButton.OnClick = function(button, modifiers) CustomLobbyMapSelect.Open(GetFrame(0)) end + --#endregion - local launch = CustomLobbyLaunchModel.GetSingleton() self.ScenarioObserver = self.Trash:Add( - LazyVarDerive(launch.ScenarioFile, function(scenarioFileLazy) - scenarioFileLazy() + LazyVarDerive(CustomLobbyLaunchModel.GetSingleton().ScenarioFile, function(lazy) + lazy() self:Refresh() - -- the preview self-updates from the model; if we're hidden, re-hide so its newly - -- built markers don't render over the active tab - if not self.Active then - self:Hide() - end end)) - - local localModel = CustomLobbyLocalModel.GetSingleton() self.IsHostObserver = self.Trash:Add( - LazyVarDerive(localModel.IsHost, function(isHostLazy) + LazyVarDerive(CustomLobbyLocalModel.GetSingleton().IsHost, function(isHostLazy) self.IsHost = isHostLazy() self:ApplyHostVisibility() - if not self.Active then - self:Hide() - end end)) end, ---@param self UICustomLobbyMapPanel __post_init = function(self) - Layouter(self.Preview) - :AtHorizontalCenterIn(self):AtTopIn(self, 8) - :Width(PreviewWidth):Height(PreviewHeight) - :End() - Layouter(self.Name):AtHorizontalCenterIn(self):AnchorToBottom(self.Preview, 8):End() - Layouter(self.Info):AtHorizontalCenterIn(self):AnchorToBottom(self.Name, 4):End() - Layouter(self.ChangeButton):AtHorizontalCenterIn(self):AtBottomIn(self, 6):End() + Layouter(self.ActionArea):AtLeftIn(self):AtRightIn(self):AtBottomIn(self):Height(ActionHeight):End() + Layouter(self.ChangeButton):AtRightIn(self.ActionArea):AtVerticalCenterIn(self.ActionArea):End() + Layouter(self.UrlButton):AtLeftIn(self.ActionArea, 6):AtVerticalCenterIn(self.ActionArea):End() + + -- the reclaim value row: amount + mass icon, amount + energy icon (fixed internal layout; + -- the group's position is set by LayoutSections) + LayoutHelpers.SetHeight(self.ReclaimValue, IconSize + 4) + Layouter(self.ReclaimMass):AtLeftIn(self.ReclaimValue):AtVerticalCenterIn(self.ReclaimValue):End() + Layouter(self.ReclaimMassIcon):AnchorToRight(self.ReclaimMass, 3):AtVerticalCenterIn(self.ReclaimValue):Width(IconSize):Height(IconSize):End() + Layouter(self.ReclaimEnergy):AnchorToRight(self.ReclaimMassIcon, 10):AtVerticalCenterIn(self.ReclaimValue):End() + Layouter(self.ReclaimEnergyIcon):AnchorToRight(self.ReclaimEnergy, 3):AtVerticalCenterIn(self.ReclaimValue):Width(IconSize):Height(IconSize):End() + + -- the description's left/right are fixed here so its Width can be bound in Initialize + -- (the TextArea reflows on the Width bind, which reads Left/Right); its top/bottom are set + -- dynamically by LayoutSections so empty sections above it collapse + Layouter(self.Description):AtLeftIn(self, 6):AtRightIn(self, 24):End() end, - --- No deferred work (no grid); kept for a uniform panel interface. + --- Builds a dim section label (Author / Reclaim / Description). Private. ---@param self UICustomLobbyMapPanel - Initialize = function(self) + ---@param text string + ---@return Text + CreateSectionLabel = function(self, text) + local label = UIUtil.CreateText(self, text, 12, UIUtil.titleFont) + label:SetColor(LabelColor) + label:DisableHitTest() + return label end, - --- Shows + refreshes the panel when it becomes the active tab; hides it otherwise. + --- Binds the description's width + builds its scrollbar, then renders. Called by the host after + --- it sizes the panel (the TextArea wraps to Width(), so it must be bound to the laid-out span + --- now — see the TextArea gotcha in ../CLAUDE.md). ---@param self UICustomLobbyMapPanel - ---@param active boolean - SetActive = function(self, active) - self.Active = active - if active then - self:Show() - self:Refresh() - self:ApplyHostVisibility() - else - self:Hide() - end + Initialize = function(self) + self.Ready = true + self.Description.Width:Set(function() return self.Description.Right() - self.Description.Left() end) + self.DescriptionScrollbar = UIUtil.CreateVertScrollbarFor(self.Description) + self:Refresh() + self:ApplyHostVisibility() end, - --- Updates the name + size/players line from the current scenario. + --- Loads the current scenario's info and fills the labelled sections (author / reclaim / + --- description) + the url link, collapsing sections the map doesn't provide. ---@param self UICustomLobbyMapPanel Refresh = function(self) - local scenarioFile = CustomLobbyLaunchModel.GetSingleton().ScenarioFile() - if not scenarioFile then - self.Name:SetText("No map selected") - self.Info:SetText("") + if not self.Ready then return end - local info = CustomLobbyMapCatalog.LoadInfo(scenarioFile) - if not info then - self.Name:SetText("Unknown map") - self.Info:SetText("") - return + local scenarioFile = CustomLobbyLaunchModel.GetSingleton().ScenarioFile() + -- `info` is a table when a readable scenario is selected, else false (no map) / nil + local info = scenarioFile and CustomLobbyMapCatalog.LoadInfo(scenarioFile) + local fields = type(info) == "table" and info or {} + + local author = fields.author + local reclaim = fields.reclaim + local description = fields.description + local hasAuthor = type(author) == "string" and author ~= "" + local hasReclaim = type(reclaim) == "table" and reclaim[1] ~= nil and reclaim[2] ~= nil + + if hasAuthor then + self.AuthorValue:SetText(author) + end + if hasReclaim then + self.ReclaimMass:SetText(FormatAmount(reclaim[1])) + self.ReclaimEnergy:SetText(FormatAmount(reclaim[2])) end - self.Name:SetText(LOC(info.name) or "?") + self.Description:SetText((type(description) == "string" and LOC(description)) or "") + + self.CurrentUrl = IsAllowedUrl(fields.url) and fields.url or false + if self.CurrentUrl then + self.UrlButton:Show() + else + self.UrlButton:Hide() + end + + self:LayoutSections(hasAuthor, hasReclaim) + self:UpdateScrollbar() + end, - local parts = {} - if info.size then - table.insert(parts, string.format("%dkm", math.floor(info.size[1] / 50))) + --- Stacks the visible sections top-to-bottom (collapsing absent ones) and floats the + --- description into the remaining space above the action area. + ---@param self UICustomLobbyMapPanel + ---@param hasAuthor boolean + ---@param hasReclaim boolean + LayoutSections = function(self, hasAuthor, hasReclaim) + local prev = false -- the bottom of the last placed value (false = panel top) + + -- places a label + value pair under `prev`, or hides both + local function place(label, value, visible) + if visible then + label:Show() + value:Show() + local builder = Layouter(label):AtLeftIn(self, 6) + if prev then + builder:AnchorToBottom(prev, 8) + else + builder:AtTopIn(self, 8) + end + builder:End() + Layouter(value):AtLeftIn(self, 6):AnchorToBottom(label, 2):End() + prev = value + else + label:Hide() + value:Hide() + end end - local players = ArmyCount(info) - if players > 0 then - table.insert(parts, players .. " players") + + place(self.AuthorLabel, self.AuthorValue, hasAuthor) + place(self.ReclaimLabel, self.ReclaimValue, hasReclaim) + + -- description always shows; its label sits under the last visible section + local builder = Layouter(self.DescriptionLabel):AtLeftIn(self, 6) + if prev then + builder:AnchorToBottom(prev, 8) + else + builder:AtTopIn(self, 8) end - self.Info:SetText(table.concat(parts, " · ")) + builder:End() + Layouter(self.Description) + :AtLeftIn(self, 6):AtRightIn(self, 24) + :AnchorToBottom(self.DescriptionLabel, 4):AnchorToTop(self.ActionArea, 8) + :End() end, --- The change-map button is host-only. @@ -183,6 +358,19 @@ local CustomLobbyMapPanel = ClassUI(Group) { end end, + --- Shows the description's scrollbar only when it overflows. + ---@param self UICustomLobbyMapPanel + UpdateScrollbar = function(self) + if not self.DescriptionScrollbar then + return + end + if self.Description:GetTextHeight() > self.Description.Height() then + self.DescriptionScrollbar:Show() + else + self.DescriptionScrollbar:Hide() + end + end, + ---@param self UICustomLobbyMapPanel OnDestroy = function(self) self.Trash:Destroy() diff --git a/lua/ui/lobby/customlobby/config/CustomLobbyModsPanel.lua b/lua/ui/lobby/customlobby/config/CustomLobbyModsPanel.lua index d75da492ba7..57311de1d03 100644 --- a/lua/ui/lobby/customlobby/config/CustomLobbyModsPanel.lua +++ b/lua/ui/lobby/customlobby/config/CustomLobbyModsPanel.lua @@ -24,9 +24,10 @@ -- shared sim mods from the launch model) and UI mods (this peer's local choice from prefs), plus -- a "Manage mods" button (available to everyone — UI mods are per-player). -- --- It is a config-interface tab panel driven by the host via `SetActive`. UI mods are prefs, not a --- reactive model field, so they're picked up on activation (`SetActive(true)` refreshes) as well --- as when the synced sim mods change. +-- It is a config-interface tab panel: the host creates it when the Mods tab is selected and +-- destroys it on switch, so it's the live/visible panel for its whole lifetime. UI mods are prefs, +-- not a reactive model field, so they're read on the first render (`Initialize`); the synced sim +-- mods additionally refresh it live. local UIUtil = import("/lua/ui/uiutil.lua") local LayoutHelpers = import("/lua/maui/layouthelpers.lua") @@ -48,6 +49,7 @@ local ScrollGap = 18 local GridContentWidth = 360 - 6 - ScrollGap local LabelMaxChars = 30 local NormalColor = 'ffc8ccd0' +local ActionHeight = 40 -- the bottom action sub-area that holds the tab's buttons --- Truncates `text` to `maxChars`, appending "…" when it had to cut. ---@param text string @@ -64,10 +66,10 @@ end ---@class UICustomLobbyModsPanel : Group ---@field Trash TrashBag ---@field Ready boolean ----@field Active boolean ---@field ModsGrid Grid ---@field Scrollbar Scrollbar | false ---@field Empty Text +---@field ActionArea Group ---@field ManageButton Button ---@field ModsObserver LazyVar local CustomLobbyModsPanel = ClassUI(Group) { @@ -79,7 +81,6 @@ local CustomLobbyModsPanel = ClassUI(Group) { self.Trash = TrashBag() self.Ready = false - self.Active = false self.Scrollbar = false self.ModsGrid = Grid(self, GridContentWidth, RowHeight) @@ -88,51 +89,41 @@ local CustomLobbyModsPanel = ClassUI(Group) { self.Empty:DisableHitTest() self.Empty:Hide() - self.ManageButton = UIUtil.CreateButtonWithDropshadow(self, '/BUTTON/medium/', "Manage mods") + -- the tab's actions live in their own sub-area pinned to the bottom; the primary action is + -- right-aligned + self.ActionArea = Group(self, "CustomLobbyModsActions") + self.ManageButton = UIUtil.CreateButtonWithDropshadow(self.ActionArea, '/BUTTON/medium/', "Manage mods") self.ManageButton.OnClick = function(button, modifiers) CustomLobbyModSelect.Open(GetFrame(0)) end - -- only the shared sim mods are a model field; UI mods (prefs) are refreshed on activation + -- only the shared sim mods are a model field; UI mods (prefs) are picked up on the first + -- render (Initialize), since the panel is created fresh each time its tab is selected self.ModsObserver = self.Trash:Add( LazyVarDerive(CustomLobbyLaunchModel.GetSingleton().GameMods, function(lazy) lazy() self:Refresh() - if not self.Active then - self:Hide() - end end)) end, ---@param self UICustomLobbyModsPanel __post_init = function(self) - Layouter(self.ManageButton):AtHorizontalCenterIn(self):AtBottomIn(self, 6):End() + Layouter(self.ActionArea):AtLeftIn(self):AtRightIn(self):AtBottomIn(self):Height(ActionHeight):End() + Layouter(self.ManageButton):AtRightIn(self.ActionArea):AtVerticalCenterIn(self.ActionArea):End() Layouter(self.ModsGrid) :AtLeftIn(self, 6):Width(GridContentWidth) - :AtTopIn(self, 6):AnchorToTop(self.ManageButton, 8) + :AtTopIn(self, 6):AnchorToTop(self.ActionArea, 8) :End() Layouter(self.Empty):AtHorizontalCenterIn(self.ModsGrid):AtTopIn(self.ModsGrid, 8):End() end, - --- Builds the grid's scrollbar; called by the host after the panel has a concrete height. + --- Builds the grid's scrollbar + does the first render (picking up the current UI-mod prefs). + --- Called by the host after it has sized the panel (the grid needs a concrete height). ---@param self UICustomLobbyModsPanel Initialize = function(self) self.Ready = true self.Scrollbar = UIUtil.CreateVertScrollbarFor(self.ModsGrid) - end, - - --- Shows + refreshes the panel when it becomes the active tab (also picks up UI-mod prefs - --- changed while it was inactive); hides it otherwise. - ---@param self UICustomLobbyModsPanel - ---@param active boolean - SetActive = function(self, active) - self.Active = active - if active then - self:Show() - self:Refresh() - else - self:Hide() - end + self:Refresh() end, --- Rebuilds the enabled-mods grid: a Game mods section (shared sim mods) + a UI mods section diff --git a/lua/ui/lobby/customlobby/config/CustomLobbyOptionsPanel.lua b/lua/ui/lobby/customlobby/config/CustomLobbyOptionsPanel.lua index 567fbdb6ff9..fbc1dd1af33 100644 --- a/lua/ui/lobby/customlobby/config/CustomLobbyOptionsPanel.lua +++ b/lua/ui/lobby/customlobby/config/CustomLobbyOptionsPanel.lua @@ -28,9 +28,10 @@ -- marker's tooltip names the precise origin (`Map: …` / `Mod: …`). The schema is derived per-peer -- from the synced scenario + mods via `optionutil`; the values are the synced `GameOptions`. -- --- It is a config-interface tab panel — the host drives it via `SetActive`. It only builds + shows --- its grid while active (and re-hides after a refresh if it isn't), so a hidden tab never renders --- over the active one. `Initialize` builds the scrollbar once the panel has a concrete height. +-- It is a config-interface tab panel: the host creates it when the Options tab is selected and +-- destroys it on switch, so it's the live/visible panel for its whole lifetime — model observers +-- just rebuild it. `Initialize` (called by the host after sizing it) builds the grid's scrollbar + +-- does the first render; the grid needs a concrete height by then. local UIUtil = import("/lua/ui/uiutil.lua") local LayoutHelpers = import("/lua/maui/layouthelpers.lua") @@ -54,6 +55,7 @@ local RowHeight = 22 local ScrollGap = 18 local GridContentWidth = 360 - 6 - ScrollGap local LabelMaxChars = 26 +local ActionHeight = 40 -- the bottom action sub-area that holds the tab's buttons local SpecialColor = 'ffd0a24c' -- marker + label tint for a map/mod option local NormalColor = 'ffc8ccd0' @@ -74,13 +76,13 @@ end ---@class UICustomLobbyOptionsPanel : Group ---@field Trash TrashBag ---@field Ready boolean ----@field Active boolean ---@field IsHost boolean ---@field HideDefaults boolean ---@field HideDefaultsToggle Checkbox ---@field OptionsGrid Grid ---@field Scrollbar Scrollbar | false ---@field Empty Text +---@field ActionArea Group ---@field EditButton Button ---@field ResetButton Button ---@field ScenarioObserver LazyVar @@ -96,7 +98,6 @@ local CustomLobbyOptionsPanel = ClassUI(Group) { self.Trash = TrashBag() self.Ready = false - self.Active = false self.IsHost = false self.HideDefaults = true self.Scrollbar = false @@ -116,76 +117,59 @@ local CustomLobbyOptionsPanel = ClassUI(Group) { self.Empty:DisableHitTest() self.Empty:Hide() - self.EditButton = UIUtil.CreateButtonWithDropshadow(self, '/BUTTON/medium/', "Options") + -- the tab's actions live in their own sub-area pinned to the bottom; the primary action + -- (open the editor) is right-aligned, the secondary (reset) sits to its left + self.ActionArea = Group(self, "CustomLobbyOptionsActions") + self.EditButton = UIUtil.CreateButtonWithDropshadow(self.ActionArea, '/BUTTON/medium/', "Options") self.EditButton.OnClick = function(button, modifiers) CustomLobbyOptionSelect.Open(GetFrame(0)) end - self.ResetButton = UIUtil.CreateButtonWithDropshadow(self, '/BUTTON/medium/', "Reset options") + self.ResetButton = UIUtil.CreateButtonWithDropshadow(self.ActionArea, '/BUTTON/medium/', "Reset options") self.ResetButton.OnClick = function(button, modifiers) CustomLobbyController.RequestResetGameOptions() end Tooltip.AddControlTooltipManual(self.ResetButton, "Reset options", "Reset every option to its default value.") + -- the panel is created/destroyed with its tab, so it's always the live/visible panel while + -- it exists — observers just rebuild (Refresh is Ready-gated); no show/hide juggling local launch = CustomLobbyLaunchModel.GetSingleton() self.ScenarioObserver = self.Trash:Add( - LazyVarDerive(launch.ScenarioFile, function(lazy) lazy(); self:OnModelChanged() end)) + LazyVarDerive(launch.ScenarioFile, function(lazy) lazy(); self:Refresh() end)) self.OptionsObserver = self.Trash:Add( - LazyVarDerive(launch.GameOptions, function(lazy) lazy(); self:OnModelChanged() end)) + LazyVarDerive(launch.GameOptions, function(lazy) lazy(); self:Refresh() end)) self.ModsObserver = self.Trash:Add( - LazyVarDerive(launch.GameMods, function(lazy) lazy(); self:OnModelChanged() end)) + LazyVarDerive(launch.GameMods, function(lazy) lazy(); self:Refresh() end)) local localModel = CustomLobbyLocalModel.GetSingleton() self.IsHostObserver = self.Trash:Add( LazyVarDerive(localModel.IsHost, function(isHostLazy) self.IsHost = isHostLazy() self:ApplyHostVisibility() - if not self.Active then - self:Hide() - end end)) end, ---@param self UICustomLobbyOptionsPanel __post_init = function(self) + Layouter(self.ActionArea):AtLeftIn(self):AtRightIn(self):AtBottomIn(self):Height(ActionHeight):End() + Layouter(self.EditButton):AtRightIn(self.ActionArea):AtVerticalCenterIn(self.ActionArea):End() + Layouter(self.ResetButton):AnchorToLeft(self.EditButton, 8):AtVerticalCenterIn(self.ActionArea):End() + Layouter(self.HideDefaultsToggle):AtLeftIn(self, 6):AtTopIn(self, 4):End() - Layouter(self.ResetButton):AtHorizontalCenterIn(self):AtBottomIn(self, 6):End() - Layouter(self.EditButton):AtHorizontalCenterIn(self):AnchorToTop(self.ResetButton, 6):End() Layouter(self.OptionsGrid) :AtLeftIn(self, 6):Width(GridContentWidth) - :AnchorToBottom(self.HideDefaultsToggle, 6):AnchorToTop(self.EditButton, 8) + :AnchorToBottom(self.HideDefaultsToggle, 6):AnchorToTop(self.ActionArea, 8) :End() Layouter(self.Empty):AtHorizontalCenterIn(self.OptionsGrid):AtTopIn(self.OptionsGrid, 8):End() end, - --- Builds the grid's scrollbar; called by the host after the panel has a concrete height. + --- Builds the grid's scrollbar + does the first render. Called by the host after it has sized + --- the panel (the grid needs a concrete height — three-phase init, /lua/ui/CLAUDE.md § 1). ---@param self UICustomLobbyOptionsPanel Initialize = function(self) self.Ready = true self.Scrollbar = UIUtil.CreateVertScrollbarFor(self.OptionsGrid) - end, - - --- A relevant model field changed: rebuild, and if we're not the active tab re-hide so the - --- freshly built rows don't render over the active one. - ---@param self UICustomLobbyOptionsPanel - OnModelChanged = function(self) self:Refresh() - if not self.Active then - self:Hide() - end - end, - - --- Shows + refreshes the panel when it becomes the active tab; hides it otherwise. - ---@param self UICustomLobbyOptionsPanel - ---@param active boolean - SetActive = function(self, active) - self.Active = active - if active then - self:Show() - self:Refresh() - self:ApplyHostVisibility() - else - self:Hide() - end + self:ApplyHostVisibility() end, --- Rebuilds the read-only options grid, grouped into Lobby / Scenario / Mods sections with the diff --git a/lua/ui/lobby/customlobby/config/CustomLobbyUnitsPanel.lua b/lua/ui/lobby/customlobby/config/CustomLobbyUnitsPanel.lua index 306b871fd66..28ac68f8c2b 100644 --- a/lua/ui/lobby/customlobby/config/CustomLobbyUnitsPanel.lua +++ b/lua/ui/lobby/customlobby/config/CustomLobbyUnitsPanel.lua @@ -21,8 +21,8 @@ --****************************************************************************************************** -- The Units tab panel of the config interface: a placeholder until the unit-restrictions slice --- lands. A config-interface tab panel driven by the host via `SetActive`; it implements the same --- panel interface (`Initialize` / `SetActive`) as the others so the host can treat them uniformly. +-- lands. A config-interface tab panel: the host creates it when the Units tab is selected and +-- destroys it on switch, and calls `Initialize` after sizing it (same interface as the others). local UIUtil = import("/lua/ui/uiutil.lua") local LayoutHelpers = import("/lua/maui/layouthelpers.lua") @@ -49,21 +49,10 @@ local CustomLobbyUnitsPanel = ClassUI(Group) { Layouter(self.Info):AtHorizontalCenterIn(self):AtTopIn(self, 16):End() end, - --- Nothing deferred; kept for a uniform panel interface. + --- Nothing deferred (no grid); kept for a uniform panel interface (the host calls it). ---@param self UICustomLobbyUnitsPanel Initialize = function(self) end, - - --- Shows or hides the panel. - ---@param self UICustomLobbyUnitsPanel - ---@param active boolean - SetActive = function(self, active) - if active then - self:Show() - else - self:Hide() - end - end, } ---@param parent Control diff --git a/lua/ui/maputil.lua b/lua/ui/maputil.lua index 15fce41dedf..20bdbc63399 100644 --- a/lua/ui/maputil.lua +++ b/lua/ui/maputil.lua @@ -96,6 +96,7 @@ ---@field norushoffsetY_ARMY_16? number ---@field preview? FileName ---@field url? string # optional link to the map's page (vault / repo); shown in the lobby +---@field author? string # optional map author; not standard, shown in the lobby when present ---@field save FileName ---@field script FileName ---@field size {[1]: number, [2]: number} From ba7cf276c9e23c17ddcb4b66c7c375f1dab06ef9 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Mon, 22 Jun 2026 19:20:12 +0200 Subject: [PATCH 31/98] Add reclaim values --- lua/ui/lobby/customlobby/config/CustomLobbyMapPanel.lua | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lua/ui/lobby/customlobby/config/CustomLobbyMapPanel.lua b/lua/ui/lobby/customlobby/config/CustomLobbyMapPanel.lua index ebed2910da1..46f621f0c15 100644 --- a/lua/ui/lobby/customlobby/config/CustomLobbyMapPanel.lua +++ b/lua/ui/lobby/customlobby/config/CustomLobbyMapPanel.lua @@ -165,12 +165,14 @@ local CustomLobbyMapPanel = ClassUI(Group) { self.ReclaimMass = UIUtil.CreateText(self.ReclaimValue, "", 13, UIUtil.bodyFont) self.ReclaimMass:SetColor(ValueColor) self.ReclaimMass:DisableHitTest() - self.ReclaimMassIcon = Bitmap(self.ReclaimValue, MassIcon) + self.ReclaimMassIcon = Bitmap(self.ReclaimValue) + self.ReclaimMassIcon:SetTexture(UIUtil.UIFile(MassIcon)) self.ReclaimMassIcon:DisableHitTest() self.ReclaimEnergy = UIUtil.CreateText(self.ReclaimValue, "", 13, UIUtil.bodyFont) self.ReclaimEnergy:SetColor(ValueColor) self.ReclaimEnergy:DisableHitTest() - self.ReclaimEnergyIcon = Bitmap(self.ReclaimValue, EnergyIcon) + self.ReclaimEnergyIcon = Bitmap(self.ReclaimValue) + self.ReclaimEnergyIcon:SetTexture(UIUtil.UIFile(EnergyIcon)) self.ReclaimEnergyIcon:DisableHitTest() --#endregion From 98d5619857c38069c1aa52b4faa42d1223232546 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Mon, 22 Jun 2026 20:26:10 +0200 Subject: [PATCH 32/98] Combine the map preview related components --- lua/ui/lobby/USER_STORIES.md | 6 +- lua/ui/lobby/customlobby/CLAUDE.md | 4 +- .../customlobby/CustomLobbyMapPreview.lua | 205 ++++++++++++++---- .../CustomLobbyMapPreviewSpawn.lua | 111 ---------- .../CustomLobbyScenarioPreview.lua | 10 +- .../config/CustomLobbyConfigInterface.lua | 4 +- .../mapselect/CustomLobbyMapSelect.lua | 26 +-- 7 files changed, 181 insertions(+), 185 deletions(-) delete mode 100644 lua/ui/lobby/customlobby/CustomLobbyMapPreviewSpawn.lua diff --git a/lua/ui/lobby/USER_STORIES.md b/lua/ui/lobby/USER_STORIES.md index 193e0df3a9e..242bbf251de 100644 --- a/lua/ui/lobby/USER_STORIES.md +++ b/lua/ui/lobby/USER_STORIES.md @@ -56,7 +56,7 @@ Roles: **Host**, **Player** (human in a slot), **Observer**, **Joining client** ## E. Observers - 🟡 As a **joining client**, I want to enter as an observer when no slot is free or when I request it, so that I can still watch. *(Observers are modelled/synced; auto-enter-as-observer on a full lobby isn't wired.)* -- 🟡 As a **player**, I want to become an observer (or request it from the host), so that I can step out of the game without leaving. *(Host moves a player to observers via the context menu; self-request not yet.)* +- 🟡 As a **player**, I want to become an observer (or request it from the host), so that I can step out of the game without leaving. *(Host moves a player to observers via the context menu, and an **"Observe" button** moves the local player out of their slot — host-side; a non-host's request-to-host path isn't wired yet.)* - ✅ As an **observer**, I want to become a player / request a specific slot, so that I can join in; *given* a slot is available (host may evict an AI). *(Right-click → "Play this slot".)* - 🟡 As a **player**, I want to see the observer list with rating, ping and CPU, so that I know who's spectating. *(Observer strip shows count + names; no rating/ping/CPU.)* - ⬜ As a **host**, I want to kick an observer, so that I can manage spectators; *and* observers are auto-kicked at launch if observers are disallowed. @@ -80,7 +80,7 @@ Roles: **Host**, **Player** (human in a slot), **Observer**, **Joining client** - ✅ As a **host**, I want to set AI multipliers (cheat/build), omni, TML randomization and expansion limits, so that AIx difficulty is tuned. *(AI column of the options dialog.)* - 🟡 As a **host**, I want a "reset to defaults" action, so that I can clear all options at once (which also unreadies everyone). *(Reset button clears to defaults; the auto-unready side-effect isn't wired.)* - ✅ As a **host**, I want map-specific and mod-provided options surfaced alongside the standard ones, so that nothing is hidden. *(Scenario + Mods columns.)* -- 🟡 As a **player**, I want to see the active (and optionally only the changed) options, so that I understand the ruleset. *(Hide-defaults + non-default marking exist, but the dialog is host-only — no read-only client view.)* +- ✅ As a **player**, I want to see the active (and optionally only the changed) options, so that I understand the ruleset. *(The config panel's **Options tab** shows the current values read-only to everyone, grouped Lobby / Scenario / Mods, with a hide-defaults toggle and origin markers; the host edits them via the Options dialog.)* ## H. Auto-teams & spawn @@ -94,7 +94,7 @@ Roles: **Host**, **Player** (human in a slot), **Observer**, **Joining client** - ✅ As a **host**, I want to browse maps with a preview, so that I can choose a map informedly. - 🟡 As a **host**, I want to filter maps by player count, size, type (official/custom), AI markers and hide-obsolete, and search by name, so that I find the right map quickly; *and* my filters persist. *(Size, player count, name search + persistence done; type/AI-markers/hide-obsolete filters not.)* -- 🟡 As a **host**, I want a resource-aware preview (mass/hydro, water/cliff/buildable masks, start spots), so that I understand the map layout. *(Start spots, mass/hydro, wrecks done; terrain masks not.)* +- 🟡 As a **host**, I want a resource-aware preview (mass/hydro, water/cliff/buildable masks, start spots), so that I understand the map layout. *(Preview shows start spots, mass/hydro, wrecks; the lobby's Map tab also shows reclaim totals + description / author / url / version; terrain masks not.)* - ✅ As a **host**, I want to pick a random map, so that I can play something fresh. - ✅ As a **player**, I want clear warnings when a map's files are missing, so that I'm not stuck on an unplayable map. *(File-health check disables Select.)* diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index c1acfd7aa89..8cc6053f55f 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -50,8 +50,8 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | [CustomLobbyMenus.lua](CustomLobbyMenus.lua) | declarative menu **definitions**: entry lists with `when(ctx)`/`action(ctx)` filtered by lobby state (`BuildSlotMenu`). Adding/state-gating an item is a one-liner here. | | [CustomLobbySlotInterface.lua](CustomLobbySlotInterface.lua) | one slot row; subscribes to its slot + CPU benchmarks; CPU column shows max units at +0 with a green→red cap-headroom square; left-click an open slot to take it / your own to toggle ready; right-click opens its context menu; the host can drag a row onto another to swap. | | [CustomLobbyObserversInterface.lua](CustomLobbyObserversInterface.lua) | observer strip; subscribes to the model's `Observers` list and shows the count + names (read-only). | -| [CustomLobbyScenarioPreview.lua](CustomLobbyScenarioPreview.lua) | **shared** map-preview *surface*: the scenario's map texture + overlays (start spots, resource/wreck markers) with aspect-correct positioning, texture-leak-safe icon sharing, per-group visibility, and three-phase init. Chrome-free; owners wrap it. Spawn appearance is the owner's via a `CreateSpawnIcon` factory. Used by both the in-lobby preview and the map-select dialog so the fiddly bits live once. | -| [CustomLobbyMapPreview.lua](CustomLobbyMapPreview.lua) / [CustomLobbyMapPreviewSpawn.lua](CustomLobbyMapPreviewSpawn.lua) | the in-lobby preview: a thin model-bound wrapper around `CustomLobbyScenarioPreview` (faction-icon spawns) + glow/brackets chrome. Subscribes to `ScenarioFile` (loads via the catalog, hands to the surface, show/hide) + each slot (refreshes the surface's spawn data, no reload). `CustomLobbyMapPreviewSpawn` is the faction spawn icon. | +| [CustomLobbyScenarioPreview.lua](CustomLobbyScenarioPreview.lua) | **shared** map-preview *surface*: the scenario's map texture + overlays (start spots, resource/wreck markers) with aspect-correct positioning, texture-leak-safe icon sharing, per-group visibility, and three-phase init. Chrome-free; `CustomLobbyMapPreview` wraps it with the frame. Spawn appearance is the owner's via a `CreateSpawnIcon` factory. | +| [CustomLobbyMapPreview.lua](CustomLobbyMapPreview.lua) | the map preview **as one whole** — the chrome (glow border on top + dark backdrop, surface inset by `Padding`), the surface, and the faction spawn icon (local `MapPreviewSpawn`). Used by **both** consumers: created `Bound = true` it subscribes to the launch model and renders the committed `ScenarioFile` with per-slot faction spawns (no reload on take/swap); created unbound (the map-select dialog) it does no model wiring and the owner drives `preview.Surface` directly (numbered-dot spawns). Exposes `.Surface` for owners to drive / anchor overlays to. | | [mapselect/](mapselect/CLAUDE.md) | the **map-select dialog** + its catalog and list, in their own folder (host-only `Popup`: searchable, filterable scenario list → `RequestSetScenario`). Self-contained sub-MVC; see [mapselect/CLAUDE.md](mapselect/CLAUDE.md) — including the **`MapPreview` texture-leak** writeup that shaped its design. | | [modselect/](modselect/CLAUDE.md) | the **mod-select dialog** + its catalog and list, built to the map-select shape (checkbox list + type filters + detail panel + **presets**). Returns a uid set; the opener routes it — sim mods → `RequestSetGameMods` (synced), UI mods → local prefs — or persists the lot standalone. Mod domain logic lives in [`/lua/ui/modutilities.lua`](/lua/ui/modutilities.lua) (the `maputil.lua` sibling, fronting `/lua/mods.lua`). See [modselect/CLAUDE.md](modselect/CLAUDE.md). | | [optionselect/](optionselect/CLAUDE.md) | the **options dialog**: three columns (lobby / scenario / mod options) over the selected scenario + mods, with search + hide-defaults filters; non-default options are marked. Derives the option *schema* per-peer (reference data) via [`/lua/ui/optionutil.lua`](/lua/ui/optionutil.lua); edits a working copy of the *values* and on OK routes the reconciled set through `RequestSetGameOptions` (synced via `GameOptions`). Host-only. See [optionselect/CLAUDE.md](optionselect/CLAUDE.md). | diff --git a/lua/ui/lobby/customlobby/CustomLobbyMapPreview.lua b/lua/ui/lobby/customlobby/CustomLobbyMapPreview.lua index 208f726166b..20458ab54ac 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyMapPreview.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyMapPreview.lua @@ -20,84 +20,190 @@ --** SOFTWARE. --****************************************************************************************************** --- The in-lobby map preview: the shared scenario-preview surface (CustomLobbyScenarioPreview) --- with the lobby's chrome — a glow border (no dialog brackets; they don't suit a preview this --- small) — wrapped around it, bound to the --- launch model. +-- The map preview, as one whole: the chrome (a glow border + dark backdrop) wrapped around the +-- shared CustomLobbyScenarioPreview surface, plus the faction spawn icon (the local +-- `MapPreviewSpawn`). Both preview consumers use this one component, so they look identical: -- --- It owns the model wiring only: it subscribes to `ScenarioFile` (load the scenario, hand it to --- the surface, show/hide) and to each slot's player (refresh the surface's spawn data, no map --- reload). The texture / overlay / positioning work all lives in the surface, which the --- map-select dialog reuses too — see CustomLobbyScenarioPreview.lua. +-- * In the lobby — created with `Bound = true`. It subscribes to the launch model and renders the +-- committed `ScenarioFile` automatically (load via the catalog, hand to the surface, show/hide), +-- with per-slot faction-icon spawns refreshed as players take/swap/recolour (no map reload). -- --- Spawns render as faction icons (CustomLobbyMapPreviewSpawn): the surface calls each icon's --- :Update(faction) for a seated spot and :Reset() for an empty one, so empty spots stay blank. +-- * In the map-select dialog — created unbound (the default). No model wiring, numbered-dot spawns +-- (the surface default); the owner drives the preview itself through `self.Surface` +-- (`SetScenario` / `SetSpawnData` / `SetOverlayVisible` / `Clear`) to show the browse candidate, +-- and anchors its own overlays (name bar, info, …) to `self.Surface` — the inner map rect. +-- +-- Layout: this group is the OUTER rect (the glow fills it); the backdrop and the surface are inset +-- by `Padding`, so the map sits within and the glow frames it. The glow renders ON TOP of the map +-- (its depth is lifted above the surface), so the ring overlays the map's edges rather than hiding +-- behind them — the texture's centre is transparent, so the map shows through. +-- +-- The texture / overlay / positioning work all lives in the surface — see CustomLobbyScenarioPreview.lua. 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 local CustomLobbyScenarioPreview = import("/lua/ui/lobby/customlobby/customlobbyscenariopreview.lua") -local CustomLobbyMapPreviewSpawn = import("/lua/ui/lobby/customlobby/customlobbymappreviewspawn.lua") local CustomLobbyMapCatalog = import("/lua/ui/lobby/customlobby/mapselect/customlobbymapcatalog.lua") local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") local LazyVarDerive = import("/lua/lazyvar.lua").Derive +local Layouter = LayoutHelpers.ReusedLayoutFor + +local GlowTexture = '/scx_menu/gameselect/map-panel-glow_bmp.dds' +local BackdropColor = 'ff000000' +local Padding = 14 -- map inset from the outer edge; the glow ring lives in this margin +local SpawnIconSize = 32 + +-------------------------------------------------------------------------------------------------- +--#region Faction spawn icon + +-- A start-position marker: a faction icon placed at a spawn. Faction-only — no lobby coupling. +-- The surface calls `:Update(faction)` for a seated spot and `:Reset()` for an empty one (so empty +-- spots stay blank). Used only when the preview is `Bound`; unbound previews get numbered dots. + +---@class UICustomLobbyMapPreviewSpawn : Bitmap +---@field Faction? number +local MapPreviewSpawn = ClassUI(Bitmap) { + + EmptyPath = "/textures/ui/common/dialogs/mapselect02/commander_alpha.dds", + FactionIconPaths = { + "/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 UICustomLobbyMapPreviewSpawn + ---@param parent Control + __init = function(self, parent) + Bitmap.__init(self, parent, self.EmptyPath) + + self.Faction = nil + self:Hide() + end, + + ---@param self UICustomLobbyMapPreviewSpawn + ---@param parent Control + __post_init = function(self, parent) + Layouter(self):Width(SpawnIconSize):Height(SpawnIconSize):Over(parent, 32):End() + end, + + ---@param self UICustomLobbyMapPreviewSpawn + 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 UICustomLobbyMapPreviewSpawn + Show = function(self) + if self.Faction then + Bitmap.Show(self) + else + self:Hide() + end + end, + + ---@param self UICustomLobbyMapPreviewSpawn + ---@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, +} + +--#endregion + +-------------------------------------------------------------------------------------------------- +--#region Map preview + +---@class UICustomLobbyMapPreviewOptions +---@field Bound? boolean # subscribe to the launch model + use faction-icon spawns (default false) ---@class UICustomLobbyMapPreview : Group ---@field Trash TrashBag ----@field Overlay Bitmap +---@field Bound boolean +---@field Glow Bitmap +---@field Backdrop Bitmap ---@field Surface UICustomLobbyScenarioPreview ----@field ScenarioObserver LazyVar ----@field PlayerObservers LazyVar[] +---@field ScenarioObserver? LazyVar +---@field PlayerObservers? LazyVar[] local CustomLobbyMapPreview = ClassUI(Group) { ---@param self UICustomLobbyMapPreview ---@param parent Control - __init = function(self, parent) - Group.__init(self, parent) + ---@param options? UICustomLobbyMapPreviewOptions + __init = function(self, parent, options) + Group.__init(self, parent, "CustomLobbyMapPreview") + options = options or {} self.Trash = TrashBag() + self.Bound = options.Bound or false - self.Overlay = UIUtil.CreateBitmap(self, '/scx_menu/gameselect/map-panel-glow_bmp.dds') + -- chrome: a dark backdrop behind the map (shows through letterboxing / before the texture + -- loads); the glow ring is lifted on top of the map in __post_init to frame its edges + self.Backdrop = Bitmap(self) + self.Backdrop:SetSolidColor(BackdropColor) + self.Backdrop:DisableHitTest() - -- the shared surface, with faction icons for spawns + -- the shared surface; faction-icon spawns when bound, numbered dots (the default) otherwise self.Surface = CustomLobbyScenarioPreview.Create(self, { - CreateSpawnIcon = function(surface, index) - return CustomLobbyMapPreviewSpawn.Create(surface) - end, + CreateSpawnIcon = self.Bound and function(surface, index) + return MapPreviewSpawn(surface) + end or nil, }) - local model = CustomLobbyLaunchModel.GetSingleton() + self.Glow = UIUtil.CreateBitmap(self, GlowTexture) + self.Glow:DisableHitTest() - -- the scenario file drives the whole preview: render on change, hide when unset - self.ScenarioObserver = self.Trash:Add( - LazyVarDerive(model.ScenarioFile, function(scenarioFileLazy) - self:OnScenarioFileChanged(scenarioFileLazy()) - end)) - - -- each slot drives only the spawn icons (faction / position) — refreshed against the - -- already-loaded scenario, so a take/swap/faction change doesn't reload the map - self.PlayerObservers = {} - for slot = 1, CustomLobbyLaunchModel.MaxSlots do - self.PlayerObservers[slot] = self.Trash:Add( - LazyVarDerive(model.Players[slot], function(playerLazy) - playerLazy() - self:OnPlayersChanged() + -- bound: the launch model drives the preview. The scenario file renders the whole map; each + -- slot drives only the spawn icons (against the already-loaded scenario, so take/swap/faction + -- changes don't reload the map). Unbound: the owner drives self.Surface directly. + if self.Bound then + local model = CustomLobbyLaunchModel.GetSingleton() + self.ScenarioObserver = self.Trash:Add( + LazyVarDerive(model.ScenarioFile, function(scenarioFileLazy) + self:OnScenarioFileChanged(scenarioFileLazy()) end)) + + self.PlayerObservers = {} + for slot = 1, CustomLobbyLaunchModel.MaxSlots do + self.PlayerObservers[slot] = self.Trash:Add( + LazyVarDerive(model.Players[slot], function(playerLazy) + playerLazy() + self:OnPlayersChanged() + end)) + end end end, ---@param self UICustomLobbyMapPreview __post_init = function(self) - LayoutHelpers.ReusedLayoutFor(self.Overlay) - :Fill(self) - :DisableHitTest(true) - :End() - - LayoutHelpers.ReusedLayoutFor(self.Surface) - :FillFixedBorder(self.Overlay, 24) - :End() + Layouter(self.Glow):Fill(self):End() + Layouter(self.Backdrop):FillFixedBorder(self.Glow, Padding):End() + Layouter(self.Surface):FillFixedBorder(self.Glow, Padding):End() + + -- render the glow above the surface and all its overlays (spawn icons sit at + -- Preview.Depth()+10), so the ring frames the map's edges instead of hiding behind them + self.Glow.Depth:Set(function() return self.Surface.Depth() + 100 end) end, ---@param self UICustomLobbyMapPreview @@ -105,7 +211,7 @@ local CustomLobbyMapPreview = ClassUI(Group) { self.Trash:Destroy() end, - --- Loads the scenario and hands it to the surface; hides the preview when unset. + --- (Bound only) Loads the scenario and hands it to the surface; hides the preview when unset. ---@param self UICustomLobbyMapPreview ---@param scenarioFile FileName | false OnScenarioFileChanged = function(self, scenarioFile) @@ -127,7 +233,7 @@ local CustomLobbyMapPreview = ClassUI(Group) { self:Show() end, - --- A slot changed: refresh the surface's spawn data (faction by start spot). + --- (Bound only) A slot changed: refresh the surface's spawn data (faction by start spot). ---@param self UICustomLobbyMapPreview OnPlayersChanged = function(self) self.Surface:SetSpawnData(self:GatherSpawnData()) @@ -149,8 +255,11 @@ local CustomLobbyMapPreview = ClassUI(Group) { end, } +--#endregion + ---@param parent Control +---@param options? UICustomLobbyMapPreviewOptions ---@return UICustomLobbyMapPreview -Create = function(parent) - return CustomLobbyMapPreview(parent) +Create = function(parent, options) + return CustomLobbyMapPreview(parent, options) end diff --git a/lua/ui/lobby/customlobby/CustomLobbyMapPreviewSpawn.lua b/lua/ui/lobby/customlobby/CustomLobbyMapPreviewSpawn.lua deleted file mode 100644 index 561f97a2dc4..00000000000 --- a/lua/ui/lobby/customlobby/CustomLobbyMapPreviewSpawn.lua +++ /dev/null @@ -1,111 +0,0 @@ ---****************************************************************************************************** ---** Copyright (c) 2026 FAForever ---** ---** 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. ---****************************************************************************************************** - --- A start-position marker on the map preview: a faction icon placed at a spawn. Copied --- from the autolobby's AutolobbyMapPreviewSpawn and kept standalone so the custom lobby --- owns its own preview stack (see CustomLobbyMapPreview.lua). Faction-only — no lobby --- coupling. - -local UIUtil = import("/lua/ui/uiutil.lua") -local LayoutHelpers = import("/lua/maui/layouthelpers.lua") - -local Bitmap = import("/lua/maui/bitmap.lua").Bitmap - ----@class UICustomLobbyMapPreviewSpawn : Bitmap ----@field Icon Bitmap ----@field Faction? number -local CustomLobbyMapPreviewSpawn = 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 = { - "/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 UICustomLobbyMapPreviewSpawn - ---@param parent Control - __init = function(self, parent) - Bitmap.__init(self, parent, self.EmptyPath) - - self.Faction = nil - self:Hide() - end, - - ---@param self UICustomLobbyMapPreviewSpawn - ---@param parent Control - __post_init = function(self, parent) - LayoutHelpers.ReusedLayoutFor(self) - :Width(32) - :Height(32) - :Over(parent, 32) - :End() - end, - - ---@param self UICustomLobbyMapPreviewSpawn - 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 UICustomLobbyMapPreviewSpawn - Show = function(self) - if self.Faction then - Bitmap.Show(self) - else - self:Hide() - end - end, - - ---@param self UICustomLobbyMapPreviewSpawn - ---@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 UICustomLobbyMapPreviewSpawn -Create = function(parent) - return CustomLobbyMapPreviewSpawn(parent) -end diff --git a/lua/ui/lobby/customlobby/CustomLobbyScenarioPreview.lua b/lua/ui/lobby/customlobby/CustomLobbyScenarioPreview.lua index 6f932754889..70316a66c8b 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyScenarioPreview.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyScenarioPreview.lua @@ -22,9 +22,9 @@ -- A reusable map-preview *surface*: the scenario's map texture plus its overlays — start -- spots, resource deposits (mass / hydrocarbon) and prebuilt wrecks — placed at the right --- spots with aspect-correct maths. It is deliberately chrome-free: no title, border, glow or --- brackets. Owners wrap it and add their own decoration (the in-lobby `CustomLobbyMapPreview` --- adds a glow + brackets; the map-select dialog adds a title bar + frame). +-- spots with aspect-correct maths. It is deliberately chrome-free: no title, border or glow. +-- Owners wrap it and add their own decoration — `CustomLobbyMapPreview` adds the glow + backdrop +-- frame (and the map-select dialog layers a title bar on top of that). -- -- It exists so the two preview consumers share ONE implementation of the fiddly bits — the -- texture-leak-safe icon sharing, the aspect-correct positioning, the three-phase init — rather @@ -35,8 +35,8 @@ -- mapselect/CLAUDE.md). The map texture itself is one `MapPreview`; don't instantiate this -- control per list row. -- --- Spawn appearance is the owner's choice via the `CreateSpawnIcon` option: the in-lobby preview --- passes faction icons (`CustomLobbyMapPreviewSpawn`), the picker passes numbered dots (the +-- Spawn appearance is the owner's choice via the `CreateSpawnIcon` option: a bound +-- `CustomLobbyMapPreview` passes faction icons, the picker passes numbered dots (the -- default). A spawn icon may implement `:Update(data)` / `:Reset()`; the surface calls `Update` -- with `SetSpawnData()[index]` when present, else `Reset` — so faction icons hide on empty -- spots while numbered dots (no Update/Reset) stay shown. diff --git a/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua b/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua index eded10c0d57..a084d3f66b8 100644 --- a/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua +++ b/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua @@ -59,7 +59,7 @@ local LazyVarDerive = import("/lua/lazyvar.lua").Derive local Layouter = LayoutHelpers.ReusedLayoutFor -- flip to tint the areas while iterating -local Debug = true +local Debug = UpdateCurrentFactoryForQueueDisplay local PreviewSize = 225 -- the preview is square (FAF maps are square) local PreviewBlockHeight = PreviewSize + 46 -- preview + name + size/players line @@ -147,7 +147,7 @@ local CustomLobbyConfigInterface = ClassUI(Group) { self.TabContentArea = CreateArea(self, "TabContentArea", 'ff8040cc') -- pinned, persistent map block — never destroyed (the preview's textures aren't freed) - self.Preview = CustomLobbyMapPreview.Create(self.PreviewArea) + self.Preview = CustomLobbyMapPreview.Create(self.PreviewArea, { Bound = true }) self.Name = UIUtil.CreateText(self.PreviewArea, "", 16, UIUtil.titleFont) self.Name:DisableHitTest() self.Info = UIUtil.CreateText(self.PreviewArea, "", 13, UIUtil.bodyFont) diff --git a/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapSelect.lua b/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapSelect.lua index fa6fcdd8aae..2afa4be87ef 100644 --- a/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapSelect.lua +++ b/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapSelect.lua @@ -30,8 +30,8 @@ -- It is a transient picker, NOT a persistent model component: -- * it subscribes to the catalog (CustomLobbyMapCatalog), which streams maps in across frames, -- * it previews the *highlighted candidate* (decoupled from the launch model) via the shared --- scenario-preview surface (CustomLobbyScenarioPreview, same control the in-lobby preview --- uses), here with numbered-dot spawns + a title bar / url link layered on top, and +-- CustomLobbyMapPreview, created unbound so this dialog drives it (vs. the in-lobby preview's +-- model binding), here with numbered-dot spawns + a title bar / url link layered on top, and -- * on Select it calls the controller intent `RequestSetScenario(file)` — the same path a -- `/map ` chat command would use. It owns no synced state. -- @@ -54,7 +54,7 @@ local Popup = import("/lua/ui/controls/popups/popup.lua").Popup local Combo = import("/lua/ui/controls/combo.lua").Combo local TextArea = import("/lua/ui/controls/textarea.lua").TextArea -local CustomLobbyScenarioPreview = import("/lua/ui/lobby/customlobby/customlobbyscenariopreview.lua") +local CustomLobbyMapPreview = import("/lua/ui/lobby/customlobby/customlobbymappreview.lua") local CustomLobbyMapCatalog = import("/lua/ui/lobby/customlobby/mapselect/customlobbymapcatalog.lua") local CustomLobbyMapList = import("/lua/ui/lobby/customlobby/mapselect/customlobbymaplist.lua") local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") @@ -256,8 +256,8 @@ end ---@field SpawnsToggle Checkbox ---@field ResourcesToggle Checkbox ---@field WrecksToggle Checkbox +---@field Preview UICustomLobbyMapPreview ---@field Surface UICustomLobbyScenarioPreview ----@field PreviewBg Bitmap ---@field PreviewTitleBar Bitmap ---@field PreviewTitle Text ---@field InfoMeta Text @@ -382,13 +382,11 @@ local CustomLobbyMapSelect = ClassUI(Group) { --#endregion --#region preview area (toggles, preview, info, description) - -- the candidate preview surface (shared with the in-lobby preview); spawns render as - -- numbered dots (the surface default). Resources/wrecks start hidden — the toggles flip - -- them; spawns start shown. - self.PreviewBg = Bitmap(self.PreviewArea) - self.PreviewBg:SetSolidColor('ff000000') - self.PreviewBg:DisableHitTest() - self.Surface = CustomLobbyScenarioPreview.Create(self.PreviewArea) + -- the candidate preview — the same component as the in-lobby preview, created unbound so we + -- drive it ourselves (no model wiring); spawns render as numbered dots (the surface default). + -- Resources/wrecks start hidden — the toggles flip them; spawns start shown. + self.Preview = CustomLobbyMapPreview.Create(self.PreviewArea) + self.Surface = self.Preview.Surface self.Surface:SetOverlayVisible('resources', false) self.Surface:SetOverlayVisible('wrecks', false) @@ -540,12 +538,12 @@ local CustomLobbyMapSelect = ClassUI(Group) { Layouter(self.SpawnsToggle):AnchorToLeft(self.ResourcesToggle, 16):AtVerticalCenterIn(self.ResourcesToggle):End() Layouter(self.WrecksToggle):AnchorToRight(self.ResourcesToggle, 16):AtVerticalCenterIn(self.ResourcesToggle):End() - Layouter(self.Surface) + -- size the preview (glow + backdrop); the surface (the map) is inset within it. Overlays + -- below anchor to self.Surface, so they land on the map, not the glow margin. + Layouter(self.Preview) :AtHorizontalCenterIn(self.PreviewArea):AnchorToBottom(self.ResourcesToggle, 10) :Width(PreviewSize):Height(PreviewSize) :End() - Layouter(self.PreviewBg):Fill(self.Surface):End() - self.PreviewBg.Depth:Set(function() return self.Surface.Depth() - 1 end) -- map name overlaid across the top of the preview Layouter(self.PreviewTitleBar):AtLeftIn(self.Surface):AtRightIn(self.Surface):AtTopIn(self.Surface):Height(26):End() From 888ed9f0d8039e10c76e71d46c67af8a62bd3634 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Mon, 22 Jun 2026 20:58:37 +0200 Subject: [PATCH 33/98] Use the label/value format --- lua/ui/lobby/customlobby/CLAUDE.md | 36 ++++ .../mapselect/CustomLobbyMapSelect.lua | 198 ++++++++++++++---- lua/ui/lobby/customlobby/modselect/CLAUDE.md | 2 +- .../modselect/CustomLobbyModSelect.lua | 132 ++++++++++-- 4 files changed, 303 insertions(+), 65 deletions(-) diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index 8cc6053f55f..030909b5a2d 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -96,6 +96,42 @@ the map-select dialog (searchable list + preview), which sets `ScenarioFile` thr - A `Derive` handler must read its own LazyVar (`function(xLazy) self:OnX(xLazy()) end`). - FAF is Lua 5.0 — no `%` operator; use `math.mod`. +## The label/value detail format + +How we present a single entity's metadata (a map, a mod). Used by the in-lobby **Map tab** +([config/CustomLobbyMapPanel.lua](config/CustomLobbyMapPanel.lua)), the **map-select dialog** +([mapselect/CustomLobbyMapSelect.lua](mapselect/CustomLobbyMapSelect.lua)) and the **mod-select +dialog** ([modselect/CustomLobbyModSelect.lua](modselect/CustomLobbyModSelect.lua)) — they read +identically. + +**Shape (top → bottom):** + +- An optional **header** — the title (16–18pt `titleFont`) over a single **quick-facts line** of + the short, always-present facts joined by `" · "` (`20km · 8 players · v3`; `v3 · Game mod`), + centred. The map dialog/tab put the preview here; the mod dialog puts the icon. +- A stack of **labelled sections**, each a **dim label above its value**: + - **Label** — `UIUtil.CreateText(parent, text, 12, UIUtil.titleFont)`, colour `LabelColor` = + `'ff8a909a'`, hit-test off. Built by a local `CreateSectionLabel`. + - **Value** — 13pt `bodyFont` in `ValueColor` = `'ffc8ccd0'` for one-liners (author, reclaim), or + a `TextArea` (same `ValueColor`) for long text (description, dependencies). + - Examples: `Author` / `Description` / `Reclaim` (mass·energy icons) / `Dependencies` / `Details`. + +**Collapse + stacking.** A `LayoutSections` / `LayoutDetail` method stacks the *visible* sections +top-to-bottom, anchoring each under the previous and **skipping (hiding) the ones the entity +doesn't declare** so the rest floats up; the long text area (description / deps) fills the +remaining space. Anchor the value's left/right statically in `__post_init` but set the *tops* +in this method — and call it from the clear path too, so an empty open doesn't leave a control +unanchored (→ circular dependency at render). + +**TextArea width** still follows the gotcha below: bind `Width` to the laid-out span in +`Initialize` (post-mount), never `__post_init`. + +**On sharing.** Each consumer keeps its **own local copy** of these helpers (`CreateSectionLabel`, +`LayoutSections`/`LayoutDetail`, `FormatAmount`, the `LabelColor`/`ValueColor` constants) rather +than a shared component — a deliberate *drift-is-fine* call, so the three can diverge as each +context needs. If they start drifting in ways that matter, that's the signal to extract a shared +`CustomLobbyMapDetails`-style view. + ## Layout / init gotchas (learned building the map-select dialog) These are the recurring footguns when writing a custom control here — all variations of diff --git a/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapSelect.lua b/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapSelect.lua index 2afa4be87ef..ebcbc4c014f 100644 --- a/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapSelect.lua +++ b/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapSelect.lua @@ -78,6 +78,13 @@ local ActionHeight = 48 local FilterHeight = 134 local StatsHeight = 22 +-- map-detail presentation (mirrors the in-lobby Map tab — see config/CustomLobbyMapPanel.lua) +local IconSize = 14 +local LabelColor = 'ff8a909a' -- the section labels (Author / Reclaim / Description) +local ValueColor = 'ffc8ccd0' +local MassIcon = "/game/build-ui/icon-mass_bmp.dds" +local EnergyIcon = "/game/build-ui/icon-energy_bmp.dds" + local PrefsKey = "customlobby_mapselect" -- Filter dropdowns. The first entry is the "no filter" option; `value` matches the scenario @@ -180,6 +187,18 @@ local function ArmyCount(scenario) return armies and table.getsize(armies) or 0 end +--- Formats a resource amount compactly: 1016424 -> "1.0M", 119858 -> "120k", 950 -> "950". +---@param amount number +---@return string +local function FormatAmount(amount) + if amount >= 1000000 then + return string.format("%.1fM", amount / 1000000) + elseif amount >= 1000 then + return string.format("%.0fk", amount / 1000) + end + return string.format("%d", amount) +end + --- Applies a comparison operator. A nil/false target means "no filter" → always passes. ---@param value number ---@param op string @@ -258,12 +277,20 @@ end ---@field WrecksToggle Checkbox ---@field Preview UICustomLobbyMapPreview ---@field Surface UICustomLobbyScenarioPreview ----@field PreviewTitleBar Bitmap ---@field PreviewTitle Text ---@field InfoMeta Text ----@field Warning Text +---@field AuthorLabel Text +---@field AuthorValue Text +---@field ReclaimLabel Text +---@field ReclaimValue Group +---@field ReclaimMass Text +---@field ReclaimMassIcon Bitmap +---@field ReclaimEnergy Text +---@field ReclaimEnergyIcon Bitmap +---@field DescriptionLabel Text ---@field Description TextArea ---@field DescriptionScrollbar Scrollbar | false +---@field Warning Text ---@field UrlButton Text ---@field CurrentUrl string | false ---@field RandomButton Button @@ -400,15 +427,54 @@ local CustomLobbyMapSelect = ClassUI(Group) { function(checked) self.Surface:SetOverlayVisible('wrecks', checked) end, "Wrecks", "Show prebuilt wreckage (if the map defines any).") - self.PreviewTitleBar = Bitmap(self.PreviewArea) - self.PreviewTitleBar:SetSolidColor('aa0a0e12') - self.PreviewTitleBar:DisableHitTest() + -- map title + info line, centred below the preview (mirrors the in-lobby Map header) self.PreviewTitle = UIUtil.CreateText(self.PreviewArea, "", 16, UIUtil.titleFont) self.PreviewTitle:DisableHitTest() + self.InfoMeta = UIUtil.CreateText(self.PreviewArea, "", 13, UIUtil.bodyFont) + self.InfoMeta:SetColor('ff9aa0a8') + self.InfoMeta:DisableHitTest() + + -- labelled detail sections below, exactly as the Map tab (config/CustomLobbyMapPanel.lua): + --#region Author section + self.AuthorLabel = self:CreateSectionLabel("Author") + self.AuthorValue = UIUtil.CreateText(self.PreviewArea, "", 13, UIUtil.bodyFont) + self.AuthorValue:SetColor(ValueColor) + self.AuthorValue:DisableHitTest() + --#endregion + + --#region Reclaim section (amount + mass icon, amount + energy icon) + self.ReclaimLabel = self:CreateSectionLabel("Reclaim") + self.ReclaimValue = Group(self.PreviewArea, "CustomLobbyMapSelectReclaim") + self.ReclaimValue:DisableHitTest() + self.ReclaimMass = UIUtil.CreateText(self.ReclaimValue, "", 13, UIUtil.bodyFont) + self.ReclaimMass:SetColor(ValueColor) + self.ReclaimMass:DisableHitTest() + self.ReclaimMassIcon = Bitmap(self.ReclaimValue) + self.ReclaimMassIcon:SetTexture(UIUtil.UIFile(MassIcon)) + self.ReclaimMassIcon:DisableHitTest() + self.ReclaimEnergy = UIUtil.CreateText(self.ReclaimValue, "", 13, UIUtil.bodyFont) + self.ReclaimEnergy:SetColor(ValueColor) + self.ReclaimEnergy:DisableHitTest() + self.ReclaimEnergyIcon = Bitmap(self.ReclaimValue) + self.ReclaimEnergyIcon:SetTexture(UIUtil.UIFile(EnergyIcon)) + self.ReclaimEnergyIcon:DisableHitTest() + --#endregion + + --#region Description section + self.DescriptionLabel = self:CreateSectionLabel("Description") + self.Description = TextArea(self.PreviewArea, 200, 80) + self.Description:SetFont(UIUtil.bodyFont, 12) + self.Description:SetColors(ValueColor, "00000000", ValueColor, "00000000") + --#endregion + + -- file-health warning (dialog-only); pinned to the bottom of the preview column + self.Warning = UIUtil.CreateText(self.PreviewArea, "", 13, UIUtil.bodyFont) + self.Warning:SetColor('ffff6b6b') + self.Warning:DisableHitTest() - -- clickable "open page" link in the title bar; shown only when the map has an allowed url + -- "Open page" link → opens an allowed map url; secondary action, bottom-left (like the tab) self.CurrentUrl = false - self.UrlButton = UIUtil.CreateText(self.PreviewArea, "Open page", 12, UIUtil.bodyFont) + self.UrlButton = UIUtil.CreateText(self.ActionArea, "Open page", 12, UIUtil.bodyFont) self.UrlButton:SetColor('ff7fb3ff') self.UrlButton:Hide() self.UrlButton.HandleEvent = function(control, event) @@ -427,16 +493,6 @@ local CustomLobbyMapSelect = ClassUI(Group) { return false end Tooltip.AddControlTooltipManual(self.UrlButton, "Map page", "Open the map's web page in your browser.") - - self.InfoMeta = UIUtil.CreateText(self.PreviewArea, "", 13, UIUtil.bodyFont) - self.InfoMeta:SetColor('ffc8ccd0') - self.Warning = UIUtil.CreateText(self.PreviewArea, "", 13, UIUtil.bodyFont) - self.Warning:SetColor('ffff6b6b') - - self.Description = TextArea(self.PreviewArea, 200, 80) - self.Description:SetFont(UIUtil.bodyFont, 12) - self.Description:SetColors('ff8a909a', "00000000", 'ff8a909a', "00000000") - self.Description:SetTextAlignment(0.5) -- centre each line, matching the info above --#endregion --#region actions @@ -545,26 +601,25 @@ local CustomLobbyMapSelect = ClassUI(Group) { :Width(PreviewSize):Height(PreviewSize) :End() - -- map name overlaid across the top of the preview - Layouter(self.PreviewTitleBar):AtLeftIn(self.Surface):AtRightIn(self.Surface):AtTopIn(self.Surface):Height(26):End() - self.PreviewTitleBar.Depth:Set(function() return self.Surface.Depth() + 20 end) - Layouter(self.PreviewTitle):AtHorizontalCenterIn(self.Surface):AtVerticalCenterIn(self.PreviewTitleBar):End() - self.PreviewTitle.Depth:Set(function() return self.PreviewTitleBar.Depth() + 1 end) - - -- url link on the right of the title bar (shown only when the map has an allowed url) - Layouter(self.UrlButton):AtRightIn(self.Surface, 8):AtVerticalCenterIn(self.PreviewTitleBar):End() - self.UrlButton.Depth:Set(function() return self.PreviewTitleBar.Depth() + 2 end) - - -- info centred under the preview - Layouter(self.InfoMeta):AtHorizontalCenterIn(self.Surface):AnchorToBottom(self.Surface, 12):End() - Layouter(self.Warning):AtHorizontalCenterIn(self.Surface):AnchorToBottom(self.InfoMeta, 4):End() - - -- description sits under the preview, exactly as wide as the map (so it reads as - -- centred, since the preview is centred in its column); scrolls when it overflows - Layouter(self.Description) - :AtLeftIn(self.Surface):AnchorToBottom(self.Warning, 10):AtBottomIn(self.PreviewArea) - :End() - self.Description.Right:Set(function() return self.Surface.Right() end) + -- title + info line, centred under the preview (the labelled sections below are placed + -- dynamically by LayoutSections so empty ones collapse) + Layouter(self.PreviewTitle):AtHorizontalCenterIn(self.PreviewArea):AnchorToBottom(self.Preview, 8):End() + Layouter(self.InfoMeta):AtHorizontalCenterIn(self.PreviewArea):AnchorToBottom(self.PreviewTitle, 2):End() + + -- the reclaim value row: amount + mass icon, amount + energy icon (fixed internal layout; + -- the group's position is set by LayoutSections) + LayoutHelpers.SetHeight(self.ReclaimValue, IconSize + 4) + Layouter(self.ReclaimMass):AtLeftIn(self.ReclaimValue):AtVerticalCenterIn(self.ReclaimValue):End() + Layouter(self.ReclaimMassIcon):AnchorToRight(self.ReclaimMass, 3):AtVerticalCenterIn(self.ReclaimValue):Width(IconSize):Height(IconSize):End() + Layouter(self.ReclaimEnergy):AnchorToRight(self.ReclaimMassIcon, 10):AtVerticalCenterIn(self.ReclaimValue):End() + Layouter(self.ReclaimEnergyIcon):AnchorToRight(self.ReclaimEnergy, 3):AtVerticalCenterIn(self.ReclaimValue):Width(IconSize):Height(IconSize):End() + + -- file-health warning pinned to the bottom-left; the description floats above it + Layouter(self.Warning):AtLeftIn(self.PreviewArea, 6):AtBottomIn(self.PreviewArea):End() + + -- the description's left/right are fixed here so its Width can be bound in Initialize (the + -- TextArea reflows on the Width bind, which reads Left/Right); top/bottom set by LayoutSections + Layouter(self.Description):AtLeftIn(self.PreviewArea, 6):AtRightIn(self.PreviewArea, 24):End() self.DescriptionScrollbar = UIUtil.CreateVertScrollbarFor(self.Description) --#endregion @@ -573,6 +628,8 @@ local CustomLobbyMapSelect = ClassUI(Group) { Layouter(self.SelectButton):AnchorToLeft(self.CancelButton, 12):AtVerticalCenterIn(self.ActionArea):End() -- Random: horizontally centred under the left area, vertically centred in the actions Layouter(self.RandomButton):AtHorizontalCenterIn(self.LeftArea):AtVerticalCenterIn(self.ActionArea):End() + -- "Open page" link: secondary action, bottom-left + Layouter(self.UrlButton):AtLeftIn(self.ActionArea, 6):AtVerticalCenterIn(self.ActionArea):End() --#endregion self.MapList:AcquireKeyboardFocus(true) @@ -798,8 +855,52 @@ local CustomLobbyMapSelect = ClassUI(Group) { end end, - --- Fills the title overlay + info line (size · players · version), the url link, and the - --- description. + --- Builds a dim section label (Author / Reclaim / Description). Private. + ---@param self UICustomLobbyMapSelect + ---@param text string + ---@return Text + CreateSectionLabel = function(self, text) + local label = UIUtil.CreateText(self.PreviewArea, text, 12, UIUtil.titleFont) + label:SetColor(LabelColor) + label:DisableHitTest() + return label + end, + + --- Stacks the visible sections under the info line (collapsing absent ones) and floats the + --- description into the space above the warning. Mirrors the Map tab's LayoutSections. + ---@param self UICustomLobbyMapSelect + ---@param hasAuthor boolean + ---@param hasReclaim boolean + LayoutSections = function(self, hasAuthor, hasReclaim) + local prev = self.InfoMeta -- the sections begin under the info line + + -- places a label + value pair under `prev`, or hides both + local function place(label, value, visible) + if visible then + label:Show() + value:Show() + Layouter(label):AtLeftIn(self.PreviewArea, 6):AnchorToBottom(prev, 8):End() + Layouter(value):AtLeftIn(self.PreviewArea, 6):AnchorToBottom(label, 2):End() + prev = value + else + label:Hide() + value:Hide() + end + end + + place(self.AuthorLabel, self.AuthorValue, hasAuthor) + place(self.ReclaimLabel, self.ReclaimValue, hasReclaim) + + -- description always shows; its label sits under the last visible section + Layouter(self.DescriptionLabel):AtLeftIn(self.PreviewArea, 6):AnchorToBottom(prev, 8):End() + Layouter(self.Description) + :AtLeftIn(self.PreviewArea, 6):AtRightIn(self.PreviewArea, 24) + :AnchorToBottom(self.DescriptionLabel, 4):AnchorToTop(self.Warning, 8) + :End() + end, + + --- Fills the title + info line (size · players · version), the labelled sections (author / + --- reclaim / description) and the url link — collapsing sections the map doesn't provide. ---@param self UICustomLobbyMapSelect ---@param scenario UILobbyScenarioInfo UpdateInfo = function(self, scenario) @@ -807,8 +908,7 @@ local CustomLobbyMapSelect = ClassUI(Group) { local parts = {} if scenario.size then - table.insert(parts, string.format("%dkm x %dkm", - math.floor(scenario.size[1] / 50), math.floor(scenario.size[2] / 50))) + table.insert(parts, string.format("%dkm", math.floor(scenario.size[1] / 50))) end local players = ArmyCount(scenario) if players > 0 then @@ -819,6 +919,19 @@ local CustomLobbyMapSelect = ClassUI(Group) { end self.InfoMeta:SetText(table.concat(parts, " · ")) + local author = scenario.author + local reclaim = scenario.reclaim + local hasAuthor = type(author) == "string" and author ~= "" + local hasReclaim = type(reclaim) == "table" and reclaim[1] ~= nil and reclaim[2] ~= nil + if hasAuthor then + self.AuthorValue:SetText(author) + end + if hasReclaim then + self.ReclaimMass:SetText(FormatAmount(reclaim[1])) + self.ReclaimEnergy:SetText(FormatAmount(reclaim[2])) + end + self.Description:SetText(scenario.description and LOC(scenario.description) or "") + -- some scenarios carry a `url` to their source/page; offer to open allowed ones if scenario.url and IsAllowedUrl(scenario.url) then self.CurrentUrl = scenario.url @@ -828,7 +941,7 @@ local CustomLobbyMapSelect = ClassUI(Group) { self.UrlButton:Hide() end - self.Description:SetText(scenario.description and LOC(scenario.description) or "") + self:LayoutSections(hasAuthor, hasReclaim) UpdateTextAreaScrollbar(self.Description, self.DescriptionScrollbar) end, @@ -843,6 +956,7 @@ local CustomLobbyMapSelect = ClassUI(Group) { self.Description:SetText("") self.CurrentUrl = false self.UrlButton:Hide() + self:LayoutSections(false, false) UpdateTextAreaScrollbar(self.Description, self.DescriptionScrollbar) end, diff --git a/lua/ui/lobby/customlobby/modselect/CLAUDE.md b/lua/ui/lobby/customlobby/modselect/CLAUDE.md index cd695b2adc9..bc580e3b809 100644 --- a/lua/ui/lobby/customlobby/modselect/CLAUDE.md +++ b/lua/ui/lobby/customlobby/modselect/CLAUDE.md @@ -13,7 +13,7 @@ standalone (the main-menu mod manager use case). | File | Role | |------|------| -| [CustomLobbyModSelect.lua](CustomLobbyModSelect.lua) | the dialog (transient `Popup`). Same **areas** layout as the map dialog (title / left {filter, list, stats} / detail / actions — flip the module `Debug` flag to tint them). Left: name/author search + **Game / UI / Unavailable** type filters (persisted to Prefs) + a checkbox list; right: the highlighted mod's icon, author/version/type, website/source links (allowlisted → `OpenURL`), scrollable description, and a requires/conflicts/missing summary. Bottom: **presets** (load / save / delete) + OK / Cancel. Owns a *working selection set*, not synced state; on OK it hands the set to an `onConfirm` callback. | +| [CustomLobbyModSelect.lua](CustomLobbyModSelect.lua) | the dialog (transient `Popup`). Same **areas** layout as the map dialog (title / left {filter, list, stats} / detail / actions — flip the module `Debug` flag to tint them). Left: name/author search + **Game / UI / Unavailable** type filters (persisted to Prefs) + a checkbox list; right: the highlighted mod's icon + title + quick-facts (version · type) + website/source links (allowlisted → `OpenURL`), then **labelled sections** matching the map dialog / Map tab — **Author / Description / Dependencies** (requires/conflicts/missing) **/ Details** (copyright/uid/location), absent sections collapsing. Bottom: **presets** (load / save / delete) + OK / Cancel. Owns a *working selection set*, not synced state; on OK it hands the set to an `onConfirm` callback. | | [CustomLobbyModCatalog.lua](CustomLobbyModCatalog.lua) | the lobby's **own** mod data layer — **reference data, never synced** (each peer enumerates its own disk; only the host's sim-mod *choice* syncs). Async-streams classified, display-ready `UILobbyModInfo` entries into a `LazyVar` (built from `ModUtilities`, which fronts `/lua/mods.lua`), so the dialog shows a live "N mods" count and fills in progressively. `EnsureLoaded` / `GetModsVar` / `FindByUid` / `Refresh`. | | [CustomLobbyModList.lua](CustomLobbyModList.lua) | the dialog's scrollable, **virtualised** list: a fixed pool of rows reused while scrolling, each a **checkbox + name + type badge**. The list doesn't own the selection — it paints checkboxes from a set the dialog hands it (`SetChecked`) and asks a predicate whether each row may be toggled (`SetCanToggle`). `OnSelect` / `OnToggle` / `OnConfirm`. | diff --git a/lua/ui/lobby/customlobby/modselect/CustomLobbyModSelect.lua b/lua/ui/lobby/customlobby/modselect/CustomLobbyModSelect.lua index 9e462dc1d38..53f29cc8cf5 100644 --- a/lua/ui/lobby/customlobby/modselect/CustomLobbyModSelect.lua +++ b/lua/ui/lobby/customlobby/modselect/CustomLobbyModSelect.lua @@ -78,6 +78,10 @@ local ActionHeight = 120 -- two stacked rows: presets on top, OK / Cancel at th local FilterHeight = 120 local StatsHeight = 22 +-- detail-panel label/value presentation (mirrors the map dialog / Map tab) +local LabelColor = 'ff8a909a' -- the section labels (Author / Description / …) +local ValueColor = 'ffc8ccd0' + local PrefsKey = "customlobby_modselect" -- Mod web pages we'll open in a browser (mods carry `url` / `github`). Matched against the URL's @@ -220,10 +224,17 @@ end ---@field GithubButton Text ---@field CurrentUrl string | false ---@field CurrentGithub string | false +---@field AuthorLabel Text +---@field AuthorValue Text +---@field DescriptionLabel Text ---@field Description TextArea ---@field DescriptionScrollbar Scrollbar | false +---@field DepsLabel Text ---@field DepsText TextArea ---@field DepsScrollbar Scrollbar | false +---@field DetailsLabel Text +---@field DetailsText TextArea +---@field DetailsScrollbar Scrollbar | false ---@field Note Text ---@field PresetLabel Text ---@field PresetCombo Combo @@ -337,7 +348,7 @@ local CustomLobbyModSelect = ClassUI(Group) { self.DetailTitle = UIUtil.CreateText(self.PreviewArea, "", 18, UIUtil.titleFont) self.DetailTitle:DisableHitTest() self.DetailMeta = UIUtil.CreateText(self.PreviewArea, "", 13, UIUtil.bodyFont) - self.DetailMeta:SetColor('ffc8ccd0') + self.DetailMeta:SetColor(ValueColor) self.DetailMeta:DisableHitTest() self.CurrentUrl = false @@ -347,14 +358,28 @@ local CustomLobbyModSelect = ClassUI(Group) { self.GithubButton = self:CreateLink("Source", "Open the mod's source repository in your browser.", function() return self.CurrentGithub end) + -- labelled detail sections below the header (Author / Description / Dependencies / Details), + -- mirroring the map dialog / Map tab — see CreateSectionLabel + LayoutDetail + self.AuthorLabel = self:CreateSectionLabel("Author") + self.AuthorValue = UIUtil.CreateText(self.PreviewArea, "", 13, UIUtil.bodyFont) + self.AuthorValue:SetColor(ValueColor) + self.AuthorValue:DisableHitTest() + + self.DescriptionLabel = self:CreateSectionLabel("Description") self.Description = TextArea(self.PreviewArea, 200, 80) self.Description:SetFont(UIUtil.bodyFont, 12) - self.Description:SetColors('ffc8ccd0', "00000000", 'ffc8ccd0', "00000000") + self.Description:SetColors(ValueColor, "00000000", ValueColor, "00000000") + self.DepsLabel = self:CreateSectionLabel("Dependencies") self.DepsText = TextArea(self.PreviewArea, 200, 60) self.DepsText:SetFont(UIUtil.bodyFont, 12) self.DepsText:SetColors('ff9aa0a8', "00000000", 'ff9aa0a8', "00000000") + self.DetailsLabel = self:CreateSectionLabel("Details") + self.DetailsText = TextArea(self.PreviewArea, 200, 52) + self.DetailsText:SetFont(UIUtil.bodyFont, 12) + self.DetailsText:SetColors('ff9aa0a8', "00000000", 'ff9aa0a8', "00000000") + self.Note = UIUtil.CreateText(self.PreviewArea, "", 12, UIUtil.bodyFont) self.Note:SetColor('ffd0a24c') self.Note:DisableHitTest() @@ -472,25 +497,33 @@ local CustomLobbyModSelect = ClassUI(Group) { --#endregion --#region detail panel + -- header: icon + title + quick-facts meta (version · type) + links Layouter(self.Icon):AtLeftIn(self.PreviewArea):AtTopIn(self.PreviewArea):Width(IconSize):Height(IconSize):End() Layouter(self.DetailTitle):AnchorToRight(self.Icon, 12):AtTopIn(self.PreviewArea, 2):End() Layouter(self.DetailMeta):AnchorToRight(self.Icon, 12):AnchorToBottom(self.DetailTitle, 6):End() Layouter(self.UrlButton):AnchorToRight(self.Icon, 12):AnchorToBottom(self.DetailMeta, 8):End() Layouter(self.GithubButton):AnchorToRight(self.UrlButton, 16):AtVerticalCenterIn(self.UrlButton):End() - Layouter(self.Description) - :AtLeftIn(self.PreviewArea):AtRightIn(self.PreviewArea, 32) - :AnchorToBottom(self.Icon, 14):Height(160) + Layouter(self.Note):AtLeftIn(self.PreviewArea):AtBottomIn(self.PreviewArea):End() + + -- Details (copyright / uid / location): a fixed block pinned above the note + Layouter(self.DetailsText) + :AtLeftIn(self.PreviewArea, 6):AtRightIn(self.PreviewArea, 32) + :AnchorToTop(self.Note, 10):Height(52) :End() - self.DescriptionScrollbar = UIUtil.CreateVertScrollbarFor(self.Description) + Layouter(self.DetailsLabel):AtLeftIn(self.PreviewArea, 6):AnchorToTop(self.DetailsText, 2):End() + self.DetailsScrollbar = UIUtil.CreateVertScrollbarFor(self.DetailsText) - Layouter(self.Note):AtLeftIn(self.PreviewArea):AtBottomIn(self.PreviewArea):End() + -- Description: left/right + height fixed here (width is bound in Initialize); its top + the + -- Author section above it are placed by LayoutDetail so Author can collapse when absent + Layouter(self.Description):AtLeftIn(self.PreviewArea, 6):AtRightIn(self.PreviewArea, 32):Height(120):End() + self.DescriptionScrollbar = UIUtil.CreateVertScrollbarFor(self.Description) - -- dependency summary fills the gap between the description and the note line; scrolls if a - -- mod lists many requirements / conflicts + -- Dependencies: fills the gap between the description and the details block; scrolls + Layouter(self.DepsLabel):AtLeftIn(self.PreviewArea, 6):AnchorToBottom(self.Description, 10):End() Layouter(self.DepsText) - :AtLeftIn(self.PreviewArea):AtRightIn(self.PreviewArea, 32) - :AnchorToBottom(self.Description, 12):AnchorToTop(self.Note, 8) + :AtLeftIn(self.PreviewArea, 6):AtRightIn(self.PreviewArea, 32) + :AnchorToBottom(self.DepsLabel, 4):AnchorToTop(self.DetailsLabel, 8) :End() self.DepsScrollbar = UIUtil.CreateVertScrollbarFor(self.DepsText) --#endregion @@ -561,6 +594,40 @@ local CustomLobbyModSelect = ClassUI(Group) { return link end, + --- Builds a dim section label (Author / Description / Dependencies / Details). Private. + ---@param self UICustomLobbyModSelect + ---@param text string + ---@return Text + CreateSectionLabel = function(self, text) + local label = UIUtil.CreateText(self.PreviewArea, text, 12, UIUtil.titleFont) + label:SetColor(LabelColor) + label:DisableHitTest() + return label + end, + + --- Places the Author section (collapsing it when the mod declares no author) and the + --- Description label/area below it — the rest of the stack (Dependencies, Details) is anchored + --- statically off the Description in __post_init. + ---@param self UICustomLobbyModSelect + ---@param hasAuthor boolean + LayoutDetail = function(self, hasAuthor) + if hasAuthor then + self.AuthorLabel:Show() + self.AuthorValue:Show() + Layouter(self.AuthorLabel):AtLeftIn(self.PreviewArea, 6):AnchorToBottom(self.Icon, 14):End() + Layouter(self.AuthorValue):AtLeftIn(self.PreviewArea, 6):AnchorToBottom(self.AuthorLabel, 2):End() + Layouter(self.DescriptionLabel):AtLeftIn(self.PreviewArea, 6):AnchorToBottom(self.AuthorValue, 8):End() + else + self.AuthorLabel:Hide() + self.AuthorValue:Hide() + Layouter(self.DescriptionLabel):AtLeftIn(self.PreviewArea, 6):AnchorToBottom(self.Icon, 14):End() + end + Layouter(self.Description) + :AtLeftIn(self.PreviewArea, 6):AtRightIn(self.PreviewArea, 32) + :AnchorToBottom(self.DescriptionLabel, 4):Height(120) + :End() + end, + --- Builds the list pool + populates + wires the selection. Called by the opener after the --- dialog is mounted + centred by Popup (three-phase init, /lua/ui/CLAUDE.md § 1). ---@param self UICustomLobbyModSelect @@ -573,6 +640,7 @@ local CustomLobbyModSelect = ClassUI(Group) { -- ReflowText, which reads the parent geometry, circular until we're mounted. self.Description.Width:Set(function() return self.Description.Right() - self.Description.Left() end) self.DepsText.Width:Set(function() return self.DepsText.Right() - self.DepsText.Left() end) + self.DetailsText.Width:Set(function() return self.DetailsText.Right() - self.DetailsText.Left() end) self.ModList:Initialize() self.ModList:SetCanToggle(function(mod) @@ -718,8 +786,8 @@ local CustomLobbyModSelect = ClassUI(Group) { end end, - --- Fills the detail panel for `mod`: icon, title, author/version/type, links, description, - --- and the dependency summary. + --- Fills the detail panel for `mod` as labelled sections (Author / Description / Dependencies / + --- Details), plus the icon, title, quick-facts meta (version · type) and links. ---@param self UICustomLobbyModSelect ---@param mod UILobbyModInfo UpdateDetail = function(self, mod) @@ -729,8 +797,8 @@ local CustomLobbyModSelect = ClassUI(Group) { self.DetailTitle:SetText(Truncate(mod.title or mod.name or "?", TitleMaxChars)) + -- quick facts beside the icon (author moved to its own labelled section below) local parts = {} - table.insert(parts, "by " .. (mod.author or "UNKNOWN")) if mod.versionText ~= "" then table.insert(parts, mod.versionText) end @@ -742,20 +810,28 @@ local CustomLobbyModSelect = ClassUI(Group) { self.CurrentGithub = IsAllowedUrl(mod.github) and mod.github or false if self.CurrentGithub then self.GithubButton:Show() else self.GithubButton:Hide() end + local author = mod.author + local hasAuthor = type(author) == "string" and author ~= "" + if hasAuthor then + self.AuthorValue:SetText(author) + end + + self.DescriptionLabel:Show() self.Description:SetText(mod.description and LOC(mod.description) or "") - -- dependency summary, then the minor identity fields (uid / copyright / location) + -- Dependencies (requires / conflicts / missing) and Details (copyright / uid / location) + -- are now their own labelled sections local deps = self:DependencySummary(mod) - local info = self:ModInfoLines(mod) - if deps ~= "" and info ~= "" then - self.DepsText:SetText(deps .. "\n\n" .. info) - else - self.DepsText:SetText(deps ~= "" and deps or info) - end + self.DepsLabel:Show() + self.DepsText:SetText(deps ~= "" and deps or "None") + self.DetailsLabel:Show() + self.DetailsText:SetText(self:ModInfoLines(mod)) self.Note:SetText("") + self:LayoutDetail(hasAuthor) UpdateTextAreaScrollbar(self.Description, self.DescriptionScrollbar) UpdateTextAreaScrollbar(self.DepsText, self.DepsScrollbar) + UpdateTextAreaScrollbar(self.DetailsText, self.DetailsScrollbar) end, --- The minor identity fields shown at the bottom of the detail block: copyright, uid, and the @@ -796,14 +872,25 @@ local CustomLobbyModSelect = ClassUI(Group) { return table.concat(lines, "\n") end, - --- Clears the detail panel (no highlight / empty list). + --- Clears the detail panel (no highlight / empty list): blanks the values and hides the + --- section labels so no empty headings linger. ---@param self UICustomLobbyModSelect ClearDetail = function(self) + -- keep the stack anchored (Description's top lives in LayoutDetail) so an empty open + -- doesn't leave it unanchored → circular dependency at render + self:LayoutDetail(false) self.Icon:Hide() self.DetailTitle:SetText("") self.DetailMeta:SetText("") + self.AuthorLabel:Hide() + self.AuthorValue:SetText("") + self.AuthorValue:Hide() + self.DescriptionLabel:Hide() self.Description:SetText("") + self.DepsLabel:Hide() self.DepsText:SetText("") + self.DetailsLabel:Hide() + self.DetailsText:SetText("") self.Note:SetText("") self.CurrentUrl = false self.CurrentGithub = false @@ -811,6 +898,7 @@ local CustomLobbyModSelect = ClassUI(Group) { self.GithubButton:Hide() UpdateTextAreaScrollbar(self.Description, self.DescriptionScrollbar) UpdateTextAreaScrollbar(self.DepsText, self.DepsScrollbar) + UpdateTextAreaScrollbar(self.DetailsText, self.DetailsScrollbar) end, --- Updates the footer: "X of Y mods" + the selected breakdown ("G game · U UI"), so the host From ee2e3da41d59d9ef64ed201f71f0b374efe90d9f Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Mon, 22 Jun 2026 21:31:42 +0200 Subject: [PATCH 34/98] Implement the layout of Bobka --- lua/ui/lobby/customlobby/CLAUDE.md | 7 +- .../customlobby/CustomLobbyInterface.lua | 230 +++++++++--------- lua/ui/lobby/customlobby/CustomLobbyTabs.lua | 199 +++++++++++++++ .../customlobby/CustomLobbyTeamScore.lua | 225 +++++++++++++++++ .../config/CustomLobbyConfigInterface.lua | 83 ++++--- .../social/CustomLobbyChatPanel.lua | 58 +++++ .../social/CustomLobbyObserversPanel.lua | 93 +++++++ 7 files changed, 747 insertions(+), 148 deletions(-) create mode 100644 lua/ui/lobby/customlobby/CustomLobbyTabs.lua create mode 100644 lua/ui/lobby/customlobby/CustomLobbyTeamScore.lua create mode 100644 lua/ui/lobby/customlobby/social/CustomLobbyChatPanel.lua create mode 100644 lua/ui/lobby/customlobby/social/CustomLobbyObserversPanel.lua diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index 030909b5a2d..999d463bf0a 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -55,8 +55,11 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | [mapselect/](mapselect/CLAUDE.md) | the **map-select dialog** + its catalog and list, in their own folder (host-only `Popup`: searchable, filterable scenario list → `RequestSetScenario`). Self-contained sub-MVC; see [mapselect/CLAUDE.md](mapselect/CLAUDE.md) — including the **`MapPreview` texture-leak** writeup that shaped its design. | | [modselect/](modselect/CLAUDE.md) | the **mod-select dialog** + its catalog and list, built to the map-select shape (checkbox list + type filters + detail panel + **presets**). Returns a uid set; the opener routes it — sim mods → `RequestSetGameMods` (synced), UI mods → local prefs — or persists the lot standalone. Mod domain logic lives in [`/lua/ui/modutilities.lua`](/lua/ui/modutilities.lua) (the `maputil.lua` sibling, fronting `/lua/mods.lua`). See [modselect/CLAUDE.md](modselect/CLAUDE.md). | | [optionselect/](optionselect/CLAUDE.md) | the **options dialog**: three columns (lobby / scenario / mod options) over the selected scenario + mods, with search + hide-defaults filters; non-default options are marked. Derives the option *schema* per-peer (reference data) via [`/lua/ui/optionutil.lua`](/lua/ui/optionutil.lua); edits a working copy of the *values* and on OK routes the reconciled set through `RequestSetGameOptions` (synced via `GameOptions`). Host-only. See [optionselect/CLAUDE.md](optionselect/CLAUDE.md). | -| [CustomLobbyInterface.lua](CustomLobbyInterface.lua) | composition root, laid out in **areas** (title / left {slots, observers, chat} / right = the config component / actions — flip the module `Debug` flag to tint them), sized for the **1024×768** floor. The slots area sizes to the active `SlotCount` (1..MaxSlots) so observers + chat reflow on smaller maps. Holds two presentation observers (SlotCount, IsHost); basic buttons wired: Leave (Esc handler), **Observe** (`RequestMoveToObserver` on the local slot); Launch is a host-only disabled stub (no launch flow yet). Also the rows' drag coordinator (`UICustomLobbySlotCoordinator`: hit-test, drop-highlight, drag ghost → `RequestSwapSlots`); `OpenDebug()` / hot-reload. | -| [config/](config/) | the lobby's right column. [`CustomLobbyConfigInterface.lua`](config/CustomLobbyConfigInterface.lua) **pins the map block** (preview + name + size/players) at the top — built once, never destroyed, because the engine never frees the preview's textures (see [mapselect/CLAUDE.md](mapselect/CLAUDE.md)) — over a **tab strip** (Map / Options / Mods / Units). The active tab's content component is **created when its tab is selected and destroyed on switch** (`Tabs[i].Create` factory; only one panel alive at a time), so churn is cheap (text/grids, no leaking textures) and there's no hidden-panel bleed. Panels: [`CustomLobbyMapPanel`](config/CustomLobbyMapPanel.lua) (Change map — preview/name now pinned in the host), [`CustomLobbyOptionsPanel`](config/CustomLobbyOptionsPanel.lua) (read-only options grouped **Lobby / Scenario / Mods** + hide-defaults toggle; map/mod options flagged with a gold marker whose tooltip names the origin — schema via [`/lua/ui/optionutil.lua`](/lua/ui/optionutil.lua)), [`CustomLobbyModsPanel`](config/CustomLobbyModsPanel.lua) (enabled mods in **Game / UI** sections; UI mods are prefs, read on first render), [`CustomLobbyUnitsPanel`](config/CustomLobbyUnitsPanel.lua) (placeholder). Each panel self-subscribes to the model + `IsHost` for its own content/host-gating, and exposes `Initialize()` (the host calls it after sizing the panel — grids need a concrete height) + `Create(parent)`. | +| [CustomLobbyInterface.lua](CustomLobbyInterface.lua) | composition root, laid out in **areas** (flip the module `Debug` flag to tint them), sized for the **1024×768** floor: a **title bar** (title · team score · Leave) over a **slots region that owns the whole top** (the rows in **two columns** — odd slots left, even right — up to 16), and a fixed-height **bottom row** split into a left tabbed panel (Chat / Observers) and a right one (the config component) with a launch footer (status + host-only Launch stub). The slots are full-height columns; `SlotCount` just shows/hides rows. Holds the SlotCount + IsHost observers; Leave (Esc handler) + **Become observer** (`RequestMoveToObserver`, in the Observers tab). Also the rows' drag coordinator (`UICustomLobbySlotCoordinator`: hit-test, drop-highlight, drag ghost → `RequestSwapSlots`); `OpenDebug()` / hot-reload. | +| [CustomLobbyTeamScore.lua](CustomLobbyTeamScore.lua) | the **accumulated team rating** in the title — `Side A N · M Side B`. Shown only for the binary auto-team formations (`tvsb`→Top/Bottom, `lvsr`→Left/Right by start position; `pvsi`→Odd/Even by start-spot parity); **hidden** for `none`/`manual` (no 2-side split) or until a map's start positions are loaded. Reads the mode from `GameOptions().AutoTeams`, the ratings from each slot's `PL`. Reference data; never writes. | +| [CustomLobbyTabs.lua](CustomLobbyTabs.lua) | a **generic tabbed panel** (strip + content; one panel alive, created on select / destroyed on switch). Construct with a `{ Label, Create }` list + optional `OnSelect`. Used for the bottom-left (Chat / Observers); the config interface keeps its own bespoke variant (it coordinates the persistent preview). | +| [social/](social/) | the lobby's **bottom-left** column (the `CustomLobbyTabs` content): [`CustomLobbyChatPanel`](social/CustomLobbyChatPanel.lua) (the **Chat** tab — placeholder until the chat slice lands) and [`CustomLobbyObserversPanel`](social/CustomLobbyObserversPanel.lua) (the **Observers** tab — the shared observer list + a host-authoritative **Become observer** button → `RequestMoveToObserver`). Each is a tab content component (`Create(parent)`, created on select / destroyed on switch). | +| [config/](config/) | the lobby's **bottom-right** column. [`CustomLobbyConfigInterface.lua`](config/CustomLobbyConfigInterface.lua) is a **tab strip** (Map / Mods / Options / Restrictions) over a content area. The **map preview + name + info** are built once and **never destroyed** (the engine never frees the preview's textures — see [mapselect/CLAUDE.md](mapselect/CLAUDE.md)); they live in the content area, shown only on the **Map tab** (preview square on the left, name/info right, the Map panel's details below). On every other tab an opaque **Cover** sits over them (we *cover* rather than `Hide()` because the preview self-shows on a `ScenarioFile` change regardless of the active tab — the hidden-panel-bleed gotcha). The active tab's content component is **created on select / destroyed on switch** (`Tabs[i].Create`; one alive), so churn is cheap and bleed-free. Panels: [`CustomLobbyMapPanel`](config/CustomLobbyMapPanel.lua) (Change map + the label/value details), [`CustomLobbyOptionsPanel`](config/CustomLobbyOptionsPanel.lua) (read-only options grouped **Lobby / Scenario / Mods** + hide-defaults toggle; map/mod options gold-flagged with an origin tooltip — schema via [`/lua/ui/optionutil.lua`](/lua/ui/optionutil.lua)), [`CustomLobbyModsPanel`](config/CustomLobbyModsPanel.lua) (enabled mods in **Game / UI** sections), [`CustomLobbyUnitsPanel`](config/CustomLobbyUnitsPanel.lua) (the **Restrictions** tab — placeholder). Each panel self-subscribes to the model + `IsHost`, and exposes `Initialize()` + `Create(parent)`. | | [/lua/ui/lobby/lobby.lua](../lobby.lua) | engine entry wrapper (`CreateLobby`/`HostGame`/`JoinGame`) → CustomLobby. Old lobby preserved at `lobby-old.lua`. | Working today: host + clients see each other (host-authoritative player sync), the diff --git a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua index df61b82df4e..82084e36e00 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua @@ -21,20 +21,26 @@ --****************************************************************************************************** -- Composition root for the custom-games lobby. It builds and lays out the children and holds only --- the two presentation observers it needs (slot count, is-host); each child subscribes to the --- model itself (see /lua/ui/lobby/TARGET_ARCHITECTURE.md). +-- the presentation observers it needs (slot count, is-host); each child subscribes to the model +-- itself (see /lua/ui/lobby/TARGET_ARCHITECTURE.md). -- -- Layout is organised into labelled *areas* (Group containers), like the dialogs — flip the -- module-level `Debug` flag to tint each so the regions are visible while iterating. Targeted at -- the 1024x768 minimum resolution: -- --- ┌ TitleArea ─────────────────────────────────────────────┐ --- │ LeftArea (slots / observers / chat) │ ConfigInterface │ --- └ ActionArea ────────────────────────────────────────────┘ +-- ┌ TitleArea ─ title · TEAM SCORE · leave ─────────────────────────────┐ +-- │ SlotsArea (slots ONLY — up to 16, two columns) │ +-- │ │ +-- ├──────────────────────────────┬───────────────────────────────────────┤ +-- │ BottomLeftArea (Chat / │ BottomRightArea (Map / Mods / Options │ +-- │ Observers — tabs) │ / Restrictions — tabs) + launch │ +-- └──────────────────────────────┴───────────────────────────────────────┘ -- --- The right column is the CustomLobbyConfigInterface component (the Map / Options / Mods / Units --- tab panel); it owns its own tabs + host-gating. The interface keeps the slot grid, observer --- strip, chat, and the action bar (status + launch). +-- The top is dedicated to the slot rows (we expect up to 16). The bottom splits in two tabbed +-- panels: the left is chat/observers (CustomLobbyTabs), the right is the config tab panel +-- (CustomLobbyConfigInterface — Map / Mods / Options / Restrictions) over a launch footer. The +-- accumulated team rating (CustomLobbyTeamScore) sits in the title, shown only for the binary +-- auto-team formations. local UIUtil = import("/lua/ui/uiutil.lua") local LayoutHelpers = import("/lua/maui/layouthelpers.lua") @@ -48,15 +54,18 @@ local CustomLobbySessionModel = import("/lua/ui/lobby/customlobby/customlobbyses local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/customlobbylocalmodel.lua") local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") local CustomLobbySlotInterface = import("/lua/ui/lobby/customlobby/customlobbyslotinterface.lua") -local CustomLobbyObserversInterface = import("/lua/ui/lobby/customlobby/customlobbyobserversinterface.lua") local CustomLobbyConfigInterface = import("/lua/ui/lobby/customlobby/config/customlobbyconfiginterface.lua") +local CustomLobbyTeamScore = import("/lua/ui/lobby/customlobby/customlobbyteamscore.lua") +local CustomLobbyTabs = import("/lua/ui/lobby/customlobby/customlobbytabs.lua") +local CustomLobbyChatPanel = import("/lua/ui/lobby/customlobby/social/customlobbychatpanel.lua") +local CustomLobbyObserversPanel = import("/lua/ui/lobby/customlobby/social/customlobbyobserverspanel.lua") local LazyVarDerive = import("/lua/lazyvar.lua").Derive local Layouter = LayoutHelpers.ReusedLayoutFor -- flip to tint each layout area so the regions are visible while iterating -local Debug = true +local Debug = false -- the lobby content is designed for the 1024x768 floor; the root fills the frame (full-screen -- backdrop) but the content is centered and capped to this size, so it never stretches on a @@ -66,10 +75,10 @@ local LobbyHeight = 768 local Pad = 8 local SlotHeight = 24 -local RightWidth = 360 -local TitleHeight = 44 -local ActionHeight = 60 -local ObserverHeight = 44 +local TitleHeight = 48 +local BottomHeight = 300 -- the bottom row of tabbed panels; the slots take the rest above +local BottomRightWidth = 420 -- config (map/mods/options/restrictions); the chat/obs panel fills the rest +local LaunchFooterHeight = 44 -- status + launch, under the config tabs --- Creates a layout area (an invisible Group with an optional debug tint). ---@param parent Control @@ -93,19 +102,19 @@ end ---@field Content Group ---@field TitleArea Group ---@field Title Text +---@field TeamScore UICustomLobbyTeamScore ---@field LeaveButton Button ----@field LeftArea Group ---@field SlotsArea Group ---@field SlotsHeader Text ---@field SlotsPanel Group +---@field SlotColumns Group[] # [1] = left column, [2] = right column ---@field Slots UICustomLobbySlotInterface[] ----@field ObserversArea Group ----@field ObserversPanel UICustomLobbyObserversInterface ----@field SpectateButton Button ----@field ChatArea Group ----@field ChatPlaceholder Text +---@field BottomLeftArea Group +---@field BottomLeftTabs UICustomLobbyTabs +---@field BottomRightArea Group +---@field ConfigArea Group ---@field Config UICustomLobbyConfigInterface ----@field ActionArea Group +---@field LaunchFooter Group ---@field StatusLabel Text ---@field LaunchButton Button ---@field SlotCountObserver LazyVar @@ -132,20 +141,17 @@ local CustomLobbyInterface = Class(Group) { --#region areas self.TitleArea = CreateArea(self.Content, "TitleArea", 'ffcc4040') - self.ActionArea = CreateArea(self.Content, "ActionArea", 'ff808080') - self.LeftArea = CreateArea(self.Content, "LeftArea", 'ff4060cc') - self.SlotsArea = CreateArea(self.LeftArea, "SlotsArea", 'ffcccc40') - self.ObserversArea = CreateArea(self.LeftArea, "ObserversArea", 'ff40cccc') - self.ChatArea = CreateArea(self.LeftArea, "ChatArea", 'ff40cc60') + self.SlotsArea = CreateArea(self.Content, "SlotsArea", 'ffcccc40') + self.BottomLeftArea = CreateArea(self.Content, "BottomLeftArea", 'ff40cc60') + self.BottomRightArea = CreateArea(self.Content, "BottomRightArea", 'ff4060cc') --#endregion - -- the right column: the Map / Options / Mods / Units tab panel (owns its own tabs + gating) - self.Config = CustomLobbyConfigInterface.Create(self.Content) - - --#region title bar + --#region title bar (title · team score · leave) self.Title = UIUtil.CreateText(self.TitleArea, "Custom game", 20, UIUtil.titleFont) self.Title:DisableHitTest() + self.TeamScore = CustomLobbyTeamScore.Create(self.TitleArea) + self.LeaveButton = UIUtil.CreateButtonWithDropshadow(self.TitleArea, '/BUTTON/medium/', "Leave") self.LeaveButton.OnClick = function(button, modifiers) -- leaving disconnects + returns to the menu via the escape handler lobby.lua @@ -154,47 +160,45 @@ local CustomLobbyInterface = Class(Group) { end --#endregion - --#region slots + --#region slots (top region — two columns; odd slots left, even right) self.SlotsHeader = UIUtil.CreateText(self.SlotsArea, "Players", 14, UIUtil.titleFont) self.SlotsHeader:SetColor('ff9aa0a8') self.SlotsHeader:DisableHitTest() self.SlotsPanel = Group(self.SlotsArea, "CustomLobbySlotsPanel") + self.SlotColumns = { + Group(self.SlotsPanel, "CustomLobbySlotsLeft"), + Group(self.SlotsPanel, "CustomLobbySlotsRight"), + } - -- One row per possible slot; the SlotCount observer reveals the active ones. + -- One row per possible slot, placed in the left (odd) or right (even) column; the + -- SlotCount observer reveals the active ones. self.Slots = {} for slot = 1, CustomLobbyLaunchModel.MaxSlots do - self.Slots[slot] = CustomLobbySlotInterface.Create(self.SlotsPanel, slot, self) + local column = self.SlotColumns[math.mod(slot, 2) == 1 and 1 or 2] + self.Slots[slot] = CustomLobbySlotInterface.Create(column, slot, self) end --#endregion - --#region observers - self.ObserversPanel = CustomLobbyObserversInterface.Create(self.ObserversArea) - - -- everyone can drop to the observers; the move is host-authoritative (a client's click - -- asks the host through the intent) - self.SpectateButton = UIUtil.CreateButtonWithDropshadow(self.ObserversArea, '/BUTTON/medium/', "Observe") - self.SpectateButton.OnClick = function(button, modifiers) - local slot = self:FindLocalSlot() - if slot then - CustomLobbyController.RequestMoveToObserver(slot) - end - end - Tooltip.AddControlTooltipManual(self.SpectateButton, "Become observer", "Leave your slot and watch as an observer.") + --#region bottom-left: chat / observers tabs + self.BottomLeftTabs = CustomLobbyTabs.Create(self.BottomLeftArea, { + Tabs = { + { Label = "Chat", Create = CustomLobbyChatPanel.Create }, + { Label = "Observers", Create = CustomLobbyObserversPanel.Create }, + }, + }) --#endregion - --#region chat (placeholder until the lobby-chat slice lands) - self.ChatPlaceholder = UIUtil.CreateText(self.ChatArea, "Chat — coming soon", 14, UIUtil.bodyFont) - self.ChatPlaceholder:SetColor('ff5a606a') - self.ChatPlaceholder:DisableHitTest() - --#endregion + --#region bottom-right: config tabs (map / mods / options / restrictions) + launch footer + self.ConfigArea = Group(self.BottomRightArea, "CustomLobbyConfigArea") + self.Config = CustomLobbyConfigInterface.Create(self.ConfigArea) - --#region action bar - self.StatusLabel = UIUtil.CreateText(self.ActionArea, "", 14, UIUtil.bodyFont) + self.LaunchFooter = Group(self.BottomRightArea, "CustomLobbyLaunchFooter") + self.StatusLabel = UIUtil.CreateText(self.LaunchFooter, "", 13, UIUtil.bodyFont) self.StatusLabel:SetColor('ff9aa0a8') self.StatusLabel:DisableHitTest() - self.LaunchButton = UIUtil.CreateButtonWithDropshadow(self.ActionArea, '/BUTTON/large/', "Launch") + self.LaunchButton = UIUtil.CreateButtonWithDropshadow(self.LaunchFooter, '/BUTTON/large/', "Launch") self.LaunchButton.OnClick = function(button, modifiers) -- launch flow isn't wired up yet end @@ -229,84 +233,89 @@ local CustomLobbyInterface = Class(Group) { --#region areas Layouter(self.TitleArea):AtLeftIn(self.Content, Pad):AtRightIn(self.Content, Pad):AtTopIn(self.Content, Pad):Height(TitleHeight):End() - Layouter(self.ActionArea):AtLeftIn(self.Content, Pad):AtRightIn(self.Content, Pad):AtBottomIn(self.Content, Pad):Height(ActionHeight):End() - Layouter(self.Config) - :AtRightIn(self.Content, Pad):Width(RightWidth) - :AnchorToBottom(self.TitleArea, Pad):AnchorToTop(self.ActionArea, Pad) + + -- the bottom row of tabbed panels has a fixed height; the slots take everything above it. + -- the right (config) panel is a fixed width; the left (chat/observers) fills the rest + Layouter(self.BottomRightArea) + :AtRightIn(self.Content, Pad):Width(BottomRightWidth) + :AtBottomIn(self.Content, Pad):Height(BottomHeight) :End() - Layouter(self.LeftArea) - :AtLeftIn(self.Content, Pad):AnchorToLeft(self.Config, Pad) - :AnchorToBottom(self.TitleArea, Pad):AnchorToTop(self.ActionArea, Pad) + Layouter(self.BottomLeftArea) + :AtLeftIn(self.Content, Pad):AtBottomIn(self.Content, Pad):Height(BottomHeight) :End() + self.BottomLeftArea.Right:Set(function() return self.BottomRightArea.Left() - LayoutHelpers.ScaleNumber(Pad) end) - -- the slots area sizes to the *active* slot count (map-derived, 1..MaxSlots) so the - -- observers + chat below it reflow up on smaller maps; OnSlotCountChanged keeps it in sync - local slotCount = CustomLobbySessionModel.GetSingleton().SlotCount() Layouter(self.SlotsArea) - :AtLeftIn(self.LeftArea):AtRightIn(self.LeftArea):AtTopIn(self.LeftArea) - :Height(20 + slotCount * SlotHeight) - :End() - Layouter(self.ObserversArea) - :AtLeftIn(self.LeftArea):AtRightIn(self.LeftArea) - :AnchorToBottom(self.SlotsArea, Pad):Height(ObserverHeight) - :End() - Layouter(self.ChatArea) - :AtLeftIn(self.LeftArea):AtRightIn(self.LeftArea) - :AnchorToBottom(self.ObserversArea, Pad):AtBottomIn(self.LeftArea) + :AtLeftIn(self.Content, Pad):AtRightIn(self.Content, Pad) + :AnchorToBottom(self.TitleArea, Pad):AnchorToTop(self.BottomLeftArea, Pad) :End() --#endregion - --#region title bar + --#region title bar (title · team score · leave) Layouter(self.Title):AtLeftIn(self.TitleArea, 8):AtVerticalCenterIn(self.TitleArea):End() Layouter(self.LeaveButton):AtRightIn(self.TitleArea):AtVerticalCenterIn(self.TitleArea):End() + Layouter(self.TeamScore) + :AnchorToRight(self.Title, Pad):AnchorToLeft(self.LeaveButton, Pad) + :AtTopIn(self.TitleArea):AtBottomIn(self.TitleArea) + :End() --#endregion - --#region slots + --#region slots (two columns; rows stack within each) Layouter(self.SlotsHeader):AtLeftIn(self.SlotsArea, 4):AtTopIn(self.SlotsArea):End() Layouter(self.SlotsPanel) :AtLeftIn(self.SlotsArea):AtRightIn(self.SlotsArea) - :AnchorToBottom(self.SlotsHeader, 4) - :Height(slotCount * SlotHeight) + :AnchorToBottom(self.SlotsHeader, 4):AtBottomIn(self.SlotsArea) :End() - -- stack the rows top-to-bottom inside the panel via sibling anchoring + -- two columns split down the middle of the panel (a small gutter between) + Layouter(self.SlotColumns[1]):AtLeftIn(self.SlotsPanel):AtTopIn(self.SlotsPanel):AtBottomIn(self.SlotsPanel):End() + self.SlotColumns[1].Right:Set(function() return self.SlotsPanel.Left() + 0.5 * self.SlotsPanel.Width() - LayoutHelpers.ScaleNumber(6) end) + Layouter(self.SlotColumns[2]):AtRightIn(self.SlotsPanel):AtTopIn(self.SlotsPanel):AtBottomIn(self.SlotsPanel):End() + self.SlotColumns[2].Left:Set(function() return self.SlotsPanel.Left() + 0.5 * self.SlotsPanel.Width() + LayoutHelpers.ScaleNumber(6) end) + + -- stack each column's rows top-to-bottom (slot i sits under slot i-2, its column-mate) for slot = 1, CustomLobbyLaunchModel.MaxSlots do local row = self.Slots[slot] - local builder = Layouter(row) - :AtLeftIn(self.SlotsPanel) - :AtRightIn(self.SlotsPanel) - :Height(SlotHeight) - if slot == 1 then - builder:AtTopIn(self.SlotsPanel) + local column = self.SlotColumns[math.mod(slot, 2) == 1 and 1 or 2] + local builder = Layouter(row):AtLeftIn(column):AtRightIn(column):Height(SlotHeight) + if slot <= 2 then + builder:AtTopIn(column) else - builder:AnchorToBottom(self.Slots[slot - 1], 0) + builder:AnchorToBottom(self.Slots[slot - 2], 0) end builder:End() end --#endregion - --#region observers - Layouter(self.SpectateButton):AtRightIn(self.ObserversArea):AtVerticalCenterIn(self.ObserversArea):End() - Layouter(self.ObserversPanel) - :AtLeftIn(self.ObserversArea):AnchorToLeft(self.SpectateButton, Pad) - :AtTopIn(self.ObserversArea):AtBottomIn(self.ObserversArea) - :End() + --#region bottom-left tabs (chat / observers) + Layouter(self.BottomLeftTabs):Fill(self.BottomLeftArea):End() --#endregion - Layouter(self.ChatPlaceholder):AtHorizontalCenterIn(self.ChatArea):AtVerticalCenterIn(self.ChatArea):End() + --#region bottom-right: config tabs over a launch footer + Layouter(self.LaunchFooter) + :AtLeftIn(self.BottomRightArea):AtRightIn(self.BottomRightArea):AtBottomIn(self.BottomRightArea) + :Height(LaunchFooterHeight) + :End() + Layouter(self.ConfigArea) + :AtLeftIn(self.BottomRightArea):AtRightIn(self.BottomRightArea):AtTopIn(self.BottomRightArea) + :AnchorToTop(self.LaunchFooter, Pad) + :End() + Layouter(self.Config):Fill(self.ConfigArea):End() - --#region action bar - Layouter(self.StatusLabel):AtLeftIn(self.ActionArea, 8):AtVerticalCenterIn(self.ActionArea):End() - Layouter(self.LaunchButton):AtRightIn(self.ActionArea):AtVerticalCenterIn(self.ActionArea):End() + Layouter(self.StatusLabel):AtLeftIn(self.LaunchFooter, 4):AtVerticalCenterIn(self.LaunchFooter):End() + Layouter(self.LaunchButton):AtRightIn(self.LaunchFooter):AtVerticalCenterIn(self.LaunchFooter):End() --#endregion - -- the config panel is now sized; let it build its grids' scrollbars + first render - -- (three-phase init — its Grids need a concrete height) + -- size-dependent children build their scrollbars / first render now that they're sized + -- (three-phase init) + self.TeamScore:Initialize() + self.BottomLeftTabs:Initialize() self.Config:Initialize() end, - --- Shows the active slots (1..count), hides the rest, and resizes the slots area to fit them - --- (so the observers + chat below reflow to the map's actual slot count, up to MaxSlots). + --- Shows the active slots (1..count) and hides the rest. The two columns are full-height, so + --- the rows simply stack from the top of each — no area resizing needed (the slots own the + --- whole top region regardless of count). ---@param self UICustomLobbyInterface ---@param count number OnSlotCountChanged = function(self, count) @@ -317,8 +326,6 @@ local CustomLobbyInterface = Class(Group) { self.Slots[slot]:Hide() end end - self.SlotsPanel.Height:Set(LayoutHelpers.ScaleNumber(count * SlotHeight)) - self.SlotsArea.Height:Set(LayoutHelpers.ScaleNumber(20 + count * SlotHeight)) end, --- Tracks host status: updates the status line and shows the launch button only to the host. @@ -334,21 +341,6 @@ local CustomLobbyInterface = Class(Group) { end end, - --- The local player's slot (the one this peer owns), or nil if they're an observer / unseated. - ---@param self UICustomLobbyInterface - ---@return number | nil - FindLocalSlot = function(self) - local launch = CustomLobbyLaunchModel.GetSingleton() - local localId = CustomLobbyLocalModel.GetSingleton().LocalPeerId() - for slot = 1, CustomLobbyLaunchModel.MaxSlots do - local player = launch.Players[slot]() - if player and player.OwnerID == localId then - return slot - end - end - return nil - end, - --------------------------------------------------------------------------- --#region Slot drag coordination (UICustomLobbySlotCoordinator) -- @@ -542,6 +534,10 @@ function OpenDebug() { PlayerName = "Spag", OwnerID = "11", Human = true }, }) + -- an auto-team mode so the title's team score shows (pvsi splits by start-spot parity, so it + -- needs no map); the four debug players already carry a PL rating + StartSpot + launch.GameOptions:Set({ AutoTeams = 'pvsi' }) + -- a stock map so the preview renders; swap to any installed scenario if this -- one isn't present (an unknown path just leaves the preview frame empty) CustomLobbyLaunchModel.SetScenario(launch, "/maps/scmp_009/scmp_009_scenario.lua") diff --git a/lua/ui/lobby/customlobby/CustomLobbyTabs.lua b/lua/ui/lobby/customlobby/CustomLobbyTabs.lua new file mode 100644 index 00000000000..ff8fb790f1b --- /dev/null +++ b/lua/ui/lobby/customlobby/CustomLobbyTabs.lua @@ -0,0 +1,199 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- A generic tabbed panel: a strip of tab buttons over a content area. The active tab's content +-- component is **created when its tab is selected and destroyed when you switch away** — only one +-- panel is alive at a time, so churn is cheap and there's no hidden-panel bleed (the same model the +-- config interface uses). +-- +-- Construct with a tab list `{ { Label = "Chat", Create = fn }, … }`, where `Create(parent)` builds +-- the content component (which may expose an `Initialize()` the container calls after sizing it). +-- An optional `OnSelect(index, label)` fires on every switch — used when a persistent sibling (e.g. +-- the map preview, which can't be churned) must be shown/hidden alongside a tab. +-- +-- The parent sizes this control and calls `Initialize()` after mounting (so the first panel reads a +-- concrete height — three-phase init, /lua/ui/CLAUDE.md § 1). + +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 + +local Layouter = LayoutHelpers.ReusedLayoutFor + +local TabHeight = 26 +local TabWidth = 92 + +local TabIdleColor = 'ff141a20' +local TabHoverColor = 'ff1f262e' +local TabActiveColor = 'ff2c3e48' + +---@class UICustomLobbyTabsOptions +---@field Tabs { Label: string, Create: fun(parent: Control): Control }[] +---@field OnSelect? fun(index: number, label: string) + +---@class UICustomLobbyTabs : Group +---@field Trash TrashBag +---@field Tabs { Label: string, Create: fun(parent: Control): Control }[] +---@field OnSelectCb? fun(index: number, label: string) +---@field TabStripArea Group +---@field TabContentArea Group +---@field TabButtons Group[] +---@field ActiveTab number +---@field CurrentPanel Control | false +local CustomLobbyTabs = ClassUI(Group) { + + ---@param self UICustomLobbyTabs + ---@param parent Control + ---@param options UICustomLobbyTabsOptions + __init = function(self, parent, options) + Group.__init(self, parent, "CustomLobbyTabs") + + self.Trash = TrashBag() + self.Tabs = options.Tabs + self.OnSelectCb = options.OnSelect + self.ActiveTab = 1 + self.CurrentPanel = false + + self.TabStripArea = Group(self, "CustomLobbyTabsStrip") + self.TabContentArea = Group(self, "CustomLobbyTabsContent") + + self.TabButtons = {} + for index = 1, table.getn(self.Tabs) do + self.TabButtons[index] = self:CreateTabButton(self.Tabs[index].Label, index) + end + end, + + ---@param self UICustomLobbyTabs + __post_init = function(self) + Layouter(self.TabStripArea):AtLeftIn(self):AtRightIn(self):AtTopIn(self):Height(TabHeight):End() + Layouter(self.TabContentArea) + :AtLeftIn(self):AtRightIn(self) + :AnchorToBottom(self.TabStripArea, 6):AtBottomIn(self) + :End() + + for index = 1, table.getn(self.TabButtons) do + local button = self.TabButtons[index] + local builder = Layouter(button):AtTopIn(self.TabStripArea):Width(TabWidth):Height(TabHeight) + if index == 1 then + builder:AtLeftIn(self.TabStripArea) + else + builder:AnchorToRight(self.TabButtons[index - 1], 2) + end + builder:End() + Layouter(button.Bg):Fill(button):End() + Layouter(button.Label):AtHorizontalCenterIn(button):AtVerticalCenterIn(button):End() + button.Bg:SetSolidColor(index == self.ActiveTab and TabActiveColor or TabIdleColor) + end + end, + + --- Opens the initial tab. Called by the parent after it has sized this control (the content + --- needs a concrete height — three-phase init). + ---@param self UICustomLobbyTabs + Initialize = function(self) + self:SelectTab(self.ActiveTab) + end, + + --- Switches tabs: destroys the current content, builds the chosen one into the content area, + --- and recolours the buttons. Clicking the active tab again is a no-op. + ---@param self UICustomLobbyTabs + ---@param index number + SelectTab = function(self, index) + if self.ActiveTab == index and self.CurrentPanel then + return + end + self.ActiveTab = index + + for i = 1, table.getn(self.TabButtons) do + self.TabButtons[i].Bg:SetSolidColor(i == index and TabActiveColor or TabIdleColor) + end + + if self.CurrentPanel then + self.CurrentPanel:Destroy() + self.CurrentPanel = false + end + + local panel = self.Tabs[index].Create(self.TabContentArea) + Layouter(panel):Fill(self.TabContentArea):End() + if panel.Initialize then + panel:Initialize() + end + self.CurrentPanel = panel + + if self.OnSelectCb then + self.OnSelectCb(index, self.Tabs[index].Label) + end + end, + + --- Builds one clickable tab button (a tinted group + label). Private. + ---@param self UICustomLobbyTabs + ---@param label string + ---@param index number + ---@return Group + CreateTabButton = function(self, label, index) + local button = Group(self.TabStripArea, "CustomLobbyTabButton") + + button.Bg = Bitmap(button) + button.Bg:SetSolidColor(TabIdleColor) + + button.Label = UIUtil.CreateText(button, label, 13, UIUtil.titleFont) + button.Label:SetColor('ffc8ccd0') + button.Label:DisableHitTest() + + button.Bg.HandleEvent = function(control, event) + if event.Type == 'ButtonPress' then + self:SelectTab(index) + return true + elseif event.Type == 'MouseEnter' then + if self.ActiveTab ~= index then + button.Bg:SetSolidColor(TabHoverColor) + end + return true + elseif event.Type == 'MouseExit' then + if self.ActiveTab ~= index then + button.Bg:SetSolidColor(TabIdleColor) + end + return true + end + return false + end + + return button + end, + + ---@param self UICustomLobbyTabs + OnDestroy = function(self) + if self.CurrentPanel then + self.CurrentPanel:Destroy() + self.CurrentPanel = false + end + self.Trash:Destroy() + end, +} + +---@param parent Control +---@param options UICustomLobbyTabsOptions +---@return UICustomLobbyTabs +Create = function(parent, options) + return CustomLobbyTabs(parent, options) +end diff --git a/lua/ui/lobby/customlobby/CustomLobbyTeamScore.lua b/lua/ui/lobby/customlobby/CustomLobbyTeamScore.lua new file mode 100644 index 00000000000..a0fb9fc7ce5 --- /dev/null +++ b/lua/ui/lobby/customlobby/CustomLobbyTeamScore.lua @@ -0,0 +1,225 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- The accumulated team rating shown at the top of the lobby — `Side A 3150 · 3025 Side B`. +-- +-- It is shown ONLY for the binary auto-team formations, where the two sides are well-defined: +-- +-- tvsb → Top / Bottom (by start-position Y) +-- lvsr → Left / Right (by start-position X) +-- pvsi → Odd / Even (by start-spot parity — needs no map) +-- +-- For `none` / `manual` there's no reliable 2-side split, so the whole widget hides. The split +-- mirrors how auto-teams actually resolve at launch (start position), so the score reads true to +-- the map; the positional modes hide until a map (with start spots) is selected. +-- +-- Self-subscribes to the model: `GameOptions` (the `AutoTeams` mode), `ScenarioFile` (start +-- positions) and every slot's player (ratings). Reference data only — it never writes the model. + +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 CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") +local CustomLobbyMapCatalog = import("/lua/ui/lobby/customlobby/mapselect/customlobbymapcatalog.lua") + +local LazyVarDerive = import("/lua/lazyvar.lua").Derive +local Layouter = LayoutHelpers.ReusedLayoutFor + +-- side labels per mode; absent modes (none / manual) hide the widget +local ModeLabels = { + tvsb = { "Top", "Bottom" }, + lvsr = { "Left", "Right" }, + pvsi = { "Odd", "Even" }, +} + +---@class UICustomLobbyTeamScore : Group +---@field Trash TrashBag +---@field LabelA Text +---@field ScoreA Text +---@field Sep Text +---@field ScoreB Text +---@field LabelB Text +---@field Ready boolean +---@field GameOptionsObserver LazyVar +---@field ScenarioObserver LazyVar +---@field PlayerObservers LazyVar[] +local CustomLobbyTeamScore = ClassUI(Group) { + + ---@param self UICustomLobbyTeamScore + ---@param parent Control + __init = function(self, parent) + Group.__init(self, parent, "CustomLobbyTeamScore") + + self.Trash = TrashBag() + self.Ready = false + + self.LabelA = UIUtil.CreateText(self, "", 14, UIUtil.titleFont) + self.LabelA:SetColor('ff9aa0a8') + self.LabelA:DisableHitTest() + self.ScoreA = UIUtil.CreateText(self, "", 16, UIUtil.titleFont) + self.ScoreA:DisableHitTest() + self.Sep = UIUtil.CreateText(self, "·", 16, UIUtil.titleFont) + self.Sep:SetColor('ff5a606a') + self.Sep:DisableHitTest() + self.ScoreB = UIUtil.CreateText(self, "", 16, UIUtil.titleFont) + self.ScoreB:DisableHitTest() + self.LabelB = UIUtil.CreateText(self, "", 14, UIUtil.titleFont) + self.LabelB:SetColor('ff9aa0a8') + self.LabelB:DisableHitTest() + + local model = CustomLobbyLaunchModel.GetSingleton() + self.GameOptionsObserver = self.Trash:Add( + LazyVarDerive(model.GameOptions, function(lazy) lazy() self:Refresh() end)) + self.ScenarioObserver = self.Trash:Add( + LazyVarDerive(model.ScenarioFile, function(lazy) lazy() self:Refresh() end)) + self.PlayerObservers = {} + for slot = 1, CustomLobbyLaunchModel.MaxSlots do + self.PlayerObservers[slot] = self.Trash:Add( + LazyVarDerive(model.Players[slot], function(lazy) lazy() self:Refresh() end)) + end + end, + + ---@param self UICustomLobbyTeamScore + __post_init = function(self) + -- centred row: LabelA ScoreA · ScoreB LabelB + Layouter(self.Sep):AtHorizontalCenterIn(self):AtVerticalCenterIn(self):End() + Layouter(self.ScoreA):AnchorToLeft(self.Sep, 10):AtVerticalCenterIn(self):End() + Layouter(self.LabelA):AnchorToLeft(self.ScoreA, 8):AtVerticalCenterIn(self):End() + Layouter(self.ScoreB):AnchorToRight(self.Sep, 10):AtVerticalCenterIn(self):End() + Layouter(self.LabelB):AnchorToRight(self.ScoreB, 8):AtVerticalCenterIn(self):End() + end, + + --- Builds the score after the parent has placed the widget (kept symmetric with the panels). + ---@param self UICustomLobbyTeamScore + Initialize = function(self) + self.Ready = true + self:Refresh() + end, + + --- Recomputes the two side totals for the current auto-team mode, or hides the widget when the + --- mode has no 2-side split (none / manual) or the sides can't be determined yet (no map). + ---@param self UICustomLobbyTeamScore + Refresh = function(self) + if not self.Ready then + return + end + local model = CustomLobbyLaunchModel.GetSingleton() + local mode = (model.GameOptions() or {}).AutoTeams + local labels = ModeLabels[mode] + if not labels then + self:Hide() + return + end + + local totals = self:Totals(mode) + if not totals then + self:Hide() + return + end + + self.LabelA:SetText(labels[1]) + self.ScoreA:SetText(tostring(totals[1])) + self.LabelB:SetText(labels[2]) + self.ScoreB:SetText(tostring(totals[2])) + self:Show() + end, + + --- The accumulated rating per side `{ a, b }` for `mode`, or nil if it can't be determined + --- (a positional mode with no map / start positions loaded yet). + ---@param self UICustomLobbyTeamScore + ---@param mode string + ---@return number[] | nil + Totals = function(self, mode) + local model = CustomLobbyLaunchModel.GetSingleton() + + -- start positions (centre split) — only needed for the positional modes + local positions, centreX, centreZ + if mode == 'tvsb' or mode == 'lvsr' then + local scenarioFile = model.ScenarioFile() + local info = scenarioFile and CustomLobbyMapCatalog.LoadInfo(scenarioFile) + if type(info) ~= "table" or not info.size then + return nil + end + positions = MapUtil.GetStartPositionsFromScenario(info, CustomLobbyMapCatalog.LoadSave(info)) + if not positions then + return nil + end + centreX = info.size[1] / 2 + centreZ = info.size[2] / 2 + end + + local a, b = 0, 0 + for slot = 1, CustomLobbyLaunchModel.MaxSlots do + local player = model.Players[slot]() + if player then + local rating = player.PL or 0 + local side = self:SideOf(mode, player, positions, centreX, centreZ) + if side == 1 then + a = a + rating + elseif side == 2 then + b = b + rating + end + end + end + return { math.floor(a), math.floor(b) } + end, + + --- Which side (1 or 2, else nil) a player falls on for `mode`. Positional modes read the + --- player's start spot against the map centre; pvsi uses the start spot's parity. + ---@param self UICustomLobbyTeamScore + ---@param mode string + ---@param player UICustomLobbyPlayer + ---@param positions? table + ---@param centreX? number + ---@param centreZ? number + ---@return number | nil + SideOf = function(self, mode, player, positions, centreX, centreZ) + local spot = player.StartSpot + if mode == 'pvsi' then + if not spot then return nil end + return (math.mod(spot, 2) == 1) and 1 or 2 + end + + local pos = spot and positions and positions[spot] + if not pos then + return nil + end + if mode == 'tvsb' then + return (pos[2] < centreZ) and 1 or 2 + else -- lvsr + return (pos[1] < centreX) and 1 or 2 + end + end, + + ---@param self UICustomLobbyTeamScore + OnDestroy = function(self) + self.Trash:Destroy() + end, +} + +---@param parent Control +---@return UICustomLobbyTeamScore +Create = function(parent) + return CustomLobbyTeamScore(parent) +end diff --git a/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua b/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua index a084d3f66b8..d7bc6063d04 100644 --- a/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua +++ b/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua @@ -59,24 +59,26 @@ local LazyVarDerive = import("/lua/lazyvar.lua").Derive local Layouter = LayoutHelpers.ReusedLayoutFor -- flip to tint the areas while iterating -local Debug = UpdateCurrentFactoryForQueueDisplay +local Debug = false -local PreviewSize = 225 -- the preview is square (FAF maps are square) -local PreviewBlockHeight = PreviewSize + 46 -- preview + name + size/players line -local TabHeight = 28 -local TabWidth = 88 -local NameMaxChars = 32 +local PreviewMaxSize = 150 -- the preview is square, capped to this (FAF maps are square) +local TabHeight = 26 +local TabWidth = 92 +local NameMaxChars = 28 local TabIdleColor = 'ff141a20' local TabHoverColor = 'ff1f262e' local TabActiveColor = 'ff2c3e48' +local CoverColor = 'ff0a0e12' -- opaque backing that hides the preview off the Map tab --- the four tabs, in order, each with the factory that builds its content component on demand +-- the four tabs, in order, each with the factory that builds its content component on demand. The +-- Map tab (index 1) is special: it shows the persistent preview block (see SelectTab). +local MapTabIndex = 1 local Tabs = { - { Label = "Map", Create = CustomLobbyMapPanel.Create }, - { Label = "Options", Create = CustomLobbyOptionsPanel.Create }, - { Label = "Mods", Create = CustomLobbyModsPanel.Create }, - { Label = "Units", Create = CustomLobbyUnitsPanel.Create }, + { Label = "Map", Create = CustomLobbyMapPanel.Create }, + { Label = "Mods", Create = CustomLobbyModsPanel.Create }, + { Label = "Options", Create = CustomLobbyOptionsPanel.Create }, + { Label = "Restrictions", Create = CustomLobbyUnitsPanel.Create }, } --- Truncates `text` to `maxChars`, appending "…" when it had to cut. @@ -121,12 +123,12 @@ end ---@class UICustomLobbyConfigInterface : Group ---@field Trash TrashBag ----@field PreviewArea Group +---@field TabStripArea Group +---@field TabContentArea Group ---@field Preview UICustomLobbyMapPreview ---@field Name Text ---@field Info Text ----@field TabStripArea Group ----@field TabContentArea Group +---@field Cover Bitmap # opaque backing shown over the content (hiding the preview) off the Map tab ---@field TabButtons Group[] ---@field ActiveTab number ---@field CurrentPanel Control | false # the live tab content component (others are destroyed) @@ -142,18 +144,24 @@ local CustomLobbyConfigInterface = ClassUI(Group) { self.ActiveTab = 1 self.CurrentPanel = false - self.PreviewArea = CreateArea(self, "PreviewArea", 'ffcc40cc') self.TabStripArea = CreateArea(self, "TabStripArea", 'ffcc8040') self.TabContentArea = CreateArea(self, "TabContentArea", 'ff8040cc') - -- pinned, persistent map block — never destroyed (the preview's textures aren't freed) - self.Preview = CustomLobbyMapPreview.Create(self.PreviewArea, { Bound = true }) - self.Name = UIUtil.CreateText(self.PreviewArea, "", 16, UIUtil.titleFont) + -- the persistent map block — created once, NEVER destroyed (the preview's textures aren't + -- freed). It lives in the content area and is shown only on the Map tab; on other tabs the + -- opaque Cover (created after it, so it's on top) hides it. We cover rather than hide it + -- because the preview self-shows on a ScenarioFile change regardless of the active tab. + self.Preview = CustomLobbyMapPreview.Create(self.TabContentArea, { Bound = true }) + self.Name = UIUtil.CreateText(self.TabContentArea, "", 16, UIUtil.titleFont) self.Name:DisableHitTest() - self.Info = UIUtil.CreateText(self.PreviewArea, "", 13, UIUtil.bodyFont) + self.Info = UIUtil.CreateText(self.TabContentArea, "", 13, UIUtil.bodyFont) self.Info:SetColor('ff9aa0a8') self.Info:DisableHitTest() + self.Cover = Bitmap(self.TabContentArea) + self.Cover:SetSolidColor(CoverColor) + self.Cover:DisableHitTest() + self.ScenarioObserver = self.Trash:Add( LazyVarDerive(CustomLobbyLaunchModel.GetSingleton().ScenarioFile, function(scenarioFileLazy) self:RefreshMapInfo(scenarioFileLazy()) @@ -167,19 +175,25 @@ local CustomLobbyConfigInterface = ClassUI(Group) { ---@param self UICustomLobbyConfigInterface __post_init = function(self) - Layouter(self.PreviewArea):AtLeftIn(self):AtRightIn(self):AtTopIn(self):Height(PreviewBlockHeight):End() - Layouter(self.TabStripArea):AtLeftIn(self):AtRightIn(self):AnchorToBottom(self.PreviewArea, 8):Height(TabHeight):End() + -- tab strip on top, content fills the rest + Layouter(self.TabStripArea):AtLeftIn(self):AtRightIn(self):AtTopIn(self):Height(TabHeight):End() Layouter(self.TabContentArea) :AtLeftIn(self):AtRightIn(self) :AnchorToBottom(self.TabStripArea, 6):AtBottomIn(self) :End() - Layouter(self.Preview) - :AtHorizontalCenterIn(self.PreviewArea):AtTopIn(self.PreviewArea, 4) - :Width(PreviewSize):Height(PreviewSize) - :End() - Layouter(self.Name):AtHorizontalCenterIn(self.PreviewArea):AnchorToBottom(self.Preview, 6):End() - Layouter(self.Info):AtHorizontalCenterIn(self.PreviewArea):AnchorToBottom(self.Name, 2):End() + -- the preview is a square pinned to the content area's top-left, capped to PreviewMaxSize; + -- name + info sit to its right. SelectTab shows/positions the rest of the Map tab. + Layouter(self.Preview):AtLeftIn(self.TabContentArea):AtTopIn(self.TabContentArea):End() + self.Preview.Height:Set(function() + return math.min(self.TabContentArea.Height(), LayoutHelpers.ScaleNumber(PreviewMaxSize)) + end) + self.Preview.Width:Set(function() return self.Preview.Height() end) + Layouter(self.Name):AnchorToRight(self.Preview, 10):AtTopIn(self.TabContentArea, 2):End() + Layouter(self.Info):AnchorToRight(self.Preview, 10):AnchorToBottom(self.Name, 2):End() + + -- the cover sits above the preview block (created after it); shown on every tab but Map + Layouter(self.Cover):Fill(self.TabContentArea):End() for index = 1, table.getn(self.TabButtons) do local button = self.TabButtons[index] @@ -291,9 +305,20 @@ local CustomLobbyConfigInterface = ClassUI(Group) { self.CurrentPanel = false end - -- build the new tab's content, size it, then let it read its (now concrete) geometry + -- build the new tab's content, size it, then let it read its (now concrete) geometry. On + -- the Map tab the preview block shows (cover hidden) and the panel fills the space to the + -- right of / below it; on every other tab the cover hides the preview and the panel fills. local panel = Tabs[index].Create(self.TabContentArea) - Layouter(panel):Fill(self.TabContentArea):End() + if index == MapTabIndex then + self.Cover:Hide() + Layouter(panel) + :AnchorToRight(self.Preview, 10):AtRightIn(self.TabContentArea) + :AnchorToBottom(self.Info, 8):AtBottomIn(self.TabContentArea) + :End() + else + self.Cover:Show() + Layouter(panel):Fill(self.TabContentArea):End() + end panel:Initialize() self.CurrentPanel = panel end, diff --git a/lua/ui/lobby/customlobby/social/CustomLobbyChatPanel.lua b/lua/ui/lobby/customlobby/social/CustomLobbyChatPanel.lua new file mode 100644 index 00000000000..7ce945384f0 --- /dev/null +++ b/lua/ui/lobby/customlobby/social/CustomLobbyChatPanel.lua @@ -0,0 +1,58 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- The Chat tab of the lobby's bottom-left tabbed panel — a placeholder until the lobby-chat slice +-- lands. A bottom-left tab content component: created when its tab is selected and destroyed on +-- switch (see ../CustomLobbyTabs.lua), so it's the live panel for its whole lifetime. + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") + +local Group = import("/lua/maui/group.lua").Group + +local Layouter = LayoutHelpers.ReusedLayoutFor + +---@class UICustomLobbyChatPanel : Group +---@field Placeholder Text +local CustomLobbyChatPanel = ClassUI(Group) { + + ---@param self UICustomLobbyChatPanel + ---@param parent Control + __init = function(self, parent) + Group.__init(self, parent, "CustomLobbyChatPanel") + + self.Placeholder = UIUtil.CreateText(self, "Chat — coming soon", 14, UIUtil.bodyFont) + self.Placeholder:SetColor('ff5a606a') + self.Placeholder:DisableHitTest() + end, + + ---@param self UICustomLobbyChatPanel + __post_init = function(self) + Layouter(self.Placeholder):AtHorizontalCenterIn(self):AtVerticalCenterIn(self):End() + end, +} + +---@param parent Control +---@return UICustomLobbyChatPanel +Create = function(parent) + return CustomLobbyChatPanel(parent) +end diff --git a/lua/ui/lobby/customlobby/social/CustomLobbyObserversPanel.lua b/lua/ui/lobby/customlobby/social/CustomLobbyObserversPanel.lua new file mode 100644 index 00000000000..1efb6da42ed --- /dev/null +++ b/lua/ui/lobby/customlobby/social/CustomLobbyObserversPanel.lua @@ -0,0 +1,93 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- The Observers tab of the lobby's bottom-left tabbed panel: the observer list (the shared +-- CustomLobbyObserversInterface, which self-subscribes to the model's `Observers`) plus a "Become +-- observer" button. Everyone may drop to observers; the move is host-authoritative — a client's +-- click asks the host through the `RequestMoveToObserver` intent. +-- +-- A bottom-left tab content component: created when its tab is selected and destroyed on switch +-- (see ../CustomLobbyTabs.lua). + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") +local Tooltip = import("/lua/ui/game/tooltip.lua") + +local Group = import("/lua/maui/group.lua").Group +local CustomLobbyObserversInterface = import("/lua/ui/lobby/customlobby/customlobbyobserversinterface.lua") +local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") +local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/customlobbylocalmodel.lua") + +local Layouter = LayoutHelpers.ReusedLayoutFor + +--- The local player's slot (the one this peer owns), or nil if they're an observer / unseated. +---@return number | nil +local function FindLocalSlot() + local launch = CustomLobbyLaunchModel.GetSingleton() + local localId = CustomLobbyLocalModel.GetSingleton().LocalPeerId() + for slot = 1, CustomLobbyLaunchModel.MaxSlots do + local player = launch.Players[slot]() + if player and player.OwnerID == localId then + return slot + end + end + return nil +end + +---@class UICustomLobbyObserversPanel : Group +---@field List UICustomLobbyObserversInterface +---@field ObserveButton Button +local CustomLobbyObserversPanel = ClassUI(Group) { + + ---@param self UICustomLobbyObserversPanel + ---@param parent Control + __init = function(self, parent) + Group.__init(self, parent, "CustomLobbyObserversPanel") + + self.ObserveButton = UIUtil.CreateButtonWithDropshadow(self, '/BUTTON/medium/', "Become observer") + self.ObserveButton.OnClick = function(button, modifiers) + local slot = FindLocalSlot() + if slot then + CustomLobbyController.RequestMoveToObserver(slot) + end + end + Tooltip.AddControlTooltipManual(self.ObserveButton, "Become observer", "Leave your slot and watch as an observer.") + + self.List = CustomLobbyObserversInterface.Create(self) + end, + + ---@param self UICustomLobbyObserversPanel + __post_init = function(self) + Layouter(self.ObserveButton):AtHorizontalCenterIn(self):AtBottomIn(self, 4):End() + Layouter(self.List) + :AtLeftIn(self):AtRightIn(self):AtTopIn(self) + :AnchorToTop(self.ObserveButton, 6) + :End() + end, +} + +---@param parent Control +---@return UICustomLobbyObserversPanel +Create = function(parent) + return CustomLobbyObserversPanel(parent) +end From 3e6dd39c84c77adcdbf3798c266d71a1f453fc2d Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Mon, 22 Jun 2026 22:25:14 +0200 Subject: [PATCH 35/98] Provide more space for the map preview --- lua/ui/lobby/USER_STORIES.md | 4 + lua/ui/lobby/customlobby/CLAUDE.md | 6 +- .../customlobby/CustomLobbyController.lua | 30 +- .../customlobby/CustomLobbyInterface.lua | 85 ++--- .../config/CustomLobbyConfigInterface.lua | 316 +----------------- .../config/CustomLobbyMapPanel.lua | 110 ++++-- .../mapselect/CustomLobbyMapSelect.lua | 3 +- 7 files changed, 185 insertions(+), 369 deletions(-) diff --git a/lua/ui/lobby/USER_STORIES.md b/lua/ui/lobby/USER_STORIES.md index 242bbf251de..7dbe93e0d85 100644 --- a/lua/ui/lobby/USER_STORIES.md +++ b/lua/ui/lobby/USER_STORIES.md @@ -157,3 +157,7 @@ Roles: **Host**, **Player** (human in a slot), **Observer**, **Joining client** - ⬜ As a **player**, I want a large map preview (with terrain/resource masks), so that I can study the map closely. - ⬜ As a **host (skirmish)**, I want a save/load dialog, so that I can resume saved games. - ⬜ As a **host**, I want confirmation prompts for destructive actions (kick, reset options), so that I don't trigger them by mistake. + +## R. New requests + +- ⬜ As a **host**, I want the ability to lock players in-place when autobalance is applied so that players that want to play together stay in the same team. \ No newline at end of file diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index 999d463bf0a..7ce2d8562b8 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -55,11 +55,11 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | [mapselect/](mapselect/CLAUDE.md) | the **map-select dialog** + its catalog and list, in their own folder (host-only `Popup`: searchable, filterable scenario list → `RequestSetScenario`). Self-contained sub-MVC; see [mapselect/CLAUDE.md](mapselect/CLAUDE.md) — including the **`MapPreview` texture-leak** writeup that shaped its design. | | [modselect/](modselect/CLAUDE.md) | the **mod-select dialog** + its catalog and list, built to the map-select shape (checkbox list + type filters + detail panel + **presets**). Returns a uid set; the opener routes it — sim mods → `RequestSetGameMods` (synced), UI mods → local prefs — or persists the lot standalone. Mod domain logic lives in [`/lua/ui/modutilities.lua`](/lua/ui/modutilities.lua) (the `maputil.lua` sibling, fronting `/lua/mods.lua`). See [modselect/CLAUDE.md](modselect/CLAUDE.md). | | [optionselect/](optionselect/CLAUDE.md) | the **options dialog**: three columns (lobby / scenario / mod options) over the selected scenario + mods, with search + hide-defaults filters; non-default options are marked. Derives the option *schema* per-peer (reference data) via [`/lua/ui/optionutil.lua`](/lua/ui/optionutil.lua); edits a working copy of the *values* and on OK routes the reconciled set through `RequestSetGameOptions` (synced via `GameOptions`). Host-only. See [optionselect/CLAUDE.md](optionselect/CLAUDE.md). | -| [CustomLobbyInterface.lua](CustomLobbyInterface.lua) | composition root, laid out in **areas** (flip the module `Debug` flag to tint them), sized for the **1024×768** floor: a **title bar** (title · team score · Leave) over a **slots region that owns the whole top** (the rows in **two columns** — odd slots left, even right — up to 16), and a fixed-height **bottom row** split into a left tabbed panel (Chat / Observers) and a right one (the config component) with a launch footer (status + host-only Launch stub). The slots are full-height columns; `SlotCount` just shows/hides rows. Holds the SlotCount + IsHost observers; Leave (Esc handler) + **Become observer** (`RequestMoveToObserver`, in the Observers tab). Also the rows' drag coordinator (`UICustomLobbySlotCoordinator`: hit-test, drop-highlight, drag ghost → `RequestSwapSlots`); `OpenDebug()` / hot-reload. | +| [CustomLobbyInterface.lua](CustomLobbyInterface.lua) | composition root, laid out in **areas** (flip the module `Debug` flag to tint them), sized for the **1024×768** floor: a **title bar** (title · team score · Leave) over a **slots region that owns the whole top** (the rows in **two columns** — odd slots left, even right — up to 16), and a **middle row** split into a left tabbed panel (Chat / Observers) and a right one (the config component), and a full-width **action bar** at the very bottom for the global actions (status + host-only Launch stub, and the like). The slots region is sized to its rows (two columns of `MaxSlots`/2); the two tabbed panels fill the room between it and the action bar. Holds the SlotCount + IsHost observers; Leave (Esc handler) + **Become observer** (`RequestMoveToObserver`, in the Observers tab). Also the rows' drag coordinator (`UICustomLobbySlotCoordinator`: hit-test, drop-highlight, drag ghost → `RequestSwapSlots`); `OpenDebug()` / hot-reload. | | [CustomLobbyTeamScore.lua](CustomLobbyTeamScore.lua) | the **accumulated team rating** in the title — `Side A N · M Side B`. Shown only for the binary auto-team formations (`tvsb`→Top/Bottom, `lvsr`→Left/Right by start position; `pvsi`→Odd/Even by start-spot parity); **hidden** for `none`/`manual` (no 2-side split) or until a map's start positions are loaded. Reads the mode from `GameOptions().AutoTeams`, the ratings from each slot's `PL`. Reference data; never writes. | | [CustomLobbyTabs.lua](CustomLobbyTabs.lua) | a **generic tabbed panel** (strip + content; one panel alive, created on select / destroyed on switch). Construct with a `{ Label, Create }` list + optional `OnSelect`. Used for the bottom-left (Chat / Observers); the config interface keeps its own bespoke variant (it coordinates the persistent preview). | | [social/](social/) | the lobby's **bottom-left** column (the `CustomLobbyTabs` content): [`CustomLobbyChatPanel`](social/CustomLobbyChatPanel.lua) (the **Chat** tab — placeholder until the chat slice lands) and [`CustomLobbyObserversPanel`](social/CustomLobbyObserversPanel.lua) (the **Observers** tab — the shared observer list + a host-authoritative **Become observer** button → `RequestMoveToObserver`). Each is a tab content component (`Create(parent)`, created on select / destroyed on switch). | -| [config/](config/) | the lobby's **bottom-right** column. [`CustomLobbyConfigInterface.lua`](config/CustomLobbyConfigInterface.lua) is a **tab strip** (Map / Mods / Options / Restrictions) over a content area. The **map preview + name + info** are built once and **never destroyed** (the engine never frees the preview's textures — see [mapselect/CLAUDE.md](mapselect/CLAUDE.md)); they live in the content area, shown only on the **Map tab** (preview square on the left, name/info right, the Map panel's details below). On every other tab an opaque **Cover** sits over them (we *cover* rather than `Hide()` because the preview self-shows on a `ScenarioFile` change regardless of the active tab — the hidden-panel-bleed gotcha). The active tab's content component is **created on select / destroyed on switch** (`Tabs[i].Create`; one alive), so churn is cheap and bleed-free. Panels: [`CustomLobbyMapPanel`](config/CustomLobbyMapPanel.lua) (Change map + the label/value details), [`CustomLobbyOptionsPanel`](config/CustomLobbyOptionsPanel.lua) (read-only options grouped **Lobby / Scenario / Mods** + hide-defaults toggle; map/mod options gold-flagged with an origin tooltip — schema via [`/lua/ui/optionutil.lua`](/lua/ui/optionutil.lua)), [`CustomLobbyModsPanel`](config/CustomLobbyModsPanel.lua) (enabled mods in **Game / UI** sections), [`CustomLobbyUnitsPanel`](config/CustomLobbyUnitsPanel.lua) (the **Restrictions** tab — placeholder). Each panel self-subscribes to the model + `IsHost`, and exposes `Initialize()` + `Create(parent)`. | +| [config/](config/) | the lobby's **bottom-right** column. [`CustomLobbyConfigInterface.lua`](config/CustomLobbyConfigInterface.lua) is now just the **tab-list definition** (Map / Mods / Options / Restrictions) handed to a generic [`CustomLobbyTabs`](CustomLobbyTabs.lua); every tab — Map included — is a normal **created-on-select / destroyed-on-switch** panel. The **Map tab owns its own preview** ([`CustomLobbyMapPanel`](config/CustomLobbyMapPanel.lua): a large square preview filling the left, with the name + size/players/version line and the label/value details stacked in the column to its right; Change map at the bottom). Churning the preview is safe here because the lobby shows only **one** current map and the engine caches map textures by name, so re-opening the Map tab re-binds the cached texture — no leak. (The texture-leak rule only bites the *map-select dialog*, which browses hundreds of maps — see [mapselect/CLAUDE.md](mapselect/CLAUDE.md).) Other panels: [`CustomLobbyOptionsPanel`](config/CustomLobbyOptionsPanel.lua) (read-only options grouped **Lobby / Scenario / Mods** + hide-defaults toggle; map/mod options gold-flagged with an origin tooltip — schema via [`/lua/ui/optionutil.lua`](/lua/ui/optionutil.lua)), [`CustomLobbyModsPanel`](config/CustomLobbyModsPanel.lua) (enabled mods in **Game / UI** sections), [`CustomLobbyUnitsPanel`](config/CustomLobbyUnitsPanel.lua) (the **Restrictions** tab — placeholder). Each panel self-subscribes to the model + `IsHost`, and exposes `Initialize()` + `Create(parent)`. | | [/lua/ui/lobby/lobby.lua](../lobby.lua) | engine entry wrapper (`CreateLobby`/`HostGame`/`JoinGame`) → CustomLobby. Old lobby preserved at `lobby-old.lua`. | Working today: host + clients see each other (host-authoritative player sync), the @@ -149,7 +149,7 @@ lobby-specific checklist. | Cascade of errors after one layout failure (half-built pools, observers firing into a broken control) | A throw mid-`Initialize` left partial state (e.g. `PoolCount` set before the rows existed), and a streaming `Derive`/thread kept calling into it. | Set "ready" counters **after** the thing they describe is fully built; guard paint/refresh paths against nil rows; gate model-observer work on a `self.Ready` flag set in `Initialize`. | | `circular dependency in lazy evaluation` with **no frame from your files** (fires during the render pass) | A control you created in `__init` is **never anchored** in `__post_init` (or you renamed/moved its layout and forgot it). Its `Left`/`Right`/`Top`/`Bottom` stay at the circular defaults, and it errors the moment it's rendered. | Every visible control needs a layout. To find which one, set `import("/lua/lazyvar.lua").ExtendedErrorMessages = true` — the error then appends the offending control's **creation stack** (file + line). Then anchor it (or hide it). | | `circular dependency` only **on hot-reload** (or when the model already has state), not on a fresh open | A `Derive` observer fires **immediately on creation** in `__init`, and its handler reads layout geometry (e.g. positions icons via `self.Preview.Width()`). Fresh open: the model field is empty, handler no-ops. Reload: the field already has a value, so it renders before the parent has laid the component out. | Gate the observer handlers behind a `self.Ready` flag (default false). Set `Ready = true` and do the first render **deferred** (`ForkThread` + `WaitFrames(1)` from `__post_init`, or an `Initialize()` the parent calls) — once the parent has actually sized you. See `CustomLobbyMapPreview`. | -| Controls in a hidden container still render — a hidden tab's list/buttons bleed over the active one | MAUI's `Hide()`/`Show()` cascade the hidden flag to the children that exist **at call time**; there's no render-time ancestor check (the `OnHide` "return true to stop the cascade" contract in [`/lua/maui/grid.lua`](/lua/maui/grid.lua) confirms it). So anything created or `Show()`-n in a panel *after* it was hidden (a rebuilt grid row, a button you re-showed) renders, because it missed the cascade. | Re-assert visibility *after* any content rebuild / button-visibility change: show the active container, then **re-`Hide()` the inactive ones** so the hide re-cascades over the new children. Don't populate or `Show()` into a container and assume an earlier `Hide()` still covers it. See `CustomLobbyConfigInterface.ReapplyVisibility`. | +| Controls in a hidden container still render — a hidden tab's list/buttons bleed over the active one | MAUI's `Hide()`/`Show()` cascade the hidden flag to the children that exist **at call time**; there's no render-time ancestor check (the `OnHide` "return true to stop the cascade" contract in [`/lua/maui/grid.lua`](/lua/maui/grid.lua) confirms it). So anything created or `Show()`-n in a panel *after* it was hidden (a rebuilt grid row, a button you re-showed) renders, because it missed the cascade. | Don't keep hidden-but-alive panels at all: **create the active panel and destroy it on switch** so only one is ever live (the [`CustomLobbyTabs`](CustomLobbyTabs.lua) model — strip + create-on-select / destroy-on-switch). If you must keep a panel alive and toggle it, re-assert visibility *after* any rebuild (show the active, re-`Hide()` the inactive) rather than trusting an earlier `Hide()`. | | A `TextArea`'s text wraps far too early (~50–60% of the visible box) | `TextArea.__init` pins `Width` to its **constructor** `width` arg via `SetDimensions`, and `ReflowText` wraps to `Width()` — but the fluent `:AtLeftIn():AtRightIn()` set only `Left`/`Right`, never `Width`. So the box renders `Left..Right` wide while the text still wraps at the stale constructor width. | Bind `Width` to the span: `self.X.Width:Set(function() return self.X.Right() - self.X.Left() end)`. **Do this in `Initialize()` (post-mount), not `__post_init`:** `TextArea` hooks `Width.OnDirty → ReflowText`, so the bind *eagerly* reads `Right()/Left()` → the parent geometry, which is still circular until `Popup` mounts the dialog. (Left/Right anchor to the parent, so no self-cycle once mounted.) | | Memory climbs ~30 MB and never drops — not on re-texture, `ClearTexture`, `Destroy`, or dialog close | `MapPreview:SetTexture` / `SetTextureFromMap` allocate textures the engine **never frees** (no release API on the control or globally). Loading one per list row × hundreds of maps leaks memory the game needs in-match. | Don't put a `MapPreview` per list row. Render at most a **few** previews total (the map-select list is text-only; only the single candidate preview loads a texture, once per selection). The same applies to per-row `Bitmap` thumbnails via `SetNewTexture`. | diff --git a/lua/ui/lobby/customlobby/CustomLobbyController.lua b/lua/ui/lobby/customlobby/CustomLobbyController.lua index 792c87ef0fd..fc9f230225a 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyController.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyController.lua @@ -502,9 +502,26 @@ function RequestTakeSlot(slot) end end +--- The number of start spots (max players) a scenario declares, or 0 when it can't be read. +---@param scenarioFile FileName | false +---@return number +local function ScenarioSlotCount(scenarioFile) + if not scenarioFile then + return 0 + end + local info = import("/lua/ui/maputil.lua").LoadScenario(scenarioFile) + local armies = info + and info.Configurations + and info.Configurations.standard + and info.Configurations.standard.teams + and info.Configurations.standard.teams[1] + and info.Configurations.standard.teams[1].armies + return armies and table.getsize(armies) or 0 +end + --- The host picks the scenario (map). Host-only — backs the map-select dialog and a ---- `/map ` chat command. Sets the scenario in the launch model and broadcasts the ---- launch config; the map preview / unit-cap react to the new `ScenarioFile`. +--- `/map ` chat command. Sets the scenario in the launch model, sizes the lobby room to the +--- map's start spots, and broadcasts both; the map preview / unit-cap react to the new `ScenarioFile`. --- --- TODO (options slice): when the scenario changes, the game-options *schema* changes too --- (the map contributes its own options). Reconcile `GameOptions` here — drop values whose @@ -522,6 +539,15 @@ function RequestSetScenario(scenarioFile) end CustomLobbyLaunchModel.SetScenario(CustomLobbyLaunchModel.GetSingleton(), scenarioFile) + + -- size the lobby room to the map's start spots, so all of its slots show (capped at MaxSlots) + local count = ScenarioSlotCount(scenarioFile) + if count > 0 then + local session = CustomLobbySessionModel.GetSingleton() + session.SlotCount:Set(math.min(count, CustomLobbyLaunchModel.MaxSlots)) + BroadcastSessionState(instance) + end + BroadcastLaunchInfo(instance) end diff --git a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua index 82084e36e00..162b75f19cb 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua @@ -30,17 +30,18 @@ -- -- ┌ TitleArea ─ title · TEAM SCORE · leave ─────────────────────────────┐ -- │ SlotsArea (slots ONLY — up to 16, two columns) │ --- │ │ -- ├──────────────────────────────┬───────────────────────────────────────┤ -- │ BottomLeftArea (Chat / │ BottomRightArea (Map / Mods / Options │ --- │ Observers — tabs) │ / Restrictions — tabs) + launch │ --- └──────────────────────────────┴───────────────────────────────────────┘ +-- │ Observers — tabs) │ / Restrictions — tabs) │ +-- ├──────────────────────────────┴───────────────────────────────────────┤ +-- │ ActionArea (status · … · launch) ─ full width │ +-- └──────────────────────────────────────────────────────────────────────┘ -- --- The top is dedicated to the slot rows (we expect up to 16). The bottom splits in two tabbed +-- The top is dedicated to the slot rows (we expect up to 16). The middle splits in two tabbed -- panels: the left is chat/observers (CustomLobbyTabs), the right is the config tab panel --- (CustomLobbyConfigInterface — Map / Mods / Options / Restrictions) over a launch footer. The --- accumulated team rating (CustomLobbyTeamScore) sits in the title, shown only for the binary --- auto-team formations. +-- (CustomLobbyConfigInterface — Map / Mods / Options / Restrictions). A full-width action bar at +-- the bottom holds the global actions (status + launch, and the like). The accumulated team rating +-- (CustomLobbyTeamScore) sits in the title, shown only for the binary auto-team formations. local UIUtil = import("/lua/ui/uiutil.lua") local LayoutHelpers = import("/lua/maui/layouthelpers.lua") @@ -65,7 +66,7 @@ local LazyVarDerive = import("/lua/lazyvar.lua").Derive local Layouter = LayoutHelpers.ReusedLayoutFor -- flip to tint each layout area so the regions are visible while iterating -local Debug = false +local Debug = true -- the lobby content is designed for the 1024x768 floor; the root fills the frame (full-screen -- backdrop) but the content is centered and capped to this size, so it never stretches on a @@ -75,10 +76,10 @@ local LobbyHeight = 768 local Pad = 8 local SlotHeight = 24 +local SlotsHeaderHeight = 24 -- the "Players" header + gap above the two slot columns local TitleHeight = 48 -local BottomHeight = 300 -- the bottom row of tabbed panels; the slots take the rest above -local BottomRightWidth = 420 -- config (map/mods/options/restrictions); the chat/obs panel fills the rest -local LaunchFooterHeight = 44 -- status + launch, under the config tabs +local BottomRightWidth = 560 -- config (map/mods/options/restrictions); the chat/obs panel fills the rest +local ActionHeight = 52 -- the full-width action bar at the very bottom (status + launch) --- Creates a layout area (an invisible Group with an optional debug tint). ---@param parent Control @@ -112,9 +113,8 @@ end ---@field BottomLeftArea Group ---@field BottomLeftTabs UICustomLobbyTabs ---@field BottomRightArea Group ----@field ConfigArea Group ----@field Config UICustomLobbyConfigInterface ----@field LaunchFooter Group +---@field Config UICustomLobbyTabs +---@field ActionArea Group ---@field StatusLabel Text ---@field LaunchButton Button ---@field SlotCountObserver LazyVar @@ -144,6 +144,7 @@ local CustomLobbyInterface = Class(Group) { self.SlotsArea = CreateArea(self.Content, "SlotsArea", 'ffcccc40') self.BottomLeftArea = CreateArea(self.Content, "BottomLeftArea", 'ff40cc60') self.BottomRightArea = CreateArea(self.Content, "BottomRightArea", 'ff4060cc') + self.ActionArea = CreateArea(self.Content, "ActionArea", 'ff808080') --#endregion --#region title bar (title · team score · leave) @@ -189,16 +190,16 @@ local CustomLobbyInterface = Class(Group) { }) --#endregion - --#region bottom-right: config tabs (map / mods / options / restrictions) + launch footer - self.ConfigArea = Group(self.BottomRightArea, "CustomLobbyConfigArea") - self.Config = CustomLobbyConfigInterface.Create(self.ConfigArea) + --#region bottom-right: config tabs (map / mods / options / restrictions) + self.Config = CustomLobbyConfigInterface.Create(self.BottomRightArea) + --#endregion - self.LaunchFooter = Group(self.BottomRightArea, "CustomLobbyLaunchFooter") - self.StatusLabel = UIUtil.CreateText(self.LaunchFooter, "", 13, UIUtil.bodyFont) + --#region action bar (full-width, bottom): status + launch and other global actions + self.StatusLabel = UIUtil.CreateText(self.ActionArea, "", 13, UIUtil.bodyFont) self.StatusLabel:SetColor('ff9aa0a8') self.StatusLabel:DisableHitTest() - self.LaunchButton = UIUtil.CreateButtonWithDropshadow(self.LaunchFooter, '/BUTTON/large/', "Launch") + self.LaunchButton = UIUtil.CreateButtonWithDropshadow(self.ActionArea, '/BUTTON/large/', "Launch") self.LaunchButton.OnClick = function(button, modifiers) -- launch flow isn't wired up yet end @@ -234,21 +235,29 @@ local CustomLobbyInterface = Class(Group) { --#region areas Layouter(self.TitleArea):AtLeftIn(self.Content, Pad):AtRightIn(self.Content, Pad):AtTopIn(self.Content, Pad):Height(TitleHeight):End() - -- the bottom row of tabbed panels has a fixed height; the slots take everything above it. - -- the right (config) panel is a fixed width; the left (chat/observers) fills the rest + -- the slots region is sized to exactly fit its rows (two columns of MaxSlots/2 = 8); the + -- bottom row of tabbed panels then fills ALL the remaining vertical room below it + local slotRows = math.ceil(CustomLobbyLaunchModel.MaxSlots / 2) + Layouter(self.SlotsArea) + :AtLeftIn(self.Content, Pad):AtRightIn(self.Content, Pad) + :AnchorToBottom(self.TitleArea, Pad):Height(SlotsHeaderHeight + slotRows * SlotHeight) + :End() + + -- the action bar spans the full width at the very bottom; the two tabbed panels fill the + -- room between the slots and it. The right (config) panel is a fixed width; the left + -- (chat/observers) fills the rest + Layouter(self.ActionArea) + :AtLeftIn(self.Content, Pad):AtRightIn(self.Content, Pad):AtBottomIn(self.Content, Pad) + :Height(ActionHeight) + :End() Layouter(self.BottomRightArea) :AtRightIn(self.Content, Pad):Width(BottomRightWidth) - :AtBottomIn(self.Content, Pad):Height(BottomHeight) + :AnchorToBottom(self.SlotsArea, Pad):AnchorToTop(self.ActionArea, Pad) :End() Layouter(self.BottomLeftArea) - :AtLeftIn(self.Content, Pad):AtBottomIn(self.Content, Pad):Height(BottomHeight) + :AtLeftIn(self.Content, Pad):AnchorToBottom(self.SlotsArea, Pad):AnchorToTop(self.ActionArea, Pad) :End() self.BottomLeftArea.Right:Set(function() return self.BottomRightArea.Left() - LayoutHelpers.ScaleNumber(Pad) end) - - Layouter(self.SlotsArea) - :AtLeftIn(self.Content, Pad):AtRightIn(self.Content, Pad) - :AnchorToBottom(self.TitleArea, Pad):AnchorToTop(self.BottomLeftArea, Pad) - :End() --#endregion --#region title bar (title · team score · leave) @@ -291,19 +300,13 @@ local CustomLobbyInterface = Class(Group) { Layouter(self.BottomLeftTabs):Fill(self.BottomLeftArea):End() --#endregion - --#region bottom-right: config tabs over a launch footer - Layouter(self.LaunchFooter) - :AtLeftIn(self.BottomRightArea):AtRightIn(self.BottomRightArea):AtBottomIn(self.BottomRightArea) - :Height(LaunchFooterHeight) - :End() - Layouter(self.ConfigArea) - :AtLeftIn(self.BottomRightArea):AtRightIn(self.BottomRightArea):AtTopIn(self.BottomRightArea) - :AnchorToTop(self.LaunchFooter, Pad) - :End() - Layouter(self.Config):Fill(self.ConfigArea):End() + --#region bottom-right: config tabs fill the panel + Layouter(self.Config):Fill(self.BottomRightArea):End() + --#endregion - Layouter(self.StatusLabel):AtLeftIn(self.LaunchFooter, 4):AtVerticalCenterIn(self.LaunchFooter):End() - Layouter(self.LaunchButton):AtRightIn(self.LaunchFooter):AtVerticalCenterIn(self.LaunchFooter):End() + --#region action bar: status on the left, launch on the right + Layouter(self.StatusLabel):AtLeftIn(self.ActionArea, 8):AtVerticalCenterIn(self.ActionArea):End() + Layouter(self.LaunchButton):AtRightIn(self.ActionArea):AtVerticalCenterIn(self.ActionArea):End() --#endregion -- size-dependent children build their scrollbars / first render now that they're sized diff --git a/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua b/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua index d7bc6063d04..e53d4474fe3 100644 --- a/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua +++ b/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua @@ -20,60 +20,22 @@ --** SOFTWARE. --****************************************************************************************************** --- The lobby's right-hand config panel: +-- The lobby's bottom-right config panel: a generic CustomLobbyTabs over the four config tabs — +-- Map / Mods / Options / Restrictions. This module is just the tab-list definition; the strip + +-- create-on-select / destroy-on-switch machinery lives in CustomLobbyTabs. -- --- ┌───────────────────────────┐ --- │ map preview │ ← pinned, persistent --- │ Name · 20km · 8p │ --- ├───────────────────────────┤ --- │ Map | Options | Mods | Un │ ← tab strip --- ├───────────────────────────┤ --- │ active tab's content │ ← created on select, destroyed on switch --- └───────────────────────────┘ --- --- The **map block is pinned** at the top — the preview plus the scenario name, size and player --- count — and built exactly once: the engine never frees the textures the preview loads (see --- mapselect/CLAUDE.md), so it must NOT be destroyed/recreated. Everything below is a **tab** whose --- content component (CustomLobby*Panel in this folder) is **created when its tab is selected and --- destroyed when you switch away** — only one tab panel exists at a time. Their content is --- text/grids (no leaking textures), so churning them is cheap, and because only the live panel --- exists there's no hidden-panel bleed to manage. --- --- Laid out by its parent (filled into the lobby's right column); flip the module `Debug` flag to --- tint the areas. - -local UIUtil = import("/lua/ui/uiutil.lua") -local LayoutHelpers = import("/lua/maui/layouthelpers.lua") +-- The Map tab owns its own preview now (it's a normal churned tab — see CustomLobbyMapPanel): the +-- lobby only ever shows ONE current map, and the engine caches map textures by name, so building +-- the preview anew each time you open the Map tab just re-binds the cached texture (no leak). That +-- replaced the earlier pinned-preview-with-a-cover design — the texture-leak rule that motivated it +-- only bites the *map-select dialog*, which browses hundreds of different maps (see mapselect/). -local Group = import("/lua/maui/group.lua").Group -local Bitmap = import("/lua/maui/bitmap.lua").Bitmap -local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") -local CustomLobbyMapPreview = import("/lua/ui/lobby/customlobby/customlobbymappreview.lua") -local CustomLobbyMapCatalog = import("/lua/ui/lobby/customlobby/mapselect/customlobbymapcatalog.lua") +local CustomLobbyTabs = import("/lua/ui/lobby/customlobby/customlobbytabs.lua") local CustomLobbyMapPanel = import("/lua/ui/lobby/customlobby/config/customlobbymappanel.lua") -local CustomLobbyOptionsPanel = import("/lua/ui/lobby/customlobby/config/customlobbyoptionspanel.lua") local CustomLobbyModsPanel = import("/lua/ui/lobby/customlobby/config/customlobbymodspanel.lua") +local CustomLobbyOptionsPanel = import("/lua/ui/lobby/customlobby/config/customlobbyoptionspanel.lua") local CustomLobbyUnitsPanel = import("/lua/ui/lobby/customlobby/config/customlobbyunitspanel.lua") -local LazyVarDerive = import("/lua/lazyvar.lua").Derive -local Layouter = LayoutHelpers.ReusedLayoutFor - --- flip to tint the areas while iterating -local Debug = false - -local PreviewMaxSize = 150 -- the preview is square, capped to this (FAF maps are square) -local TabHeight = 26 -local TabWidth = 92 -local NameMaxChars = 28 - -local TabIdleColor = 'ff141a20' -local TabHoverColor = 'ff1f262e' -local TabActiveColor = 'ff2c3e48' -local CoverColor = 'ff0a0e12' -- opaque backing that hides the preview off the Map tab - --- the four tabs, in order, each with the factory that builds its content component on demand. The --- Map tab (index 1) is special: it shows the persistent preview block (see SelectTab). -local MapTabIndex = 1 local Tabs = { { Label = "Map", Create = CustomLobbyMapPanel.Create }, { Label = "Mods", Create = CustomLobbyModsPanel.Create }, @@ -81,260 +43,10 @@ local Tabs = { { Label = "Restrictions", Create = CustomLobbyUnitsPanel.Create }, } ---- Truncates `text` to `maxChars`, appending "…" when it had to cut. ----@param text string ----@param maxChars number ----@return string -local function Truncate(text, maxChars) - text = text or "" - if string.len(text) > maxChars then - return string.sub(text, 1, maxChars - 1) .. "…" - end - return text -end - ---- Number of start spots a scenario declares, or 0. ----@param scenario UILobbyScenarioInfo ----@return number -local function ArmyCount(scenario) - local armies = scenario.Configurations - and scenario.Configurations.standard - and scenario.Configurations.standard.teams - and scenario.Configurations.standard.teams[1] - and scenario.Configurations.standard.teams[1].armies - return armies and table.getsize(armies) or 0 -end - ---- Creates a layout area (an invisible Group with an optional debug tint). ----@param parent Control ----@param name string ----@param color string ----@return Group -local function CreateArea(parent, name, color) - local area = Group(parent, name) - local bg = Bitmap(area) - bg:SetSolidColor(color) - bg:SetAlpha(Debug and 0.18 or 0.0) - bg:DisableHitTest() - Layouter(bg):Fill(area):End() - area.Bg = bg - return area -end - ----@class UICustomLobbyConfigInterface : Group ----@field Trash TrashBag ----@field TabStripArea Group ----@field TabContentArea Group ----@field Preview UICustomLobbyMapPreview ----@field Name Text ----@field Info Text ----@field Cover Bitmap # opaque backing shown over the content (hiding the preview) off the Map tab ----@field TabButtons Group[] ----@field ActiveTab number ----@field CurrentPanel Control | false # the live tab content component (others are destroyed) ----@field ScenarioObserver LazyVar -local CustomLobbyConfigInterface = ClassUI(Group) { - - ---@param self UICustomLobbyConfigInterface - ---@param parent Control - __init = function(self, parent) - Group.__init(self, parent, "CustomLobbyConfigInterface") - - self.Trash = TrashBag() - self.ActiveTab = 1 - self.CurrentPanel = false - - self.TabStripArea = CreateArea(self, "TabStripArea", 'ffcc8040') - self.TabContentArea = CreateArea(self, "TabContentArea", 'ff8040cc') - - -- the persistent map block — created once, NEVER destroyed (the preview's textures aren't - -- freed). It lives in the content area and is shown only on the Map tab; on other tabs the - -- opaque Cover (created after it, so it's on top) hides it. We cover rather than hide it - -- because the preview self-shows on a ScenarioFile change regardless of the active tab. - self.Preview = CustomLobbyMapPreview.Create(self.TabContentArea, { Bound = true }) - self.Name = UIUtil.CreateText(self.TabContentArea, "", 16, UIUtil.titleFont) - self.Name:DisableHitTest() - self.Info = UIUtil.CreateText(self.TabContentArea, "", 13, UIUtil.bodyFont) - self.Info:SetColor('ff9aa0a8') - self.Info:DisableHitTest() - - self.Cover = Bitmap(self.TabContentArea) - self.Cover:SetSolidColor(CoverColor) - self.Cover:DisableHitTest() - - self.ScenarioObserver = self.Trash:Add( - LazyVarDerive(CustomLobbyLaunchModel.GetSingleton().ScenarioFile, function(scenarioFileLazy) - self:RefreshMapInfo(scenarioFileLazy()) - end)) - - self.TabButtons = {} - for index = 1, table.getn(Tabs) do - self.TabButtons[index] = self:CreateTabButton(Tabs[index].Label, index) - end - end, - - ---@param self UICustomLobbyConfigInterface - __post_init = function(self) - -- tab strip on top, content fills the rest - Layouter(self.TabStripArea):AtLeftIn(self):AtRightIn(self):AtTopIn(self):Height(TabHeight):End() - Layouter(self.TabContentArea) - :AtLeftIn(self):AtRightIn(self) - :AnchorToBottom(self.TabStripArea, 6):AtBottomIn(self) - :End() - - -- the preview is a square pinned to the content area's top-left, capped to PreviewMaxSize; - -- name + info sit to its right. SelectTab shows/positions the rest of the Map tab. - Layouter(self.Preview):AtLeftIn(self.TabContentArea):AtTopIn(self.TabContentArea):End() - self.Preview.Height:Set(function() - return math.min(self.TabContentArea.Height(), LayoutHelpers.ScaleNumber(PreviewMaxSize)) - end) - self.Preview.Width:Set(function() return self.Preview.Height() end) - Layouter(self.Name):AnchorToRight(self.Preview, 10):AtTopIn(self.TabContentArea, 2):End() - Layouter(self.Info):AnchorToRight(self.Preview, 10):AnchorToBottom(self.Name, 2):End() - - -- the cover sits above the preview block (created after it); shown on every tab but Map - Layouter(self.Cover):Fill(self.TabContentArea):End() - - for index = 1, table.getn(self.TabButtons) do - local button = self.TabButtons[index] - local builder = Layouter(button):AtTopIn(self.TabStripArea):Width(TabWidth):Height(TabHeight) - if index == 1 then - builder:AtLeftIn(self.TabStripArea) - else - builder:AnchorToRight(self.TabButtons[index - 1], 2) - end - builder:End() - Layouter(button.Bg):Fill(button):End() - Layouter(button.Label):AtHorizontalCenterIn(button):AtVerticalCenterIn(button):End() - button.Bg:SetSolidColor(index == self.ActiveTab and TabActiveColor or TabIdleColor) - end - end, - - --- Opens the initial tab. Called by the parent after it has laid this component out — the tab - --- content (grids) needs a concrete height, which it now has. - ---@param self UICustomLobbyConfigInterface - Initialize = function(self) - self:RefreshMapInfo(CustomLobbyLaunchModel.GetSingleton().ScenarioFile()) - self:SelectTab(self.ActiveTab) - end, - - --- Updates the pinned name + size/players line (the preview self-updates from the model). - ---@param self UICustomLobbyConfigInterface - ---@param scenarioFile FileName | false - RefreshMapInfo = function(self, scenarioFile) - if not scenarioFile then - self.Name:SetText("No map selected") - self.Info:SetText("") - return - end - local info = CustomLobbyMapCatalog.LoadInfo(scenarioFile) - if not info then - self.Name:SetText("Unknown map") - self.Info:SetText("") - return - end - self.Name:SetText(Truncate(LOC(info.name) or "?", NameMaxChars)) - - local parts = {} - if info.size then - table.insert(parts, string.format("%dkm", math.floor(info.size[1] / 50))) - end - local players = ArmyCount(info) - if players > 0 then - table.insert(parts, players .. " players") - end - if info.map_version then - table.insert(parts, "v" .. tostring(info.map_version)) - end - self.Info:SetText(table.concat(parts, " · ")) - end, - - --- Builds one clickable tab button (a tinted group + label). Private. - ---@param self UICustomLobbyConfigInterface - ---@param label string - ---@param index number - ---@return Group - CreateTabButton = function(self, label, index) - local button = Group(self.TabStripArea, "CustomLobbyConfigTabButton") - - -- the background catches the click; the label is hit-disabled so it doesn't block it - button.Bg = Bitmap(button) - button.Bg:SetSolidColor(TabIdleColor) - - button.Label = UIUtil.CreateText(button, label, 14, UIUtil.titleFont) - button.Label:SetColor('ffc8ccd0') - button.Label:DisableHitTest() - - button.Bg.HandleEvent = function(control, event) - if event.Type == 'ButtonPress' then - self:SelectTab(index) - return true - elseif event.Type == 'MouseEnter' then - if self.ActiveTab ~= index then - button.Bg:SetSolidColor(TabHoverColor) - end - return true - elseif event.Type == 'MouseExit' then - if self.ActiveTab ~= index then - button.Bg:SetSolidColor(TabIdleColor) - end - return true - end - return false - end - - return button - end, - - --- Switches tabs: destroys the current content component, builds the chosen one into the - --- content area, and recolours the buttons. Clicking the active tab again is a no-op. - ---@param self UICustomLobbyConfigInterface - ---@param index number - SelectTab = function(self, index) - if self.ActiveTab == index and self.CurrentPanel then - return - end - self.ActiveTab = index - - for i = 1, table.getn(self.TabButtons) do - self.TabButtons[i].Bg:SetSolidColor(i == index and TabActiveColor or TabIdleColor) - end - - if self.CurrentPanel then - self.CurrentPanel:Destroy() - self.CurrentPanel = false - end - - -- build the new tab's content, size it, then let it read its (now concrete) geometry. On - -- the Map tab the preview block shows (cover hidden) and the panel fills the space to the - -- right of / below it; on every other tab the cover hides the preview and the panel fills. - local panel = Tabs[index].Create(self.TabContentArea) - if index == MapTabIndex then - self.Cover:Hide() - Layouter(panel) - :AnchorToRight(self.Preview, 10):AtRightIn(self.TabContentArea) - :AnchorToBottom(self.Info, 8):AtBottomIn(self.TabContentArea) - :End() - else - self.Cover:Show() - Layouter(panel):Fill(self.TabContentArea):End() - end - panel:Initialize() - self.CurrentPanel = panel - end, - - ---@param self UICustomLobbyConfigInterface - OnDestroy = function(self) - if self.CurrentPanel then - self.CurrentPanel:Destroy() - self.CurrentPanel = false - end - self.Trash:Destroy() - end, -} - +--- Builds the config tab panel (a CustomLobbyTabs). The parent sizes it and calls `Initialize()` +--- after mounting (forwarded to the tabs container — three-phase init). ---@param parent Control ----@return UICustomLobbyConfigInterface +---@return UICustomLobbyTabs Create = function(parent) - return CustomLobbyConfigInterface(parent) + return CustomLobbyTabs.Create(parent, { Tabs = Tabs }) end diff --git a/lua/ui/lobby/customlobby/config/CustomLobbyMapPanel.lua b/lua/ui/lobby/customlobby/config/CustomLobbyMapPanel.lua index 46f621f0c15..852164b4f62 100644 --- a/lua/ui/lobby/customlobby/config/CustomLobbyMapPanel.lua +++ b/lua/ui/lobby/customlobby/config/CustomLobbyMapPanel.lua @@ -52,12 +52,15 @@ local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaun local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/customlobbylocalmodel.lua") local CustomLobbyMapCatalog = import("/lua/ui/lobby/customlobby/mapselect/customlobbymapcatalog.lua") local CustomLobbyMapSelect = import("/lua/ui/lobby/customlobby/mapselect/customlobbymapselect.lua") +local CustomLobbyMapPreview = import("/lua/ui/lobby/customlobby/customlobbymappreview.lua") local LazyVarDerive = import("/lua/lazyvar.lua").Derive local Layouter = LayoutHelpers.ReusedLayoutFor local ActionHeight = 40 local IconSize = 14 +local PreviewMaxSize = 240 -- the square preview grows with the panel, capped to this +local NameMaxChars = 28 local LabelColor = 'ff8a909a' -- the section labels (Author / Reclaim / Description) local ValueColor = 'ffc8ccd0' local MassIcon = "/game/build-ui/icon-mass_bmp.dds" @@ -117,11 +120,38 @@ local function FormatAmount(amount) return string.format("%d", amount) end +--- Truncates `text` to `maxChars`, appending "…" when it had to cut. +---@param text string +---@param maxChars number +---@return string +local function Truncate(text, maxChars) + text = text or "" + if string.len(text) > maxChars then + return string.sub(text, 1, maxChars - 1) .. "…" + end + return text +end + +--- Number of start spots a scenario declares, or 0. +---@param scenario UILobbyScenarioInfo +---@return number +local function ArmyCount(scenario) + local armies = scenario.Configurations + and scenario.Configurations.standard + and scenario.Configurations.standard.teams + and scenario.Configurations.standard.teams[1] + and scenario.Configurations.standard.teams[1].armies + return armies and table.getsize(armies) or 0 +end + ---@class UICustomLobbyMapPanel : Group ---@field Trash TrashBag ---@field Ready boolean ---@field IsHost boolean ---@field CurrentUrl string | false +---@field Preview UICustomLobbyMapPreview +---@field Name Text +---@field Info Text ---@field AuthorLabel Text ---@field AuthorValue Text ---@field ReclaimLabel Text @@ -151,6 +181,15 @@ local CustomLobbyMapPanel = ClassUI(Group) { self.CurrentUrl = false self.DescriptionScrollbar = false + --#region map preview + name + size/players/version (preview left, the rest to its right) + self.Preview = CustomLobbyMapPreview.Create(self, { Bound = true }) + self.Name = UIUtil.CreateText(self, "", 16, UIUtil.titleFont) + self.Name:DisableHitTest() + self.Info = UIUtil.CreateText(self, "", 13, UIUtil.bodyFont) + self.Info:SetColor('ff9aa0a8') + self.Info:DisableHitTest() + --#endregion + --#region Author section self.AuthorLabel = self:CreateSectionLabel("Author") self.AuthorValue = UIUtil.CreateText(self, "", 13, UIUtil.bodyFont) @@ -230,6 +269,17 @@ local CustomLobbyMapPanel = ClassUI(Group) { Layouter(self.ChangeButton):AtRightIn(self.ActionArea):AtVerticalCenterIn(self.ActionArea):End() Layouter(self.UrlButton):AtLeftIn(self.ActionArea, 6):AtVerticalCenterIn(self.ActionArea):End() + -- the preview is a square on the left, growing with the panel height but capped so it never + -- swallows the details column; the name, info and labelled sections stack to its right + Layouter(self.Preview):AtLeftIn(self, 6):AtTopIn(self, 6):End() + self.Preview.Height:Set(function() + local avail = self.ActionArea.Top() - self.Preview.Top() - LayoutHelpers.ScaleNumber(8) + return math.min(avail, LayoutHelpers.ScaleNumber(PreviewMaxSize)) + end) + self.Preview.Width:Set(function() return self.Preview.Height() end) + Layouter(self.Name):AnchorToRight(self.Preview, 12):AtTopIn(self, 6):End() + Layouter(self.Info):AnchorToRight(self.Preview, 12):AnchorToBottom(self.Name, 4):End() + -- the reclaim value row: amount + mass icon, amount + energy icon (fixed internal layout; -- the group's position is set by LayoutSections) LayoutHelpers.SetHeight(self.ReclaimValue, IconSize + 4) @@ -240,8 +290,8 @@ local CustomLobbyMapPanel = ClassUI(Group) { -- the description's left/right are fixed here so its Width can be bound in Initialize -- (the TextArea reflows on the Width bind, which reads Left/Right); its top/bottom are set - -- dynamically by LayoutSections so empty sections above it collapse - Layouter(self.Description):AtLeftIn(self, 6):AtRightIn(self, 24):End() + -- dynamically by LayoutSections. Left = right of the preview, so it sits in the right column + Layouter(self.Description):AnchorToRight(self.Preview, 12):AtRightIn(self, 32):End() end, --- Builds a dim section label (Author / Reclaim / Description). Private. @@ -265,6 +315,16 @@ local CustomLobbyMapPanel = ClassUI(Group) { self.DescriptionScrollbar = UIUtil.CreateVertScrollbarFor(self.Description) self:Refresh() self:ApplyHostVisibility() + + -- the scrollbar's need depends on the reflowed line count vs the laid-out box height, and + -- neither is final until a frame after mount — Initialize can run pre-frame (the tab host + -- calls it synchronously). Re-check once settled so a fitting description doesn't keep a bar. + self.Trash:Add(ForkThread(function() + WaitFrames(1) + if not IsDestroyed(self) then + self:UpdateScrollbar() + end + end)) end, --- Loads the current scenario's info and fills the labelled sections (author / reclaim / @@ -279,6 +339,26 @@ local CustomLobbyMapPanel = ClassUI(Group) { local info = scenarioFile and CustomLobbyMapCatalog.LoadInfo(scenarioFile) local fields = type(info) == "table" and info or {} + -- header: name + the size · players · version line + if type(info) == "table" then + self.Name:SetText(Truncate(LOC(info.name) or "?", NameMaxChars)) + local parts = {} + if info.size then + table.insert(parts, string.format("%dkm", math.floor(info.size[1] / 50))) + end + local players = ArmyCount(info) + if players > 0 then + table.insert(parts, players .. " players") + end + if info.map_version then + table.insert(parts, "v" .. tostring(info.map_version)) + end + self.Info:SetText(table.concat(parts, " · ")) + else + self.Name:SetText(scenarioFile and "Unknown map" or "No map selected") + self.Info:SetText("") + end + local author = fields.author local reclaim = fields.reclaim local description = fields.description @@ -311,21 +391,16 @@ local CustomLobbyMapPanel = ClassUI(Group) { ---@param hasAuthor boolean ---@param hasReclaim boolean LayoutSections = function(self, hasAuthor, hasReclaim) - local prev = false -- the bottom of the last placed value (false = panel top) + -- everything lives in the column to the right of the preview, stacking below the info line + local prev = self.Info -- places a label + value pair under `prev`, or hides both local function place(label, value, visible) if visible then label:Show() value:Show() - local builder = Layouter(label):AtLeftIn(self, 6) - if prev then - builder:AnchorToBottom(prev, 8) - else - builder:AtTopIn(self, 8) - end - builder:End() - Layouter(value):AtLeftIn(self, 6):AnchorToBottom(label, 2):End() + Layouter(label):AnchorToRight(self.Preview, 12):AnchorToBottom(prev, 8):End() + Layouter(value):AnchorToRight(self.Preview, 12):AnchorToBottom(label, 2):End() prev = value else label:Hide() @@ -336,16 +411,11 @@ local CustomLobbyMapPanel = ClassUI(Group) { place(self.AuthorLabel, self.AuthorValue, hasAuthor) place(self.ReclaimLabel, self.ReclaimValue, hasReclaim) - -- description always shows; its label sits under the last visible section - local builder = Layouter(self.DescriptionLabel):AtLeftIn(self, 6) - if prev then - builder:AnchorToBottom(prev, 8) - else - builder:AtTopIn(self, 8) - end - builder:End() + -- description always shows; its label sits under the last visible section, then it fills + -- the rest of the right column down to the action bar + Layouter(self.DescriptionLabel):AnchorToRight(self.Preview, 12):AnchorToBottom(prev, 8):End() Layouter(self.Description) - :AtLeftIn(self, 6):AtRightIn(self, 24) + :AnchorToRight(self.Preview, 12):AtRightIn(self, 32) :AnchorToBottom(self.DescriptionLabel, 4):AnchorToTop(self.ActionArea, 8) :End() end, diff --git a/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapSelect.lua b/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapSelect.lua index ebcbc4c014f..ed1e7ce9c2c 100644 --- a/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapSelect.lua +++ b/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapSelect.lua @@ -72,7 +72,8 @@ local DialogHeight = 620 local Pad = 12 local ColumnGap = 30 -- whitespace separating the left column from the preview column local LeftWidth = 300 -local PreviewSize = 300 +local PreviewSize = 220 -- square; kept small enough that the title + sections + description fit + -- under it in the preview column (DialogHeight) without overflowing local TitleHeight = 32 local ActionHeight = 48 local FilterHeight = 134 From 2c2bf57b3e53c05225a0a82efbcdf563c6ea12e6 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Mon, 22 Jun 2026 22:31:45 +0200 Subject: [PATCH 36/98] Extend the user stories --- lua/ui/lobby/USER_STORIES.md | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/lua/ui/lobby/USER_STORIES.md b/lua/ui/lobby/USER_STORIES.md index 7dbe93e0d85..cbcc5c0c700 100644 --- a/lua/ui/lobby/USER_STORIES.md +++ b/lua/ui/lobby/USER_STORIES.md @@ -160,4 +160,29 @@ Roles: **Host**, **Player** (human in a slot), **Observer**, **Joining client** ## R. New requests -- ⬜ As a **host**, I want the ability to lock players in-place when autobalance is applied so that players that want to play together stay in the same team. \ No newline at end of file +- ⬜ As a **host**, I want the ability to lock players in-place when autobalance is applied so that players that want to play together stay in the same team. +- ⬜ As a **player**, I want the observer bug that can freeze/crash the lobby fixed — or at least a warning when it's hit — so that observing doesn't break the lobby. *(Reliable repro needs a debuggable lobby setup.)* +- ⬜ As a **player**, I want to randomise among a chosen subset of factions (multi-choice), so that I get variety without pure random. +- ⬜ As a **player**, I want my FAF avatar shown in the lobby, so that players are recognisable. +- ⬜ As a **host**, I want a "players vs AI" AutoTeams option, so that all humans are teamed against the AIs automatically. +- ⬜ As a **host**, I want closing a slot to auto-move its player to a free slot (in opti), so that rearranging doesn't drop anyone. +- ⬜ As a **host**, I want an opti team preview, so that I can see the balanced teams before committing. +- ⬜ As a **host**, I want flexible mirror balancing, so that mirror match-ups can be balanced with some give. +- ⬜ As a **host**, I want a system for balancing premades, so that pre-made groups are distributed fairly across teams. *(Related to locking players in place during autobalance, above.)* +- ⬜ As a **player**, I want easier rehosting — a dedicated in-game/lobby rehost button that works with the client, including rehosting someone else's lobby (when they're AFK, or just to duplicate it), so that rehosting is quick. *(Extends the "rehost my last game" stories in A/O.)* +- ⬜ As a **non-host player**, I want to save presets as a client, so that I keep my setups even when I'm not hosting. +- ⬜ As a **host**, I want to load presets without switching maps, so that applying a preset doesn't force a map change. +- ⬜ As a **player**, I want lobby options categorised by mod, so that mod-provided options are grouped clearly. +- ⬜ As a **player**, I want to collapse lobby-option categories, so that I can focus on the ones I care about. +- ⬜ As a **non-host player**, I want to read each option's description, so that I understand what every setting does. +- ⬜ As a **host**, I want the unit-manager bug fixed where presets stop appearing after you disable units manually, so that presets keep working. +- ⬜ As a **host**, I want better unit stats in the unit manager, so that I can judge units when restricting them. +- ⬜ As a **host**, I want faster map/option loading when many maps are installed, so that the lobby isn't slow to open. +- ⬜ As a **player**, I want faster mod-manager loading, so that opening it isn't slow. +- ⬜ As a **modder**, I want a moddable lobby UI, so that the lobby can be extended/customised by mods. +- ⬜ As a **player**, I want mod presets kept separate from lobby-option presets, so that the two don't get entangled. +- ⬜ As a **host**, I want map-specific lobby options to persist between sessions when I rehost the same map, so that I don't reconfigure them each time. +- ⬜ As a **host**, I want last game's mods NOT auto-enabled when the game didn't launch (but kept when it did, for rehosting), so that a failed launch doesn't silently carry mods forward. +- ⬜ As a **player**, I want "disable all sim / UI / all mods" buttons, so that I can clear mods in one click. +- ⬜ As a **player**, I want the mod manager's dependency-related UI updates fixed, so that enabling/disabling reflects dependencies correctly. +- ⬜ As a **developer**, I want mod-dependency management reworked in the lobby frontend so it no longer keys off singular version UUIDs, so that the dependency system is fixed at the source. \ No newline at end of file From 5e987e006ea821150ca5f5b2550e36d41a60cc36 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Tue, 23 Jun 2026 07:23:57 +0200 Subject: [PATCH 37/98] Fix depth of performance popover --- .../CustomLobbyPerformancePopover.lua | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/lua/ui/lobby/customlobby/CustomLobbyPerformancePopover.lua b/lua/ui/lobby/customlobby/CustomLobbyPerformancePopover.lua index 0072be50136..15d5379f026 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyPerformancePopover.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyPerformancePopover.lua @@ -368,11 +368,22 @@ function Show(anchor, metrics, unitCap) local popover = GetInstance() popover:SetData(metrics, unitCap) - -- float to the right of the hovered control, vertically centred on it - Layouter(popover) - :AnchorToRight(anchor, 12) - :AtVerticalCenterIn(anchor) - :End() + local frame = GetFrame(0) + + -- float beside the hovered control, vertically centred on it. If the control sits on the + -- right half of the screen, open to its LEFT so the popover doesn't run off the edge. + local builder = Layouter(popover):AtVerticalCenterIn(anchor) + local anchorCenter = (anchor.Left() + anchor.Right()) / 2 + if anchorCenter > frame.Width() / 2 then + builder:AnchorToLeft(anchor, 12) + else + builder:AnchorToRight(anchor, 12) + end + builder:End() + + -- overlay above everything else (lobby chrome, dialogs, …); child depths are bound to ours, + -- so lifting the popover lifts the whole chart with it + popover.Depth:Set(frame:GetTopmostDepth() + 1) popover:Show() end From a136988c81b019b320918bb1cb1e13802871ce90 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Tue, 23 Jun 2026 07:53:18 +0200 Subject: [PATCH 38/98] Implement the feature to launch the game --- lua/ui/lobby/USER_STORIES.md | 4 +- lua/ui/lobby/customlobby/CLAUDE.md | 10 +- .../customlobby/CustomLobbyController.lua | 132 ++++++++++++++++++ .../lobby/customlobby/CustomLobbyInstance.lua | 12 ++ .../customlobby/CustomLobbyInterface.lua | 5 +- .../lobby/customlobby/CustomLobbyMessages.lua | 19 +++ 6 files changed, 174 insertions(+), 8 deletions(-) diff --git a/lua/ui/lobby/USER_STORIES.md b/lua/ui/lobby/USER_STORIES.md index cbcc5c0c700..294208ac436 100644 --- a/lua/ui/lobby/USER_STORIES.md +++ b/lua/ui/lobby/USER_STORIES.md @@ -67,7 +67,7 @@ Roles: **Host**, **Player** (human in a slot), **Observer**, **Joining client** - ✅ As a **host**, I want to be the single source of truth, so that all clients converge on one authoritative state. *(Host-authoritative: request → validate → broadcast snapshot.)* - 🟡 As a **player**, I want my own-slot changes applied optimistically and then confirmed by the host, so that the UI feels responsive but stays consistent. *(Round-trips through the host; not yet optimistic.)* - ⬜ As a **host**, I want option-changing buttons disabled while any human is not ready, so that settings can't shift after people commit. -- ⬜ As a **host**, I want the launch button enabled only when everyone is ready (or single-player / I'm observing), so that I can't start prematurely. +- 🟡 As a **host**, I want the launch button enabled only when everyone is ready (or single-player / I'm observing), so that I can't start prematurely. *(Launch works — `RequestLaunch` builds the config, broadcasts a `LaunchGame` message and calls the engine; it's validated on click (host + a map + ≥1 seat + every other human ready, the host exempt). Not yet reactively enabled/disabled, and the block reason is only logged.)* - ⬜ As a **system**, I want to ignore a move request if the player became ready in the meantime, so that races don't corrupt slot state. ## G. Game options (host) @@ -88,7 +88,7 @@ Roles: **Host**, **Player** (human in a slot), **Observer**, **Joining client** - ⬜ As a **host**, I want manual AutoTeams by clicking map markers, so that I can hand-place teams on random spawn. - 🟡 As a **host**, I want spawn variants (fixed, random, balanced/flex/reveal, penguin-autobalance), so that start placement matches the desired fairness/secrecy. *(Selectable as a lobby option; placement is resolved at launch, which isn't wired.)* - ⬜ As a **host on an adaptive map**, I want per-slot spawn-mex, so that closed positions still contribute economy. -- ⬜ As a **system**, I want random factions/start spots/AI names and the ratings/clan tables resolved at launch, so that the final config is complete and fair. +- 🟡 As a **system**, I want random factions/start spots/AI names and the ratings/clan tables resolved at launch, so that the final config is complete and fair. *(At launch `BuildGameConfiguration` resolves random factions to a concrete one, assigns army numbers in slot order, and stamps the ratings/clan-tag tables into the game options; start-spot/AutoTeams resolution and AI names aren't done yet.)* ## I. Map selection (host) diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index 7ce2d8562b8..0397ebf7c77 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -43,9 +43,9 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | [CustomLobbyLocalModel.lua](CustomLobbyLocalModel.lua) | per-peer state, never synced: identity + CPU benchmarks. | | [CustomLobbyPerformancePopover.lua](CustomLobbyPerformancePopover.lua) | hover popover over the CPU column; hand-built bitmap bar chart of a peer's `PerformanceTrackingV2` history, with a yellow recommended-unit-cap line. | | [CustomLobbyInstance.lua](CustomLobbyInstance.lua) | thin `moho.lobby_methods` shell; validates/dispatches traffic, forwards callbacks to the controller. | -| [CustomLobbyController.lua](CustomLobbyController.lua) | host-authority logic (free functions): seating, `Process*` handlers, intents (`RequestSetReady`, `RequestTakeSlot`, `RequestSwapSlots`, `RequestEject`, `RequestMoveToObserver`, `RequestSetScenario` — all keyed by slot/bool/file so a chat command can call them too; permission is gated separately), sharing the stored CPU benchmark. | +| [CustomLobbyController.lua](CustomLobbyController.lua) | host-authority logic (free functions): seating, `Process*` handlers, intents (`RequestSetReady`, `RequestTakeSlot`, `RequestSwapSlots`, `RequestEject`, `RequestMoveToObserver`, `RequestSetScenario`, `RequestSetGameMods/Options`, `RequestResetGameOptions`, `RequestLaunch` — all keyed by slot/bool/file so a chat command can call them too; permission is gated separately), sharing the stored CPU benchmark. **Launch:** `RequestLaunch` (host-only, readiness-validated) → `BuildGameConfiguration` (seed option defaults + scenario, resolve random factions, assign army numbers + push to server, stamp ratings/clan tags, resolve sim mods via `Mods.GetGameMods`) → broadcast `LaunchGame` + `instance:LaunchGame`; clients run `ProcessLaunchGame`. | | [CustomLobbyRules.lua](CustomLobbyRules.lua) | game-rule derivations from lobby state (not view, not networking): `RecommendedUnitCap()` (per-player cap by map size, memoised scenario lookup). | -| [CustomLobbyMessages.lua](CustomLobbyMessages.lua) | message registry: `AddPlayer`, `SetPlayers` (launch model: players + observers), `SentLaunchInfo` (launch model: scenario / options / mods / teams / spawn mex), `SetSessionState` (session model: slot count / closed slots), `SetReady`, `TakeSlot`, `DisconnectPeer`, `ReportCpuBenchmark`, `SetCpuBenchmarks`. | +| [CustomLobbyMessages.lua](CustomLobbyMessages.lua) | message registry: `AddPlayer`, `SetPlayers` (launch model: players + observers), `SentLaunchInfo` (launch model: scenario / options / mods / teams / spawn mex), `SetSessionState` (session model: slot count / closed slots), `SetReady`, `TakeSlot`, `DisconnectPeer`, `LaunchGame` (host's final game config → `instance:LaunchGame`), `ReportCpuBenchmark`, `SetCpuBenchmarks`. | | [CustomLobbyContextMenu.lua](CustomLobbyContextMenu.lua) | generic framed floating menu; `Show(entries, x, y)` renders any `{label, action, enabled}` list, dismisses on item click / click-outside / Esc. Knows nothing about the lobby. | | [CustomLobbyMenus.lua](CustomLobbyMenus.lua) | declarative menu **definitions**: entry lists with `when(ctx)`/`action(ctx)` filtered by lobby state (`BuildSlotMenu`). Adding/state-gating an item is a one-liner here. | | [CustomLobbySlotInterface.lua](CustomLobbySlotInterface.lua) | one slot row; subscribes to its slot + CPU benchmarks; CPU column shows max units at +0 with a green→red cap-headroom square; left-click an open slot to take it / your own to toggle ready; right-click opens its context menu; the host can drag a row onto another to swap. | @@ -74,7 +74,11 @@ stored sim-performance benchmark is shared (no live stress test), and a **Leave* (or Esc) disconnects and returns to the menu — a leaving client frees its slot for everyone via `OnPeerDisconnected`. The host can **pick the map** via a Change-Map button → the map-select dialog (searchable list + preview), which sets `ScenarioFile` through the -`RequestSetScenario` intent and broadcasts it so every peer's preview updates. Launched via +`RequestSetScenario` intent and broadcasts it so every peer's preview updates. The host can +**launch the game** (Launch button → `RequestLaunch`): once a map is picked, ≥1 slot is seated +and every other human is ready, it builds the game config, broadcasts `LaunchGame` and hands it to +the engine, so all peers start together. (Lobby-UI teardown on launch and reactive enable/disable +of the button are still TODO.) Launched via `scripts/LaunchCustomLobby.ps1`, or inspect UI only with `UI_Lua import("/lua/ui/lobby/customlobby/customlobbyinterface.lua").OpenDebug()`. diff --git a/lua/ui/lobby/customlobby/CustomLobbyController.lua b/lua/ui/lobby/customlobby/CustomLobbyController.lua index fc9f230225a..6d4b54abab8 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyController.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyController.lua @@ -398,6 +398,13 @@ function ProcessSetCpuBenchmarks(instance, data) CustomLobbyLocalModel.GetSingleton().CpuBenchmarks:Set(data.CpuBenchmarks) end +--- Everyone launches the game with the host's configuration (the engine takes over from here). +---@param instance UICustomLobbyInstance +---@param data UICustomLobbyLaunchGameMessage +function ProcessLaunchGame(instance, data) + instance:LaunchGame(data.GameConfig) +end + --#endregion ------------------------------------------------------------------------------- @@ -621,6 +628,131 @@ function RequestResetGameOptions() BroadcastLaunchInfo(instance) end +------------------------------------------------------------------------------- +--#region Launch + +--- Why the game can't launch right now, or nil when it can. Requires at least one seated player, +--- a selected map, and every *other* human to be ready — the launching host is exempt, since +--- clicking Launch is their commit. +---@return string | nil +local function LaunchBlockReason() + local launch = CustomLobbyLaunchModel.GetSingleton() + local localId = CustomLobbyLocalModel.GetSingleton().LocalPeerId() + local seated = 0 + for slot = 1, CustomLobbyLaunchModel.MaxSlots do + local player = launch.Players[slot]() + if player then + seated = seated + 1 + if player.Human and player.OwnerID ~= localId and not player.Ready then + return "not everyone is ready" + end + end + end + if seated == 0 then + return "no players are seated" + end + if not launch.ScenarioFile() then + return "no map is selected" + end + return nil +end + +--- Builds the engine launch configuration from the launch model — mirrors the autolobby's +--- `LaunchThread` and the legacy lobby's `LaunchGame`: +--- * `GameOptions`: the host's values with lobby/scenario/mod defaults seeded over them, plus the +--- scenario file and the ratings / clan-tag tables (sim + UI read those there); +--- * `PlayerOptions`: the seated players (fresh copies; a random faction resolved to a concrete +--- one), keyed by slot, with army numbers assigned in slot order and pushed to the server; +--- * `GameMods` + `Observers` straight off the model. +---@param instance UICustomLobbyInstance +---@return UILobbyLaunchConfiguration +local function BuildGameConfiguration(instance) + local OptionUtil = import("/lua/ui/optionutil.lua") + local Factions = import("/lua/factions.lua").Factions + local factionCount = table.getn(Factions) + local launch = CustomLobbyLaunchModel.GetSingleton() + + -- the full option set: the host's chosen values, with defaults seeded for anything unset + local schema = {} + for _, option in OptionUtil.GetLobbyOptions() do table.insert(schema, option) end + for _, option in OptionUtil.GetScenarioOptions(launch.ScenarioFile()) do table.insert(schema, option) end + for _, option in OptionUtil.GetModOptions(launch.GameMods()) do table.insert(schema, option) end + local gameOptions = OptionUtil.SeedDefaults(schema, launch.GameOptions()) + gameOptions.ScenarioFile = launch.ScenarioFile() + + -- seated players (fresh copies; random faction resolved to a concrete one) + local playerOptions = {} + local ratings, clanTags = {}, {} + for slot = 1, CustomLobbyLaunchModel.MaxSlots do + local player = launch.Players[slot]() + if player then + local options = table.copy(player) + if (options.Faction or factionCount + 1) > factionCount then + options.Faction = Random(1, factionCount) + end + options.StartSpot = options.StartSpot or slot + playerOptions[slot] = options + if options.PL then ratings[options.PlayerName] = options.PL end + if options.PlayerClan then clanTags[options.PlayerName] = options.PlayerClan end + end + end + gameOptions.Ratings = ratings + gameOptions.ClanTags = clanTags + + -- army numbers are assigned in slot order; tell the server each seated player's army settings + local slots = {} + for slot, _ in playerOptions do + table.insert(slots, slot) + end + table.sort(slots) + for armyIndex, slot in ipairs(slots) do + local player = playerOptions[slot] + instance:SendPlayerOptionToServer(player.OwnerID, 'Team', player.Team) + instance:SendPlayerOptionToServer(player.OwnerID, 'Army', armyIndex) + instance:SendPlayerOptionToServer(player.OwnerID, 'StartSpot', player.StartSpot) + instance:SendPlayerOptionToServer(player.OwnerID, 'Faction', player.Faction) + end + + return { + -- the model holds the selected sim-mod uid set; the engine wants the resolved mod list + GameMods = import("/lua/mods.lua").GetGameMods(launch.GameMods()), + GameOptions = gameOptions, + PlayerOptions = playerOptions, + Observers = launch.Observers(), + } +end + +--- The host launches the game. Host-only — backs the Launch button. Validates readiness, builds +--- the launch configuration, broadcasts it so every peer launches with the same config, then +--- launches locally. The engine takes over from here (see `OnGameLaunched`). +--- +--- TODO: resolve AutoTeams (the mode lives in `GameOptions` but teams aren't applied at launch yet +--- — see USER_STORIES.md H), and gate the button reactively / surface the block reason in the UI +--- rather than only warning. +function RequestLaunch() + local instance = LobbyInstance + if not instance then + return + end + + if not CustomLobbyLocalModel.GetSingleton().IsHost() then + WARN("CustomLobby: only the host can launch the game") + return + end + + local reason = LaunchBlockReason() + if reason then + WARN("CustomLobby: cannot launch — " .. reason) + return + end + + local gameConfiguration = BuildGameConfiguration(instance) + instance:BroadcastData({ Type = 'LaunchGame', GameConfig = gameConfiguration }) + instance:LaunchGame(gameConfiguration) +end + +--#endregion + --- The host swaps the contents of two slots. Host-only (a client request isn't --- offered) — backs a host-side drag/menu and a `/swap ` chat command. ---@param slotA number diff --git a/lua/ui/lobby/customlobby/CustomLobbyInstance.lua b/lua/ui/lobby/customlobby/CustomLobbyInstance.lua index bdd6ac0e4af..1cd188e9aaa 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyInstance.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyInstance.lua @@ -79,6 +79,18 @@ CustomLobbyInstance = Class(MohoLobbyMethods) { return MohoLobbyMethods.SendData(self, peerId, data) end, + --- Tells the server a seated player's army setting at launch (host-only). No-op unless we're + --- on the GPGNet path (the FAF client); a local test launch just skips it. + ---@param self UICustomLobbyInstance + ---@param peerId UILobbyPeerId + ---@param key 'Team' | 'Army' | 'StartSpot' | 'Faction' + ---@param value any + SendPlayerOptionToServer = function(self, peerId, key, value) + if self:IsHost() and GpgNetActive() then + GpgNetSend('PlayerOption', peerId, key, value) + end + end, + --#endregion --------------------------------------------------------------------------- diff --git a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua index 162b75f19cb..6b573bc983a 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua @@ -201,10 +201,9 @@ local CustomLobbyInterface = Class(Group) { self.LaunchButton = UIUtil.CreateButtonWithDropshadow(self.ActionArea, '/BUTTON/large/', "Launch") self.LaunchButton.OnClick = function(button, modifiers) - -- launch flow isn't wired up yet + CustomLobbyController.RequestLaunch() end - self.LaunchButton:Disable() - Tooltip.AddControlTooltipManual(self.LaunchButton, "Launch", "Launching isn't wired up yet.") + Tooltip.AddControlTooltipManual(self.LaunchButton, "Launch", "Start the game with the current setup (host only). Everyone else must be ready.") --#endregion local session = CustomLobbySessionModel.GetSingleton() diff --git a/lua/ui/lobby/customlobby/CustomLobbyMessages.lua b/lua/ui/lobby/customlobby/CustomLobbyMessages.lua index 4102e111933..66abb93df91 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyMessages.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyMessages.lua @@ -189,6 +189,25 @@ CustomLobbyMessages = { end, }, + -- The host tells everyone to launch with the final game configuration (players, options, + -- mods, scenario). On receipt each peer hands it straight to the engine. + LaunchGame = { + ---@class UICustomLobbyLaunchGameMessage : UILobbyReceivedMessage + ---@field GameConfig UILobbyLaunchConfiguration + + ---@param data UICustomLobbyLaunchGameMessage + Validate = function(lobby, data) + return type(data.GameConfig) == 'table' + end, + Accept = function(lobby, data) + return IsFromHost(lobby, data) + end, + ---@param data UICustomLobbyLaunchGameMessage + Handler = function(lobby, data) + CustomLobbyController.ProcessLaunchGame(lobby, data) + end, + }, + -- A client reports its in-game sim-performance history (the rich benchmark). ReportCpuBenchmark = { ---@class UICustomLobbyReportCpuBenchmarkMessage : UILobbyReceivedMessage From 2e90dbc4b511ffb047176f0181b4196258d41e74 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Wed, 24 Jun 2026 19:53:49 +0200 Subject: [PATCH 39/98] Experiment with using trashbags --- lua/ui/lobby/customlobby/CLAUDE.md | 1 + .../customlobby/CustomLobbyController.lua | 8 +- .../lobby/customlobby/CustomLobbySession.lua | 63 +++ .../mapselect/CustomLobbyMapCatalog.lua | 363 ++++++++++++------ lua/ui/lobby/lobby.lua | 8 + 5 files changed, 331 insertions(+), 112 deletions(-) create mode 100644 lua/ui/lobby/customlobby/CustomLobbySession.lua diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index 0397ebf7c77..0b1f9944e58 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -45,6 +45,7 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | [CustomLobbyInstance.lua](CustomLobbyInstance.lua) | thin `moho.lobby_methods` shell; validates/dispatches traffic, forwards callbacks to the controller. | | [CustomLobbyController.lua](CustomLobbyController.lua) | host-authority logic (free functions): seating, `Process*` handlers, intents (`RequestSetReady`, `RequestTakeSlot`, `RequestSwapSlots`, `RequestEject`, `RequestMoveToObserver`, `RequestSetScenario`, `RequestSetGameMods/Options`, `RequestResetGameOptions`, `RequestLaunch` — all keyed by slot/bool/file so a chat command can call them too; permission is gated separately), sharing the stored CPU benchmark. **Launch:** `RequestLaunch` (host-only, readiness-validated) → `BuildGameConfiguration` (seed option defaults + scenario, resolve random factions, assign army numbers + push to server, stamp ratings/clan tags, resolve sim mods via `Mods.GetGameMods`) → broadcast `LaunchGame` + `instance:LaunchGame`; clients run `ProcessLaunchGame`. | | [CustomLobbyRules.lua](CustomLobbyRules.lua) | game-rule derivations from lobby state (not view, not networking): `RecommendedUnitCap()` (per-player cap by map size, memoised scenario lookup). | +| [CustomLobbySession.lua](CustomLobbySession.lua) | the lobby session's **main trash bag**. The lobby lives in the persistent front-end Lua state, which is *not* reset when the game launches in its own state — so anything left reachable (a running thread, a cache, a singleton) leaks for the whole match. This module owns one session-lifetime `TrashBag` (`GetTrash()`); every lobby-scoped `Destroyable` registers in it, and one `Teardown()` (clean-slate in `CreateLobby`, on leave, on `OnGameLaunched`) frees the lot. Works despite the bag being weak-valued because each singleton is pinned by its own module `Instance` local. **Rollout in progress** — the map catalog is the first resource converted; models / interface / instance follow. | | [CustomLobbyMessages.lua](CustomLobbyMessages.lua) | message registry: `AddPlayer`, `SetPlayers` (launch model: players + observers), `SentLaunchInfo` (launch model: scenario / options / mods / teams / spawn mex), `SetSessionState` (session model: slot count / closed slots), `SetReady`, `TakeSlot`, `DisconnectPeer`, `LaunchGame` (host's final game config → `instance:LaunchGame`), `ReportCpuBenchmark`, `SetCpuBenchmarks`. | | [CustomLobbyContextMenu.lua](CustomLobbyContextMenu.lua) | generic framed floating menu; `Show(entries, x, y)` renders any `{label, action, enabled}` list, dismisses on item click / click-outside / Esc. Knows nothing about the lobby. | | [CustomLobbyMenus.lua](CustomLobbyMenus.lua) | declarative menu **definitions**: entry lists with `when(ctx)`/`action(ctx)` filtered by lobby state (`BuildSlotMenu`). Adding/state-gating an item is a one-liner here. | diff --git a/lua/ui/lobby/customlobby/CustomLobbyController.lua b/lua/ui/lobby/customlobby/CustomLobbyController.lua index 6d4b54abab8..fa267224129 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyController.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyController.lua @@ -40,6 +40,7 @@ local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") local CustomLobbySessionModel = import("/lua/ui/lobby/customlobby/customlobbysessionmodel.lua") local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/customlobbylocalmodel.lua") +local CustomLobbySession = import("/lua/ui/lobby/customlobby/customlobbysession.lua") --- The live lobby object, set by the first engine callback. UI-triggered intents --- (RequestSetReady, …) reach the network through it without threading it everywhere. @@ -282,10 +283,13 @@ function OnPeerDisconnected(instance, peerName, uid) BroadcastPlayers(instance) end ---- Called when the game launches. Barebones: not wired yet. +--- Called when the game launches. The engine has taken over in its own Lua state, so the lobby's +--- front-end state can be released: free everything registered in the session trash (the map +--- catalog today; the models, interface and instance follow as they are converted to the pattern). ---@param instance UICustomLobbyInstance function OnGameLaunched(instance) - LOG("CustomLobby: GameLaunched (launch flow not implemented yet)") + LOG("CustomLobby: GameLaunched — tearing down the lobby session") + CustomLobbySession.Teardown() end --#endregion diff --git a/lua/ui/lobby/customlobby/CustomLobbySession.lua b/lua/ui/lobby/customlobby/CustomLobbySession.lua new file mode 100644 index 00000000000..ca0a6f3103a --- /dev/null +++ b/lua/ui/lobby/customlobby/CustomLobbySession.lua @@ -0,0 +1,63 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- The lobby session's "main trash bag". +-- +-- The custom lobby runs in the persistent *front-end* Lua state. That state is NOT reset when a +-- game launches in its own state, so anything we leave reachable after launch (or after leaving) — +-- a running thread, a cached table, a model singleton — leaks for the whole match. We need exactly +-- one call that frees *everything* lobby-scoped. +-- +-- This module owns a single session-lifetime `TrashBag`. Every lobby-scoped singleton that owns +-- resources (models, catalogs, the interface, the lobby instance) is a `Destroyable` added to it, +-- so `Teardown()` frees the lot at once and `GetTrash()` hands every owner the same bag. +-- +-- Why a TrashBag works here even though it is **weak-valued** (`__mode = 'v'`, see +-- /lua/system/trashbag.lua): a weak bag only holds things kept alive by a strong reference +-- elsewhere. That is precisely our singletons — each is pinned by its own module-level `Instance` +-- local, so the bag can still reach it at teardown but is never the reason it survives GC. A bare +-- `{ Destroy = fn }` disposable with no other owner would be collected before teardown; a real +-- singleton object will not. So: make resources real `ClassSimple` objects that implement +-- `Destroy`, pin them in their module local, and drop them in this bag. + +---@type TrashBag | false +local SessionTrash = false + +--- The session-lifetime trash bag. Lazily created; lives until `Teardown()`. Add any lobby-scoped +--- `Destroyable` (a model, a catalog, the interface, the lobby instance) to it. +---@return TrashBag +function GetTrash() + if not SessionTrash then + SessionTrash = TrashBag() + end + return SessionTrash +end + +--- Frees everything added to the session trash (models, catalogs, threads, UI, the lobby instance) +--- and starts a fresh bag for the next session. Idempotent — safe to call as a clean-slate guard at +--- the top of `CreateLobby`, and on both teardown paths (leave to menu, launch into the game). +function Teardown() + if SessionTrash then + SessionTrash:Destroy() + SessionTrash = false + end +end diff --git a/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapCatalog.lua b/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapCatalog.lua index 8097090a10e..1c1fa0706a9 100644 --- a/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapCatalog.lua +++ b/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapCatalog.lua @@ -39,8 +39,17 @@ -- This stays *reference data*, never on the wire: the LazyVar gives local reactivity (progress), -- not host-dictated sync. Only the host's *choice* (`ScenarioFile`, in the launch model) syncs; -- every peer enumerates its own disk. See CLAUDE.md and the `customlobby-model-choice` skill. +-- +-- LIFETIME. The catalog is a `ClassSimple` singleton implementing `Destroyable`, registered in the +-- session trash bag (see CustomLobbySession) the moment it is created. So one `CustomLobbySession +-- .Teardown()` kills its streaming load thread and drops its cached map list / save cache, instead +-- of pinning them in the persistent front-end Lua state for the whole match. The module-level +-- functions below are thin facades over the singleton, so callers are unaffected by the object +-- shape. This is the worked example for the broader singleton rework — the models and the other +-- catalog follow the same pattern. local Create = import("/lua/lazyvar.lua").Create +local CustomLobbySession = import("/lua/ui/lobby/customlobby/customlobbysession.lua") -- The catalog is the lobby's single point of contact with MapUtil's `doscript` file loaders -- (info + save) — so no other custom-lobby module imports MapUtil. Everything above that @@ -51,21 +60,18 @@ local LoadScenarioInfoFile = MapUtil.LoadScenarioInfoFile -- maps loaded per frame-slice before yielding — keeps the open responsive on big vaults local BatchSize = 5 ---- The growing list of playable skirmish maps. Re-fired (new table ref) as batches land. ----@type LazyVar -local Scenarios = Create({}) - -local Loading = false -- a load thread is currently running -local Loaded = false -- the disk has been fully enumerated - --- memoised save files (the expensive `doscript`), keyed by lowercased save path; `false` = a --- known-missing/unreadable save. FIFO-bounded so browsing a big vault doesn't grow unbounded. -local SaveCache = {} -local SaveCacheOrder = {} +-- save files memoised before the FIFO bound kicks in — the save `doscript` is expensive and +-- browsing re-loads the same maps local SaveCacheMax = 24 +-- The catalog singleton. Forward-declared here (before the class) so the class methods capture it +-- as an upvalue rather than resolving it as a global. Assigned in `GetSingleton`, cleared in +-- `Destroy`. +---@type UICustomLobbyMapCatalog | nil +local Instance = nil + ------------------------------------------------------------------------------- ---#region Enumeration +--#region Enumeration helpers (pure; no instance state) --- A scenario is listable if it's a skirmish map with at least one start spot. ---@param scenario UIScenarioInfoFile @@ -87,156 +93,293 @@ local function SortByName(a, b) return string.upper(a.name or "") < string.upper(b.name or "") end ---- Publishes a fresh (sorted) copy of the accumulator so dependents go dirty. ----@param accumulator UILobbyScenarioInfo[] -local function Publish(accumulator) - local snapshot = table.copy(accumulator) - table.sort(snapshot, SortByName) - Scenarios:Set(snapshot) -end +--#endregion ---- Enumerates `/maps` across frames, loading each map's info and streaming the playable ---- skirmish maps into the `Scenarios` LazyVar in batches. ---- TODO: mod maps (legacy also scanned `mods.AllSelectableMods()`); skipped for now. -local function LoadThread() - local files = DiskFindFiles('/maps', '*_scenario.lua') - - local accumulator = {} - local seen = 0 - for _, file in files do - local scenario = LoadScenarioInfoFile(file) - if scenario and IsPlayableSkirmish(scenario) then - scenario.file = file - table.insert(accumulator, scenario) +------------------------------------------------------------------------------- +--#region Catalog class + +--- The lobby's map catalog. One per lobby session, owned by the session trash bag. +---@class UICustomLobbyMapCatalog : Destroyable +---@field Trash TrashBag # owns the Scenarios LazyVar (freed on Destroy) +---@field Scenarios LazyVar # growing list; re-fired (new table ref) as batches land +---@field Worker thread | nil # the in-flight enumeration thread, if any +---@field Loading boolean # a load thread is currently running +---@field Loaded boolean # the disk has been fully enumerated +---@field SaveCache table # memoised saves keyed by lowercased path; false = known-missing +---@field SaveCacheOrder string[] # FIFO order of SaveCache keys, bounded by SaveCacheMax +---@field Destroyed boolean +local Catalog = ClassSimple { + + ---@param self UICustomLobbyMapCatalog + __init = function(self) + self.Trash = TrashBag() + self.Scenarios = self.Trash:Add(Create({})) + self.Worker = nil + self.Loading = false + self.Loaded = false + self.SaveCache = {} + self.SaveCacheOrder = {} + self.Destroyed = false + end, + + --- Publishes a fresh (sorted) copy of the accumulator so dependents go dirty. + ---@param self UICustomLobbyMapCatalog + ---@param accumulator UILobbyScenarioInfo[] + Publish = function(self, accumulator) + local snapshot = table.copy(accumulator) + table.sort(snapshot, SortByName) + self.Scenarios:Set(snapshot) + end, + + --- Enumerates `/maps` across frames, loading each map's info and streaming the playable + --- skirmish maps into the `Scenarios` LazyVar in batches. + --- TODO: mod maps (legacy also scanned `mods.AllSelectableMods()`); skipped for now. + ---@param self UICustomLobbyMapCatalog + LoadThread = function(self) + local files = DiskFindFiles('/maps', '*_scenario.lua') + + local accumulator = {} + local seen = 0 + for _, file in files do + local scenario = LoadScenarioInfoFile(file) + if scenario and IsPlayableSkirmish(scenario) then + scenario.file = file + table.insert(accumulator, scenario) + end + + seen = seen + 1 + if math.mod(seen, BatchSize) == 0 then + self:Publish(accumulator) + WaitFrames(5) + end end - seen = seen + 1 - if math.mod(seen, BatchSize) == 0 then - Publish(accumulator) - WaitFrames(5) + self.Loaded = true + self.Loading = false + self.Worker = nil + self:Publish(accumulator) + end, + + --- Kicks off the async enumeration if it hasn't run yet. Idempotent — safe to call on every + --- dialog open; once loaded it's a no-op and the cached list stays. + ---@param self UICustomLobbyMapCatalog + EnsureLoaded = function(self) + if self.Loading or self.Loaded then + return + end + self.Loading = true + self.Scenarios:Set({}) + -- tracked on `self` (not the trash) so Refresh can kill it without dropping the LazyVar + self.Worker = ForkThread(self.LoadThread, self) + end, + + --- The current (possibly partial) list of maps. + ---@param self UICustomLobbyMapCatalog + ---@return UILobbyScenarioInfo[] + GetScenarios = function(self) + return self.Scenarios() + end, + + --- How many maps are currently loaded. + ---@param self UICustomLobbyMapCatalog + ---@return number + GetCount = function(self) + return table.getn(self.Scenarios()) + end, + + --- Whether the disk has been fully enumerated (vs. still streaming). + ---@param self UICustomLobbyMapCatalog + ---@return boolean + IsLoaded = function(self) + return self.Loaded + end, + + --- Finds the loaded map whose file path matches `scenarioFile` (case-insensitive), or nil. + ---@param self UICustomLobbyMapCatalog + ---@param scenarioFile FileName | false + ---@return UILobbyScenarioInfo | nil + FindByFile = function(self, scenarioFile) + if not scenarioFile then + return nil + end + local target = string.lower(scenarioFile) + for _, scenario in self.Scenarios() do + if string.lower(scenario.file) == target then + return scenario + end + end + return nil + end, + + --- Loads a scenario's info for `scenarioFile`. Returns the already-enumerated entry if we have + --- it (the streamed list is the info cache), else reads it from disk. nil if unreadable. + ---@param self UICustomLobbyMapCatalog + ---@param scenarioFile FileName | false + ---@return UILobbyScenarioInfo | nil + LoadInfo = function(self, scenarioFile) + if not scenarioFile then + return nil + end + local cached = self:FindByFile(scenarioFile) + if cached then + return cached + end + local info = LoadScenarioInfoFile(scenarioFile) + if not info then + return nil + end + info.file = scenarioFile + return info --[[@as UILobbyScenarioInfo]] + end, + + --- Loads (and caches) a scenario's save file — the marker data the preview overlays need. + --- The save `doscript` is expensive, and both the in-lobby preview and the picker re-load the + --- same maps repeatedly, so results are memoised by save path with a small FIFO bound. Returns + --- nil if the save is missing / unreadable (cached as a negative so we don't retry the disk). + ---@param self UICustomLobbyMapCatalog + ---@param scenarioInfo UILobbyScenarioInfo + ---@return UIScenarioSaveFile | nil + LoadSave = function(self, scenarioInfo) + if not (scenarioInfo and scenarioInfo.save) then + return nil end - end - Loaded = true - Loading = false - Publish(accumulator) -end + local key = string.lower(scenarioInfo.save) + local cached = self.SaveCache[key] + if cached ~= nil then + return cached or nil -- `false` = known-missing + end + + ---@type UIScenarioSaveFile | false + local save = false + if DiskGetFileInfo(scenarioInfo.save) then + save = MapUtil.LoadScenarioSaveFile(scenarioInfo.save) or false + end + + self.SaveCache[key] = save + table.insert(self.SaveCacheOrder, key) + if table.getn(self.SaveCacheOrder) > SaveCacheMax then + local oldest = table.remove(self.SaveCacheOrder, 1) + self.SaveCache[oldest] = nil + end + + return save or nil + end, + + --- Drops everything so the next `EnsureLoaded` re-reads from disk (e.g. maps changed on disk). + --- Kills any in-flight load thread and resets the cached list *in place* (same LazyVar) so + --- existing subscribers stay valid — this is a reset, not a teardown. + ---@param self UICustomLobbyMapCatalog + Refresh = function(self) + if self.Worker then + KillThread(self.Worker) + self.Worker = nil + end + self.Loaded = false + self.Loading = false + self.Scenarios:Set({}) + self.SaveCache = {} + self.SaveCacheOrder = {} + end, + + --- `Destroyable`: kill the load thread and drop the cached list + save cache. Called by the + --- session trash on `Teardown()`. Idempotent. Clears the module singleton so the next access + --- rebuilds a fresh catalog (and re-registers it in the next session's trash). + ---@param self UICustomLobbyMapCatalog + Destroy = function(self) + if self.Destroyed then + return + end + self.Destroyed = true + if self.Worker then + KillThread(self.Worker) + self.Worker = nil + end + self.Loaded = false + self.Loading = false + self.SaveCache = {} + self.SaveCacheOrder = {} + self.Trash:Destroy() -- frees the Scenarios LazyVar + if Instance == self then + Instance = nil + end + end, +} --#endregion ------------------------------------------------------------------------------- ---#region Public API +--#region Singleton + facade +-- +-- The catalog data outlives any single map-select dialog (it's cached across opens) but dies with +-- the lobby session. So the singleton is created on first use and registered in the session trash; +-- `CustomLobbySession.Teardown()` destroys it. The module functions are thin facades so the five +-- call sites keep using `CustomLobbyMapCatalog.LoadInfo(file)` etc. unchanged. + +--- Returns the catalog singleton, creating (and registering it in the session trash) on first +--- access — including after a teardown, so the registry is reusable across lobby sessions. +---@return UICustomLobbyMapCatalog +function GetSingleton() + if not Instance then + Instance = Catalog() + CustomLobbySession.GetTrash():Add(Instance) + end + return Instance +end ---- Kicks off the async enumeration if it hasn't run yet. Idempotent — safe to call on every ---- dialog open; once loaded it's a no-op and the cached list stays. +--- Kicks off the async enumeration if it hasn't run yet. function EnsureLoaded() - if Loading or Loaded then - return - end - Loading = true - Scenarios:Set({}) - ForkThread(LoadThread) + GetSingleton():EnsureLoaded() end --- The maps LazyVar — subscribe to it (via `Derive`) to react as the list streams in. ---@return LazyVar function GetScenariosVar() - return Scenarios + return GetSingleton().Scenarios end --- The current (possibly partial) list of maps. ---@return UILobbyScenarioInfo[] function GetScenarios() - return Scenarios() + return GetSingleton():GetScenarios() end --- How many maps are currently loaded. ---@return number function GetCount() - return table.getn(Scenarios()) + return GetSingleton():GetCount() end --- Whether the disk has been fully enumerated (vs. still streaming). ---@return boolean function IsLoaded() - return Loaded + return GetSingleton():IsLoaded() end --- Finds the loaded map whose file path matches `scenarioFile` (case-insensitive), or nil. ---@param scenarioFile FileName | false ---@return UILobbyScenarioInfo | nil function FindByFile(scenarioFile) - if not scenarioFile then - return nil - end - local target = string.lower(scenarioFile) - for _, scenario in Scenarios() do - if string.lower(scenario.file) == target then - return scenario - end - end - return nil + return GetSingleton():FindByFile(scenarioFile) end ---- Loads a scenario's info for `scenarioFile`. Returns the already-enumerated entry if we have ---- it (the streamed list is the info cache), else reads it from disk. nil if unreadable. +--- Loads a scenario's info for `scenarioFile`. ---@param scenarioFile FileName | false ---@return UILobbyScenarioInfo | nil function LoadInfo(scenarioFile) - if not scenarioFile then - return nil - end - local cached = FindByFile(scenarioFile) - if cached then - return cached - end - local info = LoadScenarioInfoFile(scenarioFile) - if not info then - return nil - end - info.file = scenarioFile - return info --[[@as UILobbyScenarioInfo]] + return GetSingleton():LoadInfo(scenarioFile) end --- Loads (and caches) a scenario's save file — the marker data the preview overlays need. ---- The save `doscript` is expensive, and both the in-lobby preview and the picker re-load the ---- same maps repeatedly, so results are memoised by save path with a small FIFO bound. Returns ---- nil if the save is missing / unreadable (cached as a negative so we don't retry the disk). ---@param scenarioInfo UILobbyScenarioInfo ---@return UIScenarioSaveFile | nil function LoadSave(scenarioInfo) - if not (scenarioInfo and scenarioInfo.save) then - return nil - end - - local key = string.lower(scenarioInfo.save) - local cached = SaveCache[key] - if cached ~= nil then - return cached or nil -- `false` = known-missing - end - - local save = false - if DiskGetFileInfo(scenarioInfo.save) then - save = MapUtil.LoadScenarioSaveFile(scenarioInfo.save) or false - end - - SaveCache[key] = save - table.insert(SaveCacheOrder, key) - if table.getn(SaveCacheOrder) > SaveCacheMax then - local oldest = table.remove(SaveCacheOrder, 1) - SaveCache[oldest] = nil - end - - return save or nil + return GetSingleton():LoadSave(scenarioInfo) end --- Drops everything so the next `EnsureLoaded` re-reads from disk (e.g. maps changed on disk). function Refresh() - Loaded = false - Loading = false - Scenarios:Set({}) - SaveCache = {} - SaveCacheOrder = {} + GetSingleton():Refresh() end --#endregion diff --git a/lua/ui/lobby/lobby.lua b/lua/ui/lobby/lobby.lua index 4f6a8f07d0d..059530065d6 100644 --- a/lua/ui/lobby/lobby.lua +++ b/lua/ui/lobby/lobby.lua @@ -39,6 +39,7 @@ local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaun local CustomLobbySessionModel = import("/lua/ui/lobby/customlobby/customlobbysessionmodel.lua") local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/customlobbylocalmodel.lua") local CustomLobbyInterface = import("/lua/ui/lobby/customlobby/customlobbyinterface.lua") +local CustomLobbySession = import("/lua/ui/lobby/customlobby/customlobbysession.lua") local maxConnections = 16 @@ -66,6 +67,10 @@ function CreateLobby(protocol, localPort, desiredPlayerName, localPlayerUID, nat -- Hand the front-end's escape handler back so ours can take over. MenuCommon.MenuCleanup() + -- Clean slate: drop any residue from a previous lobby session (e.g. a re-host after leaving) + -- before we build this one. Frees everything registered in the session trash. + CustomLobbySession.Teardown() + -- Models first, then the view (so its components subscribe to a live model), -- then the lobby object (whose callbacks write the model). -- TODO: derive SlotCount from the chosen map instead of a fixed default. @@ -87,6 +92,9 @@ function CreateLobby(protocol, localPort, desiredPlayerName, localPlayerUID, nat Instance = false end CustomLobbyInterface.CloseDebug() + -- Free everything registered in the session trash (the map catalog today; the models, + -- interface and instance follow as they are converted to the same pattern). + CustomLobbySession.Teardown() if exitBehavior then exitBehavior() end From bdb9741374fe4433130b57d678266fca7a01ee1c Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Wed, 24 Jun 2026 22:05:42 +0200 Subject: [PATCH 40/98] Document how we want to work with freeing allocations --- .../design/session-trashbag-teardown.md | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 lua/ui/lobby/customlobby/design/session-trashbag-teardown.md diff --git a/lua/ui/lobby/customlobby/design/session-trashbag-teardown.md b/lua/ui/lobby/customlobby/design/session-trashbag-teardown.md new file mode 100644 index 00000000000..1aea5b6e39d --- /dev/null +++ b/lua/ui/lobby/customlobby/design/session-trashbag-teardown.md @@ -0,0 +1,107 @@ +# Session teardown via `Destroyable` singletons + a main trash bag + +Status: **in progress** — the map catalog is converted as the worked example; the rest of the +rollout is pending. This doc captures the pattern, the recipe, and the open decisions so we can +resume later. + +## Problem + +The custom lobby runs in the **persistent front-end Lua state**. That state is *not* reset when a +game launches in its own state. So anything we leave reachable after launch (or after leaving the +lobby) — a running `ForkThread`, a cached table, a model singleton — leaks for the **whole match**. +We need exactly one call that frees everything lobby-scoped. + +A previous attempt used a strong list of teardown callbacks because `TrashBag` is **weak-valued** +(`__mode = 'v'`, see [`/lua/system/trashbag.lua`](/lua/system/trashbag.lua)) and so cannot hold a +bare `{ Destroy = fn }` disposable (it would be GC'd before teardown). That work was never committed. + +## The pattern + +Make each lobby-scoped resource a **real object** — a `ClassSimple` that implements `Destroy` +(the `Destroyable` interface) — and register it in **one session-lifetime `TrashBag`**. Then a +single `bag:Destroy()` frees the lot. + +The weak-bag objection disappears: a weak bag only fails to hold things that nothing else +references. Each singleton is **pinned by its own module-level `Instance` local**, so the bag can +still reach it at teardown but is never the reason it survives GC. Make resources real objects, +pin them in their module local, drop them in the bag. + +### The owner: `CustomLobbySession.lua` + +A tiny module owning one session-lifetime bag: + +- `GetTrash()` — hands every owner the same bag (lazily created). +- `Teardown()` — `bag:Destroy()` then start a fresh bag; idempotent. + +`Teardown()` is called at three lifecycle points (all wired): + +- top of `CreateLobby` (clean slate before building a new session), +- the leave path (escape handler in [`/lua/ui/lobby/lobby.lua`](/lua/ui/lobby/lobby.lua)), +- `OnGameLaunched` in [`CustomLobbyController.lua`](../CustomLobbyController.lua). + +### The recipe (per resource) + +1. Wrap the module state in a `ClassSimple` with an `__init` and a `Destroy`. +2. Forward-declare `local Instance` **above** the class so the class methods capture it as an + upvalue (not a global — this trips `undefined-global` otherwise). +3. `Destroy`: idempotent (guard with a `Destroyed` flag), release real resources (kill threads, + destroy the instance's own `Trash`), then `if Instance == self then Instance = nil end` so the + next session rebuilds fresh. +4. `GetSingleton()` creates on first use and `CustomLobbySession.GetTrash():Add(Instance)` + (self-registration), so it re-registers in each new session's bag. +5. Keep the public module functions as **thin facades** over the singleton so callers don't change + (decision below). + +### Worked example: the map catalog + +[`mapselect/CustomLobbyMapCatalog.lua`](../mapselect/CustomLobbyMapCatalog.lua) is the reference +implementation. `UICustomLobbyMapCatalog : Destroyable`: + +- `Destroy()` kills the in-flight load thread, drops the caches, destroys its own `Trash` (which + frees the `Scenarios` LazyVar), and nils the module singleton. +- The load thread is tracked on `self.Worker` (**not** in the trash) so `Refresh()` can kill it + while keeping the **same** `Scenarios` LazyVar — replacing the LazyVar would orphan the dialog's + `Derive` subscribers. +- The module functions (`LoadInfo`, `LoadSave`, `GetScenarios`, …) are thin facades; the five call + sites are untouched. + +## Open decisions (settle before converting the interface) + +1. **Teardown ordering — the main risk.** A flat `TrashBag` destroys in arbitrary order + (`for k, trash in self`). Fine while resources are independent (today). But once the + **interface** and the **models** are both in the bag, the UI's `Derive` observers point at the + models' LazyVars. If a model is destroyed before the UI, an observer could fire into freed + state. *Probably* safe — destroying a control empties its own trash, severing its observers, so + the dependency edge is cut from whichever side dies first — but the old design used LIFO + deliberately. **Decide:** either prove edge-severing makes order irrelevant, or keep the + interface out of the shared bag and have it own its teardown (registering only a "nil my + singleton" disposable). + +2. **Facade: permanent or migration shim?** The catalog now has two surfaces — `Catalog:LoadInfo()` + (object) and module `LoadInfo()` (delegating). Keeping it stabilises call sites; dropping it + (`GetSingleton():LoadInfo()`) removes a layer but touches callers. Current lean: **keep** for + query-style services like the catalog. + +3. **Models stay free-function-style.** The three models are intentionally plain data tables with + free-function writers (`SetPlayer(model, …)`) — the documented autolobby idiom. For them, + `Destroy` should be **thin**: nil `ModelInstance`, let the LazyVars GC. Do **not** convert their + write helpers to methods. Accept that "service" singletons (catalog: real methods) and "data" + singletons (models: free functions + minimal `Destroy`) legitimately differ in shape. + +### Minor wrinkles (non-blocking) + +- Self-registration gives `GetSingleton()` a first-access side effect (mutates the session bag). + Harmless given `Teardown()` is the clean-slate; a singleton touched before `CreateLobby` just gets + rebuilt. +- Hot-reload: if the load thread is mid-stream when the file is saved, the old instance lives on + (the thread is a GC root) streaming into a dead LazyVar until it finishes. Dev-only; the explicit + `Destroy` gives us a hook if it ever matters. + +## Rollout order (proposed) + +1. ✅ Map catalog (done — reference implementation). +2. The three models (low-risk, thin `Destroy`). +3. The mod catalog (mirror the map catalog). +4. `CustomLobbyRules` map-dimension cache. +5. The interface + performance popover — **after** the ordering decision (#1 above). +6. Track the lobby instance in the session bag (drop the manual `Instance:Destroy()` in lobby.lua). From 56908fe1eaae9effb183a83a15eeb82aff6279e1 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Wed, 24 Jun 2026 22:32:14 +0200 Subject: [PATCH 41/98] Refactor lobby back to what we have now --- lua/ui/lobby/customlobby/CLAUDE.md | 12 +- .../customlobby/CustomLobbyInterface.lua | 155 ++++++++------- .../config/CustomLobbyConfigInterface.lua | 179 ++++++++++++++++-- .../config/CustomLobbyModsPanel.lua | 28 +-- .../config/CustomLobbyOptionsPanel.lua | 69 ++----- 5 files changed, 269 insertions(+), 174 deletions(-) diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index 0b1f9944e58..0d49e8f0ee6 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -56,11 +56,11 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | [mapselect/](mapselect/CLAUDE.md) | the **map-select dialog** + its catalog and list, in their own folder (host-only `Popup`: searchable, filterable scenario list → `RequestSetScenario`). Self-contained sub-MVC; see [mapselect/CLAUDE.md](mapselect/CLAUDE.md) — including the **`MapPreview` texture-leak** writeup that shaped its design. | | [modselect/](modselect/CLAUDE.md) | the **mod-select dialog** + its catalog and list, built to the map-select shape (checkbox list + type filters + detail panel + **presets**). Returns a uid set; the opener routes it — sim mods → `RequestSetGameMods` (synced), UI mods → local prefs — or persists the lot standalone. Mod domain logic lives in [`/lua/ui/modutilities.lua`](/lua/ui/modutilities.lua) (the `maputil.lua` sibling, fronting `/lua/mods.lua`). See [modselect/CLAUDE.md](modselect/CLAUDE.md). | | [optionselect/](optionselect/CLAUDE.md) | the **options dialog**: three columns (lobby / scenario / mod options) over the selected scenario + mods, with search + hide-defaults filters; non-default options are marked. Derives the option *schema* per-peer (reference data) via [`/lua/ui/optionutil.lua`](/lua/ui/optionutil.lua); edits a working copy of the *values* and on OK routes the reconciled set through `RequestSetGameOptions` (synced via `GameOptions`). Host-only. See [optionselect/CLAUDE.md](optionselect/CLAUDE.md). | -| [CustomLobbyInterface.lua](CustomLobbyInterface.lua) | composition root, laid out in **areas** (flip the module `Debug` flag to tint them), sized for the **1024×768** floor: a **title bar** (title · team score · Leave) over a **slots region that owns the whole top** (the rows in **two columns** — odd slots left, even right — up to 16), and a **middle row** split into a left tabbed panel (Chat / Observers) and a right one (the config component), and a full-width **action bar** at the very bottom for the global actions (status + host-only Launch stub, and the like). The slots region is sized to its rows (two columns of `MaxSlots`/2); the two tabbed panels fill the room between it and the action bar. Holds the SlotCount + IsHost observers; Leave (Esc handler) + **Become observer** (`RequestMoveToObserver`, in the Observers tab). Also the rows' drag coordinator (`UICustomLobbySlotCoordinator`: hit-test, drop-highlight, drag ghost → `RequestSwapSlots`); `OpenDebug()` / hot-reload. | +| [CustomLobbyInterface.lua](CustomLobbyInterface.lua) | composition root, laid out in **areas** (flip the module `Debug` flag to tint them), sized for the **1024×768** floor. A **one-column** layout (the two-column variant was reverted by community request): a **title bar** (title · team score · Leave) over a **left column** split vertically — the **slots** on top (a single column of rows, up to 16, height tracking the slot count) and the **Chat / Observers** tabs below — beside a fixed-width **right column** (the config component: a pinned map preview + facts line over Options / Mods / Restrictions tabs). A full-width **action bar** at the bottom holds the global actions: status + the host-only **Settings** button (opens the options editor) + the host-only **Launch**. Holds the SlotCount + IsHost observers; Leave (Esc handler) + **Become observer** (`RequestMoveToObserver`, in the Observers tab). Also the rows' drag coordinator (`UICustomLobbySlotCoordinator`: hit-test, drop-highlight, drag ghost → `RequestSwapSlots`); `OpenDebug()` / hot-reload. | | [CustomLobbyTeamScore.lua](CustomLobbyTeamScore.lua) | the **accumulated team rating** in the title — `Side A N · M Side B`. Shown only for the binary auto-team formations (`tvsb`→Top/Bottom, `lvsr`→Left/Right by start position; `pvsi`→Odd/Even by start-spot parity); **hidden** for `none`/`manual` (no 2-side split) or until a map's start positions are loaded. Reads the mode from `GameOptions().AutoTeams`, the ratings from each slot's `PL`. Reference data; never writes. | | [CustomLobbyTabs.lua](CustomLobbyTabs.lua) | a **generic tabbed panel** (strip + content; one panel alive, created on select / destroyed on switch). Construct with a `{ Label, Create }` list + optional `OnSelect`. Used for the bottom-left (Chat / Observers); the config interface keeps its own bespoke variant (it coordinates the persistent preview). | | [social/](social/) | the lobby's **bottom-left** column (the `CustomLobbyTabs` content): [`CustomLobbyChatPanel`](social/CustomLobbyChatPanel.lua) (the **Chat** tab — placeholder until the chat slice lands) and [`CustomLobbyObserversPanel`](social/CustomLobbyObserversPanel.lua) (the **Observers** tab — the shared observer list + a host-authoritative **Become observer** button → `RequestMoveToObserver`). Each is a tab content component (`Create(parent)`, created on select / destroyed on switch). | -| [config/](config/) | the lobby's **bottom-right** column. [`CustomLobbyConfigInterface.lua`](config/CustomLobbyConfigInterface.lua) is now just the **tab-list definition** (Map / Mods / Options / Restrictions) handed to a generic [`CustomLobbyTabs`](CustomLobbyTabs.lua); every tab — Map included — is a normal **created-on-select / destroyed-on-switch** panel. The **Map tab owns its own preview** ([`CustomLobbyMapPanel`](config/CustomLobbyMapPanel.lua): a large square preview filling the left, with the name + size/players/version line and the label/value details stacked in the column to its right; Change map at the bottom). Churning the preview is safe here because the lobby shows only **one** current map and the engine caches map textures by name, so re-opening the Map tab re-binds the cached texture — no leak. (The texture-leak rule only bites the *map-select dialog*, which browses hundreds of maps — see [mapselect/CLAUDE.md](mapselect/CLAUDE.md).) Other panels: [`CustomLobbyOptionsPanel`](config/CustomLobbyOptionsPanel.lua) (read-only options grouped **Lobby / Scenario / Mods** + hide-defaults toggle; map/mod options gold-flagged with an origin tooltip — schema via [`/lua/ui/optionutil.lua`](/lua/ui/optionutil.lua)), [`CustomLobbyModsPanel`](config/CustomLobbyModsPanel.lua) (enabled mods in **Game / UI** sections), [`CustomLobbyUnitsPanel`](config/CustomLobbyUnitsPanel.lua) (the **Restrictions** tab — placeholder). Each panel self-subscribes to the model + `IsHost`, and exposes `Initialize()` + `Create(parent)`. | +| [config/](config/) | the lobby's **right** column. [`CustomLobbyConfigInterface.lua`](config/CustomLobbyConfigInterface.lua) is the column **composition**: a bound square `CustomLobbyMapPreview` **pinned** at the top, a name + size/players/version facts line under it, and a [`CustomLobbyTabs`](CustomLobbyTabs.lua) (**Options / Mods / Restrictions**) filling the rest. All three tab panels are now **read-only**: their per-domain action buttons (open editor / reset / manage mods) are removed — the grid/content fills the whole panel — and the interface's action-bar **Settings** button is the only edit entry point for now. [`CustomLobbyOptionsPanel`](config/CustomLobbyOptionsPanel.lua) (options grouped **Lobby / Scenario / Mods** + hide-defaults toggle; map/mod options gold-flagged with an origin tooltip — schema via [`/lua/ui/optionutil.lua`](/lua/ui/optionutil.lua)), [`CustomLobbyModsPanel`](config/CustomLobbyModsPanel.lua) (enabled mods in **Game / UI** sections), [`CustomLobbyUnitsPanel`](config/CustomLobbyUnitsPanel.lua) (the **Restrictions** placeholder). Each self-subscribes to the model and exposes `Initialize()` + `Create(parent)`. **Parked** (built, but unwired): [`CustomLobbyMapPanel`](config/CustomLobbyMapPanel.lua) (the full Map tab — preview + label/value details + Change-map), now superseded by the pinned preview. Churning a preview is safe here because the lobby shows only **one** current map and the engine caches map textures by name (the texture-leak rule only bites the *map-select dialog* — see [mapselect/CLAUDE.md](mapselect/CLAUDE.md)). | | [/lua/ui/lobby/lobby.lua](../lobby.lua) | engine entry wrapper (`CreateLobby`/`HostGame`/`JoinGame`) → CustomLobby. Old lobby preserved at `lobby-old.lua`. | Working today: host + clients see each other (host-authoritative player sync), the @@ -73,9 +73,11 @@ ready toggles round-trip, players can **take an open slot** (click it) and the h strip, and an observer rejoins via right-click → **Play this slot**. Each peer's stored sim-performance benchmark is shared (no live stress test), and a **Leave** button (or Esc) disconnects and returns to the menu — a leaving client frees its slot for -everyone via `OnPeerDisconnected`. The host can **pick the map** via a Change-Map button → -the map-select dialog (searchable list + preview), which sets `ScenarioFile` through the -`RequestSetScenario` intent and broadcasts it so every peer's preview updates. The host can +everyone via `OnPeerDisconnected`. The host can edit the **game options** via the action-bar +**Settings** button → the options dialog (synced through `RequestSetGameOptions`). (The per-domain +**Change-map** and **mod-select** entry points are temporarily removed during the layout rework — +the dialogs themselves still exist and will be rewired once it lands; `RequestSetScenario` / +`RequestSetGameMods` remain callable, e.g. from chat commands.) The host can **launch the game** (Launch button → `RequestLaunch`): once a map is picked, ≥1 slot is seated and every other human is ready, it builds the game config, broadcasts `LaunchGame` and hands it to the engine, so all peers start together. (Lobby-UI teardown on launch and reactive enable/disable diff --git a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua index 6b573bc983a..7d29dcb4373 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua @@ -28,20 +28,27 @@ -- module-level `Debug` flag to tint each so the regions are visible while iterating. Targeted at -- the 1024x768 minimum resolution: -- --- ┌ TitleArea ─ title · TEAM SCORE · leave ─────────────────────────────┐ --- │ SlotsArea (slots ONLY — up to 16, two columns) │ --- ├──────────────────────────────┬───────────────────────────────────────┤ --- │ BottomLeftArea (Chat / │ BottomRightArea (Map / Mods / Options │ --- │ Observers — tabs) │ / Restrictions — tabs) │ --- ├──────────────────────────────┴───────────────────────────────────────┤ --- │ ActionArea (status · … · launch) ─ full width │ --- └──────────────────────────────────────────────────────────────────────┘ +-- ┌ TitleArea ─ title · TEAM SCORE · leave ───────────────────────────────────┐ +-- ├──────────────────────────────────────┬─────────────────────────────────────┤ +-- │ SlotsArea (slots — ONE column, │ RightArea (the map preview + facts │ +-- │ top-left, up to 16) │ line + read-only options summary) │ +-- ├──────────────────────────────────────┤ │ +-- │ BottomLeftArea (Chat / Observers │ │ +-- │ — tabs) │ │ +-- ├──────────────────────────────────────┴─────────────────────────────────────┤ +-- │ ActionArea (status · … · Settings · Launch) ─ full width │ +-- └────────────────────────────────────────────────────────────────────────────┘ -- --- The top is dedicated to the slot rows (we expect up to 16). The middle splits in two tabbed --- panels: the left is chat/observers (CustomLobbyTabs), the right is the config tab panel --- (CustomLobbyConfigInterface — Map / Mods / Options / Restrictions). A full-width action bar at --- the bottom holds the global actions (status + launch, and the like). The accumulated team rating +-- A one-column layout (the two-column variant was reverted by community request). The LEFT column +-- splits vertically: the slot rows on top (one column, up to 16), the chat/observers tabs +-- (CustomLobbyTabs) below. The RIGHT column is the map + options (CustomLobbyConfigInterface — a +-- bound map preview, a name/size/players/version facts line, and the read-only options summary). A +-- full-width action bar at the bottom holds the global actions (status + the generic Settings +-- button, which opens the options editor, + the host-only Launch). The accumulated team rating -- (CustomLobbyTeamScore) sits in the title, shown only for the binary auto-team formations. +-- +-- The per-domain edit buttons (change-map, mod-select) are removed for now — only the generic +-- Settings button remains — and will be reintegrated once the rework is complete. local UIUtil = import("/lua/ui/uiutil.lua") local LayoutHelpers = import("/lua/maui/layouthelpers.lua") @@ -60,6 +67,7 @@ local CustomLobbyTeamScore = import("/lua/ui/lobby/customlobby/customlobbyteamsc local CustomLobbyTabs = import("/lua/ui/lobby/customlobby/customlobbytabs.lua") local CustomLobbyChatPanel = import("/lua/ui/lobby/customlobby/social/customlobbychatpanel.lua") local CustomLobbyObserversPanel = import("/lua/ui/lobby/customlobby/social/customlobbyobserverspanel.lua") +local CustomLobbyOptionSelect = import("/lua/ui/lobby/customlobby/optionselect/customlobbyoptionselect.lua") local LazyVarDerive = import("/lua/lazyvar.lua").Derive @@ -76,10 +84,10 @@ local LobbyHeight = 768 local Pad = 8 local SlotHeight = 24 -local SlotsHeaderHeight = 24 -- the "Players" header + gap above the two slot columns +local SlotsHeaderHeight = 24 -- the "Players" header + gap above the single slot column local TitleHeight = 48 -local BottomRightWidth = 560 -- config (map/mods/options/restrictions); the chat/obs panel fills the rest -local ActionHeight = 52 -- the full-width action bar at the very bottom (status + launch) +local RightWidth = 360 -- the right column (map preview + options summary); the left fills the rest +local ActionHeight = 52 -- the full-width action bar at the very bottom (status + Settings + launch) --- Creates a layout area (an invisible Group with an optional debug tint). ---@param parent Control @@ -108,14 +116,14 @@ end ---@field SlotsArea Group ---@field SlotsHeader Text ---@field SlotsPanel Group ----@field SlotColumns Group[] # [1] = left column, [2] = right column ---@field Slots UICustomLobbySlotInterface[] ---@field BottomLeftArea Group ---@field BottomLeftTabs UICustomLobbyTabs ----@field BottomRightArea Group ----@field Config UICustomLobbyTabs +---@field RightArea Group +---@field Config UICustomLobbyConfigInterface ---@field ActionArea Group ---@field StatusLabel Text +---@field SettingsButton Button ---@field LaunchButton Button ---@field SlotCountObserver LazyVar ---@field IsHostObserver LazyVar @@ -143,7 +151,7 @@ local CustomLobbyInterface = Class(Group) { self.TitleArea = CreateArea(self.Content, "TitleArea", 'ffcc4040') self.SlotsArea = CreateArea(self.Content, "SlotsArea", 'ffcccc40') self.BottomLeftArea = CreateArea(self.Content, "BottomLeftArea", 'ff40cc60') - self.BottomRightArea = CreateArea(self.Content, "BottomRightArea", 'ff4060cc') + self.RightArea = CreateArea(self.Content, "RightArea", 'ff4060cc') self.ActionArea = CreateArea(self.Content, "ActionArea", 'ff808080') --#endregion @@ -161,23 +169,18 @@ local CustomLobbyInterface = Class(Group) { end --#endregion - --#region slots (top region — two columns; odd slots left, even right) + --#region slots (top-left region — a single column of rows) self.SlotsHeader = UIUtil.CreateText(self.SlotsArea, "Players", 14, UIUtil.titleFont) self.SlotsHeader:SetColor('ff9aa0a8') self.SlotsHeader:DisableHitTest() self.SlotsPanel = Group(self.SlotsArea, "CustomLobbySlotsPanel") - self.SlotColumns = { - Group(self.SlotsPanel, "CustomLobbySlotsLeft"), - Group(self.SlotsPanel, "CustomLobbySlotsRight"), - } - -- One row per possible slot, placed in the left (odd) or right (even) column; the - -- SlotCount observer reveals the active ones. + -- One row per possible slot, stacked in a single column; the SlotCount observer reveals the + -- active ones. self.Slots = {} for slot = 1, CustomLobbyLaunchModel.MaxSlots do - local column = self.SlotColumns[math.mod(slot, 2) == 1 and 1 or 2] - self.Slots[slot] = CustomLobbySlotInterface.Create(column, slot, self) + self.Slots[slot] = CustomLobbySlotInterface.Create(self.SlotsPanel, slot, self) end --#endregion @@ -190,15 +193,23 @@ local CustomLobbyInterface = Class(Group) { }) --#endregion - --#region bottom-right: config tabs (map / mods / options / restrictions) - self.Config = CustomLobbyConfigInterface.Create(self.BottomRightArea) + --#region right column: the map preview + facts line + read-only options summary + self.Config = CustomLobbyConfigInterface.Create(self.RightArea) --#endregion - --#region action bar (full-width, bottom): status + launch and other global actions + --#region action bar (full-width, bottom): status + Settings + launch self.StatusLabel = UIUtil.CreateText(self.ActionArea, "", 13, UIUtil.bodyFont) self.StatusLabel:SetColor('ff9aa0a8') self.StatusLabel:DisableHitTest() + -- the single generic settings button (host-only); opens the options editor. The per-domain + -- edit buttons (change-map, mod-select) are removed until the rework is complete. + self.SettingsButton = UIUtil.CreateButtonWithDropshadow(self.ActionArea, '/BUTTON/medium/', "Settings") + self.SettingsButton.OnClick = function(button, modifiers) + CustomLobbyOptionSelect.Open(GetFrame(0)) + end + Tooltip.AddControlTooltipManual(self.SettingsButton, "Settings", "Open the game options (host only).") + self.LaunchButton = UIUtil.CreateButtonWithDropshadow(self.ActionArea, '/BUTTON/large/', "Launch") self.LaunchButton.OnClick = function(button, modifiers) CustomLobbyController.RequestLaunch() @@ -232,31 +243,35 @@ local CustomLobbyInterface = Class(Group) { Layouter(self.Content):AtCenterIn(self):End() --#region areas - Layouter(self.TitleArea):AtLeftIn(self.Content, Pad):AtRightIn(self.Content, Pad):AtTopIn(self.Content, Pad):Height(TitleHeight):End() - - -- the slots region is sized to exactly fit its rows (two columns of MaxSlots/2 = 8); the - -- bottom row of tabbed panels then fills ALL the remaining vertical room below it - local slotRows = math.ceil(CustomLobbyLaunchModel.MaxSlots / 2) - Layouter(self.SlotsArea) - :AtLeftIn(self.Content, Pad):AtRightIn(self.Content, Pad) - :AnchorToBottom(self.TitleArea, Pad):Height(SlotsHeaderHeight + slotRows * SlotHeight) - :End() + local session = CustomLobbySessionModel.GetSingleton() - -- the action bar spans the full width at the very bottom; the two tabbed panels fill the - -- room between the slots and it. The right (config) panel is a fixed width; the left - -- (chat/observers) fills the rest + -- the title and the action bar span the full width, top and bottom + Layouter(self.TitleArea):AtLeftIn(self.Content, Pad):AtRightIn(self.Content, Pad):AtTopIn(self.Content, Pad):Height(TitleHeight):End() Layouter(self.ActionArea) :AtLeftIn(self.Content, Pad):AtRightIn(self.Content, Pad):AtBottomIn(self.Content, Pad) :Height(ActionHeight) :End() - Layouter(self.BottomRightArea) - :AtRightIn(self.Content, Pad):Width(BottomRightWidth) - :AnchorToBottom(self.SlotsArea, Pad):AnchorToTop(self.ActionArea, Pad) + + -- the right column (map + options) is a fixed width filling the height between them + Layouter(self.RightArea) + :AtRightIn(self.Content, Pad):Width(RightWidth) + :AnchorToBottom(self.TitleArea, Pad):AnchorToTop(self.ActionArea, Pad) :End() + + -- the left column splits vertically: slots on top, chat/observers below. The slots region + -- is sized to its visible rows (one column) so chat/observers grows for smaller games; both + -- stop at the left edge of the right column + Layouter(self.SlotsArea):AtLeftIn(self.Content, Pad):AnchorToBottom(self.TitleArea, Pad):End() + self.SlotsArea.Right:Set(function() return self.RightArea.Left() - LayoutHelpers.ScaleNumber(Pad) end) + self.SlotsArea.Height:Set(function() + local rows = math.max(session.SlotCount(), 1) + return LayoutHelpers.ScaleNumber(SlotsHeaderHeight) + rows * LayoutHelpers.ScaleNumber(SlotHeight) + end) + Layouter(self.BottomLeftArea) :AtLeftIn(self.Content, Pad):AnchorToBottom(self.SlotsArea, Pad):AnchorToTop(self.ActionArea, Pad) :End() - self.BottomLeftArea.Right:Set(function() return self.BottomRightArea.Left() - LayoutHelpers.ScaleNumber(Pad) end) + self.BottomLeftArea.Right:Set(function() return self.RightArea.Left() - LayoutHelpers.ScaleNumber(Pad) end) --#endregion --#region title bar (title · team score · leave) @@ -268,28 +283,21 @@ local CustomLobbyInterface = Class(Group) { :End() --#endregion - --#region slots (two columns; rows stack within each) + --#region slots (a single column; rows stack top-to-bottom) Layouter(self.SlotsHeader):AtLeftIn(self.SlotsArea, 4):AtTopIn(self.SlotsArea):End() Layouter(self.SlotsPanel) :AtLeftIn(self.SlotsArea):AtRightIn(self.SlotsArea) :AnchorToBottom(self.SlotsHeader, 4):AtBottomIn(self.SlotsArea) :End() - -- two columns split down the middle of the panel (a small gutter between) - Layouter(self.SlotColumns[1]):AtLeftIn(self.SlotsPanel):AtTopIn(self.SlotsPanel):AtBottomIn(self.SlotsPanel):End() - self.SlotColumns[1].Right:Set(function() return self.SlotsPanel.Left() + 0.5 * self.SlotsPanel.Width() - LayoutHelpers.ScaleNumber(6) end) - Layouter(self.SlotColumns[2]):AtRightIn(self.SlotsPanel):AtTopIn(self.SlotsPanel):AtBottomIn(self.SlotsPanel):End() - self.SlotColumns[2].Left:Set(function() return self.SlotsPanel.Left() + 0.5 * self.SlotsPanel.Width() + LayoutHelpers.ScaleNumber(6) end) - - -- stack each column's rows top-to-bottom (slot i sits under slot i-2, its column-mate) + -- stack the rows top-to-bottom (slot i sits under slot i-1) for slot = 1, CustomLobbyLaunchModel.MaxSlots do local row = self.Slots[slot] - local column = self.SlotColumns[math.mod(slot, 2) == 1 and 1 or 2] - local builder = Layouter(row):AtLeftIn(column):AtRightIn(column):Height(SlotHeight) - if slot <= 2 then - builder:AtTopIn(column) + local builder = Layouter(row):AtLeftIn(self.SlotsPanel):AtRightIn(self.SlotsPanel):Height(SlotHeight) + if slot == 1 then + builder:AtTopIn(self.SlotsPanel) else - builder:AnchorToBottom(self.Slots[slot - 2], 0) + builder:AnchorToBottom(self.Slots[slot - 1], 0) end builder:End() end @@ -299,13 +307,14 @@ local CustomLobbyInterface = Class(Group) { Layouter(self.BottomLeftTabs):Fill(self.BottomLeftArea):End() --#endregion - --#region bottom-right: config tabs fill the panel - Layouter(self.Config):Fill(self.BottomRightArea):End() + --#region right column: map preview + options summary fill the panel + Layouter(self.Config):Fill(self.RightArea):End() --#endregion - --#region action bar: status on the left, launch on the right + --#region action bar: status on the left, Settings + launch on the right Layouter(self.StatusLabel):AtLeftIn(self.ActionArea, 8):AtVerticalCenterIn(self.ActionArea):End() Layouter(self.LaunchButton):AtRightIn(self.ActionArea):AtVerticalCenterIn(self.ActionArea):End() + Layouter(self.SettingsButton):AnchorToLeft(self.LaunchButton, 8):AtVerticalCenterIn(self.ActionArea):End() --#endregion -- size-dependent children build their scrollbars / first render now that they're sized @@ -315,9 +324,8 @@ local CustomLobbyInterface = Class(Group) { self.Config:Initialize() end, - --- Shows the active slots (1..count) and hides the rest. The two columns are full-height, so - --- the rows simply stack from the top of each — no area resizing needed (the slots own the - --- whole top region regardless of count). + --- Shows the active slots (1..count) and hides the rest. The slots region's height tracks the + --- count (bound in __post_init), so the chat/observers panel below grows for smaller games. ---@param self UICustomLobbyInterface ---@param count number OnSlotCountChanged = function(self, count) @@ -330,16 +338,19 @@ local CustomLobbyInterface = Class(Group) { end end, - --- Tracks host status: updates the status line and shows the launch button only to the host. - --- (The right-column config panel gates its own host-only buttons.) + --- Tracks host status: updates the status line and shows the host-only action-bar buttons + --- (Settings + Launch) only to the host. (The options editor the Settings button opens is + --- host-gated regardless; the right-column options summary stays read-only-visible to everyone.) ---@param self UICustomLobbyInterface ---@param isHost boolean OnIsHostChanged = function(self, isHost) self.StatusLabel:SetText(isHost and "You are the host." or "The host controls the game.") - if isHost then - self.LaunchButton:Show() - else - self.LaunchButton:Hide() + for _, button in { self.SettingsButton, self.LaunchButton } do + if isHost then + button:Show() + else + button:Hide() + end end end, diff --git a/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua b/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua index e53d4474fe3..e0a61618507 100644 --- a/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua +++ b/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua @@ -20,33 +20,174 @@ --** SOFTWARE. --****************************************************************************************************** --- The lobby's bottom-right config panel: a generic CustomLobbyTabs over the four config tabs — --- Map / Mods / Options / Restrictions. This module is just the tab-list definition; the strip + --- create-on-select / destroy-on-switch machinery lives in CustomLobbyTabs. +-- The lobby's right column: the map preview pinned on top, three tabs below — -- --- The Map tab owns its own preview now (it's a normal churned tab — see CustomLobbyMapPanel): the --- lobby only ever shows ONE current map, and the engine caches map textures by name, so building --- the preview anew each time you open the Map tab just re-binds the cached texture (no leak). That --- replaced the earlier pinned-preview-with-a-cover design — the texture-leak rule that motivated it --- only bites the *map-select dialog*, which browses hundreds of different maps (see mapselect/). +-- ┌ map preview (square, bound) ─┐ +-- │ │ +-- └──────────────────────────────┘ +-- Seton's Clutch +-- 20km · 8 players · v3 +-- [ Options | Mods | Restrictions ] +-- ┌ active tab's panel ──────────┐ +-- │ … │ +-- └──────────────────────────────┘ +-- +-- The preview is the shared bound `CustomLobbyMapPreview` (subscribes to `ScenarioFile`, renders +-- faction spawns; the engine caches the single current map's texture, so churn is free). It is +-- pinned above the tabs — only the panel below churns on tab switch — with a short name + +-- size/players/version facts line between them. The three tab panels are all read-only: their own +-- per-domain action buttons are gone (the interface's action-bar Settings button opens the options +-- editor); the change-map / mod-select entry points return when the config rework resumes. +-- +-- This used to be a four-tab strip including a Map tab; the Map preview is now the pinned header. + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") +local Group = import("/lua/maui/group.lua").Group +local CustomLobbyMapPreview = import("/lua/ui/lobby/customlobby/customlobbymappreview.lua") local CustomLobbyTabs = import("/lua/ui/lobby/customlobby/customlobbytabs.lua") -local CustomLobbyMapPanel = import("/lua/ui/lobby/customlobby/config/customlobbymappanel.lua") -local CustomLobbyModsPanel = import("/lua/ui/lobby/customlobby/config/customlobbymodspanel.lua") local CustomLobbyOptionsPanel = import("/lua/ui/lobby/customlobby/config/customlobbyoptionspanel.lua") +local CustomLobbyModsPanel = import("/lua/ui/lobby/customlobby/config/customlobbymodspanel.lua") local CustomLobbyUnitsPanel = import("/lua/ui/lobby/customlobby/config/customlobbyunitspanel.lua") +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") +local CustomLobbyMapCatalog = import("/lua/ui/lobby/customlobby/mapselect/customlobbymapcatalog.lua") + +local LazyVarDerive = import("/lua/lazyvar.lua").Derive +local Layouter = LayoutHelpers.ReusedLayoutFor + +local Inset = 6 +local PreviewMaxSize = 280 -- the square preview grows with the column width, capped here +local NameMaxChars = 30 +local FactsColor = 'ff9aa0a8' + +--- Truncates `text` to `maxChars`, appending "…" when it had to cut. (Local copy — drift-fine, see +--- ../CLAUDE.md "On sharing".) +---@param text string +---@param maxChars number +---@return string +local function Truncate(text, maxChars) + text = text or "" + if string.len(text) > maxChars then + return string.sub(text, 1, maxChars - 1) .. "…" + end + return text +end + +--- Number of start spots a scenario declares, or 0. +---@param scenario UILobbyScenarioInfo +---@return number +local function ArmyCount(scenario) + local armies = scenario.Configurations + and scenario.Configurations.standard + and scenario.Configurations.standard.teams + and scenario.Configurations.standard.teams[1] + and scenario.Configurations.standard.teams[1].armies + return armies and table.getsize(armies) or 0 +end + +---@class UICustomLobbyConfigInterface : Group +---@field Trash TrashBag +---@field Preview UICustomLobbyMapPreview +---@field Name Text +---@field Info Text +---@field Tabs UICustomLobbyTabs +---@field ScenarioObserver LazyVar +local CustomLobbyConfigInterface = ClassUI(Group) { + + ---@param self UICustomLobbyConfigInterface + ---@param parent Control + __init = function(self, parent) + Group.__init(self, parent, "CustomLobbyConfigInterface") + + self.Trash = TrashBag() + + self.Preview = CustomLobbyMapPreview.Create(self, { Bound = true }) + + self.Name = UIUtil.CreateText(self, "", 15, UIUtil.titleFont) + self.Name:DisableHitTest() + self.Info = UIUtil.CreateText(self, "", 12, UIUtil.bodyFont) + self.Info:SetColor(FactsColor) + self.Info:DisableHitTest() + + -- the read-only config tabs below the preview (created-on-select / destroyed-on-switch) + self.Tabs = CustomLobbyTabs.Create(self, { + Tabs = { + { Label = "Options", Create = CustomLobbyOptionsPanel.Create }, + { Label = "Mods", Create = CustomLobbyModsPanel.Create }, + { Label = "Restrictions", Create = CustomLobbyUnitsPanel.Create }, + }, + }) + + self.ScenarioObserver = self.Trash:Add( + LazyVarDerive(CustomLobbyLaunchModel.GetSingleton().ScenarioFile, function(scenarioFileLazy) + scenarioFileLazy() + self:RefreshFacts() + end)) + end, + + ---@param self UICustomLobbyConfigInterface + __post_init = function(self) + -- a centred square preview at the top, capped so it never crowds the tabs out + Layouter(self.Preview):AtHorizontalCenterIn(self):AtTopIn(self, Inset):End() + self.Preview.Width:Set(function() + return math.min(self.Width() - LayoutHelpers.ScaleNumber(2 * Inset), LayoutHelpers.ScaleNumber(PreviewMaxSize)) + end) + self.Preview.Height:Set(function() return self.Preview.Width() end) + + Layouter(self.Name):AtHorizontalCenterIn(self):AnchorToBottom(self.Preview, 8):End() + Layouter(self.Info):AtHorizontalCenterIn(self):AnchorToBottom(self.Name, 2):End() + + -- the tabs fill the rest of the column below the facts line + Layouter(self.Tabs) + :AtLeftIn(self):AtRightIn(self) + :AnchorToBottom(self.Info, 8):AtBottomIn(self) + :End() + end, + + --- Three-phase init: the interface calls this after sizing the column. Forwards to the tabs + --- container (its first panel's grid needs a concrete height) and renders the first facts line. + ---@param self UICustomLobbyConfigInterface + Initialize = function(self) + self:RefreshFacts() + self.Tabs:Initialize() + end, + + --- Renders the map name + the size · players · version facts line for the current scenario. + ---@param self UICustomLobbyConfigInterface + RefreshFacts = function(self) + local scenarioFile = CustomLobbyLaunchModel.GetSingleton().ScenarioFile() + local info = scenarioFile and CustomLobbyMapCatalog.LoadInfo(scenarioFile) + if type(info) == "table" then + self.Name:SetText(Truncate(LOC(info.name) or "?", NameMaxChars)) + local parts = {} + if info.size then + table.insert(parts, string.format("%dkm", math.floor(info.size[1] / 50))) + end + local players = ArmyCount(info) + if players > 0 then + table.insert(parts, players .. " players") + end + if info.map_version then + table.insert(parts, "v" .. tostring(info.map_version)) + end + self.Info:SetText(table.concat(parts, " · ")) + else + self.Name:SetText(scenarioFile and "Unknown map" or "No map selected") + self.Info:SetText("") + end + end, -local Tabs = { - { Label = "Map", Create = CustomLobbyMapPanel.Create }, - { Label = "Mods", Create = CustomLobbyModsPanel.Create }, - { Label = "Options", Create = CustomLobbyOptionsPanel.Create }, - { Label = "Restrictions", Create = CustomLobbyUnitsPanel.Create }, + ---@param self UICustomLobbyConfigInterface + OnDestroy = function(self) + self.Trash:Destroy() + end, } ---- Builds the config tab panel (a CustomLobbyTabs). The parent sizes it and calls `Initialize()` ---- after mounting (forwarded to the tabs container — three-phase init). +--- Builds the right-column config composition. The parent sizes it and calls `Initialize()` after +--- mounting (three-phase init). ---@param parent Control ----@return UICustomLobbyTabs +---@return UICustomLobbyConfigInterface Create = function(parent) - return CustomLobbyTabs.Create(parent, { Tabs = Tabs }) + return CustomLobbyConfigInterface(parent) end diff --git a/lua/ui/lobby/customlobby/config/CustomLobbyModsPanel.lua b/lua/ui/lobby/customlobby/config/CustomLobbyModsPanel.lua index 57311de1d03..430445d7472 100644 --- a/lua/ui/lobby/customlobby/config/CustomLobbyModsPanel.lua +++ b/lua/ui/lobby/customlobby/config/CustomLobbyModsPanel.lua @@ -21,13 +21,13 @@ --****************************************************************************************************** -- The Mods tab panel of the config interface: the enabled mods, grouped into Game mods (the --- shared sim mods from the launch model) and UI mods (this peer's local choice from prefs), plus --- a "Manage mods" button (available to everyone — UI mods are per-player). +-- shared sim mods from the launch model) and UI mods (this peer's local choice from prefs). The +-- grid fills the whole panel — the "Manage mods" button is gone for now (the per-domain edit +-- buttons are removed during the layout rework and will be reconsidered when it resumes). -- --- It is a config-interface tab panel: the host creates it when the Mods tab is selected and --- destroys it on switch, so it's the live/visible panel for its whole lifetime. UI mods are prefs, --- not a reactive model field, so they're read on the first render (`Initialize`); the synced sim --- mods additionally refresh it live. +-- It is a tab panel: created when its tab is selected and destroyed on switch, so it's the +-- live/visible panel for its whole lifetime. UI mods are prefs, not a reactive model field, so +-- they're read on the first render (`Initialize`); the synced sim mods additionally refresh it live. local UIUtil = import("/lua/ui/uiutil.lua") local LayoutHelpers = import("/lua/maui/layouthelpers.lua") @@ -37,7 +37,6 @@ local Bitmap = import("/lua/maui/bitmap.lua").Bitmap local Grid = import("/lua/maui/grid.lua").Grid local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") -local CustomLobbyModSelect = import("/lua/ui/lobby/customlobby/modselect/customlobbymodselect.lua") local ModUtilities = import("/lua/ui/modutilities.lua") local Mods = import("/lua/mods.lua") @@ -49,7 +48,6 @@ local ScrollGap = 18 local GridContentWidth = 360 - 6 - ScrollGap local LabelMaxChars = 30 local NormalColor = 'ffc8ccd0' -local ActionHeight = 40 -- the bottom action sub-area that holds the tab's buttons --- Truncates `text` to `maxChars`, appending "…" when it had to cut. ---@param text string @@ -69,8 +67,6 @@ end ---@field ModsGrid Grid ---@field Scrollbar Scrollbar | false ---@field Empty Text ----@field ActionArea Group ----@field ManageButton Button ---@field ModsObserver LazyVar local CustomLobbyModsPanel = ClassUI(Group) { @@ -89,14 +85,6 @@ local CustomLobbyModsPanel = ClassUI(Group) { self.Empty:DisableHitTest() self.Empty:Hide() - -- the tab's actions live in their own sub-area pinned to the bottom; the primary action is - -- right-aligned - self.ActionArea = Group(self, "CustomLobbyModsActions") - self.ManageButton = UIUtil.CreateButtonWithDropshadow(self.ActionArea, '/BUTTON/medium/', "Manage mods") - self.ManageButton.OnClick = function(button, modifiers) - CustomLobbyModSelect.Open(GetFrame(0)) - end - -- only the shared sim mods are a model field; UI mods (prefs) are picked up on the first -- render (Initialize), since the panel is created fresh each time its tab is selected self.ModsObserver = self.Trash:Add( @@ -108,11 +96,9 @@ local CustomLobbyModsPanel = ClassUI(Group) { ---@param self UICustomLobbyModsPanel __post_init = function(self) - Layouter(self.ActionArea):AtLeftIn(self):AtRightIn(self):AtBottomIn(self):Height(ActionHeight):End() - Layouter(self.ManageButton):AtRightIn(self.ActionArea):AtVerticalCenterIn(self.ActionArea):End() Layouter(self.ModsGrid) :AtLeftIn(self, 6):Width(GridContentWidth) - :AtTopIn(self, 6):AnchorToTop(self.ActionArea, 8) + :AtTopIn(self, 6):AtBottomIn(self, 4) :End() Layouter(self.Empty):AtHorizontalCenterIn(self.ModsGrid):AtTopIn(self.ModsGrid, 8):End() end, diff --git a/lua/ui/lobby/customlobby/config/CustomLobbyOptionsPanel.lua b/lua/ui/lobby/customlobby/config/CustomLobbyOptionsPanel.lua index fbc1dd1af33..b6761c3e60b 100644 --- a/lua/ui/lobby/customlobby/config/CustomLobbyOptionsPanel.lua +++ b/lua/ui/lobby/customlobby/config/CustomLobbyOptionsPanel.lua @@ -20,18 +20,20 @@ --** SOFTWARE. --****************************************************************************************************** --- The Options tab panel of the config interface: a read-only view of the current option values, --- grouped into Lobby / Scenario / Mods sections, with a hide-defaults toggle, and host-only --- "Options" (open the editor) + "Reset options" buttons. +-- The Options tab of the config interface: a read-only view of the current option values, grouped +-- into Lobby / Scenario / Mods sections, with a hide-defaults toggle. The grid fills the whole +-- panel — the per-domain action buttons (open editor / reset) are gone; the interface's action-bar +-- Settings button owns opening the options editor, and they'll be reconsidered when the config +-- rework resumes. -- -- Options that come from the map or a mod are flagged with a gold marker + tinted label; the -- marker's tooltip names the precise origin (`Map: …` / `Mod: …`). The schema is derived per-peer -- from the synced scenario + mods via `optionutil`; the values are the synced `GameOptions`. -- --- It is a config-interface tab panel: the host creates it when the Options tab is selected and --- destroys it on switch, so it's the live/visible panel for its whole lifetime — model observers --- just rebuild it. `Initialize` (called by the host after sizing it) builds the grid's scrollbar + --- does the first render; the grid needs a concrete height by then. +-- It is a tab panel: created when its tab is selected and destroyed on switch, so it's the +-- live/visible panel for its whole lifetime — model observers just rebuild it. `Initialize` (called +-- by the tabs container after sizing it) builds the grid's scrollbar + does the first render; the +-- grid needs a concrete height by then. local UIUtil = import("/lua/ui/uiutil.lua") local LayoutHelpers = import("/lua/maui/layouthelpers.lua") @@ -42,10 +44,7 @@ local Bitmap = import("/lua/maui/bitmap.lua").Bitmap local Grid = import("/lua/maui/grid.lua").Grid local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") -local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/customlobbylocalmodel.lua") -local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") local CustomLobbyMapCatalog = import("/lua/ui/lobby/customlobby/mapselect/customlobbymapcatalog.lua") -local CustomLobbyOptionSelect = import("/lua/ui/lobby/customlobby/optionselect/customlobbyoptionselect.lua") local OptionUtil = import("/lua/ui/optionutil.lua") local LazyVarDerive = import("/lua/lazyvar.lua").Derive @@ -55,7 +54,6 @@ local RowHeight = 22 local ScrollGap = 18 local GridContentWidth = 360 - 6 - ScrollGap local LabelMaxChars = 26 -local ActionHeight = 40 -- the bottom action sub-area that holds the tab's buttons local SpecialColor = 'ffd0a24c' -- marker + label tint for a map/mod option local NormalColor = 'ffc8ccd0' @@ -76,19 +74,14 @@ end ---@class UICustomLobbyOptionsPanel : Group ---@field Trash TrashBag ---@field Ready boolean ----@field IsHost boolean ---@field HideDefaults boolean ---@field HideDefaultsToggle Checkbox ---@field OptionsGrid Grid ---@field Scrollbar Scrollbar | false ---@field Empty Text ----@field ActionArea Group ----@field EditButton Button ----@field ResetButton Button ---@field ScenarioObserver LazyVar ---@field OptionsObserver LazyVar ---@field ModsObserver LazyVar ----@field IsHostObserver LazyVar local CustomLobbyOptionsPanel = ClassUI(Group) { ---@param self UICustomLobbyOptionsPanel @@ -98,7 +91,6 @@ local CustomLobbyOptionsPanel = ClassUI(Group) { self.Trash = TrashBag() self.Ready = false - self.IsHost = false self.HideDefaults = true self.Scrollbar = false @@ -117,19 +109,6 @@ local CustomLobbyOptionsPanel = ClassUI(Group) { self.Empty:DisableHitTest() self.Empty:Hide() - -- the tab's actions live in their own sub-area pinned to the bottom; the primary action - -- (open the editor) is right-aligned, the secondary (reset) sits to its left - self.ActionArea = Group(self, "CustomLobbyOptionsActions") - self.EditButton = UIUtil.CreateButtonWithDropshadow(self.ActionArea, '/BUTTON/medium/', "Options") - self.EditButton.OnClick = function(button, modifiers) - CustomLobbyOptionSelect.Open(GetFrame(0)) - end - self.ResetButton = UIUtil.CreateButtonWithDropshadow(self.ActionArea, '/BUTTON/medium/', "Reset options") - self.ResetButton.OnClick = function(button, modifiers) - CustomLobbyController.RequestResetGameOptions() - end - Tooltip.AddControlTooltipManual(self.ResetButton, "Reset options", "Reset every option to its default value.") - -- the panel is created/destroyed with its tab, so it's always the live/visible panel while -- it exists — observers just rebuild (Refresh is Ready-gated); no show/hide juggling local launch = CustomLobbyLaunchModel.GetSingleton() @@ -139,37 +118,25 @@ local CustomLobbyOptionsPanel = ClassUI(Group) { LazyVarDerive(launch.GameOptions, function(lazy) lazy(); self:Refresh() end)) self.ModsObserver = self.Trash:Add( LazyVarDerive(launch.GameMods, function(lazy) lazy(); self:Refresh() end)) - - local localModel = CustomLobbyLocalModel.GetSingleton() - self.IsHostObserver = self.Trash:Add( - LazyVarDerive(localModel.IsHost, function(isHostLazy) - self.IsHost = isHostLazy() - self:ApplyHostVisibility() - end)) end, ---@param self UICustomLobbyOptionsPanel __post_init = function(self) - Layouter(self.ActionArea):AtLeftIn(self):AtRightIn(self):AtBottomIn(self):Height(ActionHeight):End() - Layouter(self.EditButton):AtRightIn(self.ActionArea):AtVerticalCenterIn(self.ActionArea):End() - Layouter(self.ResetButton):AnchorToLeft(self.EditButton, 8):AtVerticalCenterIn(self.ActionArea):End() - Layouter(self.HideDefaultsToggle):AtLeftIn(self, 6):AtTopIn(self, 4):End() Layouter(self.OptionsGrid) :AtLeftIn(self, 6):Width(GridContentWidth) - :AnchorToBottom(self.HideDefaultsToggle, 6):AnchorToTop(self.ActionArea, 8) + :AnchorToBottom(self.HideDefaultsToggle, 6):AtBottomIn(self, 4) :End() Layouter(self.Empty):AtHorizontalCenterIn(self.OptionsGrid):AtTopIn(self.OptionsGrid, 8):End() end, - --- Builds the grid's scrollbar + does the first render. Called by the host after it has sized - --- the panel (the grid needs a concrete height — three-phase init, /lua/ui/CLAUDE.md § 1). + --- Builds the grid's scrollbar + does the first render. Called by the tabs container after it + --- has sized the panel (the grid needs a concrete height — three-phase init, /lua/ui/CLAUDE.md § 1). ---@param self UICustomLobbyOptionsPanel Initialize = function(self) self.Ready = true self.Scrollbar = UIUtil.CreateVertScrollbarFor(self.OptionsGrid) self:Refresh() - self:ApplyHostVisibility() end, --- Rebuilds the read-only options grid, grouped into Lobby / Scenario / Mods sections with the @@ -293,18 +260,6 @@ local CustomLobbyOptionsPanel = ClassUI(Group) { return row end, - --- The Options + Reset buttons are host-only. - ---@param self UICustomLobbyOptionsPanel - ApplyHostVisibility = function(self) - for _, button in { self.EditButton, self.ResetButton } do - if self.IsHost then - button:Show() - else - button:Hide() - end - end - end, - --- Shows the scrollbar only when the grid overflows. ---@param self UICustomLobbyOptionsPanel UpdateScrollbar = function(self) From 9c69d3bee9068d2db31c138c808656cd52da4aaf Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Wed, 24 Jun 2026 22:41:16 +0200 Subject: [PATCH 42/98] Add basic interactions with the map (icons need improvements) --- lua/ui/lobby/customlobby/CLAUDE.md | 4 +- .../CustomLobbyScenarioPreview.lua | 22 ++- .../config/CustomLobbyConfigInterface.lua | 165 +++++++++++++++++- 3 files changed, 180 insertions(+), 11 deletions(-) diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index 0d49e8f0ee6..cf228f84c49 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -51,7 +51,7 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | [CustomLobbyMenus.lua](CustomLobbyMenus.lua) | declarative menu **definitions**: entry lists with `when(ctx)`/`action(ctx)` filtered by lobby state (`BuildSlotMenu`). Adding/state-gating an item is a one-liner here. | | [CustomLobbySlotInterface.lua](CustomLobbySlotInterface.lua) | one slot row; subscribes to its slot + CPU benchmarks; CPU column shows max units at +0 with a green→red cap-headroom square; left-click an open slot to take it / your own to toggle ready; right-click opens its context menu; the host can drag a row onto another to swap. | | [CustomLobbyObserversInterface.lua](CustomLobbyObserversInterface.lua) | observer strip; subscribes to the model's `Observers` list and shows the count + names (read-only). | -| [CustomLobbyScenarioPreview.lua](CustomLobbyScenarioPreview.lua) | **shared** map-preview *surface*: the scenario's map texture + overlays (start spots, resource/wreck markers) with aspect-correct positioning, texture-leak-safe icon sharing, per-group visibility, and three-phase init. Chrome-free; `CustomLobbyMapPreview` wraps it with the frame. Spawn appearance is the owner's via a `CreateSpawnIcon` factory. | +| [CustomLobbyScenarioPreview.lua](CustomLobbyScenarioPreview.lua) | **shared** map-preview *surface*: the scenario's map texture + overlays (start spots, resource/wreck markers, plus a **dummy translucent `WaterMask`** placeholder until a real mask exists) with aspect-correct positioning, texture-leak-safe icon sharing, per-group visibility (`SetOverlayVisible('spawns'\|'resources'\|'wrecks'\|'water', …)`), and three-phase init. Chrome-free; `CustomLobbyMapPreview` wraps it with the frame. Spawn appearance is the owner's via a `CreateSpawnIcon` factory. | | [CustomLobbyMapPreview.lua](CustomLobbyMapPreview.lua) | the map preview **as one whole** — the chrome (glow border on top + dark backdrop, surface inset by `Padding`), the surface, and the faction spawn icon (local `MapPreviewSpawn`). Used by **both** consumers: created `Bound = true` it subscribes to the launch model and renders the committed `ScenarioFile` with per-slot faction spawns (no reload on take/swap); created unbound (the map-select dialog) it does no model wiring and the owner drives `preview.Surface` directly (numbered-dot spawns). Exposes `.Surface` for owners to drive / anchor overlays to. | | [mapselect/](mapselect/CLAUDE.md) | the **map-select dialog** + its catalog and list, in their own folder (host-only `Popup`: searchable, filterable scenario list → `RequestSetScenario`). Self-contained sub-MVC; see [mapselect/CLAUDE.md](mapselect/CLAUDE.md) — including the **`MapPreview` texture-leak** writeup that shaped its design. | | [modselect/](modselect/CLAUDE.md) | the **mod-select dialog** + its catalog and list, built to the map-select shape (checkbox list + type filters + detail panel + **presets**). Returns a uid set; the opener routes it — sim mods → `RequestSetGameMods` (synced), UI mods → local prefs — or persists the lot standalone. Mod domain logic lives in [`/lua/ui/modutilities.lua`](/lua/ui/modutilities.lua) (the `maputil.lua` sibling, fronting `/lua/mods.lua`). See [modselect/CLAUDE.md](modselect/CLAUDE.md). | @@ -60,7 +60,7 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | [CustomLobbyTeamScore.lua](CustomLobbyTeamScore.lua) | the **accumulated team rating** in the title — `Side A N · M Side B`. Shown only for the binary auto-team formations (`tvsb`→Top/Bottom, `lvsr`→Left/Right by start position; `pvsi`→Odd/Even by start-spot parity); **hidden** for `none`/`manual` (no 2-side split) or until a map's start positions are loaded. Reads the mode from `GameOptions().AutoTeams`, the ratings from each slot's `PL`. Reference data; never writes. | | [CustomLobbyTabs.lua](CustomLobbyTabs.lua) | a **generic tabbed panel** (strip + content; one panel alive, created on select / destroyed on switch). Construct with a `{ Label, Create }` list + optional `OnSelect`. Used for the bottom-left (Chat / Observers); the config interface keeps its own bespoke variant (it coordinates the persistent preview). | | [social/](social/) | the lobby's **bottom-left** column (the `CustomLobbyTabs` content): [`CustomLobbyChatPanel`](social/CustomLobbyChatPanel.lua) (the **Chat** tab — placeholder until the chat slice lands) and [`CustomLobbyObserversPanel`](social/CustomLobbyObserversPanel.lua) (the **Observers** tab — the shared observer list + a host-authoritative **Become observer** button → `RequestMoveToObserver`). Each is a tab content component (`Create(parent)`, created on select / destroyed on switch). | -| [config/](config/) | the lobby's **right** column. [`CustomLobbyConfigInterface.lua`](config/CustomLobbyConfigInterface.lua) is the column **composition**: a bound square `CustomLobbyMapPreview` **pinned** at the top, a name + size/players/version facts line under it, and a [`CustomLobbyTabs`](CustomLobbyTabs.lua) (**Options / Mods / Restrictions**) filling the rest. All three tab panels are now **read-only**: their per-domain action buttons (open editor / reset / manage mods) are removed — the grid/content fills the whole panel — and the interface's action-bar **Settings** button is the only edit entry point for now. [`CustomLobbyOptionsPanel`](config/CustomLobbyOptionsPanel.lua) (options grouped **Lobby / Scenario / Mods** + hide-defaults toggle; map/mod options gold-flagged with an origin tooltip — schema via [`/lua/ui/optionutil.lua`](/lua/ui/optionutil.lua)), [`CustomLobbyModsPanel`](config/CustomLobbyModsPanel.lua) (enabled mods in **Game / UI** sections), [`CustomLobbyUnitsPanel`](config/CustomLobbyUnitsPanel.lua) (the **Restrictions** placeholder). Each self-subscribes to the model and exposes `Initialize()` + `Create(parent)`. **Parked** (built, but unwired): [`CustomLobbyMapPanel`](config/CustomLobbyMapPanel.lua) (the full Map tab — preview + label/value details + Change-map), now superseded by the pinned preview. Churning a preview is safe here because the lobby shows only **one** current map and the engine caches map textures by name (the texture-leak rule only bites the *map-select dialog* — see [mapselect/CLAUDE.md](mapselect/CLAUDE.md)). | +| [config/](config/) | the lobby's **right** column. [`CustomLobbyConfigInterface.lua`](config/CustomLobbyConfigInterface.lua) is the column **composition**: a bound square `CustomLobbyMapPreview` **pinned** at the top with a vertical **preview tool strip** to its right (local `PreviewTool` icon buttons — toggles for army/start icons, mass+hydro deposits and the dummy water mask, driving `preview.Surface:SetOverlayVisible`, + a host-only **change-map** config icon at the bottom → the map-select dialog), a name + size/players/version facts line under it, and a [`CustomLobbyTabs`](CustomLobbyTabs.lua) (**Options / Mods / Restrictions**) filling the rest. All three tab panels are now **read-only**: their per-domain action buttons (open editor / reset / manage mods) are removed — the grid/content fills the whole panel — and the interface's action-bar **Settings** button is the only edit entry point for now. [`CustomLobbyOptionsPanel`](config/CustomLobbyOptionsPanel.lua) (options grouped **Lobby / Scenario / Mods** + hide-defaults toggle; map/mod options gold-flagged with an origin tooltip — schema via [`/lua/ui/optionutil.lua`](/lua/ui/optionutil.lua)), [`CustomLobbyModsPanel`](config/CustomLobbyModsPanel.lua) (enabled mods in **Game / UI** sections), [`CustomLobbyUnitsPanel`](config/CustomLobbyUnitsPanel.lua) (the **Restrictions** placeholder). Each self-subscribes to the model and exposes `Initialize()` + `Create(parent)`. **Parked** (built, but unwired): [`CustomLobbyMapPanel`](config/CustomLobbyMapPanel.lua) (the full Map tab — preview + label/value details + Change-map), now superseded by the pinned preview. Churning a preview is safe here because the lobby shows only **one** current map and the engine caches map textures by name (the texture-leak rule only bites the *map-select dialog* — see [mapselect/CLAUDE.md](mapselect/CLAUDE.md)). | | [/lua/ui/lobby/lobby.lua](../lobby.lua) | engine entry wrapper (`CreateLobby`/`HostGame`/`JoinGame`) → CustomLobby. Old lobby preserved at `lobby-old.lua`. | Working today: host + clients see each other (host-authoritative player sync), the diff --git a/lua/ui/lobby/customlobby/CustomLobbyScenarioPreview.lua b/lua/ui/lobby/customlobby/CustomLobbyScenarioPreview.lua index 70316a66c8b..aa3087838a0 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyScenarioPreview.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyScenarioPreview.lua @@ -70,12 +70,14 @@ local SpawnDotSize = 18 ---@field MassTemplate Bitmap # hidden; resource markers share its texture ---@field EnergyTemplate Bitmap ---@field WreckTemplate Bitmap +---@field WaterMask Bitmap # DUMMY placeholder water tint (no real mask yet) ---@field SpawnIcons table ---@field ResourceIcons Control[] ---@field WreckIcons Control[] ---@field ShowSpawns boolean ---@field ShowResources boolean ---@field ShowWrecks boolean +---@field ShowWater boolean ---@field ScenarioInfo? UILobbyScenarioInfo ---@field ScenarioSave? UIScenarioSaveFile ---@field SpawnData table # per-start-spot data handed to spawn icons' :Update @@ -102,6 +104,7 @@ local CustomLobbyScenarioPreview = ClassUI(Group) { self.ShowSpawns = true self.ShowResources = true self.ShowWrecks = true + self.ShowWater = false self.ScenarioInfo = nil self.ScenarioSave = nil @@ -114,6 +117,14 @@ local CustomLobbyScenarioPreview = ClassUI(Group) { self.Preview = MapPreview(self) + -- DUMMY water overlay: a translucent blue tint over the whole map until a real water mask + -- exists. Sits above the map texture but below the resource/wreck/spawn markers. Hidden by + -- default; the 'water' overlay toggle reveals it. + self.WaterMask = Bitmap(self) + self.WaterMask:SetSolidColor('66123a66') + self.WaterMask:DisableHitTest() + self.WaterMask:Hide() + self.MassTemplate = self:CreateTemplateBitmap(MassIcon) self.EnergyTemplate = self:CreateTemplateBitmap(EnergyIcon) self.WreckTemplate = self:CreateTemplateBitmap(WreckIcon) @@ -122,6 +133,8 @@ local CustomLobbyScenarioPreview = ClassUI(Group) { ---@param self UICustomLobbyScenarioPreview __post_init = function(self) Layouter(self.Preview):Fill(self):End() + Layouter(self.WaterMask):Fill(self.Preview):End() + self.WaterMask.Depth:Set(function() return self.Preview.Depth() + 1 end) -- our PARENT sizes us after this returns, so self.Preview.Width() isn't concrete yet; -- defer the first render a frame, then let SetScenario/SetSpawnData drive updates @@ -169,7 +182,7 @@ local CustomLobbyScenarioPreview = ClassUI(Group) { end end, - --- Shows/hides an overlay group: 'spawns' | 'resources' | 'wrecks'. + --- Shows/hides an overlay group: 'spawns' | 'resources' | 'wrecks' | 'water'. ---@param self UICustomLobbyScenarioPreview ---@param kind string ---@param visible boolean @@ -180,6 +193,8 @@ local CustomLobbyScenarioPreview = ClassUI(Group) { self.ShowResources = visible elseif kind == 'wrecks' then self.ShowWrecks = visible + elseif kind == 'water' then + self.ShowWater = visible end self:ApplyVisibility() end, @@ -309,6 +324,11 @@ local CustomLobbyScenarioPreview = ClassUI(Group) { setVisible(self.SpawnIcons, self.ShowSpawns) setVisible(self.ResourceIcons, self.ShowResources) setVisible(self.WreckIcons, self.ShowWrecks) + if self.ShowWater then + self.WaterMask:Show() + else + self.WaterMask:Hide() + end end, --- The save's master-chain markers, or an empty table. diff --git a/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua b/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua index e0a61618507..6b7f14a9958 100644 --- a/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua +++ b/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua @@ -43,24 +43,128 @@ local UIUtil = import("/lua/ui/uiutil.lua") local LayoutHelpers = import("/lua/maui/layouthelpers.lua") +local Tooltip = import("/lua/ui/game/tooltip.lua") local Group = import("/lua/maui/group.lua").Group +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap local CustomLobbyMapPreview = import("/lua/ui/lobby/customlobby/customlobbymappreview.lua") local CustomLobbyTabs = import("/lua/ui/lobby/customlobby/customlobbytabs.lua") local CustomLobbyOptionsPanel = import("/lua/ui/lobby/customlobby/config/customlobbyoptionspanel.lua") local CustomLobbyModsPanel = import("/lua/ui/lobby/customlobby/config/customlobbymodspanel.lua") local CustomLobbyUnitsPanel = import("/lua/ui/lobby/customlobby/config/customlobbyunitspanel.lua") +local CustomLobbyMapSelect = import("/lua/ui/lobby/customlobby/mapselect/customlobbymapselect.lua") local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") +local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/customlobbylocalmodel.lua") local CustomLobbyMapCatalog = import("/lua/ui/lobby/customlobby/mapselect/customlobbymapcatalog.lua") local LazyVarDerive = import("/lua/lazyvar.lua").Derive local Layouter = LayoutHelpers.ReusedLayoutFor local Inset = 6 -local PreviewMaxSize = 280 -- the square preview grows with the column width, capped here local NameMaxChars = 30 local FactsColor = 'ff9aa0a8' +-- the preview tool strip (to the right of the map): square icon buttons +local ToolSize = 28 +local ToolGap = 6 +local ToolIconInset = 5 +local ToolIdle = 'ff141a20' +local ToolHover = 'ff1f262e' +local ToolActive = 'ff2c4a5e' -- a lit toggle's background +local ToolIconDim = 0.40 -- icon alpha when a toggle is off + +-- icon textures (skin-relative; resolved through UIFile). The army icon reuses the start-position +-- commander silhouette; resources the mass icon; water a map pin; config an edit glyph. +local ArmyIcon = '/dialogs/mapselect02/commander_alpha.dds' +local ResourceIcon = '/game/build-ui/icon-mass_bmp.dds' +local WaterIcon = '/game/camera-btn/pinned_btn_up.dds' +local ConfigIcon = '/game/menu-btns/config_btn_up.dds' + +-- A small square icon button used in the preview tool strip. A toggle flips Active on click and +-- calls `OnToggle(active)`; an action button (isToggle = false) just calls `OnPress`. The look is +-- the tab convention: idle / hover / lit-when-active background, with the icon dimmed when off. +---@class UICustomLobbyPreviewTool : Group +---@field Bg Bitmap +---@field Icon Bitmap +---@field IsToggle boolean +---@field Active boolean +---@field Hovered boolean +---@field OnToggle? fun(active: boolean) +---@field OnPress? fun() +local PreviewTool = ClassUI(Group) { + + ---@param self UICustomLobbyPreviewTool + ---@param parent Control + ---@param texture FileName + ---@param isToggle boolean + __init = function(self, parent, texture, isToggle) + Group.__init(self, parent, "CustomLobbyPreviewTool") + + self.IsToggle = isToggle or false + self.Active = true + self.Hovered = false + + self.Bg = Bitmap(self) + self.Bg:SetSolidColor(ToolIdle) + + self.Icon = UIUtil.CreateBitmap(self, texture) + self.Icon:DisableHitTest() + + self.Bg.HandleEvent = function(control, event) + if event.Type == 'ButtonPress' then + if self.IsToggle then + self.Active = not self.Active + self:ApplyVisual() + if self.OnToggle then + self.OnToggle(self.Active) + end + elseif self.OnPress then + self.OnPress() + end + return true + elseif event.Type == 'MouseEnter' then + self.Hovered = true + self:ApplyVisual() + return true + elseif event.Type == 'MouseExit' then + self.Hovered = false + self:ApplyVisual() + return true + end + return false + end + end, + + ---@param self UICustomLobbyPreviewTool + __post_init = function(self) + Layouter(self.Bg):Fill(self):End() + Layouter(self.Icon):AtCenterIn(self):Width(ToolSize - 2 * ToolIconInset):Height(ToolSize - 2 * ToolIconInset):End() + self:ApplyVisual() + end, + + --- Repaints the background + icon for the current active/hover state. + ---@param self UICustomLobbyPreviewTool + ApplyVisual = function(self) + local bg = ToolIdle + if self.IsToggle and self.Active then + bg = ToolActive + elseif self.Hovered then + bg = ToolHover + end + self.Bg:SetSolidColor(bg) + local lit = (not self.IsToggle) or self.Active + self.Icon:SetAlpha(lit and 1.0 or ToolIconDim) + end, + + --- Sets the toggle state without firing `OnToggle` (for syncing the initial visual). + ---@param self UICustomLobbyPreviewTool + ---@param active boolean + SetActive = function(self, active) + self.Active = active + self:ApplyVisual() + end, +} + --- Truncates `text` to `maxChars`, appending "…" when it had to cut. (Local copy — drift-fine, see --- ../CLAUDE.md "On sharing".) ---@param text string @@ -91,8 +195,13 @@ end ---@field Preview UICustomLobbyMapPreview ---@field Name Text ---@field Info Text +---@field ArmyToggle UICustomLobbyPreviewTool +---@field ResourceToggle UICustomLobbyPreviewTool +---@field WaterToggle UICustomLobbyPreviewTool +---@field ConfigButton UICustomLobbyPreviewTool ---@field Tabs UICustomLobbyTabs ---@field ScenarioObserver LazyVar +---@field IsHostObserver LazyVar local CustomLobbyConfigInterface = ClassUI(Group) { ---@param self UICustomLobbyConfigInterface @@ -110,6 +219,29 @@ local CustomLobbyConfigInterface = ClassUI(Group) { self.Info:SetColor(FactsColor) self.Info:DisableHitTest() + --#region preview tool strip (to the right of the map): overlay toggles + change-scenario + local surface = self.Preview.Surface + + self.ArmyToggle = PreviewTool(self, ArmyIcon, true) + self.ArmyToggle.OnToggle = function(active) surface:SetOverlayVisible('spawns', active) end + Tooltip.AddControlTooltipManual(self.ArmyToggle.Bg, "Army icons", "Show or hide the start-position army icons.") + + self.ResourceToggle = PreviewTool(self, ResourceIcon, true) + self.ResourceToggle.OnToggle = function(active) surface:SetOverlayVisible('resources', active) end + Tooltip.AddControlTooltipManual(self.ResourceToggle.Bg, "Mass & hydrocarbon", "Show or hide the mass and hydrocarbon deposit icons.") + + -- water defaults OFF (the surface's dummy mask starts hidden); sync the toggle's look + self.WaterToggle = PreviewTool(self, WaterIcon, true) + self.WaterToggle:SetActive(false) + self.WaterToggle.OnToggle = function(active) surface:SetOverlayVisible('water', active) end + Tooltip.AddControlTooltipManual(self.WaterToggle.Bg, "Water", "Show or hide the water (placeholder).") + + -- host-only action: open the map-select dialog to change the scenario + self.ConfigButton = PreviewTool(self, ConfigIcon, false) + self.ConfigButton.OnPress = function() CustomLobbyMapSelect.Open(GetFrame(0)) end + Tooltip.AddControlTooltipManual(self.ConfigButton.Bg, "Change map", "Pick a different scenario (host only).") + --#endregion + -- the read-only config tabs below the preview (created-on-select / destroyed-on-switch) self.Tabs = CustomLobbyTabs.Create(self, { Tabs = { @@ -124,19 +256,36 @@ local CustomLobbyConfigInterface = ClassUI(Group) { scenarioFileLazy() self:RefreshFacts() end)) + self.IsHostObserver = self.Trash:Add( + LazyVarDerive(CustomLobbyLocalModel.GetSingleton().IsHost, function(isHostLazy) + if isHostLazy() then + self.ConfigButton:Show() + else + self.ConfigButton:Hide() + end + end)) end, ---@param self UICustomLobbyConfigInterface __post_init = function(self) - -- a centred square preview at the top, capped so it never crowds the tabs out - Layouter(self.Preview):AtHorizontalCenterIn(self):AtTopIn(self, Inset):End() - self.Preview.Width:Set(function() - return math.min(self.Width() - LayoutHelpers.ScaleNumber(2 * Inset), LayoutHelpers.ScaleNumber(PreviewMaxSize)) - end) + -- the tool strip: a right-aligned vertical column of square buttons; the three toggles stack + -- from the top, the config button is pinned at the bottom (aligned with the preview's base) + local function placeTool(tool) + return Layouter(tool):AtRightIn(self, Inset):Width(ToolSize):Height(ToolSize) + end + placeTool(self.ArmyToggle):AtTopIn(self, Inset):End() + placeTool(self.ResourceToggle):AnchorToBottom(self.ArmyToggle, ToolGap):End() + placeTool(self.WaterToggle):AnchorToBottom(self.ResourceToggle, ToolGap):End() + placeTool(self.ConfigButton):End() + self.ConfigButton.Top:Set(function() return self.Preview.Bottom() - LayoutHelpers.ScaleNumber(ToolSize) end) + + -- the square preview fills the column from the left inset up to the tool strip + Layouter(self.Preview):AtLeftIn(self, Inset):AtTopIn(self, Inset):End() + self.Preview.Right:Set(function() return self.ArmyToggle.Left() - LayoutHelpers.ScaleNumber(ToolGap) end) self.Preview.Height:Set(function() return self.Preview.Width() end) - Layouter(self.Name):AtHorizontalCenterIn(self):AnchorToBottom(self.Preview, 8):End() - Layouter(self.Info):AtHorizontalCenterIn(self):AnchorToBottom(self.Name, 2):End() + Layouter(self.Name):AtHorizontalCenterIn(self.Preview):AnchorToBottom(self.Preview, 8):End() + Layouter(self.Info):AtHorizontalCenterIn(self.Preview):AnchorToBottom(self.Name, 2):End() -- the tabs fill the rest of the column below the facts line Layouter(self.Tabs) From 1dfeab407c6209cd0e8445b67ab24722a133d0a7 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Wed, 24 Jun 2026 22:49:13 +0200 Subject: [PATCH 43/98] Extract the slots area into a separate component --- lua/ui/lobby/customlobby/CLAUDE.md | 3 +- .../customlobby/CustomLobbyInterface.lua | 198 +------------ .../customlobby/CustomLobbySlotsInterface.lua | 260 ++++++++++++++++++ 3 files changed, 274 insertions(+), 187 deletions(-) create mode 100644 lua/ui/lobby/customlobby/CustomLobbySlotsInterface.lua diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index cf228f84c49..9af4a3e50df 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -50,13 +50,14 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | [CustomLobbyContextMenu.lua](CustomLobbyContextMenu.lua) | generic framed floating menu; `Show(entries, x, y)` renders any `{label, action, enabled}` list, dismisses on item click / click-outside / Esc. Knows nothing about the lobby. | | [CustomLobbyMenus.lua](CustomLobbyMenus.lua) | declarative menu **definitions**: entry lists with `when(ctx)`/`action(ctx)` filtered by lobby state (`BuildSlotMenu`). Adding/state-gating an item is a one-liner here. | | [CustomLobbySlotInterface.lua](CustomLobbySlotInterface.lua) | one slot row; subscribes to its slot + CPU benchmarks; CPU column shows max units at +0 with a green→red cap-headroom square; left-click an open slot to take it / your own to toggle ready; right-click opens its context menu; the host can drag a row onto another to swap. | +| [CustomLobbySlotsInterface.lua](CustomLobbySlotsInterface.lua) | the **slot column**: a "Players" header over the single column of `CustomLobbySlotInterface` rows; subscribes to `SlotCount` and reveals the active rows (1..count). Also the rows' **drag coordinator** (`UICustomLobbySlotCoordinator`: hit-test which row a screen point is in, drop-highlight, drag ghost → `RequestSwapSlots`) — it owns this because it alone knows every row's rect. Exports `HeightForSlots(count)` so the composition root can size the slot area to the visible rows (single source for the row-height math). | | [CustomLobbyObserversInterface.lua](CustomLobbyObserversInterface.lua) | observer strip; subscribes to the model's `Observers` list and shows the count + names (read-only). | | [CustomLobbyScenarioPreview.lua](CustomLobbyScenarioPreview.lua) | **shared** map-preview *surface*: the scenario's map texture + overlays (start spots, resource/wreck markers, plus a **dummy translucent `WaterMask`** placeholder until a real mask exists) with aspect-correct positioning, texture-leak-safe icon sharing, per-group visibility (`SetOverlayVisible('spawns'\|'resources'\|'wrecks'\|'water', …)`), and three-phase init. Chrome-free; `CustomLobbyMapPreview` wraps it with the frame. Spawn appearance is the owner's via a `CreateSpawnIcon` factory. | | [CustomLobbyMapPreview.lua](CustomLobbyMapPreview.lua) | the map preview **as one whole** — the chrome (glow border on top + dark backdrop, surface inset by `Padding`), the surface, and the faction spawn icon (local `MapPreviewSpawn`). Used by **both** consumers: created `Bound = true` it subscribes to the launch model and renders the committed `ScenarioFile` with per-slot faction spawns (no reload on take/swap); created unbound (the map-select dialog) it does no model wiring and the owner drives `preview.Surface` directly (numbered-dot spawns). Exposes `.Surface` for owners to drive / anchor overlays to. | | [mapselect/](mapselect/CLAUDE.md) | the **map-select dialog** + its catalog and list, in their own folder (host-only `Popup`: searchable, filterable scenario list → `RequestSetScenario`). Self-contained sub-MVC; see [mapselect/CLAUDE.md](mapselect/CLAUDE.md) — including the **`MapPreview` texture-leak** writeup that shaped its design. | | [modselect/](modselect/CLAUDE.md) | the **mod-select dialog** + its catalog and list, built to the map-select shape (checkbox list + type filters + detail panel + **presets**). Returns a uid set; the opener routes it — sim mods → `RequestSetGameMods` (synced), UI mods → local prefs — or persists the lot standalone. Mod domain logic lives in [`/lua/ui/modutilities.lua`](/lua/ui/modutilities.lua) (the `maputil.lua` sibling, fronting `/lua/mods.lua`). See [modselect/CLAUDE.md](modselect/CLAUDE.md). | | [optionselect/](optionselect/CLAUDE.md) | the **options dialog**: three columns (lobby / scenario / mod options) over the selected scenario + mods, with search + hide-defaults filters; non-default options are marked. Derives the option *schema* per-peer (reference data) via [`/lua/ui/optionutil.lua`](/lua/ui/optionutil.lua); edits a working copy of the *values* and on OK routes the reconciled set through `RequestSetGameOptions` (synced via `GameOptions`). Host-only. See [optionselect/CLAUDE.md](optionselect/CLAUDE.md). | -| [CustomLobbyInterface.lua](CustomLobbyInterface.lua) | composition root, laid out in **areas** (flip the module `Debug` flag to tint them), sized for the **1024×768** floor. A **one-column** layout (the two-column variant was reverted by community request): a **title bar** (title · team score · Leave) over a **left column** split vertically — the **slots** on top (a single column of rows, up to 16, height tracking the slot count) and the **Chat / Observers** tabs below — beside a fixed-width **right column** (the config component: a pinned map preview + facts line over Options / Mods / Restrictions tabs). A full-width **action bar** at the bottom holds the global actions: status + the host-only **Settings** button (opens the options editor) + the host-only **Launch**. Holds the SlotCount + IsHost observers; Leave (Esc handler) + **Become observer** (`RequestMoveToObserver`, in the Observers tab). Also the rows' drag coordinator (`UICustomLobbySlotCoordinator`: hit-test, drop-highlight, drag ghost → `RequestSwapSlots`); `OpenDebug()` / hot-reload. | +| [CustomLobbyInterface.lua](CustomLobbyInterface.lua) | composition root, laid out in **areas** (flip the module `Debug` flag to tint them), sized for the **1024×768** floor. A **one-column** layout (the two-column variant was reverted by community request): a **title bar** (title · team score · Leave) over a **left column** split vertically — the **slots** on top (a single column of rows, up to 16, height tracking the slot count) and the **Chat / Observers** tabs below — beside a fixed-width **right column** (the config component: a pinned map preview + facts line over Options / Mods / Restrictions tabs). A full-width **action bar** at the bottom holds the global actions: status + the host-only **Settings** button (opens the options editor) + the host-only **Launch**. Reads IsHost (action-bar buttons) + SlotCount (to size the slot area via `CustomLobbySlotsInterface.HeightForSlots`); Leave (Esc handler) + **Become observer** (`RequestMoveToObserver`, in the Observers tab). The slot rows + their drag coordination now live in [`CustomLobbySlotsInterface`](CustomLobbySlotsInterface.lua), which fills the slot area. `OpenDebug()` / hot-reload. | | [CustomLobbyTeamScore.lua](CustomLobbyTeamScore.lua) | the **accumulated team rating** in the title — `Side A N · M Side B`. Shown only for the binary auto-team formations (`tvsb`→Top/Bottom, `lvsr`→Left/Right by start position; `pvsi`→Odd/Even by start-spot parity); **hidden** for `none`/`manual` (no 2-side split) or until a map's start positions are loaded. Reads the mode from `GameOptions().AutoTeams`, the ratings from each slot's `PL`. Reference data; never writes. | | [CustomLobbyTabs.lua](CustomLobbyTabs.lua) | a **generic tabbed panel** (strip + content; one panel alive, created on select / destroyed on switch). Construct with a `{ Label, Create }` list + optional `OnSelect`. Used for the bottom-left (Chat / Observers); the config interface keeps its own bespoke variant (it coordinates the persistent preview). | | [social/](social/) | the lobby's **bottom-left** column (the `CustomLobbyTabs` content): [`CustomLobbyChatPanel`](social/CustomLobbyChatPanel.lua) (the **Chat** tab — placeholder until the chat slice lands) and [`CustomLobbyObserversPanel`](social/CustomLobbyObserversPanel.lua) (the **Observers** tab — the shared observer list + a host-authoritative **Become observer** button → `RequestMoveToObserver`). Each is a tab content component (`Create(parent)`, created on select / destroyed on switch). | diff --git a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua index 7d29dcb4373..3baec7ed43e 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua @@ -20,9 +20,10 @@ --** SOFTWARE. --****************************************************************************************************** --- Composition root for the custom-games lobby. It builds and lays out the children and holds only --- the presentation observers it needs (slot count, is-host); each child subscribes to the model --- itself (see /lua/ui/lobby/TARGET_ARCHITECTURE.md). +-- Composition root for the custom-games lobby. It builds the area children and lays them out; each +-- child component subscribes to the model itself (see /lua/ui/lobby/TARGET_ARCHITECTURE.md). The +-- only model it reads directly is IsHost (for the action-bar buttons) and SlotCount (to size the +-- slot area); the slot rows + their drag coordination live in CustomLobbySlotsInterface. -- -- Layout is organised into labelled *areas* (Group containers), like the dialogs — flip the -- module-level `Debug` flag to tint each so the regions are visible while iterating. Targeted at @@ -61,7 +62,7 @@ local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaun local CustomLobbySessionModel = import("/lua/ui/lobby/customlobby/customlobbysessionmodel.lua") local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/customlobbylocalmodel.lua") local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") -local CustomLobbySlotInterface = import("/lua/ui/lobby/customlobby/customlobbyslotinterface.lua") +local CustomLobbySlotsInterface = import("/lua/ui/lobby/customlobby/customlobbyslotsinterface.lua") local CustomLobbyConfigInterface = import("/lua/ui/lobby/customlobby/config/customlobbyconfiginterface.lua") local CustomLobbyTeamScore = import("/lua/ui/lobby/customlobby/customlobbyteamscore.lua") local CustomLobbyTabs = import("/lua/ui/lobby/customlobby/customlobbytabs.lua") @@ -83,8 +84,6 @@ local LobbyWidth = 1024 local LobbyHeight = 768 local Pad = 8 -local SlotHeight = 24 -local SlotsHeaderHeight = 24 -- the "Players" header + gap above the single slot column local TitleHeight = 48 local RightWidth = 360 -- the right column (map preview + options summary); the left fills the rest local ActionHeight = 52 -- the full-width action bar at the very bottom (status + Settings + launch) @@ -105,7 +104,7 @@ local function CreateArea(parent, name, color) return area end ----@class UICustomLobbyInterface : Group, UICustomLobbySlotCoordinator +---@class UICustomLobbyInterface : Group ---@field Trash TrashBag ---@field Background Bitmap ---@field Content Group @@ -114,9 +113,7 @@ end ---@field TeamScore UICustomLobbyTeamScore ---@field LeaveButton Button ---@field SlotsArea Group ----@field SlotsHeader Text ----@field SlotsPanel Group ----@field Slots UICustomLobbySlotInterface[] +---@field Slots UICustomLobbySlotsInterface ---@field BottomLeftArea Group ---@field BottomLeftTabs UICustomLobbyTabs ---@field RightArea Group @@ -125,10 +122,7 @@ end ---@field StatusLabel Text ---@field SettingsButton Button ---@field LaunchButton Button ----@field SlotCountObserver LazyVar ---@field IsHostObserver LazyVar ----@field HighlightedSlot number | false # slot currently shown as a drop target ----@field DragGhost Group | false # floating label following the cursor mid-drag local CustomLobbyInterface = Class(Group) { ---@param self UICustomLobbyInterface @@ -137,8 +131,6 @@ local CustomLobbyInterface = Class(Group) { Group.__init(self, parent, "CustomLobbyInterface") self.Trash = TrashBag() - self.HighlightedSlot = false - self.DragGhost = false self.Background = Bitmap(self) self.Background:SetSolidColor('ff0a0a0a') @@ -169,19 +161,8 @@ local CustomLobbyInterface = Class(Group) { end --#endregion - --#region slots (top-left region — a single column of rows) - self.SlotsHeader = UIUtil.CreateText(self.SlotsArea, "Players", 14, UIUtil.titleFont) - self.SlotsHeader:SetColor('ff9aa0a8') - self.SlotsHeader:DisableHitTest() - - self.SlotsPanel = Group(self.SlotsArea, "CustomLobbySlotsPanel") - - -- One row per possible slot, stacked in a single column; the SlotCount observer reveals the - -- active ones. - self.Slots = {} - for slot = 1, CustomLobbyLaunchModel.MaxSlots do - self.Slots[slot] = CustomLobbySlotInterface.Create(self.SlotsPanel, slot, self) - end + --#region slots (top-left region — a single column of rows, owns its own drag coordination) + self.Slots = CustomLobbySlotsInterface.Create(self.SlotsArea) --#endregion --#region bottom-left: chat / observers tabs @@ -217,12 +198,6 @@ local CustomLobbyInterface = Class(Group) { Tooltip.AddControlTooltipManual(self.LaunchButton, "Launch", "Start the game with the current setup (host only). Everyone else must be ready.") --#endregion - local session = CustomLobbySessionModel.GetSingleton() - self.SlotCountObserver = self.Trash:Add( - LazyVarDerive(session.SlotCount, function(slotCountLazy) - self:OnSlotCountChanged(slotCountLazy()) - end)) - local localModel = CustomLobbyLocalModel.GetSingleton() self.IsHostObserver = self.Trash:Add( LazyVarDerive(localModel.IsHost, function(isHostLazy) @@ -263,10 +238,7 @@ local CustomLobbyInterface = Class(Group) { -- stop at the left edge of the right column Layouter(self.SlotsArea):AtLeftIn(self.Content, Pad):AnchorToBottom(self.TitleArea, Pad):End() self.SlotsArea.Right:Set(function() return self.RightArea.Left() - LayoutHelpers.ScaleNumber(Pad) end) - self.SlotsArea.Height:Set(function() - local rows = math.max(session.SlotCount(), 1) - return LayoutHelpers.ScaleNumber(SlotsHeaderHeight) + rows * LayoutHelpers.ScaleNumber(SlotHeight) - end) + self.SlotsArea.Height:Set(function() return CustomLobbySlotsInterface.HeightForSlots(session.SlotCount()) end) Layouter(self.BottomLeftArea) :AtLeftIn(self.Content, Pad):AnchorToBottom(self.SlotsArea, Pad):AnchorToTop(self.ActionArea, Pad) @@ -283,24 +255,8 @@ local CustomLobbyInterface = Class(Group) { :End() --#endregion - --#region slots (a single column; rows stack top-to-bottom) - Layouter(self.SlotsHeader):AtLeftIn(self.SlotsArea, 4):AtTopIn(self.SlotsArea):End() - Layouter(self.SlotsPanel) - :AtLeftIn(self.SlotsArea):AtRightIn(self.SlotsArea) - :AnchorToBottom(self.SlotsHeader, 4):AtBottomIn(self.SlotsArea) - :End() - - -- stack the rows top-to-bottom (slot i sits under slot i-1) - for slot = 1, CustomLobbyLaunchModel.MaxSlots do - local row = self.Slots[slot] - local builder = Layouter(row):AtLeftIn(self.SlotsPanel):AtRightIn(self.SlotsPanel):Height(SlotHeight) - if slot == 1 then - builder:AtTopIn(self.SlotsPanel) - else - builder:AnchorToBottom(self.Slots[slot - 1], 0) - end - builder:End() - end + --#region slots fill their area (the component stacks the rows + coordinates dragging) + Layouter(self.Slots):Fill(self.SlotsArea):End() --#endregion --#region bottom-left tabs (chat / observers) @@ -324,20 +280,6 @@ local CustomLobbyInterface = Class(Group) { self.Config:Initialize() end, - --- Shows the active slots (1..count) and hides the rest. The slots region's height tracks the - --- count (bound in __post_init), so the chat/observers panel below grows for smaller games. - ---@param self UICustomLobbyInterface - ---@param count number - OnSlotCountChanged = function(self, count) - for slot = 1, CustomLobbyLaunchModel.MaxSlots do - if slot <= count then - self.Slots[slot]:Show() - else - self.Slots[slot]:Hide() - end - end - end, - --- Tracks host status: updates the status line and shows the host-only action-bar buttons --- (Settings + Launch) only to the host. (The options editor the Settings button opens is --- host-gated regardless; the right-column options summary stays read-only-visible to everyone.) @@ -354,122 +296,6 @@ local CustomLobbyInterface = Class(Group) { end end, - --------------------------------------------------------------------------- - --#region Slot drag coordination (UICustomLobbySlotCoordinator) - -- - -- The slot rows raise the gesture; this container owns it because it's the only - -- thing that knows every row's rect. The "what's grabbed / where" state here is - -- purely visual — a drop resolves to the RequestSwapSlots intent, host-authoritative. - - --- Only the host can drag, and only a slot that holds a player (you grab a token). - ---@param self UICustomLobbyInterface - ---@param slot number - ---@return boolean - CanDrag = function(self, slot) - if not CustomLobbyLocalModel.GetSingleton().IsHost() then - return false - end - return CustomLobbyLaunchModel.GetSingleton().Players[slot]() ~= false - end, - - --- The active slot whose row contains the screen point, or nil. - ---@param self UICustomLobbyInterface - ---@param x number - ---@param y number - ---@return number | nil - SlotIndexAt = function(self, x, y) - local count = CustomLobbySessionModel.GetSingleton().SlotCount() - for slot = 1, count do - local row = self.Slots[slot] - if row and x >= row.Left() and x <= row.Right() and y >= row.Top() and y <= row.Bottom() then - return slot - end - end - return nil - end, - - --- Mid-drag: follow the cursor with the ghost and highlight the row under it. - ---@param self UICustomLobbyInterface - ---@param source number - ---@param x number - ---@param y number - OnSlotDragMove = function(self, source, x, y) - if not self.DragGhost then - self.DragGhost = self:CreateDragGhost(source) - end - self.DragGhost.Left:Set(x + LayoutHelpers.ScaleNumber(12)) - self.DragGhost.Top:Set(y + LayoutHelpers.ScaleNumber(8)) - self:HighlightSlot(self:SlotIndexAt(x, y)) - end, - - --- Drop: swap the source slot with whatever row the cursor is over (a move if it's - --- empty). Dropping outside any row is a no-op. - ---@param self UICustomLobbyInterface - ---@param source number - ---@param x number - ---@param y number - OnSlotDrop = function(self, source, x, y) - local target = self:SlotIndexAt(x, y) - if target and target ~= source then - CustomLobbyController.RequestSwapSlots(source, target) - end - end, - - --- Clears the transient drag visuals. - ---@param self UICustomLobbyInterface - OnSlotDragEnd = function(self) - self:HighlightSlot(nil) - if self.DragGhost then - self.DragGhost:Destroy() - self.DragGhost = false - end - end, - - --- Moves the drop-target highlight to `slot` (nil clears it). - ---@param self UICustomLobbyInterface - ---@param slot number | nil - HighlightSlot = function(self, slot) - slot = slot or false - if self.HighlightedSlot == slot then - return - end - if self.HighlightedSlot and self.Slots[self.HighlightedSlot] then - self.Slots[self.HighlightedSlot]:SetDropHighlight(false) - end - self.HighlightedSlot = slot - if slot and self.Slots[slot] then - self.Slots[slot]:SetDropHighlight(true) - end - end, - - --- Builds the floating drag label (the grabbed player's name) on the interface so - --- it draws above the rows. Destroyed in OnSlotDragEnd. - ---@param self UICustomLobbyInterface - ---@param source number - ---@return Group - CreateDragGhost = function(self, source) - local player = CustomLobbyLaunchModel.GetSingleton().Players[source]() - local name = (player and player.PlayerName) or ("Slot " .. tostring(source)) - - local ghost = Group(self, "CustomLobbyDragGhost") - ghost:DisableHitTest() - - local bg = Bitmap(ghost) - bg:SetSolidColor('cc101418') - bg:DisableHitTest() - - local label = UIUtil.CreateText(ghost, name, 14, UIUtil.bodyFont) - label:DisableHitTest() - - Layouter(label):AtLeftTopIn(ghost, 6, 3):End() - Layouter(bg):Fill(ghost):End() - ghost.Width:Set(function() return label.Width() + LayoutHelpers.ScaleNumber(12) end) - ghost.Height:Set(function() return label.Height() + LayoutHelpers.ScaleNumber(6) end) - return ghost - end, - - --#endregion - ---@param self UICustomLobbyInterface OnDestroy = function(self) self.Trash:Destroy() diff --git a/lua/ui/lobby/customlobby/CustomLobbySlotsInterface.lua b/lua/ui/lobby/customlobby/CustomLobbySlotsInterface.lua new file mode 100644 index 00000000000..c12466cca6a --- /dev/null +++ b/lua/ui/lobby/customlobby/CustomLobbySlotsInterface.lua @@ -0,0 +1,260 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- The lobby's slot column: a "Players" header over a single column of slot rows (one +-- CustomLobbySlotInterface per possible slot). It subscribes to the session's SlotCount and reveals +-- the active rows (1..count), hiding the rest. +-- +-- It is also the rows' **drag coordinator** (`UICustomLobbySlotCoordinator`): the slot rows raise +-- the drag gesture but this container owns it, because it is the only thing that knows every row's +-- rect. The "what's grabbed / where" state here is purely visual — a drop resolves to the +-- host-authoritative `RequestSwapSlots` intent. +-- +-- The composition root fills its slot area with this component and sizes that area to the visible +-- rows via `HeightForSlots(count)` (kept here so the row height math has a single source), so the +-- chat/observers panel below grows for smaller games. + +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 +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") +local CustomLobbySessionModel = import("/lua/ui/lobby/customlobby/customlobbysessionmodel.lua") +local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/customlobbylocalmodel.lua") +local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") +local CustomLobbySlotInterface = import("/lua/ui/lobby/customlobby/customlobbyslotinterface.lua") + +local LazyVarDerive = import("/lua/lazyvar.lua").Derive +local Layouter = LayoutHelpers.ReusedLayoutFor + +local SlotHeight = 24 +local HeaderHeight = 24 -- the "Players" header + gap above the rows + +---@class UICustomLobbySlotsInterface : Group, UICustomLobbySlotCoordinator +---@field Trash TrashBag +---@field Header Text +---@field Panel Group +---@field Rows UICustomLobbySlotInterface[] +---@field SlotCountObserver LazyVar +---@field HighlightedSlot number | false # slot currently shown as a drop target +---@field DragGhost Group | false # floating label following the cursor mid-drag +local CustomLobbySlotsInterface = Class(Group) { + + ---@param self UICustomLobbySlotsInterface + ---@param parent Control + __init = function(self, parent) + Group.__init(self, parent, "CustomLobbySlotsInterface") + + self.Trash = TrashBag() + self.HighlightedSlot = false + self.DragGhost = false + + self.Header = UIUtil.CreateText(self, "Players", 14, UIUtil.titleFont) + self.Header:SetColor('ff9aa0a8') + self.Header:DisableHitTest() + + self.Panel = Group(self, "CustomLobbySlotsPanel") + + -- One row per possible slot, stacked in a single column; the SlotCount observer reveals the + -- active ones. + self.Rows = {} + for slot = 1, CustomLobbyLaunchModel.MaxSlots do + self.Rows[slot] = CustomLobbySlotInterface.Create(self.Panel, slot, self) + end + + local session = CustomLobbySessionModel.GetSingleton() + self.SlotCountObserver = self.Trash:Add( + LazyVarDerive(session.SlotCount, function(slotCountLazy) + self:OnSlotCountChanged(slotCountLazy()) + end)) + end, + + ---@param self UICustomLobbySlotsInterface + __post_init = function(self) + Layouter(self.Header):AtLeftIn(self, 4):AtTopIn(self):End() + Layouter(self.Panel) + :AtLeftIn(self):AtRightIn(self) + :AnchorToBottom(self.Header, 4):AtBottomIn(self) + :End() + + -- stack the rows top-to-bottom (slot i sits under slot i-1) + for slot = 1, CustomLobbyLaunchModel.MaxSlots do + local row = self.Rows[slot] + local builder = Layouter(row):AtLeftIn(self.Panel):AtRightIn(self.Panel):Height(SlotHeight) + if slot == 1 then + builder:AtTopIn(self.Panel) + else + builder:AnchorToBottom(self.Rows[slot - 1], 0) + end + builder:End() + end + end, + + --- Shows the active slots (1..count) and hides the rest. + ---@param self UICustomLobbySlotsInterface + ---@param count number + OnSlotCountChanged = function(self, count) + for slot = 1, CustomLobbyLaunchModel.MaxSlots do + if slot <= count then + self.Rows[slot]:Show() + else + self.Rows[slot]:Hide() + end + end + end, + + --------------------------------------------------------------------------- + --#region Slot drag coordination (UICustomLobbySlotCoordinator) + -- + -- A drop resolves to the RequestSwapSlots intent, host-authoritative; the state here is purely + -- visual. + + --- Only the host can drag, and only a slot that holds a player (you grab a token). + ---@param self UICustomLobbySlotsInterface + ---@param slot number + ---@return boolean + CanDrag = function(self, slot) + if not CustomLobbyLocalModel.GetSingleton().IsHost() then + return false + end + return CustomLobbyLaunchModel.GetSingleton().Players[slot]() ~= false + end, + + --- The active slot whose row contains the screen point, or nil. + ---@param self UICustomLobbySlotsInterface + ---@param x number + ---@param y number + ---@return number | nil + SlotIndexAt = function(self, x, y) + local count = CustomLobbySessionModel.GetSingleton().SlotCount() + for slot = 1, count do + local row = self.Rows[slot] + if row and x >= row.Left() and x <= row.Right() and y >= row.Top() and y <= row.Bottom() then + return slot + end + end + return nil + end, + + --- Mid-drag: follow the cursor with the ghost and highlight the row under it. + ---@param self UICustomLobbySlotsInterface + ---@param source number + ---@param x number + ---@param y number + OnSlotDragMove = function(self, source, x, y) + if not self.DragGhost then + self.DragGhost = self:CreateDragGhost(source) + end + self.DragGhost.Left:Set(x + LayoutHelpers.ScaleNumber(12)) + self.DragGhost.Top:Set(y + LayoutHelpers.ScaleNumber(8)) + self:HighlightSlot(self:SlotIndexAt(x, y)) + end, + + --- Drop: swap the source slot with whatever row the cursor is over (a move if it's + --- empty). Dropping outside any row is a no-op. + ---@param self UICustomLobbySlotsInterface + ---@param source number + ---@param x number + ---@param y number + OnSlotDrop = function(self, source, x, y) + local target = self:SlotIndexAt(x, y) + if target and target ~= source then + CustomLobbyController.RequestSwapSlots(source, target) + end + end, + + --- Clears the transient drag visuals. + ---@param self UICustomLobbySlotsInterface + OnSlotDragEnd = function(self) + self:HighlightSlot(nil) + if self.DragGhost then + self.DragGhost:Destroy() + self.DragGhost = false + end + end, + + --- Moves the drop-target highlight to `slot` (nil clears it). + ---@param self UICustomLobbySlotsInterface + ---@param slot number | nil + HighlightSlot = function(self, slot) + slot = slot or false + if self.HighlightedSlot == slot then + return + end + if self.HighlightedSlot and self.Rows[self.HighlightedSlot] then + self.Rows[self.HighlightedSlot]:SetDropHighlight(false) + end + self.HighlightedSlot = slot + if slot and self.Rows[slot] then + self.Rows[slot]:SetDropHighlight(true) + end + end, + + --- Builds the floating drag label (the grabbed player's name) so it draws above the rows. + --- Destroyed in OnSlotDragEnd. + ---@param self UICustomLobbySlotsInterface + ---@param source number + ---@return Group + CreateDragGhost = function(self, source) + local player = CustomLobbyLaunchModel.GetSingleton().Players[source]() + local name = (player and player.PlayerName) or ("Slot " .. tostring(source)) + + local ghost = Group(self, "CustomLobbyDragGhost") + ghost:DisableHitTest() + + local bg = Bitmap(ghost) + bg:SetSolidColor('cc101418') + bg:DisableHitTest() + + local label = UIUtil.CreateText(ghost, name, 14, UIUtil.bodyFont) + label:DisableHitTest() + + Layouter(label):AtLeftTopIn(ghost, 6, 3):End() + Layouter(bg):Fill(ghost):End() + ghost.Width:Set(function() return label.Width() + LayoutHelpers.ScaleNumber(12) end) + ghost.Height:Set(function() return label.Height() + LayoutHelpers.ScaleNumber(6) end) + return ghost + end, + + --#endregion + + ---@param self UICustomLobbySlotsInterface + OnDestroy = function(self) + self.Trash:Destroy() + end, +} + +--- The (scaled) height needed to show `count` slot rows plus the header — the composition root sizes +--- the slot area with this so the rows fit exactly and the panel below floats up. +---@param count number +---@return number +HeightForSlots = function(count) + local rows = math.max(count or 0, 1) + return LayoutHelpers.ScaleNumber(HeaderHeight) + rows * LayoutHelpers.ScaleNumber(SlotHeight) +end + +---@param parent Control +---@return UICustomLobbySlotsInterface +Create = function(parent) + return CustomLobbySlotsInterface(parent) +end From 6d168a04b253d1c1261338f0a99f1a02617dd11e Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Wed, 24 Jun 2026 22:49:21 +0200 Subject: [PATCH 44/98] Turn off lazyvar evaluation --- lua/lazyvar.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/lazyvar.lua b/lua/lazyvar.lua index dae7a9963a7..ca7e1fefab3 100644 --- a/lua/lazyvar.lua +++ b/lua/lazyvar.lua @@ -9,7 +9,7 @@ local setmetatable = setmetatable -- Set this true to get tracebacks in error messages. It slows down lazyvars a lot, -- so don't use except when debugging. -local ExtendedErrorMessages = true +local ExtendedErrorMessages = false local EvalContext = nil local WeakKeyMeta = { __mode = 'k' } From 499e42dcfb3ee49778613d19f8a9d7aa7970d417 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Wed, 24 Jun 2026 23:06:35 +0200 Subject: [PATCH 45/98] Rework how we determine what slot is on what team when autoteams is enabled --- lua/ui/lobby/customlobby/CustomLobbyRules.lua | 76 +++++++++++++++++++ .../customlobby/CustomLobbyTeamScore.lua | 74 ++++-------------- 2 files changed, 89 insertions(+), 61 deletions(-) diff --git a/lua/ui/lobby/customlobby/CustomLobbyRules.lua b/lua/ui/lobby/customlobby/CustomLobbyRules.lua index 65b276d7d6a..e2d082b7692 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyRules.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyRules.lua @@ -85,6 +85,82 @@ function RecommendedUnitCap() return UnitsPerPlayer(CurrentMapDimension(model)) * players end +------------------------------------------------------------------------------- +--#region Auto-team sides + +-- The AutoTeams modes that resolve to exactly two sides; everything else (none / manual) has no +-- binary split. The labels mirror how the modes actually resolve at launch. +local BinaryModeLabels = { + tvsb = { "Top", "Bottom" }, -- by start-position Y + lvsr = { "Left", "Right" }, -- by start-position X + pvsi = { "Odd", "Even" }, -- by start-spot parity (needs no map) +} + +--- The current AutoTeams mode when it forms two sides (tvsb / lvsr / pvsi), else nil. +---@return string | nil +function AutoTeamMode() + local mode = (CustomLobbyLaunchModel.GetSingleton().GameOptions() or {}).AutoTeams + return BinaryModeLabels[mode] and mode or nil +end + +--- The two side labels for a binary mode (e.g. `{ "Left", "Right" }`), or nil for a non-binary mode. +---@param mode string | nil +---@return string[] | nil +function SideLabels(mode) + return mode and BinaryModeLabels[mode] or nil +end + +--- Builds a side resolver for the current binary AutoTeams mode: a function `startSpot -> 1|2|nil` +--- (nil = "unresolved", i.e. a positional mode whose map/start positions aren't loaded yet). Returns +--- `resolver, resolved`: +--- * `resolver` is nil only when there is no binary mode; +--- * `resolved` is false when the mode is positional but positions are unavailable — callers that +--- need a definite split (e.g. the team score) hide in that case, while the two-column slot +--- layout still renders both columns and just withholds the side labels until it flips true. +--- Positional modes load the map start positions ONCE here, so a caller resolves all spots cheaply. +---@return (fun(startSpot: number): number | nil) | nil resolver +---@return boolean resolved +function BuildSideResolver() + local mode = AutoTeamMode() + if not mode then + return nil, false + end + + if mode == 'pvsi' then + return function(spot) + if not spot then return nil end + return (math.mod(spot, 2) == 1) and 1 or 2 + end, true + end + + -- positional: resolve each spot against the map centre (positions loaded once) + local MapUtil = import("/lua/ui/maputil.lua") + local CustomLobbyMapCatalog = import("/lua/ui/lobby/customlobby/mapselect/customlobbymapcatalog.lua") + local model = CustomLobbyLaunchModel.GetSingleton() + local scenarioFile = model.ScenarioFile() + local info = scenarioFile and CustomLobbyMapCatalog.LoadInfo(scenarioFile) + if type(info) ~= "table" or not info.size then + return function(spot) return nil end, false -- mode set, positions unknown → unresolved + end + + local positions = MapUtil.GetStartPositionsFromScenario(info, CustomLobbyMapCatalog.LoadSave(info)) + if not positions then + return function(spot) return nil end, false + end + local centreX, centreZ = info.size[1] / 2, info.size[2] / 2 + return function(spot) + local pos = spot and positions[spot] + if not pos then return nil end + if mode == 'tvsb' then + return (pos[2] < centreZ) and 1 or 2 + else -- lvsr + return (pos[1] < centreX) and 1 or 2 + end + end, true +end + +--#endregion + ------------------------------------------------------------------------------- --#region Debugging diff --git a/lua/ui/lobby/customlobby/CustomLobbyTeamScore.lua b/lua/ui/lobby/customlobby/CustomLobbyTeamScore.lua index a0fb9fc7ce5..de8c3c94d66 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyTeamScore.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyTeamScore.lua @@ -36,23 +36,15 @@ -- positions) and every slot's player (ratings). Reference data only — it never writes the model. 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 CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") -local CustomLobbyMapCatalog = import("/lua/ui/lobby/customlobby/mapselect/customlobbymapcatalog.lua") +local CustomLobbyRules = import("/lua/ui/lobby/customlobby/customlobbyrules.lua") local LazyVarDerive = import("/lua/lazyvar.lua").Derive local Layouter = LayoutHelpers.ReusedLayoutFor --- side labels per mode; absent modes (none / manual) hide the widget -local ModeLabels = { - tvsb = { "Top", "Bottom" }, - lvsr = { "Left", "Right" }, - pvsi = { "Odd", "Even" }, -} - ---@class UICustomLobbyTeamScore : Group ---@field Trash TrashBag ---@field LabelA Text @@ -124,15 +116,14 @@ local CustomLobbyTeamScore = ClassUI(Group) { if not self.Ready then return end - local model = CustomLobbyLaunchModel.GetSingleton() - local mode = (model.GameOptions() or {}).AutoTeams - local labels = ModeLabels[mode] + local mode = CustomLobbyRules.AutoTeamMode() + local labels = CustomLobbyRules.SideLabels(mode) if not labels then self:Hide() return end - local totals = self:Totals(mode) + local totals = self:Totals() if not totals then self:Hide() return @@ -145,36 +136,24 @@ local CustomLobbyTeamScore = ClassUI(Group) { self:Show() end, - --- The accumulated rating per side `{ a, b }` for `mode`, or nil if it can't be determined - --- (a positional mode with no map / start positions loaded yet). + --- The accumulated rating per side `{ a, b }`, or nil if the split can't be determined yet (a + --- positional mode with no map / start positions loaded). Each seated player is attributed by + --- the shared side resolver, keyed on its start spot. ---@param self UICustomLobbyTeamScore - ---@param mode string ---@return number[] | nil - Totals = function(self, mode) - local model = CustomLobbyLaunchModel.GetSingleton() - - -- start positions (centre split) — only needed for the positional modes - local positions, centreX, centreZ - if mode == 'tvsb' or mode == 'lvsr' then - local scenarioFile = model.ScenarioFile() - local info = scenarioFile and CustomLobbyMapCatalog.LoadInfo(scenarioFile) - if type(info) ~= "table" or not info.size then - return nil - end - positions = MapUtil.GetStartPositionsFromScenario(info, CustomLobbyMapCatalog.LoadSave(info)) - if not positions then - return nil - end - centreX = info.size[1] / 2 - centreZ = info.size[2] / 2 + Totals = function(self) + local resolver, resolved = CustomLobbyRules.BuildSideResolver() + if not (resolver and resolved) then + return nil end + local model = CustomLobbyLaunchModel.GetSingleton() local a, b = 0, 0 for slot = 1, CustomLobbyLaunchModel.MaxSlots do local player = model.Players[slot]() if player then local rating = player.PL or 0 - local side = self:SideOf(mode, player, positions, centreX, centreZ) + local side = resolver(player.StartSpot) if side == 1 then a = a + rating elseif side == 2 then @@ -185,33 +164,6 @@ local CustomLobbyTeamScore = ClassUI(Group) { return { math.floor(a), math.floor(b) } end, - --- Which side (1 or 2, else nil) a player falls on for `mode`. Positional modes read the - --- player's start spot against the map centre; pvsi uses the start spot's parity. - ---@param self UICustomLobbyTeamScore - ---@param mode string - ---@param player UICustomLobbyPlayer - ---@param positions? table - ---@param centreX? number - ---@param centreZ? number - ---@return number | nil - SideOf = function(self, mode, player, positions, centreX, centreZ) - local spot = player.StartSpot - if mode == 'pvsi' then - if not spot then return nil end - return (math.mod(spot, 2) == 1) and 1 or 2 - end - - local pos = spot and positions and positions[spot] - if not pos then - return nil - end - if mode == 'tvsb' then - return (pos[2] < centreZ) and 1 or 2 - else -- lvsr - return (pos[1] < centreX) and 1 or 2 - end - end, - ---@param self UICustomLobbyTeamScore OnDestroy = function(self) self.Trash:Destroy() From 1185acb038a144a622ec184f3ad076044b626f16 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Wed, 24 Jun 2026 23:21:19 +0200 Subject: [PATCH 46/98] Rework existing setup to a one-column setup with shared base functionality per slot --- lua/ui/lobby/customlobby/CLAUDE.md | 3 +- .../customlobby/CustomLobbyInterface.lua | 6 +- .../CustomLobbySlotBase.lua} | 288 ++++++++++-------- .../{ => slots}/CustomLobbySlotsInterface.lua | 103 ++----- .../onecolumn/CustomLobbyOneColumnSlots.lua | 117 +++++++ .../slots/onecolumn/CustomLobbySlotRow.lua | 138 +++++++++ 6 files changed, 451 insertions(+), 204 deletions(-) rename lua/ui/lobby/customlobby/{CustomLobbySlotInterface.lua => slots/CustomLobbySlotBase.lua} (62%) rename lua/ui/lobby/customlobby/{ => slots}/CustomLobbySlotsInterface.lua (67%) create mode 100644 lua/ui/lobby/customlobby/slots/onecolumn/CustomLobbyOneColumnSlots.lua create mode 100644 lua/ui/lobby/customlobby/slots/onecolumn/CustomLobbySlotRow.lua diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index 9af4a3e50df..09dc8266136 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -49,8 +49,7 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | [CustomLobbyMessages.lua](CustomLobbyMessages.lua) | message registry: `AddPlayer`, `SetPlayers` (launch model: players + observers), `SentLaunchInfo` (launch model: scenario / options / mods / teams / spawn mex), `SetSessionState` (session model: slot count / closed slots), `SetReady`, `TakeSlot`, `DisconnectPeer`, `LaunchGame` (host's final game config → `instance:LaunchGame`), `ReportCpuBenchmark`, `SetCpuBenchmarks`. | | [CustomLobbyContextMenu.lua](CustomLobbyContextMenu.lua) | generic framed floating menu; `Show(entries, x, y)` renders any `{label, action, enabled}` list, dismisses on item click / click-outside / Esc. Knows nothing about the lobby. | | [CustomLobbyMenus.lua](CustomLobbyMenus.lua) | declarative menu **definitions**: entry lists with `when(ctx)`/`action(ctx)` filtered by lobby state (`BuildSlotMenu`). Adding/state-gating an item is a one-liner here. | -| [CustomLobbySlotInterface.lua](CustomLobbySlotInterface.lua) | one slot row; subscribes to its slot + CPU benchmarks; CPU column shows max units at +0 with a green→red cap-headroom square; left-click an open slot to take it / your own to toggle ready; right-click opens its context menu; the host can drag a row onto another to swap. | -| [CustomLobbySlotsInterface.lua](CustomLobbySlotsInterface.lua) | the **slot column**: a "Players" header over the single column of `CustomLobbySlotInterface` rows; subscribes to `SlotCount` and reveals the active rows (1..count). Also the rows' **drag coordinator** (`UICustomLobbySlotCoordinator`: hit-test which row a screen point is in, drop-highlight, drag ghost → `RequestSwapSlots`) — it owns this because it alone knows every row's rect. Exports `HeightForSlots(count)` so the composition root can size the slot area to the visible rows (single source for the row-height math). | +| [slots/](slots/) | the **slot subsystem** (its own folder, a sub-folder per layout). [`CustomLobbySlotsInterface`](slots/CustomLobbySlotsInterface.lua) is the entry the composition root mounts: a "Players" header over the **active layout body**, picked by the AutoTeams mode (one-column for the non-team modes; a two-column team layout for the binary modes — *the two-column layout + the swap land in the next step; for now it is always one-column*). It is the rows' **drag coordinator** (`UICustomLobbySlotCoordinator`: hit-test which row a point is in, drop-highlight, drag ghost → `RequestSwapSlots`) for *every* layout — it alone needs to hit-test across rows — so the layout bodies stay pure build/place/reveal and never duplicate the drag logic; it also exposes `PreferredHeight()` so the root sizes the slot area to the visible rows. [`CustomLobbySlotBase`](slots/CustomLobbySlotBase.lua) holds **all slot behaviour** (the slot + CPU subscriptions, the CPU-cap math, click/right-click/drag-to-swap + intents, the background/highlight/click overlays) and defers painting to four hooks (`CreateContents` / `LayoutContents` / `RenderPlayer` / `RenderCpu`); the two presentations subclass it. [`onecolumn/`](slots/onecolumn/): [`CustomLobbyOneColumnSlots`](slots/onecolumn/CustomLobbyOneColumnSlots.lua) (the body — stacks thin rows in one column, reveals 1..count, exports `HeightForCount`) + [`CustomLobbySlotRow`](slots/onecolumn/CustomLobbySlotRow.lua) (the thin one-line presentation). `twocolumn/` (the fat `CustomLobbySlotCard` + team layout) follows in the next step. | | [CustomLobbyObserversInterface.lua](CustomLobbyObserversInterface.lua) | observer strip; subscribes to the model's `Observers` list and shows the count + names (read-only). | | [CustomLobbyScenarioPreview.lua](CustomLobbyScenarioPreview.lua) | **shared** map-preview *surface*: the scenario's map texture + overlays (start spots, resource/wreck markers, plus a **dummy translucent `WaterMask`** placeholder until a real mask exists) with aspect-correct positioning, texture-leak-safe icon sharing, per-group visibility (`SetOverlayVisible('spawns'\|'resources'\|'wrecks'\|'water', …)`), and three-phase init. Chrome-free; `CustomLobbyMapPreview` wraps it with the frame. Spawn appearance is the owner's via a `CreateSpawnIcon` factory. | | [CustomLobbyMapPreview.lua](CustomLobbyMapPreview.lua) | the map preview **as one whole** — the chrome (glow border on top + dark backdrop, surface inset by `Padding`), the surface, and the faction spawn icon (local `MapPreviewSpawn`). Used by **both** consumers: created `Bound = true` it subscribes to the launch model and renders the committed `ScenarioFile` with per-slot faction spawns (no reload on take/swap); created unbound (the map-select dialog) it does no model wiring and the owner drives `preview.Surface` directly (numbered-dot spawns). Exposes `.Surface` for owners to drive / anchor overlays to. | diff --git a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua index 3baec7ed43e..87c62b55fdb 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua @@ -62,7 +62,7 @@ local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaun local CustomLobbySessionModel = import("/lua/ui/lobby/customlobby/customlobbysessionmodel.lua") local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/customlobbylocalmodel.lua") local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") -local CustomLobbySlotsInterface = import("/lua/ui/lobby/customlobby/customlobbyslotsinterface.lua") +local CustomLobbySlotsInterface = import("/lua/ui/lobby/customlobby/slots/customlobbyslotsinterface.lua") local CustomLobbyConfigInterface = import("/lua/ui/lobby/customlobby/config/customlobbyconfiginterface.lua") local CustomLobbyTeamScore = import("/lua/ui/lobby/customlobby/customlobbyteamscore.lua") local CustomLobbyTabs = import("/lua/ui/lobby/customlobby/customlobbytabs.lua") @@ -218,8 +218,6 @@ local CustomLobbyInterface = Class(Group) { Layouter(self.Content):AtCenterIn(self):End() --#region areas - local session = CustomLobbySessionModel.GetSingleton() - -- the title and the action bar span the full width, top and bottom Layouter(self.TitleArea):AtLeftIn(self.Content, Pad):AtRightIn(self.Content, Pad):AtTopIn(self.Content, Pad):Height(TitleHeight):End() Layouter(self.ActionArea) @@ -238,7 +236,7 @@ local CustomLobbyInterface = Class(Group) { -- stop at the left edge of the right column Layouter(self.SlotsArea):AtLeftIn(self.Content, Pad):AnchorToBottom(self.TitleArea, Pad):End() self.SlotsArea.Right:Set(function() return self.RightArea.Left() - LayoutHelpers.ScaleNumber(Pad) end) - self.SlotsArea.Height:Set(function() return CustomLobbySlotsInterface.HeightForSlots(session.SlotCount()) end) + self.SlotsArea.Height:Set(function() return self.Slots:PreferredHeight() end) Layouter(self.BottomLeftArea) :AtLeftIn(self.Content, Pad):AnchorToBottom(self.SlotsArea, Pad):AnchorToTop(self.ActionArea, Pad) diff --git a/lua/ui/lobby/customlobby/CustomLobbySlotInterface.lua b/lua/ui/lobby/customlobby/slots/CustomLobbySlotBase.lua similarity index 62% rename from lua/ui/lobby/customlobby/CustomLobbySlotInterface.lua rename to lua/ui/lobby/customlobby/slots/CustomLobbySlotBase.lua index 1cbcdce36c3..afdf3facfe9 100644 --- a/lua/ui/lobby/customlobby/CustomLobbySlotInterface.lua +++ b/lua/ui/lobby/customlobby/slots/CustomLobbySlotBase.lua @@ -20,10 +20,26 @@ --** SOFTWARE. --****************************************************************************************************** --- A single slot row. Subscribes to its own slot LazyVar (`model.Players[slot]`) and --- renders it; a change to one slot re-fires only this row. Read-only for now — --- interactive controls (faction/colour/team/ready) call controller intents in a --- later slice. See /lua/ui/lobby/TARGET_ARCHITECTURE.md § 6. +-- The behaviour of a single slot, shared by every presentation (the thin CustomLobbySlotRow and the +-- fat CustomLobbySlotCard). It owns *everything except the visible widgets and their layout*: +-- +-- * the model subscription (`model.Players[slot]`) + the CPU-benchmark subscription, +-- * the CPU-cap math (most-played category, +0 unit count, green→red headroom step), +-- * the click / right-click / drag-to-swap gesture handling and the controller intents, +-- * the layout-agnostic overlays (background, drop highlight, the full-row click catcher). +-- +-- A presentation subclasses this (`Class(import(...).SlotBase) { ... }`) and implements four hooks: +-- +-- CreateContents(self) build the visible widgets (+ the CPU hover zone, wired to +-- `self:HandleCpuHoverEvent`); called from this base's `__init`. +-- LayoutContents(self) lay them out; called from this base's `__post_init`. +-- RenderPlayer(self, view) paint a player (or the empty state when `view` is nil) — `view` is a +-- normalised table { colorHex, name, nameColor, faction, team, ready, +-- readyColor } so all the formatting stays here, in one place. +-- RenderCpu(self, view) paint the CPU column (or clear it when `view` is nil) — `view` is +-- { text, textColor, indicatorColor?, showIndicator }. +-- +-- This keeps the drag/CPU/intent logic single-sourced; the presentations are pure arrangement. local UIUtil = import("/lua/ui/uiutil.lua") local LayoutHelpers = import("/lua/maui/layouthelpers.lua") @@ -122,28 +138,36 @@ local DragThreshold = 5 ---@field OnSlotDrop fun(self: UICustomLobbySlotCoordinator, source: number, x: number, y: number) ---@field OnSlotDragEnd fun(self: UICustomLobbySlotCoordinator) ----@class UICustomLobbySlotInterface : Group +--- A presentation-supplied view of a seated player (nil = the empty state). +---@class UICustomLobbySlotPlayerView +---@field colorHex string +---@field name string +---@field nameColor string +---@field faction string +---@field team string +---@field ready string +---@field readyColor string + +--- A presentation-supplied view of the CPU column (nil = no data, clear it). +---@class UICustomLobbySlotCpuView +---@field text string +---@field textColor string +---@field indicatorColor? Color +---@field showIndicator boolean + +---@class UICustomLobbySlotBase : Group ---@field Trash TrashBag ---@field SlotIndex number ---@field Coordinator UICustomLobbySlotCoordinator ---@field Background Bitmap ---@field DropHighlight Bitmap ---@field ClickArea Bitmap ----@field CpuHover Bitmap ----@field SlotNumber Text ----@field ColorSwatch Bitmap ----@field Name Text ----@field Faction Text ----@field Cpu Text ----@field CpuIndicator Bitmap ----@field Team Text ----@field Ready Text ---@field PlayerObserver LazyVar ---@field CpuObserver LazyVar ---@field CurrentPlayer UICustomLobbyPlayer | false -local CustomLobbySlotInterface = Class(Group) { +local CustomLobbySlotBase = Class(Group) { - ---@param self UICustomLobbySlotInterface + ---@param self UICustomLobbySlotBase ---@param parent Control ---@param slotIndex number ---@param coordinator UICustomLobbySlotCoordinator @@ -165,23 +189,7 @@ local CustomLobbySlotInterface = Class(Group) { self.DropHighlight:SetAlpha(0.0) self.DropHighlight:DisableHitTest() - self.SlotNumber = UIUtil.CreateText(self, tostring(slotIndex), 14, UIUtil.bodyFont) - self.ColorSwatch = Bitmap(self) - self.ColorSwatch:SetSolidColor('00000000') - self.Name = UIUtil.CreateText(self, "", 14, UIUtil.bodyFont) - self.Faction = UIUtil.CreateText(self, "", 14, UIUtil.bodyFont) - self.Cpu = UIUtil.CreateText(self, "", 14, UIUtil.bodyFont) - -- a small square left of the CPU label: green when the machine sustains the - -- recommended unit cap at full speed, fading to red the more the sim must slow - self.CpuIndicator = Bitmap(self) - self.CpuIndicator:SetSolidColor('ff7ad97a') - self.CpuIndicator:SetAlpha(0.0) - self.CpuIndicator:DisableHitTest() - self.Team = UIUtil.CreateText(self, "", 14, UIUtil.bodyFont) - self.Ready = UIUtil.CreateText(self, "", 14, UIUtil.bodyFont) - - -- transparent overlay that catches clicks on the whole row; for now a click - -- on your own slot toggles ready (the one interactive message this far) + -- transparent overlay that catches clicks on the whole row (take / ready / context / drag) self.ClickArea = Bitmap(self) self.ClickArea:SetSolidColor('00000000') self.ClickArea.HandleEvent = function(control, event) @@ -196,26 +204,8 @@ local CustomLobbySlotInterface = Class(Group) { return false end - -- a hover zone over the CPU score; entering it pops the performance chart - self.CpuHover = Bitmap(self) - self.CpuHover:SetSolidColor('00000000') - self.CpuHover.HandleEvent = function(control, event) - if event.Type == 'MouseEnter' then - self:OnCpuHoverEnter() - return true - elseif event.Type == 'MouseExit' then - CustomLobbyPerformancePopover.Hide() - return true - elseif event.Type == 'ButtonPress' then - if event.Modifiers.Right then - self:OnRowContext(event) - else - self:OnRowPress(event) - end - return true - end - return false - end + -- the presentation builds its visible widgets (+ the CPU hover zone) + self:CreateContents() local model = CustomLobbyLaunchModel.GetSingleton() self.PlayerObserver = self.Trash:Add( @@ -232,63 +222,78 @@ local CustomLobbySlotInterface = Class(Group) { end)) end, - ---@param self UICustomLobbySlotInterface + ---@param self UICustomLobbySlotBase ---@param parent Control __post_init = function(self, parent) Layouter(self.Background):Fill(self):End() Layouter(self.DropHighlight):Fill(self):End() Layouter(self.ClickArea):Fill(self):Over(self, 10):End() - Layouter(self.CpuHover):Fill(self.Cpu):Over(self, 20):End() - - Layouter(self.SlotNumber):AtLeftIn(self, 6):AtVerticalCenterIn(self):End() - Layouter(self.ColorSwatch):AnchorToRight(self.SlotNumber, 8):AtVerticalCenterIn(self):Width(14):Height(14):End() - Layouter(self.Name):AnchorToRight(self.ColorSwatch, 8):AtVerticalCenterIn(self):End() - Layouter(self.Ready):AtRightIn(self, 8):AtVerticalCenterIn(self):End() - Layouter(self.Team):AnchorToLeft(self.Ready, 12):AtVerticalCenterIn(self):End() - Layouter(self.Cpu):AnchorToLeft(self.Team, 12):AtVerticalCenterIn(self):End() - Layouter(self.CpuIndicator):AnchorToLeft(self.Cpu, 5):AtVerticalCenterIn(self):Width(8):Height(12):End() - Layouter(self.Faction):AnchorToLeft(self.CpuIndicator, 10):AtVerticalCenterIn(self):End() + + -- the presentation lays out its widgets (its CPU hover zone sits above ClickArea, Over 20) + self:LayoutContents() + end, + + --------------------------------------------------------------------------- + --#region Presentation hooks (implemented by the subclass) + + --- Builds the visible widgets + the CPU hover zone (wired to `self:HandleCpuHoverEvent`). + ---@param self UICustomLobbySlotBase + CreateContents = function(self) + end, + + --- Lays out the widgets built in CreateContents. + ---@param self UICustomLobbySlotBase + LayoutContents = function(self) + end, + + --- Paints a player, or the empty state when `view` is nil. + ---@param self UICustomLobbySlotBase + ---@param view UICustomLobbySlotPlayerView | nil + RenderPlayer = function(self, view) + end, + + --- Paints the CPU column, or clears it when `view` is nil. + ---@param self UICustomLobbySlotBase + ---@param view UICustomLobbySlotCpuView | nil + RenderCpu = function(self, view) end, - --- Renders the slot from its player (or the empty state). - ---@param self UICustomLobbySlotInterface + --#endregion + + --------------------------------------------------------------------------- + --#region Rendering (computes the view, defers painting to the hooks) + + --- Normalises the slot's player into a view and hands it to the presentation. + ---@param self UICustomLobbySlotBase ---@param player UICustomLobbyPlayer | false OnPlayerChanged = function(self, player) self.CurrentPlayer = player self:RefreshCpu() if not player then - self.ColorSwatch:SetSolidColor('00000000') - self.Name:SetText("- open -") - self.Name:SetColor('ff888888') - self.Faction:SetText("") - self.Team:SetText("") - self.Ready:SetText("") + self:RenderPlayer(nil) return end - local colorHex = GameColors.PlayerColors[player.PlayerColor] or 'ffffffff' - self.ColorSwatch:SetSolidColor(colorHex) - - self.Name:SetText(player.PlayerName or "?") - self.Name:SetColor(player.Human and 'ffffffff' or 'ffd9c97a') - - self.Faction:SetText(FactionLabel(player.Faction)) - self.Team:SetText(player.Team and player.Team > 1 and ("T" .. (player.Team - 1)) or "-") - self.Ready:SetText(player.Ready and "ready" or "") - self.Ready:SetColor(player.Ready and 'ff7ad97a' or 'ff888888') + self:RenderPlayer({ + colorHex = GameColors.PlayerColors[player.PlayerColor] or 'ffffffff', + name = player.PlayerName or "?", + nameColor = player.Human and 'ffffffff' or 'ffd9c97a', + faction = FactionLabel(player.Faction), + team = (player.Team and player.Team > 1) and ("T" .. (player.Team - 1)) or "-", + ready = player.Ready and "ready" or "", + readyColor = player.Ready and 'ff7ad97a' or 'ff888888', + }) end, - --- Renders the CPU column from this slot player's shared sim-performance benchmark: - --- the label is the max units the machine handled at full speed (+0), and the square - --- is green if that already covers the recommended cap, fading to red the further the - --- sim has to slow down (down to -4) to reach it. - ---@param self UICustomLobbySlotInterface + --- Computes the CPU view from this slot player's shared sim-performance benchmark: the label is + --- the max units the machine handled at full speed (+0), and the indicator is green if that + --- already covers the recommended cap, fading to red the further the sim must slow (down to -4). + ---@param self UICustomLobbySlotBase RefreshCpu = function(self) local player = self.CurrentPlayer if not player then - self.Cpu:SetText("") - self.CpuIndicator:SetAlpha(0.0) + self:RenderCpu(nil) return end @@ -296,42 +301,66 @@ local CustomLobbySlotInterface = Class(Group) { local category = PickCategory(metrics) local atZero = category and category[BucketForRate(0)] if not (atZero and atZero.UnitCount) then - self.Cpu:SetText("—") - self.Cpu:SetColor('ff9aa0a8') - self.CpuIndicator:SetAlpha(0.0) + self:RenderCpu({ text = "—", textColor = 'ff9aa0a8', showIndicator = false }) return end local maxAtZero = atZero.UnitCount.Max or 0 - self.Cpu:SetText(FormatUnits(maxAtZero)) - self.Cpu:SetColor('ff9aa0a8') + local view = { text = FormatUnits(maxAtZero), textColor = 'ff9aa0a8', showIndicator = false } local cap = CustomLobbyRules.RecommendedUnitCap() - if not cap or cap <= 0 then - self.CpuIndicator:SetAlpha(0.0) - return - end - - -- how many sim-rate steps below +0 are needed before the machine sustains the - -- cap (0 = fine at +0); worst case (red) if even -4 falls short - local step = 0 - if maxAtZero < cap then - step = 4 - for s = 1, 4 do - local bucket = category[BucketForRate(-s)] - if bucket and bucket.UnitCount and (bucket.UnitCount.Max or 0) >= cap then - step = s - break + if cap and cap > 0 then + -- how many sim-rate steps below +0 are needed before the machine sustains the + -- cap (0 = fine at +0); worst case (red) if even -4 falls short + local step = 0 + if maxAtZero < cap then + step = 4 + for s = 1, 4 do + local bucket = category[BucketForRate(-s)] + if bucket and bucket.UnitCount and (bucket.UnitCount.Max or 0) >= cap then + step = s + break + end end end + view.indicatorColor = StepColor(step) + view.showIndicator = true end - self.CpuIndicator:SetSolidColor(StepColor(step)) - self.CpuIndicator:SetAlpha(1.0) + self:RenderCpu(view) + end, + + --#endregion + + --------------------------------------------------------------------------- + --#region Interaction + + --- Routes the CPU hover zone's events: enter shows the popover, exit hides it, a press is a + --- click / context like the rest of the row. The presentation attaches this to its hover bitmap. + ---@param self UICustomLobbySlotBase + ---@param event KeyEvent + ---@return boolean + HandleCpuHoverEvent = function(self, event) + if event.Type == 'MouseEnter' then + self:OnCpuHoverEnter() + return true + elseif event.Type == 'MouseExit' then + CustomLobbyPerformancePopover.Hide() + return true + elseif event.Type == 'ButtonPress' then + if event.Modifiers.Right then + self:OnRowContext(event) + else + self:OnRowPress(event) + end + return true + end + return false end, - --- Mouse entered the CPU score: show this player's sim-performance popover. - ---@param self UICustomLobbySlotInterface + --- Mouse entered the CPU score: show this player's sim-performance popover. The presentation + --- passes the control to anchor the popover to (its CPU label). + ---@param self UICustomLobbySlotBase OnCpuHoverEnter = function(self) local player = self.CurrentPlayer if not player then @@ -339,12 +368,20 @@ local CustomLobbySlotInterface = Class(Group) { return end local benchmark = CustomLobbyLocalModel.GetSingleton().CpuBenchmarks()[player.OwnerID] - CustomLobbyPerformancePopover.Show(self.Cpu, benchmark, CustomLobbyRules.RecommendedUnitCap()) + CustomLobbyPerformancePopover.Show(self:CpuAnchor(), benchmark, CustomLobbyRules.RecommendedUnitCap()) + end, + + --- The control the performance popover anchors to (the CPU label). Overridable; defaults to the + --- whole row if a presentation has no dedicated CPU control. + ---@param self UICustomLobbySlotBase + ---@return Control + CpuAnchor = function(self) + return self.Cpu or self end, --- A press on the row: if the coordinator allows dragging this slot (host, holding --- a player) start a drag-to-swap; otherwise it's a plain click. - ---@param self UICustomLobbySlotInterface + ---@param self UICustomLobbySlotBase ---@param event KeyEvent OnRowPress = function(self, event) if self.Coordinator and self.Coordinator:CanDrag(self.SlotIndex) then @@ -356,7 +393,7 @@ local CustomLobbySlotInterface = Class(Group) { --- Right-click: open this slot's context menu (entries depend on lobby state — --- see CustomLobbyMenus). Empty menus simply don't show. - ---@param self UICustomLobbySlotInterface + ---@param self UICustomLobbySlotBase ---@param event KeyEvent OnRowContext = function(self, event) CustomLobbyPerformancePopover.Hide() @@ -367,7 +404,7 @@ local CustomLobbySlotInterface = Class(Group) { --- travels past DragThreshold — under it, the release is treated as a click, so --- take/ready still work. Movement + drop are routed to the coordinator (it owns --- the hit-test across rows); a drop resolves to RequestSwapSlots. - ---@param self UICustomLobbySlotInterface + ---@param self UICustomLobbySlotBase ---@param event KeyEvent BeginDrag = function(self, event) -- the press may have started on the CPU hover zone; the captured mouse won't @@ -405,7 +442,7 @@ local CustomLobbySlotInterface = Class(Group) { end, --- Toggles the drop-target highlight (called by the coordinator during a drag). - ---@param self UICustomLobbySlotInterface + ---@param self UICustomLobbySlotBase ---@param on boolean SetDropHighlight = function(self, on) self.DropHighlight:SetAlpha(on and 0.15 or 0.0) @@ -413,7 +450,7 @@ local CustomLobbySlotInterface = Class(Group) { --- Click on the row (a controller intent — the host applies and broadcasts it): --- an open slot is taken by the local player; your own slot toggles ready. - ---@param self UICustomLobbySlotInterface + ---@param self UICustomLobbySlotBase OnClicked = function(self) local player = self.CurrentPlayer if not player then @@ -425,16 +462,13 @@ local CustomLobbySlotInterface = Class(Group) { end end, - ---@param self UICustomLobbySlotInterface + --#endregion + + ---@param self UICustomLobbySlotBase OnDestroy = function(self) self.Trash:Destroy() end, } ----@param parent Control ----@param slotIndex number ----@param coordinator UICustomLobbySlotCoordinator ----@return UICustomLobbySlotInterface -Create = function(parent, slotIndex, coordinator) - return CustomLobbySlotInterface(parent, slotIndex, coordinator) -end +-- exported for the presentations to subclass (`Class(import(...).SlotBase) { ... }`) +SlotBase = CustomLobbySlotBase diff --git a/lua/ui/lobby/customlobby/CustomLobbySlotsInterface.lua b/lua/ui/lobby/customlobby/slots/CustomLobbySlotsInterface.lua similarity index 67% rename from lua/ui/lobby/customlobby/CustomLobbySlotsInterface.lua rename to lua/ui/lobby/customlobby/slots/CustomLobbySlotsInterface.lua index c12466cca6a..4cf4816d70d 100644 --- a/lua/ui/lobby/customlobby/CustomLobbySlotsInterface.lua +++ b/lua/ui/lobby/customlobby/slots/CustomLobbySlotsInterface.lua @@ -20,18 +20,19 @@ --** SOFTWARE. --****************************************************************************************************** --- The lobby's slot column: a "Players" header over a single column of slot rows (one --- CustomLobbySlotInterface per possible slot). It subscribes to the session's SlotCount and reveals --- the active rows (1..count), hiding the rest. +-- The slot subsystem's entry point: the "Players" header over the active *layout body*, plus the +-- shared drag coordinator. The composition root mounts this and fills its area with it. -- --- It is also the rows' **drag coordinator** (`UICustomLobbySlotCoordinator`): the slot rows raise --- the drag gesture but this container owns it, because it is the only thing that knows every row's --- rect. The "what's grabbed / where" state here is purely visual — a drop resolves to the --- host-authoritative `RequestSwapSlots` intent. +-- One layout is alive at a time, picked by the AutoTeams mode: the one-column layout +-- ([onecolumn/CustomLobbyOneColumnSlots](onecolumn/CustomLobbyOneColumnSlots.lua)) for the non-team +-- modes, and the two-column team layout for the binary modes (left/right, top/bottom, even/odd). +-- (The two-column layout + the AutoTeams-driven swap land in the next step; for now it is always +-- one-column.) -- --- The composition root fills its slot area with this component and sizes that area to the visible --- rows via `HeightForSlots(count)` (kept here so the row height math has a single source), so the --- chat/observers panel below grows for smaller games. +-- This selector is the rows' **drag coordinator** (`UICustomLobbySlotCoordinator`) for *every* +-- layout, because it alone needs to hit-test across the rows and float the drag ghost — so the +-- layout bodies stay pure "build + place + reveal" and never duplicate the drag logic. Each body is +-- handed this selector as its rows' coordinator and exposes its `Rows` for the hit-test. local UIUtil = import("/lua/ui/uiutil.lua") local LayoutHelpers = import("/lua/maui/layouthelpers.lua") @@ -42,22 +43,18 @@ local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaun local CustomLobbySessionModel = import("/lua/ui/lobby/customlobby/customlobbysessionmodel.lua") local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/customlobbylocalmodel.lua") local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") -local CustomLobbySlotInterface = import("/lua/ui/lobby/customlobby/customlobbyslotinterface.lua") +local CustomLobbyOneColumnSlots = import("/lua/ui/lobby/customlobby/slots/onecolumn/customlobbyonecolumnslots.lua") -local LazyVarDerive = import("/lua/lazyvar.lua").Derive local Layouter = LayoutHelpers.ReusedLayoutFor -local SlotHeight = 24 -local HeaderHeight = 24 -- the "Players" header + gap above the rows +local HeaderHeight = 24 -- the "Players" header + gap above the layout body ---@class UICustomLobbySlotsInterface : Group, UICustomLobbySlotCoordinator ---@field Trash TrashBag ---@field Header Text ----@field Panel Group ----@field Rows UICustomLobbySlotInterface[] ----@field SlotCountObserver LazyVar ----@field HighlightedSlot number | false # slot currently shown as a drop target ----@field DragGhost Group | false # floating label following the cursor mid-drag +---@field Body UICustomLobbyOneColumnSlots # the active layout body +---@field HighlightedSlot number | false # slot currently shown as a drop target +---@field DragGhost Group | false # floating label following the cursor mid-drag local CustomLobbySlotsInterface = Class(Group) { ---@param self UICustomLobbySlotsInterface @@ -73,61 +70,34 @@ local CustomLobbySlotsInterface = Class(Group) { self.Header:SetColor('ff9aa0a8') self.Header:DisableHitTest() - self.Panel = Group(self, "CustomLobbySlotsPanel") - - -- One row per possible slot, stacked in a single column; the SlotCount observer reveals the - -- active ones. - self.Rows = {} - for slot = 1, CustomLobbyLaunchModel.MaxSlots do - self.Rows[slot] = CustomLobbySlotInterface.Create(self.Panel, slot, self) - end - - local session = CustomLobbySessionModel.GetSingleton() - self.SlotCountObserver = self.Trash:Add( - LazyVarDerive(session.SlotCount, function(slotCountLazy) - self:OnSlotCountChanged(slotCountLazy()) - end)) + -- the active layout body; the selector is its rows' coordinator (passed as `self`) + self.Body = CustomLobbyOneColumnSlots.Create(self, self) end, ---@param self UICustomLobbySlotsInterface __post_init = function(self) Layouter(self.Header):AtLeftIn(self, 4):AtTopIn(self):End() - Layouter(self.Panel) + Layouter(self.Body) :AtLeftIn(self):AtRightIn(self) :AnchorToBottom(self.Header, 4):AtBottomIn(self) :End() - - -- stack the rows top-to-bottom (slot i sits under slot i-1) - for slot = 1, CustomLobbyLaunchModel.MaxSlots do - local row = self.Rows[slot] - local builder = Layouter(row):AtLeftIn(self.Panel):AtRightIn(self.Panel):Height(SlotHeight) - if slot == 1 then - builder:AtTopIn(self.Panel) - else - builder:AnchorToBottom(self.Rows[slot - 1], 0) - end - builder:End() - end end, - --- Shows the active slots (1..count) and hides the rest. + --- The (scaled) height the slot area wants: the header plus the active layout's row block. The + --- composition root binds the slot area's height to this (it reads SlotCount, so it re-fires as + --- the lobby fills/empties). ---@param self UICustomLobbySlotsInterface - ---@param count number - OnSlotCountChanged = function(self, count) - for slot = 1, CustomLobbyLaunchModel.MaxSlots do - if slot <= count then - self.Rows[slot]:Show() - else - self.Rows[slot]:Hide() - end - end + ---@return number + PreferredHeight = function(self) + local count = CustomLobbySessionModel.GetSingleton().SlotCount() + return LayoutHelpers.ScaleNumber(HeaderHeight) + CustomLobbyOneColumnSlots.HeightForCount(math.max(count, 1)) end, --------------------------------------------------------------------------- --#region Slot drag coordination (UICustomLobbySlotCoordinator) -- -- A drop resolves to the RequestSwapSlots intent, host-authoritative; the state here is purely - -- visual. + -- visual. Rows belong to the active layout body, reached via `self.Body.Rows`. --- Only the host can drag, and only a slot that holds a player (you grab a token). ---@param self UICustomLobbySlotsInterface @@ -148,7 +118,7 @@ local CustomLobbySlotsInterface = Class(Group) { SlotIndexAt = function(self, x, y) local count = CustomLobbySessionModel.GetSingleton().SlotCount() for slot = 1, count do - local row = self.Rows[slot] + local row = self.Body.Rows[slot] if row and x >= row.Left() and x <= row.Right() and y >= row.Top() and y <= row.Bottom() then return slot end @@ -201,12 +171,12 @@ local CustomLobbySlotsInterface = Class(Group) { if self.HighlightedSlot == slot then return end - if self.HighlightedSlot and self.Rows[self.HighlightedSlot] then - self.Rows[self.HighlightedSlot]:SetDropHighlight(false) + if self.HighlightedSlot and self.Body.Rows[self.HighlightedSlot] then + self.Body.Rows[self.HighlightedSlot]:SetDropHighlight(false) end self.HighlightedSlot = slot - if slot and self.Rows[slot] then - self.Rows[slot]:SetDropHighlight(true) + if slot and self.Body.Rows[slot] then + self.Body.Rows[slot]:SetDropHighlight(true) end end, @@ -244,15 +214,6 @@ local CustomLobbySlotsInterface = Class(Group) { end, } ---- The (scaled) height needed to show `count` slot rows plus the header — the composition root sizes ---- the slot area with this so the rows fit exactly and the panel below floats up. ----@param count number ----@return number -HeightForSlots = function(count) - local rows = math.max(count or 0, 1) - return LayoutHelpers.ScaleNumber(HeaderHeight) + rows * LayoutHelpers.ScaleNumber(SlotHeight) -end - ---@param parent Control ---@return UICustomLobbySlotsInterface Create = function(parent) diff --git a/lua/ui/lobby/customlobby/slots/onecolumn/CustomLobbyOneColumnSlots.lua b/lua/ui/lobby/customlobby/slots/onecolumn/CustomLobbyOneColumnSlots.lua new file mode 100644 index 00000000000..0d43b164564 --- /dev/null +++ b/lua/ui/lobby/customlobby/slots/onecolumn/CustomLobbyOneColumnSlots.lua @@ -0,0 +1,117 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- The one-column slot layout: every slot stacked in a single full-width column of thin +-- CustomLobbySlotRow rows. Used for the non-team AutoTeams modes (none / manual). +-- +-- It is a *layout body* under CustomLobbySlotsInterface: that selector owns the "Players" header and +-- is the rows' drag coordinator (it alone needs to hit-test across rows), so this body just builds +-- the rows, stacks them, reveals the active ones (1..SlotCount), and exposes `Rows` for the +-- coordinator + `HeightForCount` for the selector's preferred-height calc. The selector passes +-- itself as the `coordinator` each row is wired to. + +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") + +local Group = import("/lua/maui/group.lua").Group +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") +local CustomLobbySessionModel = import("/lua/ui/lobby/customlobby/customlobbysessionmodel.lua") +local CustomLobbySlotRow = import("/lua/ui/lobby/customlobby/slots/onecolumn/customlobbyslotrow.lua") + +local LazyVarDerive = import("/lua/lazyvar.lua").Derive +local Layouter = LayoutHelpers.ReusedLayoutFor + +local SlotHeight = 24 + +---@class UICustomLobbyOneColumnSlots : Group +---@field Trash TrashBag +---@field Coordinator UICustomLobbySlotCoordinator +---@field Rows UICustomLobbySlotBase[] +---@field SlotCountObserver LazyVar +local CustomLobbyOneColumnSlots = Class(Group) { + + ---@param self UICustomLobbyOneColumnSlots + ---@param parent Control + ---@param coordinator UICustomLobbySlotCoordinator + __init = function(self, parent, coordinator) + Group.__init(self, parent, "CustomLobbyOneColumnSlots") + + self.Trash = TrashBag() + self.Coordinator = coordinator + + self.Rows = {} + for slot = 1, CustomLobbyLaunchModel.MaxSlots do + self.Rows[slot] = CustomLobbySlotRow.Create(self, slot, coordinator) + end + + self.SlotCountObserver = self.Trash:Add( + LazyVarDerive(CustomLobbySessionModel.GetSingleton().SlotCount, function(slotCountLazy) + self:OnSlotCountChanged(slotCountLazy()) + end)) + end, + + ---@param self UICustomLobbyOneColumnSlots + __post_init = function(self) + -- stack the rows top-to-bottom (slot i sits under slot i-1) + for slot = 1, CustomLobbyLaunchModel.MaxSlots do + local row = self.Rows[slot] + local builder = Layouter(row):AtLeftIn(self):AtRightIn(self):Height(SlotHeight) + if slot == 1 then + builder:AtTopIn(self) + else + builder:AnchorToBottom(self.Rows[slot - 1], 0) + end + builder:End() + end + end, + + --- Shows the active slots (1..count) and hides the rest. + ---@param self UICustomLobbyOneColumnSlots + ---@param count number + OnSlotCountChanged = function(self, count) + for slot = 1, CustomLobbyLaunchModel.MaxSlots do + if slot <= count then + self.Rows[slot]:Show() + else + self.Rows[slot]:Hide() + end + end + end, + + ---@param self UICustomLobbyOneColumnSlots + OnDestroy = function(self) + self.Trash:Destroy() + end, +} + +--- The (scaled) height of the row block for `count` slots — the selector adds the header on top. +---@param count number +---@return number +HeightForCount = function(count) + return math.max(count or 0, 1) * LayoutHelpers.ScaleNumber(SlotHeight) +end + +---@param parent Control +---@param coordinator UICustomLobbySlotCoordinator +---@return UICustomLobbyOneColumnSlots +Create = function(parent, coordinator) + return CustomLobbyOneColumnSlots(parent, coordinator) +end diff --git a/lua/ui/lobby/customlobby/slots/onecolumn/CustomLobbySlotRow.lua b/lua/ui/lobby/customlobby/slots/onecolumn/CustomLobbySlotRow.lua new file mode 100644 index 00000000000..1c5b84eb848 --- /dev/null +++ b/lua/ui/lobby/customlobby/slots/onecolumn/CustomLobbySlotRow.lua @@ -0,0 +1,138 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- The thin slot presentation: one full-width, single-line row used by the one-column layout — +-- +-- 1 ▣ PlayerName Cybran ▢ 1.4k T1 ready +-- +-- It is pure arrangement over CustomLobbySlotBase: it builds the widgets, lays them out in a row, +-- and assigns the base's normalised player / CPU views to them. All behaviour (subscriptions, CPU +-- math, drag-to-swap, intents) lives in the base. + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") + +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap +local CustomLobbySlotBase = import("/lua/ui/lobby/customlobby/slots/customlobbyslotbase.lua").SlotBase + +local Layouter = LayoutHelpers.ReusedLayoutFor + +---@class UICustomLobbySlotRow : UICustomLobbySlotBase +---@field SlotNumber Text +---@field ColorSwatch Bitmap +---@field Name Text +---@field Faction Text +---@field Cpu Text +---@field CpuIndicator Bitmap +---@field Team Text +---@field Ready Text +---@field CpuHover Bitmap +local CustomLobbySlotRow = Class(CustomLobbySlotBase) { + + ---@param self UICustomLobbySlotRow + CreateContents = function(self) + self.SlotNumber = UIUtil.CreateText(self, tostring(self.SlotIndex), 14, UIUtil.bodyFont) + self.ColorSwatch = Bitmap(self) + self.ColorSwatch:SetSolidColor('00000000') + self.Name = UIUtil.CreateText(self, "", 14, UIUtil.bodyFont) + self.Faction = UIUtil.CreateText(self, "", 14, UIUtil.bodyFont) + self.Cpu = UIUtil.CreateText(self, "", 14, UIUtil.bodyFont) + -- a small square left of the CPU label: green when the machine sustains the + -- recommended unit cap at full speed, fading to red the more the sim must slow + self.CpuIndicator = Bitmap(self) + self.CpuIndicator:SetSolidColor('ff7ad97a') + self.CpuIndicator:SetAlpha(0.0) + self.CpuIndicator:DisableHitTest() + self.Team = UIUtil.CreateText(self, "", 14, UIUtil.bodyFont) + self.Ready = UIUtil.CreateText(self, "", 14, UIUtil.bodyFont) + + -- a hover zone over the CPU score; the base routes enter/exit/press + self.CpuHover = Bitmap(self) + self.CpuHover:SetSolidColor('00000000') + self.CpuHover.HandleEvent = function(control, event) + return self:HandleCpuHoverEvent(event) + end + end, + + ---@param self UICustomLobbySlotRow + LayoutContents = function(self) + Layouter(self.SlotNumber):AtLeftIn(self, 6):AtVerticalCenterIn(self):End() + Layouter(self.ColorSwatch):AnchorToRight(self.SlotNumber, 8):AtVerticalCenterIn(self):Width(14):Height(14):End() + Layouter(self.Name):AnchorToRight(self.ColorSwatch, 8):AtVerticalCenterIn(self):End() + Layouter(self.Ready):AtRightIn(self, 8):AtVerticalCenterIn(self):End() + Layouter(self.Team):AnchorToLeft(self.Ready, 12):AtVerticalCenterIn(self):End() + Layouter(self.Cpu):AnchorToLeft(self.Team, 12):AtVerticalCenterIn(self):End() + Layouter(self.CpuIndicator):AnchorToLeft(self.Cpu, 5):AtVerticalCenterIn(self):Width(8):Height(12):End() + Layouter(self.Faction):AnchorToLeft(self.CpuIndicator, 10):AtVerticalCenterIn(self):End() + + Layouter(self.CpuHover):Fill(self.Cpu):Over(self, 20):End() + end, + + ---@param self UICustomLobbySlotRow + ---@param view UICustomLobbySlotPlayerView | nil + RenderPlayer = function(self, view) + if not view then + self.ColorSwatch:SetSolidColor('00000000') + self.Name:SetText("- open -") + self.Name:SetColor('ff888888') + self.Faction:SetText("") + self.Team:SetText("") + self.Ready:SetText("") + return + end + + self.ColorSwatch:SetSolidColor(view.colorHex) + self.Name:SetText(view.name) + self.Name:SetColor(view.nameColor) + self.Faction:SetText(view.faction) + self.Team:SetText(view.team) + self.Ready:SetText(view.ready) + self.Ready:SetColor(view.readyColor) + end, + + ---@param self UICustomLobbySlotRow + ---@param view UICustomLobbySlotCpuView | nil + RenderCpu = function(self, view) + if not view then + self.Cpu:SetText("") + self.CpuIndicator:SetAlpha(0.0) + return + end + + self.Cpu:SetText(view.text) + self.Cpu:SetColor(view.textColor) + if view.showIndicator then + self.CpuIndicator:SetSolidColor(view.indicatorColor) + self.CpuIndicator:SetAlpha(1.0) + else + self.CpuIndicator:SetAlpha(0.0) + end + end, +} + +---@param parent Control +---@param slotIndex number +---@param coordinator UICustomLobbySlotCoordinator +---@return UICustomLobbySlotRow +Create = function(parent, slotIndex, coordinator) + return CustomLobbySlotRow(parent, slotIndex, coordinator) +end From 5baa95f7a336e7895de15e5cdcd9bf2f6ac2a801 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Wed, 24 Jun 2026 23:31:48 +0200 Subject: [PATCH 47/98] Implement one-column and two-column layout --- lua/lazyvar.lua | 2 +- lua/ui/lobby/customlobby/CLAUDE.md | 2 +- .../customlobby/slots/CustomLobbySlotBase.lua | 57 ++++- .../slots/CustomLobbySlotsInterface.lua | 86 ++++++- .../slots/onecolumn/CustomLobbySlotRow.lua | 41 ---- .../slots/twocolumn/CustomLobbySlotCard.lua | 99 ++++++++ .../twocolumn/CustomLobbyTwoColumnSlots.lua | 216 ++++++++++++++++++ 7 files changed, 443 insertions(+), 60 deletions(-) create mode 100644 lua/ui/lobby/customlobby/slots/twocolumn/CustomLobbySlotCard.lua create mode 100644 lua/ui/lobby/customlobby/slots/twocolumn/CustomLobbyTwoColumnSlots.lua diff --git a/lua/lazyvar.lua b/lua/lazyvar.lua index ca7e1fefab3..dae7a9963a7 100644 --- a/lua/lazyvar.lua +++ b/lua/lazyvar.lua @@ -9,7 +9,7 @@ local setmetatable = setmetatable -- Set this true to get tracebacks in error messages. It slows down lazyvars a lot, -- so don't use except when debugging. -local ExtendedErrorMessages = false +local ExtendedErrorMessages = true local EvalContext = nil local WeakKeyMeta = { __mode = 'k' } diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index 09dc8266136..b0c026b4c54 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -49,7 +49,7 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | [CustomLobbyMessages.lua](CustomLobbyMessages.lua) | message registry: `AddPlayer`, `SetPlayers` (launch model: players + observers), `SentLaunchInfo` (launch model: scenario / options / mods / teams / spawn mex), `SetSessionState` (session model: slot count / closed slots), `SetReady`, `TakeSlot`, `DisconnectPeer`, `LaunchGame` (host's final game config → `instance:LaunchGame`), `ReportCpuBenchmark`, `SetCpuBenchmarks`. | | [CustomLobbyContextMenu.lua](CustomLobbyContextMenu.lua) | generic framed floating menu; `Show(entries, x, y)` renders any `{label, action, enabled}` list, dismisses on item click / click-outside / Esc. Knows nothing about the lobby. | | [CustomLobbyMenus.lua](CustomLobbyMenus.lua) | declarative menu **definitions**: entry lists with `when(ctx)`/`action(ctx)` filtered by lobby state (`BuildSlotMenu`). Adding/state-gating an item is a one-liner here. | -| [slots/](slots/) | the **slot subsystem** (its own folder, a sub-folder per layout). [`CustomLobbySlotsInterface`](slots/CustomLobbySlotsInterface.lua) is the entry the composition root mounts: a "Players" header over the **active layout body**, picked by the AutoTeams mode (one-column for the non-team modes; a two-column team layout for the binary modes — *the two-column layout + the swap land in the next step; for now it is always one-column*). It is the rows' **drag coordinator** (`UICustomLobbySlotCoordinator`: hit-test which row a point is in, drop-highlight, drag ghost → `RequestSwapSlots`) for *every* layout — it alone needs to hit-test across rows — so the layout bodies stay pure build/place/reveal and never duplicate the drag logic; it also exposes `PreferredHeight()` so the root sizes the slot area to the visible rows. [`CustomLobbySlotBase`](slots/CustomLobbySlotBase.lua) holds **all slot behaviour** (the slot + CPU subscriptions, the CPU-cap math, click/right-click/drag-to-swap + intents, the background/highlight/click overlays) and defers painting to four hooks (`CreateContents` / `LayoutContents` / `RenderPlayer` / `RenderCpu`); the two presentations subclass it. [`onecolumn/`](slots/onecolumn/): [`CustomLobbyOneColumnSlots`](slots/onecolumn/CustomLobbyOneColumnSlots.lua) (the body — stacks thin rows in one column, reveals 1..count, exports `HeightForCount`) + [`CustomLobbySlotRow`](slots/onecolumn/CustomLobbySlotRow.lua) (the thin one-line presentation). `twocolumn/` (the fat `CustomLobbySlotCard` + team layout) follows in the next step. | +| [slots/](slots/) | the **slot subsystem** (its own folder, a sub-folder per layout). [`CustomLobbySlotsInterface`](slots/CustomLobbySlotsInterface.lua) is the entry the composition root mounts: a "Players" header over the **active layout body**, picked by the AutoTeams mode — one-column for the non-team modes, the two-column team layout for the binary modes (left/right, top/bottom, even/odd). It observes `GameOptions` and swaps the body when the kind flips (create-on-mode / destroy-on-switch); a change *within* the binary modes is handled by the two-column body's own re-layout. It is the rows' **drag coordinator** (`UICustomLobbySlotCoordinator`: hit-test which row a point is in, drop-highlight, drag ghost → `RequestSwapSlots`) for *every* layout — it alone needs to hit-test across rows — so the layout bodies stay pure build/place/reveal and never duplicate the drag logic; it also exposes `PreferredHeight()` (computed from the mode + model, swap-order-independent) so the root sizes the slot area. [`CustomLobbySlotBase`](slots/CustomLobbySlotBase.lua) holds **all slot behaviour** (the slot + CPU subscriptions, the CPU-cap math, click/right-click/drag-to-swap + intents, the background/highlight/click overlays) and the default `RenderPlayer`/`RenderCpu` that paint the standard named controls from normalised views; a presentation subclasses it and implements just `CreateContents` / `LayoutContents`. [`onecolumn/`](slots/onecolumn/): [`CustomLobbyOneColumnSlots`](slots/onecolumn/CustomLobbyOneColumnSlots.lua) (stacks thin rows, reveals 1..count) + [`CustomLobbySlotRow`](slots/onecolumn/CustomLobbySlotRow.lua) (thin one-line presentation). [`twocolumn/`](slots/twocolumn/): [`CustomLobbyTwoColumnSlots`](slots/twocolumn/CustomLobbyTwoColumnSlots.lua) (splits slots into two team columns via [`CustomLobbyRules.BuildSideResolver`](CustomLobbyRules.lua), with per-column team labels and the "two columns, unresolved" fallback — parity fill + no labels until a positional map's start positions load) + [`CustomLobbySlotCard`](slots/twocolumn/CustomLobbySlotCard.lua) (fat half-width / two-line presentation). Each body exports `HeightForCount`. | | [CustomLobbyObserversInterface.lua](CustomLobbyObserversInterface.lua) | observer strip; subscribes to the model's `Observers` list and shows the count + names (read-only). | | [CustomLobbyScenarioPreview.lua](CustomLobbyScenarioPreview.lua) | **shared** map-preview *surface*: the scenario's map texture + overlays (start spots, resource/wreck markers, plus a **dummy translucent `WaterMask`** placeholder until a real mask exists) with aspect-correct positioning, texture-leak-safe icon sharing, per-group visibility (`SetOverlayVisible('spawns'\|'resources'\|'wrecks'\|'water', …)`), and three-phase init. Chrome-free; `CustomLobbyMapPreview` wraps it with the frame. Spawn appearance is the owner's via a `CreateSpawnIcon` factory. | | [CustomLobbyMapPreview.lua](CustomLobbyMapPreview.lua) | the map preview **as one whole** — the chrome (glow border on top + dark backdrop, surface inset by `Padding`), the surface, and the faction spawn icon (local `MapPreviewSpawn`). Used by **both** consumers: created `Bound = true` it subscribes to the launch model and renders the committed `ScenarioFile` with per-slot faction spawns (no reload on take/swap); created unbound (the map-select dialog) it does no model wiring and the owner drives `preview.Surface` directly (numbered-dot spawns). Exposes `.Surface` for owners to drive / anchor overlays to. | diff --git a/lua/ui/lobby/customlobby/slots/CustomLobbySlotBase.lua b/lua/ui/lobby/customlobby/slots/CustomLobbySlotBase.lua index afdf3facfe9..c6c997e9180 100644 --- a/lua/ui/lobby/customlobby/slots/CustomLobbySlotBase.lua +++ b/lua/ui/lobby/customlobby/slots/CustomLobbySlotBase.lua @@ -28,16 +28,17 @@ -- * the click / right-click / drag-to-swap gesture handling and the controller intents, -- * the layout-agnostic overlays (background, drop highlight, the full-row click catcher). -- --- A presentation subclasses this (`Class(import(...).SlotBase) { ... }`) and implements four hooks: +-- A presentation subclasses this (`Class(import(...).SlotBase) { ... }`) and implements two hooks: -- -- CreateContents(self) build the visible widgets (+ the CPU hover zone, wired to -- `self:HandleCpuHoverEvent`); called from this base's `__init`. -- LayoutContents(self) lay them out; called from this base's `__post_init`. --- RenderPlayer(self, view) paint a player (or the empty state when `view` is nil) — `view` is a --- normalised table { colorHex, name, nameColor, faction, team, ready, --- readyColor } so all the formatting stays here, in one place. --- RenderCpu(self, view) paint the CPU column (or clear it when `view` is nil) — `view` is --- { text, textColor, indicatorColor?, showIndicator }. +-- +-- A presentation just has to provide the standard named controls — `ColorSwatch`, `Name`, `Faction`, +-- `Team`, `Ready`, `Cpu` (Texts) and `CpuIndicator` (Bitmap) — arranged however it likes; the base's +-- `RenderPlayer` / `RenderCpu` paint those from the normalised player / CPU views, so the formatting +-- (faction label, `T1`, ready/CPU colours, unit string) stays here, in one place. A presentation that +-- needs a different mapping (e.g. a faction *icon*) can override `RenderPlayer` / `RenderCpu`. -- -- This keeps the drag/CPU/intent logic single-sourced; the presentations are pure arrangement. @@ -165,6 +166,14 @@ local DragThreshold = 5 ---@field PlayerObserver LazyVar ---@field CpuObserver LazyVar ---@field CurrentPlayer UICustomLobbyPlayer | false +-- the standard named controls a presentation must provide (the base's Render* paint these): +---@field ColorSwatch Bitmap +---@field Name Text +---@field Faction Text +---@field Team Text +---@field Ready Text +---@field Cpu Text +---@field CpuIndicator Bitmap local CustomLobbySlotBase = Class(Group) { ---@param self UICustomLobbySlotBase @@ -246,16 +255,48 @@ local CustomLobbySlotBase = Class(Group) { LayoutContents = function(self) end, - --- Paints a player, or the empty state when `view` is nil. + --- Paints a player (or the empty state when `view` is nil) onto the standard named controls. + --- Overridable for a presentation that maps the fields differently. ---@param self UICustomLobbySlotBase ---@param view UICustomLobbySlotPlayerView | nil RenderPlayer = function(self, view) + if not view then + self.ColorSwatch:SetSolidColor('00000000') + self.Name:SetText("- open -") + self.Name:SetColor('ff888888') + self.Faction:SetText("") + self.Team:SetText("") + self.Ready:SetText("") + return + end + + self.ColorSwatch:SetSolidColor(view.colorHex) + self.Name:SetText(view.name) + self.Name:SetColor(view.nameColor) + self.Faction:SetText(view.faction) + self.Team:SetText(view.team) + self.Ready:SetText(view.ready) + self.Ready:SetColor(view.readyColor) end, - --- Paints the CPU column, or clears it when `view` is nil. + --- Paints the CPU column (or clears it when `view` is nil) onto the standard named controls. ---@param self UICustomLobbySlotBase ---@param view UICustomLobbySlotCpuView | nil RenderCpu = function(self, view) + if not view then + self.Cpu:SetText("") + self.CpuIndicator:SetAlpha(0.0) + return + end + + self.Cpu:SetText(view.text) + self.Cpu:SetColor(view.textColor) + if view.showIndicator then + self.CpuIndicator:SetSolidColor(view.indicatorColor) + self.CpuIndicator:SetAlpha(1.0) + else + self.CpuIndicator:SetAlpha(0.0) + end end, --#endregion diff --git a/lua/ui/lobby/customlobby/slots/CustomLobbySlotsInterface.lua b/lua/ui/lobby/customlobby/slots/CustomLobbySlotsInterface.lua index 4cf4816d70d..df8eae60b3b 100644 --- a/lua/ui/lobby/customlobby/slots/CustomLobbySlotsInterface.lua +++ b/lua/ui/lobby/customlobby/slots/CustomLobbySlotsInterface.lua @@ -43,18 +43,26 @@ local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaun local CustomLobbySessionModel = import("/lua/ui/lobby/customlobby/customlobbysessionmodel.lua") local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/customlobbylocalmodel.lua") local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") +local CustomLobbyRules = import("/lua/ui/lobby/customlobby/customlobbyrules.lua") local CustomLobbyOneColumnSlots = import("/lua/ui/lobby/customlobby/slots/onecolumn/customlobbyonecolumnslots.lua") +local CustomLobbyTwoColumnSlots = import("/lua/ui/lobby/customlobby/slots/twocolumn/customlobbytwocolumnslots.lua") +local LazyVarDerive = import("/lua/lazyvar.lua").Derive local Layouter = LayoutHelpers.ReusedLayoutFor local HeaderHeight = 24 -- the "Players" header + gap above the layout body +---@alias UICustomLobbySlotsBody UICustomLobbyOneColumnSlots | UICustomLobbyTwoColumnSlots + ---@class UICustomLobbySlotsInterface : Group, UICustomLobbySlotCoordinator ---@field Trash TrashBag ---@field Header Text ----@field Body UICustomLobbyOneColumnSlots # the active layout body ----@field HighlightedSlot number | false # slot currently shown as a drop target ----@field DragGhost Group | false # floating label following the cursor mid-drag +---@field Body UICustomLobbySlotsBody | false # the active layout body +---@field LayoutKind "one" | "two" | false # which layout Body currently is +---@field Mounted boolean # true once __post_init has laid us out +---@field GameOptionsObserver LazyVar +---@field HighlightedSlot number | false # slot currently shown as a drop target +---@field DragGhost Group | false # floating label following the cursor mid-drag local CustomLobbySlotsInterface = Class(Group) { ---@param self UICustomLobbySlotsInterface @@ -65,32 +73,92 @@ local CustomLobbySlotsInterface = Class(Group) { self.Trash = TrashBag() self.HighlightedSlot = false self.DragGhost = false + self.Mounted = false + self.Body = false + self.LayoutKind = false self.Header = UIUtil.CreateText(self, "Players", 14, UIUtil.titleFont) self.Header:SetColor('ff9aa0a8') self.Header:DisableHitTest() - -- the active layout body; the selector is its rows' coordinator (passed as `self`) - self.Body = CustomLobbyOneColumnSlots.Create(self, self) + -- the active layout body, picked by the AutoTeams mode (created now, laid out on mount) + self:RebuildBody() + + -- a binary AutoTeams mode (left/right, top/bottom, even/odd) swaps in the two-column layout; + -- everything else uses one column + self.GameOptionsObserver = self.Trash:Add( + LazyVarDerive(CustomLobbyLaunchModel.GetSingleton().GameOptions, function(lazy) + lazy() + self:OnAutoTeamsChanged() + end)) end, ---@param self UICustomLobbySlotsInterface __post_init = function(self) Layouter(self.Header):AtLeftIn(self, 4):AtTopIn(self):End() + self.Mounted = true + self:LayoutBody() + end, + + --- The layout kind the current AutoTeams mode calls for: "two" for a binary team mode, else "one". + ---@param self UICustomLobbySlotsInterface + ---@return "one" | "two" + KindForMode = function(self) + return CustomLobbyRules.AutoTeamMode() and "two" or "one" + end, + + --- (Re)creates the layout body for the current mode, destroying the previous one. Lays it out + --- immediately when already mounted (a live mode switch); otherwise __post_init lays it out. + ---@param self UICustomLobbySlotsInterface + RebuildBody = function(self) + local kind = self:KindForMode() + self.LayoutKind = kind + if self.Body then + self.Body:Destroy() + end + -- the selector is the rows' coordinator regardless of layout (passed as `self`) + if kind == "two" then + self.Body = CustomLobbyTwoColumnSlots.Create(self, self) + else + self.Body = CustomLobbyOneColumnSlots.Create(self, self) + end + if self.Mounted then + self:LayoutBody() + end + end, + + --- Fills the area below the header with the active body. + ---@param self UICustomLobbySlotsInterface + LayoutBody = function(self) + if not self.Body then + return + end Layouter(self.Body) :AtLeftIn(self):AtRightIn(self) :AnchorToBottom(self.Header, 4):AtBottomIn(self) :End() end, - --- The (scaled) height the slot area wants: the header plus the active layout's row block. The - --- composition root binds the slot area's height to this (it reads SlotCount, so it re-fires as - --- the lobby fills/empties). + --- The mode changed: swap the layout body if the kind (one vs two column) flipped. A change + --- within the same kind (e.g. lvsr→tvsb) is handled by the body's own re-layout. + ---@param self UICustomLobbySlotsInterface + OnAutoTeamsChanged = function(self) + if self:KindForMode() ~= self.LayoutKind then + self:RebuildBody() + end + end, + + --- The (scaled) height the slot area wants: the header plus the active layout's column block. + --- Computed straight from the mode + model (not the live body) so it re-fires correctly on a mode + --- swap regardless of observer order. The composition root binds the slot area's height to this. ---@param self UICustomLobbySlotsInterface ---@return number PreferredHeight = function(self) local count = CustomLobbySessionModel.GetSingleton().SlotCount() - return LayoutHelpers.ScaleNumber(HeaderHeight) + CustomLobbyOneColumnSlots.HeightForCount(math.max(count, 1)) + local body = self:KindForMode() == "two" + and CustomLobbyTwoColumnSlots.HeightForCount(count) + or CustomLobbyOneColumnSlots.HeightForCount(math.max(count, 1)) + return LayoutHelpers.ScaleNumber(HeaderHeight) + body end, --------------------------------------------------------------------------- diff --git a/lua/ui/lobby/customlobby/slots/onecolumn/CustomLobbySlotRow.lua b/lua/ui/lobby/customlobby/slots/onecolumn/CustomLobbySlotRow.lua index 1c5b84eb848..a799368c8c1 100644 --- a/lua/ui/lobby/customlobby/slots/onecolumn/CustomLobbySlotRow.lua +++ b/lua/ui/lobby/customlobby/slots/onecolumn/CustomLobbySlotRow.lua @@ -86,47 +86,6 @@ local CustomLobbySlotRow = Class(CustomLobbySlotBase) { Layouter(self.CpuHover):Fill(self.Cpu):Over(self, 20):End() end, - - ---@param self UICustomLobbySlotRow - ---@param view UICustomLobbySlotPlayerView | nil - RenderPlayer = function(self, view) - if not view then - self.ColorSwatch:SetSolidColor('00000000') - self.Name:SetText("- open -") - self.Name:SetColor('ff888888') - self.Faction:SetText("") - self.Team:SetText("") - self.Ready:SetText("") - return - end - - self.ColorSwatch:SetSolidColor(view.colorHex) - self.Name:SetText(view.name) - self.Name:SetColor(view.nameColor) - self.Faction:SetText(view.faction) - self.Team:SetText(view.team) - self.Ready:SetText(view.ready) - self.Ready:SetColor(view.readyColor) - end, - - ---@param self UICustomLobbySlotRow - ---@param view UICustomLobbySlotCpuView | nil - RenderCpu = function(self, view) - if not view then - self.Cpu:SetText("") - self.CpuIndicator:SetAlpha(0.0) - return - end - - self.Cpu:SetText(view.text) - self.Cpu:SetColor(view.textColor) - if view.showIndicator then - self.CpuIndicator:SetSolidColor(view.indicatorColor) - self.CpuIndicator:SetAlpha(1.0) - else - self.CpuIndicator:SetAlpha(0.0) - end - end, } ---@param parent Control diff --git a/lua/ui/lobby/customlobby/slots/twocolumn/CustomLobbySlotCard.lua b/lua/ui/lobby/customlobby/slots/twocolumn/CustomLobbySlotCard.lua new file mode 100644 index 00000000000..84078f3282a --- /dev/null +++ b/lua/ui/lobby/customlobby/slots/twocolumn/CustomLobbySlotCard.lua @@ -0,0 +1,99 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- The fat slot presentation: a half-width, double-height card used by the two-column team layout. +-- The same data as the thin row, reflowed onto two lines so it reads in a narrow column — +-- +-- ┌──────────────────────────┐ +-- │ ▣ PlayerName ready │ line 1: colour swatch · name · ready +-- │ Cybran · T1 1.4k ▢ │ line 2: faction · team · cpu (+ headroom square) +-- └──────────────────────────┘ +-- +-- It is pure arrangement over CustomLobbySlotBase: it builds the standard named controls and lays +-- them out; the base paints them and owns all behaviour (subscriptions, CPU math, drag-to-swap, +-- intents). + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") + +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap +local CustomLobbySlotBase = import("/lua/ui/lobby/customlobby/slots/customlobbyslotbase.lua").SlotBase + +local Layouter = LayoutHelpers.ReusedLayoutFor + +---@class UICustomLobbySlotCard : UICustomLobbySlotBase +---@field ColorSwatch Bitmap +---@field Name Text +---@field Ready Text +---@field Faction Text +---@field Team Text +---@field Cpu Text +---@field CpuIndicator Bitmap +---@field CpuHover Bitmap +local CustomLobbySlotCard = Class(CustomLobbySlotBase) { + + ---@param self UICustomLobbySlotCard + CreateContents = function(self) + self.ColorSwatch = Bitmap(self) + self.ColorSwatch:SetSolidColor('00000000') + self.Name = UIUtil.CreateText(self, "", 14, UIUtil.bodyFont) + self.Ready = UIUtil.CreateText(self, "", 12, UIUtil.bodyFont) + self.Faction = UIUtil.CreateText(self, "", 12, UIUtil.bodyFont) + self.Team = UIUtil.CreateText(self, "", 12, UIUtil.bodyFont) + self.Cpu = UIUtil.CreateText(self, "", 12, UIUtil.bodyFont) + self.CpuIndicator = Bitmap(self) + self.CpuIndicator:SetSolidColor('ff7ad97a') + self.CpuIndicator:SetAlpha(0.0) + self.CpuIndicator:DisableHitTest() + + -- a hover zone over the CPU score; the base routes enter/exit/press + self.CpuHover = Bitmap(self) + self.CpuHover:SetSolidColor('00000000') + self.CpuHover.HandleEvent = function(control, event) + return self:HandleCpuHoverEvent(event) + end + end, + + ---@param self UICustomLobbySlotCard + LayoutContents = function(self) + -- line 1 (top): swatch · name … ready + Layouter(self.ColorSwatch):AtLeftIn(self, 6):AtTopIn(self, 7):Width(14):Height(14):End() + Layouter(self.Name):AnchorToRight(self.ColorSwatch, 6):AtVerticalCenterIn(self.ColorSwatch):End() + Layouter(self.Ready):AtRightIn(self, 6):AtVerticalCenterIn(self.ColorSwatch):End() + + -- line 2 (bottom): faction · team … [indicator] cpu + Layouter(self.Faction):AtLeftIn(self, 6):AtBottomIn(self, 7):End() + Layouter(self.Team):AnchorToRight(self.Faction, 8):AtVerticalCenterIn(self.Faction):End() + Layouter(self.Cpu):AtRightIn(self, 6):AtVerticalCenterIn(self.Faction):End() + Layouter(self.CpuIndicator):AnchorToLeft(self.Cpu, 5):AtVerticalCenterIn(self.Faction):Width(8):Height(12):End() + + Layouter(self.CpuHover):Fill(self.Cpu):Over(self, 20):End() + end, +} + +---@param parent Control +---@param slotIndex number +---@param coordinator UICustomLobbySlotCoordinator +---@return UICustomLobbySlotCard +Create = function(parent, slotIndex, coordinator) + return CustomLobbySlotCard(parent, slotIndex, coordinator) +end diff --git a/lua/ui/lobby/customlobby/slots/twocolumn/CustomLobbyTwoColumnSlots.lua b/lua/ui/lobby/customlobby/slots/twocolumn/CustomLobbyTwoColumnSlots.lua new file mode 100644 index 00000000000..115e0aafb13 --- /dev/null +++ b/lua/ui/lobby/customlobby/slots/twocolumn/CustomLobbyTwoColumnSlots.lua @@ -0,0 +1,216 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- The two-column slot layout: the binary AutoTeams modes (left/right, top/bottom, even/odd) split +-- the slots into two team columns of half-width, double-height CustomLobbySlotCard cards. Each +-- slot's side comes from CustomLobbyRules.BuildSideResolver (start position for the positional +-- modes, start-spot parity for pvsi). +-- +-- "Two columns, unresolved": for a positional mode whose map / start positions aren't loaded yet the +-- resolver can't place anyone, so we still show both columns but fill them by slot-index parity and +-- withhold the side labels (Left / Right / …); once positions resolve, a Relayout snaps the cards to +-- their true sides and reveals the labels. +-- +-- It is a *layout body* under CustomLobbySlotsInterface: that selector owns the header and is the +-- rows' drag coordinator, so this body just builds the cards, places them by side, reveals the +-- active ones, and (with the module HeightForCount) reports how tall the taller column is. The +-- selector passes itself as the cards' coordinator. + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") + +local Group = import("/lua/maui/group.lua").Group +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") +local CustomLobbySessionModel = import("/lua/ui/lobby/customlobby/customlobbysessionmodel.lua") +local CustomLobbyRules = import("/lua/ui/lobby/customlobby/customlobbyrules.lua") +local CustomLobbySlotCard = import("/lua/ui/lobby/customlobby/slots/twocolumn/customlobbyslotcard.lua") + +local LazyVarDerive = import("/lua/lazyvar.lua").Derive +local Layouter = LayoutHelpers.ReusedLayoutFor + +local CardHeight = 48 +local CardGap = 2 +local ColumnGap = 8 -- the gutter between the two columns +local LabelHeight = 20 -- the per-column team label (shown only when resolved) +local LabelColor = 'ff8a909a' + +--- Assigns the visible slots (1..count) to the two team columns, in slot order. Returns `cols` +--- (`{ [1] = {slots…}, [2] = {slots…} }`) and `resolved` (false when a positional mode's start +--- positions aren't loaded — sides then fall back to slot-index parity and the labels are withheld). +---@param count number +---@return table cols +---@return boolean resolved +local function ComputeColumns(count) + local resolver, resolved = CustomLobbyRules.BuildSideResolver() + local cols = { {}, {} } + for slot = 1, count do + local side = resolved and resolver and resolver(slot) or nil + if side ~= 1 and side ~= 2 then + side = (math.mod(slot, 2) == 1) and 1 or 2 + end + table.insert(cols[side], slot) + end + return cols, resolved +end + +---@class UICustomLobbyTwoColumnSlots : Group +---@field Trash TrashBag +---@field Coordinator UICustomLobbySlotCoordinator +---@field Rows UICustomLobbySlotBase[] +---@field Columns Group[] # [1] = side A container, [2] = side B +---@field Labels Text[] # [1]/[2] per-column team labels +---@field Ready boolean +---@field SlotCountObserver LazyVar +---@field ScenarioObserver LazyVar +---@field GameOptionsObserver LazyVar +local CustomLobbyTwoColumnSlots = Class(Group) { + + ---@param self UICustomLobbyTwoColumnSlots + ---@param parent Control + ---@param coordinator UICustomLobbySlotCoordinator + __init = function(self, parent, coordinator) + Group.__init(self, parent, "CustomLobbyTwoColumnSlots") + + self.Trash = TrashBag() + self.Coordinator = coordinator + self.Ready = false + + self.Columns = { Group(self, "Col1"), Group(self, "Col2") } + self.Labels = { + UIUtil.CreateText(self.Columns[1], "", 12, UIUtil.titleFont), + UIUtil.CreateText(self.Columns[2], "", 12, UIUtil.titleFont), + } + for _, label in self.Labels do + label:SetColor(LabelColor) + label:DisableHitTest() + end + + self.Rows = {} + for slot = 1, CustomLobbyLaunchModel.MaxSlots do + self.Rows[slot] = CustomLobbySlotCard.Create(self, slot, coordinator) + end + + -- the side split depends on the mode + the map's start positions, so re-place on any of them + local launch = CustomLobbyLaunchModel.GetSingleton() + self.GameOptionsObserver = self.Trash:Add( + LazyVarDerive(launch.GameOptions, function(lazy) lazy(); self:Relayout() end)) + self.ScenarioObserver = self.Trash:Add( + LazyVarDerive(launch.ScenarioFile, function(lazy) lazy(); self:Relayout() end)) + self.SlotCountObserver = self.Trash:Add( + LazyVarDerive(CustomLobbySessionModel.GetSingleton().SlotCount, function(lazy) + lazy() + self:Relayout() + end)) + end, + + ---@param self UICustomLobbyTwoColumnSlots + __post_init = function(self) + -- two columns split down the middle with a small gutter (a column is the positioning frame + -- the cards anchor into; the side label sits at its top) + Layouter(self.Columns[1]):AtLeftIn(self):AtTopIn(self):AtBottomIn(self):End() + self.Columns[1].Right:Set(function() return self.Left() + 0.5 * self.Width() - LayoutHelpers.ScaleNumber(0.5 * ColumnGap) end) + Layouter(self.Columns[2]):AtRightIn(self):AtTopIn(self):AtBottomIn(self):End() + self.Columns[2].Left:Set(function() return self.Left() + 0.5 * self.Width() + LayoutHelpers.ScaleNumber(0.5 * ColumnGap) end) + + Layouter(self.Labels[1]):AtHorizontalCenterIn(self.Columns[1]):AtTopIn(self.Columns[1]):End() + Layouter(self.Labels[2]):AtHorizontalCenterIn(self.Columns[2]):AtTopIn(self.Columns[2]):End() + + self.Ready = true + self:Relayout() + end, + + --- Splits the active slots into the two columns and stacks each column's cards; shows the side + --- labels only when the sides are resolved. Re-run whenever the mode / map / slot count changes. + ---@param self UICustomLobbyTwoColumnSlots + Relayout = function(self) + if not self.Ready then + return + end + local count = CustomLobbySessionModel.GetSingleton().SlotCount() + local cols, resolved = ComputeColumns(count) + local labels = CustomLobbyRules.SideLabels(CustomLobbyRules.AutoTeamMode()) + local labelled = resolved and labels ~= nil + + for column = 1, 2 do + local label = self.Labels[column] + if labelled then + label:SetText(labels[column]) + label:Show() + else + label:Hide() + end + + -- stack this column's cards under the label (or the column top when unlabelled) + local prev = nil + for _, slot in cols[column] do + local card = self.Rows[slot] + local builder = Layouter(card):AtLeftIn(self.Columns[column]):AtRightIn(self.Columns[column]):Height(CardHeight) + if not prev then + if labelled then + builder:AnchorToBottom(label, 2) + else + builder:AtTopIn(self.Columns[column]) + end + else + builder:AnchorToBottom(prev, CardGap) + end + builder:End() + card:Show() + prev = card + end + end + + -- hide the rows past the active count (every slot 1..count landed in a column above) + for slot = count + 1, CustomLobbyLaunchModel.MaxSlots do + self.Rows[slot]:Hide() + end + end, + + ---@param self UICustomLobbyTwoColumnSlots + OnDestroy = function(self) + self.Trash:Destroy() + end, +} + +--- The (scaled) height of the taller team column for `count` slots — the selector adds the header. +--- Mirrors Relayout's split so the area is sized to what's actually drawn (label + stacked cards). +---@param count number +---@return number +HeightForCount = function(count) + local cols, resolved = ComputeColumns(count) + local rows = math.max(table.getn(cols[1]), table.getn(cols[2])) + local height = rows * LayoutHelpers.ScaleNumber(CardHeight) + if rows > 1 then + height = height + (rows - 1) * LayoutHelpers.ScaleNumber(CardGap) + end + if resolved then + height = height + LayoutHelpers.ScaleNumber(LabelHeight) + end + return height +end + +---@param parent Control +---@param coordinator UICustomLobbySlotCoordinator +---@return UICustomLobbyTwoColumnSlots +Create = function(parent, coordinator) + return CustomLobbyTwoColumnSlots(parent, coordinator) +end From d4e1ca7eb73eb4fee361cafefdf128feef0f5b3a Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Wed, 24 Jun 2026 23:45:49 +0200 Subject: [PATCH 48/98] Add separate layout for two columns --- lua/ui/lobby/customlobby/CLAUDE.md | 4 +- .../customlobby/CustomLobbyInterface.lua | 33 +++---- .../twocolumn/CustomLobbyTwoColumnSlots.lua | 96 +++++++------------ 3 files changed, 51 insertions(+), 82 deletions(-) diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index b0c026b4c54..1586594ea47 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -49,7 +49,7 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | [CustomLobbyMessages.lua](CustomLobbyMessages.lua) | message registry: `AddPlayer`, `SetPlayers` (launch model: players + observers), `SentLaunchInfo` (launch model: scenario / options / mods / teams / spawn mex), `SetSessionState` (session model: slot count / closed slots), `SetReady`, `TakeSlot`, `DisconnectPeer`, `LaunchGame` (host's final game config → `instance:LaunchGame`), `ReportCpuBenchmark`, `SetCpuBenchmarks`. | | [CustomLobbyContextMenu.lua](CustomLobbyContextMenu.lua) | generic framed floating menu; `Show(entries, x, y)` renders any `{label, action, enabled}` list, dismisses on item click / click-outside / Esc. Knows nothing about the lobby. | | [CustomLobbyMenus.lua](CustomLobbyMenus.lua) | declarative menu **definitions**: entry lists with `when(ctx)`/`action(ctx)` filtered by lobby state (`BuildSlotMenu`). Adding/state-gating an item is a one-liner here. | -| [slots/](slots/) | the **slot subsystem** (its own folder, a sub-folder per layout). [`CustomLobbySlotsInterface`](slots/CustomLobbySlotsInterface.lua) is the entry the composition root mounts: a "Players" header over the **active layout body**, picked by the AutoTeams mode — one-column for the non-team modes, the two-column team layout for the binary modes (left/right, top/bottom, even/odd). It observes `GameOptions` and swaps the body when the kind flips (create-on-mode / destroy-on-switch); a change *within* the binary modes is handled by the two-column body's own re-layout. It is the rows' **drag coordinator** (`UICustomLobbySlotCoordinator`: hit-test which row a point is in, drop-highlight, drag ghost → `RequestSwapSlots`) for *every* layout — it alone needs to hit-test across rows — so the layout bodies stay pure build/place/reveal and never duplicate the drag logic; it also exposes `PreferredHeight()` (computed from the mode + model, swap-order-independent) so the root sizes the slot area. [`CustomLobbySlotBase`](slots/CustomLobbySlotBase.lua) holds **all slot behaviour** (the slot + CPU subscriptions, the CPU-cap math, click/right-click/drag-to-swap + intents, the background/highlight/click overlays) and the default `RenderPlayer`/`RenderCpu` that paint the standard named controls from normalised views; a presentation subclasses it and implements just `CreateContents` / `LayoutContents`. [`onecolumn/`](slots/onecolumn/): [`CustomLobbyOneColumnSlots`](slots/onecolumn/CustomLobbyOneColumnSlots.lua) (stacks thin rows, reveals 1..count) + [`CustomLobbySlotRow`](slots/onecolumn/CustomLobbySlotRow.lua) (thin one-line presentation). [`twocolumn/`](slots/twocolumn/): [`CustomLobbyTwoColumnSlots`](slots/twocolumn/CustomLobbyTwoColumnSlots.lua) (splits slots into two team columns via [`CustomLobbyRules.BuildSideResolver`](CustomLobbyRules.lua), with per-column team labels and the "two columns, unresolved" fallback — parity fill + no labels until a positional map's start positions load) + [`CustomLobbySlotCard`](slots/twocolumn/CustomLobbySlotCard.lua) (fat half-width / two-line presentation). Each body exports `HeightForCount`. | +| [slots/](slots/) | the **slot subsystem** (its own folder, a sub-folder per layout). [`CustomLobbySlotsInterface`](slots/CustomLobbySlotsInterface.lua) is the entry the composition root mounts: a "Players" header over the **active layout body**, picked by the AutoTeams mode — one-column for the non-team modes, the two-column team layout for the binary modes (left/right, top/bottom, even/odd). It observes `GameOptions` and swaps the body when the kind flips (create-on-mode / destroy-on-switch); a change *within* the binary modes is handled by the two-column body's own re-layout. It is the rows' **drag coordinator** (`UICustomLobbySlotCoordinator`: hit-test which row a point is in, drop-highlight, drag ghost → `RequestSwapSlots`) for *every* layout — it alone needs to hit-test across rows — so the layout bodies stay pure build/place/reveal and never duplicate the drag logic; it also exposes `PreferredHeight()` (computed from the mode + model, swap-order-independent) so the root sizes the slot area. [`CustomLobbySlotBase`](slots/CustomLobbySlotBase.lua) holds **all slot behaviour** (the slot + CPU subscriptions, the CPU-cap math, click/right-click/drag-to-swap + intents, the background/highlight/click overlays) and the default `RenderPlayer`/`RenderCpu` that paint the standard named controls from normalised views; a presentation subclasses it and implements just `CreateContents` / `LayoutContents`. [`onecolumn/`](slots/onecolumn/): [`CustomLobbyOneColumnSlots`](slots/onecolumn/CustomLobbyOneColumnSlots.lua) (stacks thin rows, reveals 1..count) + [`CustomLobbySlotRow`](slots/onecolumn/CustomLobbySlotRow.lua) (thin one-line presentation). [`twocolumn/`](slots/twocolumn/): [`CustomLobbyTwoColumnSlots`](slots/twocolumn/CustomLobbyTwoColumnSlots.lua) (splits slots into two team columns via [`CustomLobbyRules.BuildSideResolver`](CustomLobbyRules.lua), with a [`CustomLobbyTeamScore`](CustomLobbyTeamScore.lua) strip across the top as the Left/Right indicator, and the "two columns, unresolved" fallback — parity fill + the score self-hides until a positional map's start positions load) + [`CustomLobbySlotCard`](slots/twocolumn/CustomLobbySlotCard.lua) (fat half-width / two-line presentation). Each body exports `HeightForCount`. | | [CustomLobbyObserversInterface.lua](CustomLobbyObserversInterface.lua) | observer strip; subscribes to the model's `Observers` list and shows the count + names (read-only). | | [CustomLobbyScenarioPreview.lua](CustomLobbyScenarioPreview.lua) | **shared** map-preview *surface*: the scenario's map texture + overlays (start spots, resource/wreck markers, plus a **dummy translucent `WaterMask`** placeholder until a real mask exists) with aspect-correct positioning, texture-leak-safe icon sharing, per-group visibility (`SetOverlayVisible('spawns'\|'resources'\|'wrecks'\|'water', …)`), and three-phase init. Chrome-free; `CustomLobbyMapPreview` wraps it with the frame. Spawn appearance is the owner's via a `CreateSpawnIcon` factory. | | [CustomLobbyMapPreview.lua](CustomLobbyMapPreview.lua) | the map preview **as one whole** — the chrome (glow border on top + dark backdrop, surface inset by `Padding`), the surface, and the faction spawn icon (local `MapPreviewSpawn`). Used by **both** consumers: created `Bound = true` it subscribes to the launch model and renders the committed `ScenarioFile` with per-slot faction spawns (no reload on take/swap); created unbound (the map-select dialog) it does no model wiring and the owner drives `preview.Surface` directly (numbered-dot spawns). Exposes `.Surface` for owners to drive / anchor overlays to. | @@ -57,7 +57,7 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | [modselect/](modselect/CLAUDE.md) | the **mod-select dialog** + its catalog and list, built to the map-select shape (checkbox list + type filters + detail panel + **presets**). Returns a uid set; the opener routes it — sim mods → `RequestSetGameMods` (synced), UI mods → local prefs — or persists the lot standalone. Mod domain logic lives in [`/lua/ui/modutilities.lua`](/lua/ui/modutilities.lua) (the `maputil.lua` sibling, fronting `/lua/mods.lua`). See [modselect/CLAUDE.md](modselect/CLAUDE.md). | | [optionselect/](optionselect/CLAUDE.md) | the **options dialog**: three columns (lobby / scenario / mod options) over the selected scenario + mods, with search + hide-defaults filters; non-default options are marked. Derives the option *schema* per-peer (reference data) via [`/lua/ui/optionutil.lua`](/lua/ui/optionutil.lua); edits a working copy of the *values* and on OK routes the reconciled set through `RequestSetGameOptions` (synced via `GameOptions`). Host-only. See [optionselect/CLAUDE.md](optionselect/CLAUDE.md). | | [CustomLobbyInterface.lua](CustomLobbyInterface.lua) | composition root, laid out in **areas** (flip the module `Debug` flag to tint them), sized for the **1024×768** floor. A **one-column** layout (the two-column variant was reverted by community request): a **title bar** (title · team score · Leave) over a **left column** split vertically — the **slots** on top (a single column of rows, up to 16, height tracking the slot count) and the **Chat / Observers** tabs below — beside a fixed-width **right column** (the config component: a pinned map preview + facts line over Options / Mods / Restrictions tabs). A full-width **action bar** at the bottom holds the global actions: status + the host-only **Settings** button (opens the options editor) + the host-only **Launch**. Reads IsHost (action-bar buttons) + SlotCount (to size the slot area via `CustomLobbySlotsInterface.HeightForSlots`); Leave (Esc handler) + **Become observer** (`RequestMoveToObserver`, in the Observers tab). The slot rows + their drag coordination now live in [`CustomLobbySlotsInterface`](CustomLobbySlotsInterface.lua), which fills the slot area. `OpenDebug()` / hot-reload. | -| [CustomLobbyTeamScore.lua](CustomLobbyTeamScore.lua) | the **accumulated team rating** in the title — `Side A N · M Side B`. Shown only for the binary auto-team formations (`tvsb`→Top/Bottom, `lvsr`→Left/Right by start position; `pvsi`→Odd/Even by start-spot parity); **hidden** for `none`/`manual` (no 2-side split) or until a map's start positions are loaded. Reads the mode from `GameOptions().AutoTeams`, the ratings from each slot's `PL`. Reference data; never writes. | +| [CustomLobbyTeamScore.lua](CustomLobbyTeamScore.lua) | the **accumulated team rating** side indicator — `Side A N · M Side B`. Hosted in the strip atop the two-column slot layout (it doubles as the columns' Left/Right header). Shown only for the binary auto-team formations; **hidden** for `none`/`manual` or until a positional map's start positions load. Reads the mode + side split from [`CustomLobbyRules`](CustomLobbyRules.lua) (`AutoTeamMode` / `SideLabels` / `BuildSideResolver`) and the ratings from each slot's `PL`. Reference data; never writes. | | [CustomLobbyTabs.lua](CustomLobbyTabs.lua) | a **generic tabbed panel** (strip + content; one panel alive, created on select / destroyed on switch). Construct with a `{ Label, Create }` list + optional `OnSelect`. Used for the bottom-left (Chat / Observers); the config interface keeps its own bespoke variant (it coordinates the persistent preview). | | [social/](social/) | the lobby's **bottom-left** column (the `CustomLobbyTabs` content): [`CustomLobbyChatPanel`](social/CustomLobbyChatPanel.lua) (the **Chat** tab — placeholder until the chat slice lands) and [`CustomLobbyObserversPanel`](social/CustomLobbyObserversPanel.lua) (the **Observers** tab — the shared observer list + a host-authoritative **Become observer** button → `RequestMoveToObserver`). Each is a tab content component (`Create(parent)`, created on select / destroyed on switch). | | [config/](config/) | the lobby's **right** column. [`CustomLobbyConfigInterface.lua`](config/CustomLobbyConfigInterface.lua) is the column **composition**: a bound square `CustomLobbyMapPreview` **pinned** at the top with a vertical **preview tool strip** to its right (local `PreviewTool` icon buttons — toggles for army/start icons, mass+hydro deposits and the dummy water mask, driving `preview.Surface:SetOverlayVisible`, + a host-only **change-map** config icon at the bottom → the map-select dialog), a name + size/players/version facts line under it, and a [`CustomLobbyTabs`](CustomLobbyTabs.lua) (**Options / Mods / Restrictions**) filling the rest. All three tab panels are now **read-only**: their per-domain action buttons (open editor / reset / manage mods) are removed — the grid/content fills the whole panel — and the interface's action-bar **Settings** button is the only edit entry point for now. [`CustomLobbyOptionsPanel`](config/CustomLobbyOptionsPanel.lua) (options grouped **Lobby / Scenario / Mods** + hide-defaults toggle; map/mod options gold-flagged with an origin tooltip — schema via [`/lua/ui/optionutil.lua`](/lua/ui/optionutil.lua)), [`CustomLobbyModsPanel`](config/CustomLobbyModsPanel.lua) (enabled mods in **Game / UI** sections), [`CustomLobbyUnitsPanel`](config/CustomLobbyUnitsPanel.lua) (the **Restrictions** placeholder). Each self-subscribes to the model and exposes `Initialize()` + `Create(parent)`. **Parked** (built, but unwired): [`CustomLobbyMapPanel`](config/CustomLobbyMapPanel.lua) (the full Map tab — preview + label/value details + Change-map), now superseded by the pinned preview. Churning a preview is safe here because the lobby shows only **one** current map and the engine caches map textures by name (the texture-leak rule only bites the *map-select dialog* — see [mapselect/CLAUDE.md](mapselect/CLAUDE.md)). | diff --git a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua index 87c62b55fdb..02dbfea6ff6 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua @@ -29,10 +29,10 @@ -- module-level `Debug` flag to tint each so the regions are visible while iterating. Targeted at -- the 1024x768 minimum resolution: -- --- ┌ TitleArea ─ title · TEAM SCORE · leave ───────────────────────────────────┐ +-- ┌ TitleArea ─ title · leave ─────────────────────────────────────────────────┐ -- ├──────────────────────────────────────┬─────────────────────────────────────┤ --- │ SlotsArea (slots — ONE column, │ RightArea (the map preview + facts │ --- │ top-left, up to 16) │ line + read-only options summary) │ +-- │ SlotsArea (slots — one or two team │ RightArea (the map preview + facts │ +-- │ columns, top-left, up to 16) │ line + read-only options summary) │ -- ├──────────────────────────────────────┤ │ -- │ BottomLeftArea (Chat / Observers │ │ -- │ — tabs) │ │ @@ -40,13 +40,13 @@ -- │ ActionArea (status · … · Settings · Launch) ─ full width │ -- └────────────────────────────────────────────────────────────────────────────┘ -- --- A one-column layout (the two-column variant was reverted by community request). The LEFT column --- splits vertically: the slot rows on top (one column, up to 16), the chat/observers tabs --- (CustomLobbyTabs) below. The RIGHT column is the map + options (CustomLobbyConfigInterface — a --- bound map preview, a name/size/players/version facts line, and the read-only options summary). A --- full-width action bar at the bottom holds the global actions (status + the generic Settings --- button, which opens the options editor, + the host-only Launch). The accumulated team rating --- (CustomLobbyTeamScore) sits in the title, shown only for the binary auto-team formations. +-- The LEFT column splits vertically: the slots on top (CustomLobbySlotsInterface — one column, or +-- two team columns for the binary auto-team modes, with the team-rating indicator atop the cards), +-- the chat/observers tabs (CustomLobbyTabs) below. The RIGHT column is the map + options +-- (CustomLobbyConfigInterface — a bound map preview, a name/size/players/version facts line, and the +-- read-only options summary). A full-width action bar at the bottom holds the global actions (status +-- + the generic Settings button, which opens the options editor, + the host-only Launch). The title +-- bar is just the title + Leave. -- -- The per-domain edit buttons (change-map, mod-select) are removed for now — only the generic -- Settings button remains — and will be reintegrated once the rework is complete. @@ -64,7 +64,6 @@ local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/customlobbylocal local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") local CustomLobbySlotsInterface = import("/lua/ui/lobby/customlobby/slots/customlobbyslotsinterface.lua") local CustomLobbyConfigInterface = import("/lua/ui/lobby/customlobby/config/customlobbyconfiginterface.lua") -local CustomLobbyTeamScore = import("/lua/ui/lobby/customlobby/customlobbyteamscore.lua") local CustomLobbyTabs = import("/lua/ui/lobby/customlobby/customlobbytabs.lua") local CustomLobbyChatPanel = import("/lua/ui/lobby/customlobby/social/customlobbychatpanel.lua") local CustomLobbyObserversPanel = import("/lua/ui/lobby/customlobby/social/customlobbyobserverspanel.lua") @@ -110,7 +109,6 @@ end ---@field Content Group ---@field TitleArea Group ---@field Title Text ----@field TeamScore UICustomLobbyTeamScore ---@field LeaveButton Button ---@field SlotsArea Group ---@field Slots UICustomLobbySlotsInterface @@ -147,12 +145,10 @@ local CustomLobbyInterface = Class(Group) { self.ActionArea = CreateArea(self.Content, "ActionArea", 'ff808080') --#endregion - --#region title bar (title · team score · leave) + --#region title bar (title · leave) self.Title = UIUtil.CreateText(self.TitleArea, "Custom game", 20, UIUtil.titleFont) self.Title:DisableHitTest() - self.TeamScore = CustomLobbyTeamScore.Create(self.TitleArea) - self.LeaveButton = UIUtil.CreateButtonWithDropshadow(self.TitleArea, '/BUTTON/medium/', "Leave") self.LeaveButton.OnClick = function(button, modifiers) -- leaving disconnects + returns to the menu via the escape handler lobby.lua @@ -244,13 +240,9 @@ local CustomLobbyInterface = Class(Group) { self.BottomLeftArea.Right:Set(function() return self.RightArea.Left() - LayoutHelpers.ScaleNumber(Pad) end) --#endregion - --#region title bar (title · team score · leave) + --#region title bar (title · leave) Layouter(self.Title):AtLeftIn(self.TitleArea, 8):AtVerticalCenterIn(self.TitleArea):End() Layouter(self.LeaveButton):AtRightIn(self.TitleArea):AtVerticalCenterIn(self.TitleArea):End() - Layouter(self.TeamScore) - :AnchorToRight(self.Title, Pad):AnchorToLeft(self.LeaveButton, Pad) - :AtTopIn(self.TitleArea):AtBottomIn(self.TitleArea) - :End() --#endregion --#region slots fill their area (the component stacks the rows + coordinates dragging) @@ -273,7 +265,6 @@ local CustomLobbyInterface = Class(Group) { -- size-dependent children build their scrollbars / first render now that they're sized -- (three-phase init) - self.TeamScore:Initialize() self.BottomLeftTabs:Initialize() self.Config:Initialize() end, diff --git a/lua/ui/lobby/customlobby/slots/twocolumn/CustomLobbyTwoColumnSlots.lua b/lua/ui/lobby/customlobby/slots/twocolumn/CustomLobbyTwoColumnSlots.lua index 115e0aafb13..e8efa2909cc 100644 --- a/lua/ui/lobby/customlobby/slots/twocolumn/CustomLobbyTwoColumnSlots.lua +++ b/lua/ui/lobby/customlobby/slots/twocolumn/CustomLobbyTwoColumnSlots.lua @@ -25,23 +25,25 @@ -- slot's side comes from CustomLobbyRules.BuildSideResolver (start position for the positional -- modes, start-spot parity for pvsi). -- --- "Two columns, unresolved": for a positional mode whose map / start positions aren't loaded yet the --- resolver can't place anyone, so we still show both columns but fill them by slot-index parity and --- withhold the side labels (Left / Right / …); once positions resolve, a Relayout snaps the cards to --- their true sides and reveals the labels. +-- The CustomLobbyTeamScore widget sits in a strip across the top — it *is* the side indicator +-- (`Left 3150 · 3025 Right`), so it doubles as the columns' header. It self-hides for the +-- "two columns, unresolved" case (a positional mode whose map / start positions aren't loaded yet), +-- where we still show both columns but fill them by slot-index parity; once positions resolve a +-- Relayout snaps the cards to their true sides and the score reappears. The strip is reserved either +-- way, so the cards don't jump when it appears. -- --- It is a *layout body* under CustomLobbySlotsInterface: that selector owns the header and is the --- rows' drag coordinator, so this body just builds the cards, places them by side, reveals the --- active ones, and (with the module HeightForCount) reports how tall the taller column is. The +-- It is a *layout body* under CustomLobbySlotsInterface: that selector owns the "Players" header and +-- is the rows' drag coordinator, so this body just builds the cards, places them by side, reveals +-- the active ones, and (with the module HeightForCount) reports how tall the taller column is. The -- selector passes itself as the cards' coordinator. -local UIUtil = import("/lua/ui/uiutil.lua") local LayoutHelpers = import("/lua/maui/layouthelpers.lua") local Group = import("/lua/maui/group.lua").Group local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") local CustomLobbySessionModel = import("/lua/ui/lobby/customlobby/customlobbysessionmodel.lua") local CustomLobbyRules = import("/lua/ui/lobby/customlobby/customlobbyrules.lua") +local CustomLobbyTeamScore = import("/lua/ui/lobby/customlobby/customlobbyteamscore.lua") local CustomLobbySlotCard = import("/lua/ui/lobby/customlobby/slots/twocolumn/customlobbyslotcard.lua") local LazyVarDerive = import("/lua/lazyvar.lua").Derive @@ -50,15 +52,13 @@ local Layouter = LayoutHelpers.ReusedLayoutFor local CardHeight = 48 local CardGap = 2 local ColumnGap = 8 -- the gutter between the two columns -local LabelHeight = 20 -- the per-column team label (shown only when resolved) -local LabelColor = 'ff8a909a' +local ScoreHeight = 26 -- the team-score strip across the top (the side indicator) ---- Assigns the visible slots (1..count) to the two team columns, in slot order. Returns `cols` ---- (`{ [1] = {slots…}, [2] = {slots…} }`) and `resolved` (false when a positional mode's start ---- positions aren't loaded — sides then fall back to slot-index parity and the labels are withheld). +--- Assigns the visible slots (1..count) to the two team columns, in slot order. Sides come from the +--- shared resolver; an unresolvable slot (a positional mode whose positions aren't loaded, or a slot +--- past the map's start spots) falls back to slot-index parity so both columns still populate. ---@param count number ----@return table cols ----@return boolean resolved +---@return table cols # { [1] = {slots…}, [2] = {slots…} } local function ComputeColumns(count) local resolver, resolved = CustomLobbyRules.BuildSideResolver() local cols = { {}, {} } @@ -69,15 +69,15 @@ local function ComputeColumns(count) end table.insert(cols[side], slot) end - return cols, resolved + return cols end ---@class UICustomLobbyTwoColumnSlots : Group ---@field Trash TrashBag ---@field Coordinator UICustomLobbySlotCoordinator ---@field Rows UICustomLobbySlotBase[] ----@field Columns Group[] # [1] = side A container, [2] = side B ----@field Labels Text[] # [1]/[2] per-column team labels +---@field TeamScore UICustomLobbyTeamScore # the side indicator strip atop the columns +---@field Columns Group[] # [1] = side A container, [2] = side B ---@field Ready boolean ---@field SlotCountObserver LazyVar ---@field ScenarioObserver LazyVar @@ -94,15 +94,11 @@ local CustomLobbyTwoColumnSlots = Class(Group) { self.Coordinator = coordinator self.Ready = false + -- the side indicator (Left/Top/Odd · ratings · Right/Bottom/Even); self-manages its content + -- and visibility (it hides when the sides can't be determined yet) + self.TeamScore = CustomLobbyTeamScore.Create(self) + self.Columns = { Group(self, "Col1"), Group(self, "Col2") } - self.Labels = { - UIUtil.CreateText(self.Columns[1], "", 12, UIUtil.titleFont), - UIUtil.CreateText(self.Columns[2], "", 12, UIUtil.titleFont), - } - for _, label in self.Labels do - label:SetColor(LabelColor) - label:DisableHitTest() - end self.Rows = {} for slot = 1, CustomLobbyLaunchModel.MaxSlots do @@ -124,52 +120,37 @@ local CustomLobbyTwoColumnSlots = Class(Group) { ---@param self UICustomLobbyTwoColumnSlots __post_init = function(self) - -- two columns split down the middle with a small gutter (a column is the positioning frame - -- the cards anchor into; the side label sits at its top) - Layouter(self.Columns[1]):AtLeftIn(self):AtTopIn(self):AtBottomIn(self):End() + -- the team-score strip spans the top; the two columns fill the rest, split down the middle + -- with a small gutter (a column is the positioning frame the cards anchor into) + Layouter(self.TeamScore):AtLeftIn(self):AtRightIn(self):AtTopIn(self):Height(ScoreHeight):End() + + Layouter(self.Columns[1]):AtLeftIn(self):AnchorToBottom(self.TeamScore, 2):AtBottomIn(self):End() self.Columns[1].Right:Set(function() return self.Left() + 0.5 * self.Width() - LayoutHelpers.ScaleNumber(0.5 * ColumnGap) end) - Layouter(self.Columns[2]):AtRightIn(self):AtTopIn(self):AtBottomIn(self):End() + Layouter(self.Columns[2]):AtRightIn(self):AnchorToBottom(self.TeamScore, 2):AtBottomIn(self):End() self.Columns[2].Left:Set(function() return self.Left() + 0.5 * self.Width() + LayoutHelpers.ScaleNumber(0.5 * ColumnGap) end) - Layouter(self.Labels[1]):AtHorizontalCenterIn(self.Columns[1]):AtTopIn(self.Columns[1]):End() - Layouter(self.Labels[2]):AtHorizontalCenterIn(self.Columns[2]):AtTopIn(self.Columns[2]):End() - self.Ready = true + self.TeamScore:Initialize() self:Relayout() end, - --- Splits the active slots into the two columns and stacks each column's cards; shows the side - --- labels only when the sides are resolved. Re-run whenever the mode / map / slot count changes. + --- Splits the active slots into the two columns and stacks each column's cards from its top; + --- re-run whenever the mode / map / slot count changes. ---@param self UICustomLobbyTwoColumnSlots Relayout = function(self) if not self.Ready then return end local count = CustomLobbySessionModel.GetSingleton().SlotCount() - local cols, resolved = ComputeColumns(count) - local labels = CustomLobbyRules.SideLabels(CustomLobbyRules.AutoTeamMode()) - local labelled = resolved and labels ~= nil + local cols = ComputeColumns(count) for column = 1, 2 do - local label = self.Labels[column] - if labelled then - label:SetText(labels[column]) - label:Show() - else - label:Hide() - end - - -- stack this column's cards under the label (or the column top when unlabelled) local prev = nil for _, slot in cols[column] do local card = self.Rows[slot] local builder = Layouter(card):AtLeftIn(self.Columns[column]):AtRightIn(self.Columns[column]):Height(CardHeight) if not prev then - if labelled then - builder:AnchorToBottom(label, 2) - else - builder:AtTopIn(self.Columns[column]) - end + builder:AtTopIn(self.Columns[column]) else builder:AnchorToBottom(prev, CardGap) end @@ -191,20 +172,17 @@ local CustomLobbyTwoColumnSlots = Class(Group) { end, } ---- The (scaled) height of the taller team column for `count` slots — the selector adds the header. ---- Mirrors Relayout's split so the area is sized to what's actually drawn (label + stacked cards). +--- The (scaled) height of the score strip plus the taller team column for `count` slots — the +--- selector adds the "Players" header. Mirrors Relayout's split so the area fits what's drawn. ---@param count number ---@return number HeightForCount = function(count) - local cols, resolved = ComputeColumns(count) + local cols = ComputeColumns(count) local rows = math.max(table.getn(cols[1]), table.getn(cols[2])) - local height = rows * LayoutHelpers.ScaleNumber(CardHeight) + local height = LayoutHelpers.ScaleNumber(ScoreHeight) + rows * LayoutHelpers.ScaleNumber(CardHeight) if rows > 1 then height = height + (rows - 1) * LayoutHelpers.ScaleNumber(CardGap) end - if resolved then - height = height + LayoutHelpers.ScaleNumber(LabelHeight) - end return height end From 171c99fa4bf24717c7dc16e4b99bc6a1ccbc8870 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Wed, 24 Jun 2026 23:57:27 +0200 Subject: [PATCH 49/98] Make cards face each other --- lua/ui/lobby/customlobby/CLAUDE.md | 2 +- .../slots/twocolumn/CustomLobbySlotCard.lua | 78 +++++++++++++++---- .../twocolumn/CustomLobbyTwoColumnSlots.lua | 3 +- 3 files changed, 68 insertions(+), 15 deletions(-) diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index 1586594ea47..3730403e973 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -49,7 +49,7 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | [CustomLobbyMessages.lua](CustomLobbyMessages.lua) | message registry: `AddPlayer`, `SetPlayers` (launch model: players + observers), `SentLaunchInfo` (launch model: scenario / options / mods / teams / spawn mex), `SetSessionState` (session model: slot count / closed slots), `SetReady`, `TakeSlot`, `DisconnectPeer`, `LaunchGame` (host's final game config → `instance:LaunchGame`), `ReportCpuBenchmark`, `SetCpuBenchmarks`. | | [CustomLobbyContextMenu.lua](CustomLobbyContextMenu.lua) | generic framed floating menu; `Show(entries, x, y)` renders any `{label, action, enabled}` list, dismisses on item click / click-outside / Esc. Knows nothing about the lobby. | | [CustomLobbyMenus.lua](CustomLobbyMenus.lua) | declarative menu **definitions**: entry lists with `when(ctx)`/`action(ctx)` filtered by lobby state (`BuildSlotMenu`). Adding/state-gating an item is a one-liner here. | -| [slots/](slots/) | the **slot subsystem** (its own folder, a sub-folder per layout). [`CustomLobbySlotsInterface`](slots/CustomLobbySlotsInterface.lua) is the entry the composition root mounts: a "Players" header over the **active layout body**, picked by the AutoTeams mode — one-column for the non-team modes, the two-column team layout for the binary modes (left/right, top/bottom, even/odd). It observes `GameOptions` and swaps the body when the kind flips (create-on-mode / destroy-on-switch); a change *within* the binary modes is handled by the two-column body's own re-layout. It is the rows' **drag coordinator** (`UICustomLobbySlotCoordinator`: hit-test which row a point is in, drop-highlight, drag ghost → `RequestSwapSlots`) for *every* layout — it alone needs to hit-test across rows — so the layout bodies stay pure build/place/reveal and never duplicate the drag logic; it also exposes `PreferredHeight()` (computed from the mode + model, swap-order-independent) so the root sizes the slot area. [`CustomLobbySlotBase`](slots/CustomLobbySlotBase.lua) holds **all slot behaviour** (the slot + CPU subscriptions, the CPU-cap math, click/right-click/drag-to-swap + intents, the background/highlight/click overlays) and the default `RenderPlayer`/`RenderCpu` that paint the standard named controls from normalised views; a presentation subclasses it and implements just `CreateContents` / `LayoutContents`. [`onecolumn/`](slots/onecolumn/): [`CustomLobbyOneColumnSlots`](slots/onecolumn/CustomLobbyOneColumnSlots.lua) (stacks thin rows, reveals 1..count) + [`CustomLobbySlotRow`](slots/onecolumn/CustomLobbySlotRow.lua) (thin one-line presentation). [`twocolumn/`](slots/twocolumn/): [`CustomLobbyTwoColumnSlots`](slots/twocolumn/CustomLobbyTwoColumnSlots.lua) (splits slots into two team columns via [`CustomLobbyRules.BuildSideResolver`](CustomLobbyRules.lua), with a [`CustomLobbyTeamScore`](CustomLobbyTeamScore.lua) strip across the top as the Left/Right indicator, and the "two columns, unresolved" fallback — parity fill + the score self-hides until a positional map's start positions load) + [`CustomLobbySlotCard`](slots/twocolumn/CustomLobbySlotCard.lua) (fat half-width / two-line presentation). Each body exports `HeightForCount`. | +| [slots/](slots/) | the **slot subsystem** (its own folder, a sub-folder per layout). [`CustomLobbySlotsInterface`](slots/CustomLobbySlotsInterface.lua) is the entry the composition root mounts: a "Players" header over the **active layout body**, picked by the AutoTeams mode — one-column for the non-team modes, the two-column team layout for the binary modes (left/right, top/bottom, even/odd). It observes `GameOptions` and swaps the body when the kind flips (create-on-mode / destroy-on-switch); a change *within* the binary modes is handled by the two-column body's own re-layout. It is the rows' **drag coordinator** (`UICustomLobbySlotCoordinator`: hit-test which row a point is in, drop-highlight, drag ghost → `RequestSwapSlots`) for *every* layout — it alone needs to hit-test across rows — so the layout bodies stay pure build/place/reveal and never duplicate the drag logic; it also exposes `PreferredHeight()` (computed from the mode + model, swap-order-independent) so the root sizes the slot area. [`CustomLobbySlotBase`](slots/CustomLobbySlotBase.lua) holds **all slot behaviour** (the slot + CPU subscriptions, the CPU-cap math, click/right-click/drag-to-swap + intents, the background/highlight/click overlays) and the default `RenderPlayer`/`RenderCpu` that paint the standard named controls from normalised views; a presentation subclasses it and implements just `CreateContents` / `LayoutContents`. [`onecolumn/`](slots/onecolumn/): [`CustomLobbyOneColumnSlots`](slots/onecolumn/CustomLobbyOneColumnSlots.lua) (stacks thin rows, reveals 1..count) + [`CustomLobbySlotRow`](slots/onecolumn/CustomLobbySlotRow.lua) (thin one-line presentation). [`twocolumn/`](slots/twocolumn/): [`CustomLobbyTwoColumnSlots`](slots/twocolumn/CustomLobbyTwoColumnSlots.lua) (splits slots into two team columns via [`CustomLobbyRules.BuildSideResolver`](CustomLobbyRules.lua), with a [`CustomLobbyTeamScore`](CustomLobbyTeamScore.lua) strip across the top as the Left/Right indicator, and the "two columns, unresolved" fallback — parity fill + the score self-hides until a positional map's start positions load) + [`CustomLobbySlotCard`](slots/twocolumn/CustomLobbySlotCard.lua) (fat half-width / two-line presentation; `SetMirrored` flips the right column's cards so the two teams face each other). Each body exports `HeightForCount`. | | [CustomLobbyObserversInterface.lua](CustomLobbyObserversInterface.lua) | observer strip; subscribes to the model's `Observers` list and shows the count + names (read-only). | | [CustomLobbyScenarioPreview.lua](CustomLobbyScenarioPreview.lua) | **shared** map-preview *surface*: the scenario's map texture + overlays (start spots, resource/wreck markers, plus a **dummy translucent `WaterMask`** placeholder until a real mask exists) with aspect-correct positioning, texture-leak-safe icon sharing, per-group visibility (`SetOverlayVisible('spawns'\|'resources'\|'wrecks'\|'water', …)`), and three-phase init. Chrome-free; `CustomLobbyMapPreview` wraps it with the frame. Spawn appearance is the owner's via a `CreateSpawnIcon` factory. | | [CustomLobbyMapPreview.lua](CustomLobbyMapPreview.lua) | the map preview **as one whole** — the chrome (glow border on top + dark backdrop, surface inset by `Padding`), the surface, and the faction spawn icon (local `MapPreviewSpawn`). Used by **both** consumers: created `Bound = true` it subscribes to the launch model and renders the committed `ScenarioFile` with per-slot faction spawns (no reload on take/swap); created unbound (the map-select dialog) it does no model wiring and the owner drives `preview.Surface` directly (numbered-dot spawns). Exposes `.Surface` for owners to drive / anchor overlays to. | diff --git a/lua/ui/lobby/customlobby/slots/twocolumn/CustomLobbySlotCard.lua b/lua/ui/lobby/customlobby/slots/twocolumn/CustomLobbySlotCard.lua index 84078f3282a..381a088ecbd 100644 --- a/lua/ui/lobby/customlobby/slots/twocolumn/CustomLobbySlotCard.lua +++ b/lua/ui/lobby/customlobby/slots/twocolumn/CustomLobbySlotCard.lua @@ -23,10 +23,14 @@ -- The fat slot presentation: a half-width, double-height card used by the two-column team layout. -- The same data as the thin row, reflowed onto two lines so it reads in a narrow column — -- --- ┌──────────────────────────┐ --- │ ▣ PlayerName ready │ line 1: colour swatch · name · ready --- │ Cybran · T1 1.4k ▢ │ line 2: faction · team · cpu (+ headroom square) --- └──────────────────────────┘ +-- left column right column (mirrored, via SetMirrored) +-- ┌──────────────────────────┐ ┌──────────────────────────┐ +-- │ ▣ PlayerName ready │ │ ready PlayerName ▣ │ line 1: swatch · name · ready +-- │ Cybran · T1 1.4k ▢ │ │ ▢ 1.4k T1 · Cybran │ line 2: faction · team · cpu +-- └──────────────────────────┘ └──────────────────────────┘ +-- +-- The right column is laid out mirrored so the two teams face each other (the leading edge — +-- swatch + name, faction — sits on the inner side). -- -- It is pure arrangement over CustomLobbySlotBase: it builds the standard named controls and lays -- them out; the base paints them and owns all behaviour (subscriptions, CPU math, drag-to-swap, @@ -40,6 +44,16 @@ local CustomLobbySlotBase = import("/lua/ui/lobby/customlobby/slots/customlobbys local Layouter = LayoutHelpers.ReusedLayoutFor +--- Restores a control's Left/Right to the default circular relationship, clearing whichever edge a +--- previous layout pass pinned. Re-mirroring re-anchors the *opposite* horizontal edge, so without +--- this the old pin survives and the control stays stuck to its old side. Leaves Width/Height alone +--- (unlike Control.ResetLayout), so a Text keeps its intrinsic auto-width. +---@param control Control +local function ResetHorizontalEdges(control) + control.Left:Set(function() return control.Right() - control.Width() end) + control.Right:Set(function() return control.Left() + control.Width() end) +end + ---@class UICustomLobbySlotCard : UICustomLobbySlotBase ---@field ColorSwatch Bitmap ---@field Name Text @@ -49,10 +63,13 @@ local Layouter = LayoutHelpers.ReusedLayoutFor ---@field Cpu Text ---@field CpuIndicator Bitmap ---@field CpuHover Bitmap +---@field Mirrored boolean # right-column cards lay out mirrored so the two teams face each other local CustomLobbySlotCard = Class(CustomLobbySlotBase) { ---@param self UICustomLobbySlotCard CreateContents = function(self) + self.Mirrored = false + self.ColorSwatch = Bitmap(self) self.ColorSwatch:SetSolidColor('00000000') self.Name = UIUtil.CreateText(self, "", 14, UIUtil.bodyFont) @@ -73,21 +90,56 @@ local CustomLobbySlotCard = Class(CustomLobbySlotBase) { end end, + --- Lays the card out, mirrored for a right-column card so the leading edge (swatch + name, + --- faction) is on the *inner* side and the two teams face each other. Texts auto-size their + --- width, so anchoring the opposite edge right-aligns them — no manual measuring needed. ---@param self UICustomLobbySlotCard LayoutContents = function(self) - -- line 1 (top): swatch · name … ready - Layouter(self.ColorSwatch):AtLeftIn(self, 6):AtTopIn(self, 7):Width(14):Height(14):End() - Layouter(self.Name):AnchorToRight(self.ColorSwatch, 6):AtVerticalCenterIn(self.ColorSwatch):End() - Layouter(self.Ready):AtRightIn(self, 6):AtVerticalCenterIn(self.ColorSwatch):End() + -- clear any horizontal edge a previous (opposite-orientation) pass pinned, so only the new + -- anchor binds each control (the vertical anchors don't change between orientations) + for _, control in { self.ColorSwatch, self.Name, self.Ready, self.Faction, self.Team, self.Cpu, self.CpuIndicator } do + ResetHorizontalEdges(control) + end + + if self.Mirrored then + -- line 1 (top): ready … name · swatch + Layouter(self.ColorSwatch):AtRightIn(self, 6):AtTopIn(self, 7):Width(14):Height(14):End() + Layouter(self.Name):AnchorToLeft(self.ColorSwatch, 6):AtVerticalCenterIn(self.ColorSwatch):End() + Layouter(self.Ready):AtLeftIn(self, 6):AtVerticalCenterIn(self.ColorSwatch):End() - -- line 2 (bottom): faction · team … [indicator] cpu - Layouter(self.Faction):AtLeftIn(self, 6):AtBottomIn(self, 7):End() - Layouter(self.Team):AnchorToRight(self.Faction, 8):AtVerticalCenterIn(self.Faction):End() - Layouter(self.Cpu):AtRightIn(self, 6):AtVerticalCenterIn(self.Faction):End() - Layouter(self.CpuIndicator):AnchorToLeft(self.Cpu, 5):AtVerticalCenterIn(self.Faction):Width(8):Height(12):End() + -- line 2 (bottom): cpu [indicator] … team · faction + Layouter(self.Faction):AtRightIn(self, 6):AtBottomIn(self, 7):End() + Layouter(self.Team):AnchorToLeft(self.Faction, 8):AtVerticalCenterIn(self.Faction):End() + Layouter(self.Cpu):AtLeftIn(self, 6):AtVerticalCenterIn(self.Faction):End() + Layouter(self.CpuIndicator):AnchorToRight(self.Cpu, 5):AtVerticalCenterIn(self.Faction):Width(8):Height(12):End() + else + -- line 1 (top): swatch · name … ready + Layouter(self.ColorSwatch):AtLeftIn(self, 6):AtTopIn(self, 7):Width(14):Height(14):End() + Layouter(self.Name):AnchorToRight(self.ColorSwatch, 6):AtVerticalCenterIn(self.ColorSwatch):End() + Layouter(self.Ready):AtRightIn(self, 6):AtVerticalCenterIn(self.ColorSwatch):End() + + -- line 2 (bottom): faction · team … [indicator] cpu + Layouter(self.Faction):AtLeftIn(self, 6):AtBottomIn(self, 7):End() + Layouter(self.Team):AnchorToRight(self.Faction, 8):AtVerticalCenterIn(self.Faction):End() + Layouter(self.Cpu):AtRightIn(self, 6):AtVerticalCenterIn(self.Faction):End() + Layouter(self.CpuIndicator):AnchorToLeft(self.Cpu, 5):AtVerticalCenterIn(self.Faction):Width(8):Height(12):End() + end Layouter(self.CpuHover):Fill(self.Cpu):Over(self, 20):End() end, + + --- Sets the mirror state (the two-column layout calls this per the card's column) and re-lays the + --- contents if it changed. + ---@param self UICustomLobbySlotCard + ---@param mirrored boolean + SetMirrored = function(self, mirrored) + mirrored = mirrored or false + if self.Mirrored == mirrored then + return + end + self.Mirrored = mirrored + self:LayoutContents() + end, } ---@param parent Control diff --git a/lua/ui/lobby/customlobby/slots/twocolumn/CustomLobbyTwoColumnSlots.lua b/lua/ui/lobby/customlobby/slots/twocolumn/CustomLobbyTwoColumnSlots.lua index e8efa2909cc..7d2f03cf2c7 100644 --- a/lua/ui/lobby/customlobby/slots/twocolumn/CustomLobbyTwoColumnSlots.lua +++ b/lua/ui/lobby/customlobby/slots/twocolumn/CustomLobbyTwoColumnSlots.lua @@ -75,7 +75,7 @@ end ---@class UICustomLobbyTwoColumnSlots : Group ---@field Trash TrashBag ---@field Coordinator UICustomLobbySlotCoordinator ----@field Rows UICustomLobbySlotBase[] +---@field Rows UICustomLobbySlotCard[] ---@field TeamScore UICustomLobbyTeamScore # the side indicator strip atop the columns ---@field Columns Group[] # [1] = side A container, [2] = side B ---@field Ready boolean @@ -148,6 +148,7 @@ local CustomLobbyTwoColumnSlots = Class(Group) { local prev = nil for _, slot in cols[column] do local card = self.Rows[slot] + card:SetMirrored(column == 2) -- the right column faces inward (mirrored) local builder = Layouter(card):AtLeftIn(self.Columns[column]):AtRightIn(self.Columns[column]):Height(CardHeight) if not prev then builder:AtTopIn(self.Columns[column]) From a9155e0f62c4b7853f45d8a6f6760ff857f5d72a Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Thu, 25 Jun 2026 07:34:02 +0200 Subject: [PATCH 50/98] Add config buttons and badges to tabs --- lua/ui/lobby/customlobby/CLAUDE.md | 18 +- .../customlobby/CustomLobbyController.lua | 160 +++++++++++++- .../customlobby/CustomLobbyInterface.lua | 78 +++++-- .../customlobby/CustomLobbyLaunchModel.lua | 2 + .../lobby/customlobby/CustomLobbyMessages.lua | 1 + .../customlobby/CustomLobbySessionModel.lua | 10 + lua/ui/lobby/customlobby/CustomLobbyTabs.lua | 208 ++++++++++++++++-- .../config/CustomLobbyConfigInterface.lua | 91 +++++++- .../slots/CustomLobbySlotsInterface.lua | 154 +++++++++++++ lua/ui/optionutil.lua | 23 ++ scripts/LaunchCustomLobby.ps1 | 42 +++- 11 files changed, 742 insertions(+), 45 deletions(-) diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index 3730403e973..73a3605e283 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -27,8 +27,10 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` LazyVars** so one slot's change re-fires only that row. Write helpers (`SetPlayer`, `SetPlayerField`, `AddObserver`, …) keep the copy-then-`Set` discipline. - **[CustomLobbySessionModel.lua](CustomLobbySessionModel.lua)** — shared but **not** - launched: lobby-room management (slot count, closed slots). A closed slot is just empty - at launch and slot count is map-derived presentation — neither reaches the scenario. + launched: lobby-room management (slot count, closed slots, `SlotsPinned`). A closed slot is + just empty at launch and slot count is map-derived presentation — neither reaches the scenario. + `SlotsPinned` locks seating so the host rejects client slot-takes (only the host may move + players), enforced in `ProcessTakeSlot`. - **[CustomLobbyLocalModel.lua](CustomLobbyLocalModel.lua)** — **per-peer, never synced**: identity (`LocalPeerId` / `HostID` / `IsHost`, set on the connection handshake) + connectivity (CPU benchmarks now; ping later). Broadcasting identity would corrupt the @@ -43,24 +45,24 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | [CustomLobbyLocalModel.lua](CustomLobbyLocalModel.lua) | per-peer state, never synced: identity + CPU benchmarks. | | [CustomLobbyPerformancePopover.lua](CustomLobbyPerformancePopover.lua) | hover popover over the CPU column; hand-built bitmap bar chart of a peer's `PerformanceTrackingV2` history, with a yellow recommended-unit-cap line. | | [CustomLobbyInstance.lua](CustomLobbyInstance.lua) | thin `moho.lobby_methods` shell; validates/dispatches traffic, forwards callbacks to the controller. | -| [CustomLobbyController.lua](CustomLobbyController.lua) | host-authority logic (free functions): seating, `Process*` handlers, intents (`RequestSetReady`, `RequestTakeSlot`, `RequestSwapSlots`, `RequestEject`, `RequestMoveToObserver`, `RequestSetScenario`, `RequestSetGameMods/Options`, `RequestResetGameOptions`, `RequestLaunch` — all keyed by slot/bool/file so a chat command can call them too; permission is gated separately), sharing the stored CPU benchmark. **Launch:** `RequestLaunch` (host-only, readiness-validated) → `BuildGameConfiguration` (seed option defaults + scenario, resolve random factions, assign army numbers + push to server, stamp ratings/clan tags, resolve sim mods via `Mods.GetGameMods`) → broadcast `LaunchGame` + `instance:LaunchGame`; clients run `ProcessLaunchGame`. | +| [CustomLobbyController.lua](CustomLobbyController.lua) | host-authority logic (free functions): seating, `Process*` handlers, intents (`RequestSetReady`, `RequestTakeSlot`, `RequestSwapSlots`, `RequestEject`, `RequestMoveToObserver`, `RequestSetScenario`, `RequestSetGameMods/Options`, `RequestResetGameOptions`, `RequestSetSlotsPinned`, `RequestAutoBalance` (stub), `RequestReopenClosedSlots`, `RequestLaunch` — all keyed by slot/bool/file so a chat command can call them too; permission is gated separately), sharing the stored CPU benchmark. **Launch:** `RequestLaunch` (host-only, readiness-validated) → `BuildGameConfiguration` (seed option defaults + scenario, resolve random factions, assign army numbers + push to server, stamp ratings/clan tags, resolve sim mods via `Mods.GetGameMods`) → broadcast `LaunchGame` + `instance:LaunchGame`; clients run `ProcessLaunchGame`. | | [CustomLobbyRules.lua](CustomLobbyRules.lua) | game-rule derivations from lobby state (not view, not networking): `RecommendedUnitCap()` (per-player cap by map size, memoised scenario lookup). | | [CustomLobbySession.lua](CustomLobbySession.lua) | the lobby session's **main trash bag**. The lobby lives in the persistent front-end Lua state, which is *not* reset when the game launches in its own state — so anything left reachable (a running thread, a cache, a singleton) leaks for the whole match. This module owns one session-lifetime `TrashBag` (`GetTrash()`); every lobby-scoped `Destroyable` registers in it, and one `Teardown()` (clean-slate in `CreateLobby`, on leave, on `OnGameLaunched`) frees the lot. Works despite the bag being weak-valued because each singleton is pinned by its own module `Instance` local. **Rollout in progress** — the map catalog is the first resource converted; models / interface / instance follow. | | [CustomLobbyMessages.lua](CustomLobbyMessages.lua) | message registry: `AddPlayer`, `SetPlayers` (launch model: players + observers), `SentLaunchInfo` (launch model: scenario / options / mods / teams / spawn mex), `SetSessionState` (session model: slot count / closed slots), `SetReady`, `TakeSlot`, `DisconnectPeer`, `LaunchGame` (host's final game config → `instance:LaunchGame`), `ReportCpuBenchmark`, `SetCpuBenchmarks`. | | [CustomLobbyContextMenu.lua](CustomLobbyContextMenu.lua) | generic framed floating menu; `Show(entries, x, y)` renders any `{label, action, enabled}` list, dismisses on item click / click-outside / Esc. Knows nothing about the lobby. | | [CustomLobbyMenus.lua](CustomLobbyMenus.lua) | declarative menu **definitions**: entry lists with `when(ctx)`/`action(ctx)` filtered by lobby state (`BuildSlotMenu`). Adding/state-gating an item is a one-liner here. | -| [slots/](slots/) | the **slot subsystem** (its own folder, a sub-folder per layout). [`CustomLobbySlotsInterface`](slots/CustomLobbySlotsInterface.lua) is the entry the composition root mounts: a "Players" header over the **active layout body**, picked by the AutoTeams mode — one-column for the non-team modes, the two-column team layout for the binary modes (left/right, top/bottom, even/odd). It observes `GameOptions` and swaps the body when the kind flips (create-on-mode / destroy-on-switch); a change *within* the binary modes is handled by the two-column body's own re-layout. It is the rows' **drag coordinator** (`UICustomLobbySlotCoordinator`: hit-test which row a point is in, drop-highlight, drag ghost → `RequestSwapSlots`) for *every* layout — it alone needs to hit-test across rows — so the layout bodies stay pure build/place/reveal and never duplicate the drag logic; it also exposes `PreferredHeight()` (computed from the mode + model, swap-order-independent) so the root sizes the slot area. [`CustomLobbySlotBase`](slots/CustomLobbySlotBase.lua) holds **all slot behaviour** (the slot + CPU subscriptions, the CPU-cap math, click/right-click/drag-to-swap + intents, the background/highlight/click overlays) and the default `RenderPlayer`/`RenderCpu` that paint the standard named controls from normalised views; a presentation subclasses it and implements just `CreateContents` / `LayoutContents`. [`onecolumn/`](slots/onecolumn/): [`CustomLobbyOneColumnSlots`](slots/onecolumn/CustomLobbyOneColumnSlots.lua) (stacks thin rows, reveals 1..count) + [`CustomLobbySlotRow`](slots/onecolumn/CustomLobbySlotRow.lua) (thin one-line presentation). [`twocolumn/`](slots/twocolumn/): [`CustomLobbyTwoColumnSlots`](slots/twocolumn/CustomLobbyTwoColumnSlots.lua) (splits slots into two team columns via [`CustomLobbyRules.BuildSideResolver`](CustomLobbyRules.lua), with a [`CustomLobbyTeamScore`](CustomLobbyTeamScore.lua) strip across the top as the Left/Right indicator, and the "two columns, unresolved" fallback — parity fill + the score self-hides until a positional map's start positions load) + [`CustomLobbySlotCard`](slots/twocolumn/CustomLobbySlotCard.lua) (fat half-width / two-line presentation; `SetMirrored` flips the right column's cards so the two teams face each other). Each body exports `HeightForCount`. | +| [slots/](slots/) | the **slot subsystem** (its own folder, a sub-folder per layout). [`CustomLobbySlotsInterface`](slots/CustomLobbySlotsInterface.lua) is the entry the composition root mounts: a "Players" header over the **active layout body**, picked by the AutoTeams mode — one-column for the non-team modes, the two-column team layout for the binary modes (left/right, top/bottom, even/odd). It observes `GameOptions` and swaps the body when the kind flips (create-on-mode / destroy-on-switch); a change *within* the binary modes is handled by the two-column body's own re-layout. It is the rows' **drag coordinator** (`UICustomLobbySlotCoordinator`: hit-test which row a point is in, drop-highlight, drag ghost → `RequestSwapSlots`) for *every* layout — it alone needs to hit-test across rows — so the layout bodies stay pure build/place/reveal and never duplicate the drag logic; it also exposes `PreferredHeight()` (computed from the mode + model, swap-order-independent) so the root sizes the slot area. The header band carries a **host-only tool strip** (right-aligned: pin seating · auto-balance · reopen closed slots) of small icon buttons (local `SlotTool`, mirroring the config column's `PreviewTool`); the pin button is lit from the synced `SlotsPinned` and the strip is hidden for clients. [`CustomLobbySlotBase`](slots/CustomLobbySlotBase.lua) holds **all slot behaviour** (the slot + CPU subscriptions, the CPU-cap math, click/right-click/drag-to-swap + intents, the background/highlight/click overlays) and the default `RenderPlayer`/`RenderCpu` that paint the standard named controls from normalised views; a presentation subclasses it and implements just `CreateContents` / `LayoutContents`. [`onecolumn/`](slots/onecolumn/): [`CustomLobbyOneColumnSlots`](slots/onecolumn/CustomLobbyOneColumnSlots.lua) (stacks thin rows, reveals 1..count) + [`CustomLobbySlotRow`](slots/onecolumn/CustomLobbySlotRow.lua) (thin one-line presentation). [`twocolumn/`](slots/twocolumn/): [`CustomLobbyTwoColumnSlots`](slots/twocolumn/CustomLobbyTwoColumnSlots.lua) (splits slots into two team columns via [`CustomLobbyRules.BuildSideResolver`](CustomLobbyRules.lua), with a [`CustomLobbyTeamScore`](CustomLobbyTeamScore.lua) strip across the top as the Left/Right indicator, and the "two columns, unresolved" fallback — parity fill + the score self-hides until a positional map's start positions load) + [`CustomLobbySlotCard`](slots/twocolumn/CustomLobbySlotCard.lua) (fat half-width / two-line presentation; `SetMirrored` flips the right column's cards so the two teams face each other). Each body exports `HeightForCount`. | | [CustomLobbyObserversInterface.lua](CustomLobbyObserversInterface.lua) | observer strip; subscribes to the model's `Observers` list and shows the count + names (read-only). | | [CustomLobbyScenarioPreview.lua](CustomLobbyScenarioPreview.lua) | **shared** map-preview *surface*: the scenario's map texture + overlays (start spots, resource/wreck markers, plus a **dummy translucent `WaterMask`** placeholder until a real mask exists) with aspect-correct positioning, texture-leak-safe icon sharing, per-group visibility (`SetOverlayVisible('spawns'\|'resources'\|'wrecks'\|'water', …)`), and three-phase init. Chrome-free; `CustomLobbyMapPreview` wraps it with the frame. Spawn appearance is the owner's via a `CreateSpawnIcon` factory. | | [CustomLobbyMapPreview.lua](CustomLobbyMapPreview.lua) | the map preview **as one whole** — the chrome (glow border on top + dark backdrop, surface inset by `Padding`), the surface, and the faction spawn icon (local `MapPreviewSpawn`). Used by **both** consumers: created `Bound = true` it subscribes to the launch model and renders the committed `ScenarioFile` with per-slot faction spawns (no reload on take/swap); created unbound (the map-select dialog) it does no model wiring and the owner drives `preview.Surface` directly (numbered-dot spawns). Exposes `.Surface` for owners to drive / anchor overlays to. | | [mapselect/](mapselect/CLAUDE.md) | the **map-select dialog** + its catalog and list, in their own folder (host-only `Popup`: searchable, filterable scenario list → `RequestSetScenario`). Self-contained sub-MVC; see [mapselect/CLAUDE.md](mapselect/CLAUDE.md) — including the **`MapPreview` texture-leak** writeup that shaped its design. | | [modselect/](modselect/CLAUDE.md) | the **mod-select dialog** + its catalog and list, built to the map-select shape (checkbox list + type filters + detail panel + **presets**). Returns a uid set; the opener routes it — sim mods → `RequestSetGameMods` (synced), UI mods → local prefs — or persists the lot standalone. Mod domain logic lives in [`/lua/ui/modutilities.lua`](/lua/ui/modutilities.lua) (the `maputil.lua` sibling, fronting `/lua/mods.lua`). See [modselect/CLAUDE.md](modselect/CLAUDE.md). | | [optionselect/](optionselect/CLAUDE.md) | the **options dialog**: three columns (lobby / scenario / mod options) over the selected scenario + mods, with search + hide-defaults filters; non-default options are marked. Derives the option *schema* per-peer (reference data) via [`/lua/ui/optionutil.lua`](/lua/ui/optionutil.lua); edits a working copy of the *values* and on OK routes the reconciled set through `RequestSetGameOptions` (synced via `GameOptions`). Host-only. See [optionselect/CLAUDE.md](optionselect/CLAUDE.md). | -| [CustomLobbyInterface.lua](CustomLobbyInterface.lua) | composition root, laid out in **areas** (flip the module `Debug` flag to tint them), sized for the **1024×768** floor. A **one-column** layout (the two-column variant was reverted by community request): a **title bar** (title · team score · Leave) over a **left column** split vertically — the **slots** on top (a single column of rows, up to 16, height tracking the slot count) and the **Chat / Observers** tabs below — beside a fixed-width **right column** (the config component: a pinned map preview + facts line over Options / Mods / Restrictions tabs). A full-width **action bar** at the bottom holds the global actions: status + the host-only **Settings** button (opens the options editor) + the host-only **Launch**. Reads IsHost (action-bar buttons) + SlotCount (to size the slot area via `CustomLobbySlotsInterface.HeightForSlots`); Leave (Esc handler) + **Become observer** (`RequestMoveToObserver`, in the Observers tab). The slot rows + their drag coordination now live in [`CustomLobbySlotsInterface`](CustomLobbySlotsInterface.lua), which fills the slot area. `OpenDebug()` / hot-reload. | +| [CustomLobbyInterface.lua](CustomLobbyInterface.lua) | composition root, laid out in **areas** (flip the module `Debug` flag to tint them), sized for the **1024×768** floor. A **one-column** layout (the two-column variant was reverted by community request): a **title bar** (title · team score) over a **left column** split vertically — the **slots** on top (a single column of rows, up to 16, height tracking the slot count) and the **Chat / Observers** tabs below — beside a fixed-width **right column** (the config component: a pinned map preview + facts line over Options / Mods / Restrictions tabs). A full-width **action bar** at the bottom holds the global actions: **Leave** + status on the left, the host-only **Settings** button (opens the options editor) + the host-only **Launch** on the right. Reads IsHost (action-bar buttons) + SlotCount (to size the slot area via `CustomLobbySlotsInterface.HeightForSlots`); Leave (Esc handler) + **Become observer** (`RequestMoveToObserver`, in the Observers tab). The slot rows + their drag coordination now live in [`CustomLobbySlotsInterface`](CustomLobbySlotsInterface.lua), which fills the slot area. `OpenDebug()` / hot-reload. | | [CustomLobbyTeamScore.lua](CustomLobbyTeamScore.lua) | the **accumulated team rating** side indicator — `Side A N · M Side B`. Hosted in the strip atop the two-column slot layout (it doubles as the columns' Left/Right header). Shown only for the binary auto-team formations; **hidden** for `none`/`manual` or until a positional map's start positions load. Reads the mode + side split from [`CustomLobbyRules`](CustomLobbyRules.lua) (`AutoTeamMode` / `SideLabels` / `BuildSideResolver`) and the ratings from each slot's `PL`. Reference data; never writes. | -| [CustomLobbyTabs.lua](CustomLobbyTabs.lua) | a **generic tabbed panel** (strip + content; one panel alive, created on select / destroyed on switch). Construct with a `{ Label, Create }` list + optional `OnSelect`. Used for the bottom-left (Chat / Observers); the config interface keeps its own bespoke variant (it coordinates the persistent preview). | -| [social/](social/) | the lobby's **bottom-left** column (the `CustomLobbyTabs` content): [`CustomLobbyChatPanel`](social/CustomLobbyChatPanel.lua) (the **Chat** tab — placeholder until the chat slice lands) and [`CustomLobbyObserversPanel`](social/CustomLobbyObserversPanel.lua) (the **Observers** tab — the shared observer list + a host-authoritative **Become observer** button → `RequestMoveToObserver`). Each is a tab content component (`Create(parent)`, created on select / destroyed on switch). | -| [config/](config/) | the lobby's **right** column. [`CustomLobbyConfigInterface.lua`](config/CustomLobbyConfigInterface.lua) is the column **composition**: a bound square `CustomLobbyMapPreview` **pinned** at the top with a vertical **preview tool strip** to its right (local `PreviewTool` icon buttons — toggles for army/start icons, mass+hydro deposits and the dummy water mask, driving `preview.Surface:SetOverlayVisible`, + a host-only **change-map** config icon at the bottom → the map-select dialog), a name + size/players/version facts line under it, and a [`CustomLobbyTabs`](CustomLobbyTabs.lua) (**Options / Mods / Restrictions**) filling the rest. All three tab panels are now **read-only**: their per-domain action buttons (open editor / reset / manage mods) are removed — the grid/content fills the whole panel — and the interface's action-bar **Settings** button is the only edit entry point for now. [`CustomLobbyOptionsPanel`](config/CustomLobbyOptionsPanel.lua) (options grouped **Lobby / Scenario / Mods** + hide-defaults toggle; map/mod options gold-flagged with an origin tooltip — schema via [`/lua/ui/optionutil.lua`](/lua/ui/optionutil.lua)), [`CustomLobbyModsPanel`](config/CustomLobbyModsPanel.lua) (enabled mods in **Game / UI** sections), [`CustomLobbyUnitsPanel`](config/CustomLobbyUnitsPanel.lua) (the **Restrictions** placeholder). Each self-subscribes to the model and exposes `Initialize()` + `Create(parent)`. **Parked** (built, but unwired): [`CustomLobbyMapPanel`](config/CustomLobbyMapPanel.lua) (the full Map tab — preview + label/value details + Change-map), now superseded by the pinned preview. Churning a preview is safe here because the lobby shows only **one** current map and the engine caches map textures by name (the texture-leak rule only bites the *map-select dialog* — see [mapselect/CLAUDE.md](mapselect/CLAUDE.md)). | +| [CustomLobbyTabs.lua](CustomLobbyTabs.lua) | a **generic tabbed panel** (strip + content; one panel alive, created on select / destroyed on switch). Tabs **divide the strip evenly** across its width. Construct with a `{ Label, Create, Badge?, Action? }` list + optional `OnSelect`. A tab's optional `Badge` LazyVar drives a grey **count pill** to the right of the label; its optional `Action` (`{ Create, Visible? }`) is a small button the owner builds **inside the tab, left of the label** (e.g. a config gear), whose `Visible` LazyVar hides it (collapsing it from the layout) when it doesn't apply. The action, label and pill are centred together as one cluster; any absent/hidden/empty piece contributes 0 width so the rest re-centres — the container just mirrors the LazyVars, the owner decides what they mean. Used for the bottom-left (Chat / Observers) and the config interface's Options / Mods / Restrictions. | +| [social/](social/) | the lobby's **bottom-left** column (the `CustomLobbyTabs` content): [`CustomLobbyChatPanel`](social/CustomLobbyChatPanel.lua) (the **Chat** tab — placeholder until the chat slice lands) and [`CustomLobbyObserversPanel`](social/CustomLobbyObserversPanel.lua) (the **Observers** tab — the shared observer list + a host-authoritative **Become observer** button → `RequestMoveToObserver`). Each is a tab content component (`Create(parent)`, created on select / destroyed on switch). Both tabs mirror the config column's shape — a per-tab **config gear** (`CustomLobbyInterface`'s local `GearAction`; both no-ops with a "coming soon" tooltip for now) + a right-side **count pill**: Observers shows the live observer count, Chat a dummy until the chat slice lands. | +| [config/](config/) | the lobby's **right** column. [`CustomLobbyConfigInterface.lua`](config/CustomLobbyConfigInterface.lua) is the column **composition**: a bound square `CustomLobbyMapPreview` **pinned** at the top with a vertical **preview tool strip** to its right (local `PreviewTool` icon buttons — toggles for army/start icons, mass+hydro deposits and the dummy water mask, driving `preview.Surface:SetOverlayVisible`, + a host-only **change-map** config icon at the bottom → the map-select dialog), a name + size/players/version facts line under it, and a [`CustomLobbyTabs`](CustomLobbyTabs.lua) (**Options / Mods / Restrictions**) filling the rest. Each tab carries its own **config gear** (`CustomLobbyTabs`' per-tab `Action`, built by the interface's `GearAction` helper) — a skinned button **inside the tab, left of the label** — that opens that tab's editor: Options → `CustomLobbyOptionSelect`, Mods → `CustomLobbyModSelect`, Restrictions → none yet (no gear). The Options gear is **host-only**: its `Visible` LazyVar is the `IsHost` field, so it's **hidden** for clients and the label re-centres; the Mods gear shows for everyone (UI mods are local; the sim portion is host-gated inside the dialog). The interface also owns the tabs' **count badges** as computed LazyVars over the launch model — Options shows the non-default-option count (`OptionUtil.CountNonDefault`), Mods shows `sim / ui` (synced sim mods / this peer's UI-mod prefs), Restrictions is wired but empty until the restrictions slice lands. All three tab panels are now **read-only**: their per-domain action buttons (open editor / reset / manage mods) are removed — the grid/content fills the whole panel — and the action-bar **Settings** button (now joined by this gear) is the edit entry point. [`CustomLobbyOptionsPanel`](config/CustomLobbyOptionsPanel.lua) (options grouped **Lobby / Scenario / Mods** + hide-defaults toggle; map/mod options gold-flagged with an origin tooltip — schema via [`/lua/ui/optionutil.lua`](/lua/ui/optionutil.lua)), [`CustomLobbyModsPanel`](config/CustomLobbyModsPanel.lua) (enabled mods in **Game / UI** sections), [`CustomLobbyUnitsPanel`](config/CustomLobbyUnitsPanel.lua) (the **Restrictions** placeholder). Each self-subscribes to the model and exposes `Initialize()` + `Create(parent)`. **Parked** (built, but unwired): [`CustomLobbyMapPanel`](config/CustomLobbyMapPanel.lua) (the full Map tab — preview + label/value details + Change-map), now superseded by the pinned preview. Churning a preview is safe here because the lobby shows only **one** current map and the engine caches map textures by name (the texture-leak rule only bites the *map-select dialog* — see [mapselect/CLAUDE.md](mapselect/CLAUDE.md)). | | [/lua/ui/lobby/lobby.lua](../lobby.lua) | engine entry wrapper (`CreateLobby`/`HostGame`/`JoinGame`) → CustomLobby. Old lobby preserved at `lobby-old.lua`. | Working today: host + clients see each other (host-authoritative player sync), the diff --git a/lua/ui/lobby/customlobby/CustomLobbyController.lua b/lua/ui/lobby/customlobby/CustomLobbyController.lua index fa267224129..732503714de 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyController.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyController.lua @@ -47,6 +47,9 @@ local CustomLobbySession = import("/lua/ui/lobby/customlobby/customlobbysession. ---@type UICustomLobbyInstance | false local LobbyInstance = false +-- delay between the close-assert and the open broadcast in RequestReopenClosedSlots +local ReopenClosedSlotsDelay = 0.5 + ------------------------------------------------------------------------------- --#region Helpers @@ -197,12 +200,74 @@ local function SwapSlots(instance, slotA, slotB) BroadcastPlayers(instance) end +--- Reads a numeric command-line argument (e.g. `/mean 1500`), falling back to the default +--- when it is absent or unparseable. +---@param key string +---@param default number +---@return number +local function GetCommandLineNumber(key, default) + local arg = GetCommandLineArg(key, 1) + if arg and arg[1] then + return tonumber(arg[1]) or default + end + return default +end + +--- Reads a string command-line argument (e.g. `/clan Yps`), falling back to the default when +--- it is absent. +---@param key string +---@param default string +---@return string +local function GetCommandLineString(key, default) + local arg = GetCommandLineArg(key, 1) + if arg and arg[1] then + return tostring(arg[1]) + end + return default +end + +--- Reads the faction from the flag args (`/uef` `/aeon` `/cybran` `/seraphim`), falling back to +--- the default when none is present. Mirrors the autolobby's CreateLocalPlayer. +---@param default number +---@return number +local function GetCommandLineFaction(default) + for index, faction in import("/lua/factions.lua").Factions do + if HasCommandLineArg("/" .. faction.Key) then + return index + end + end + return default +end + --- Builds the local player's options from the profile / engine name. ---@param instance UICustomLobbyInstance ---@return UICustomLobbyPlayer function CreateLocalPlayer(instance) local name = instance:GetLocalPlayerName() or import("/lua/user/prefs.lua").GetFromCurrentProfile('Name') or "Player" - return import("/lua/ui/lobby/lobbycomm.lua").GetDefaultPlayerOptions(name) + local player = import("/lua/ui/lobby/lobbycomm.lua").GetDefaultPlayerOptions(name) + + -- player info from the command line: the FAF client passes the player's real values, and the + -- dev launch script (scripts/LaunchCustomLobby.ps1) seeds random ones. Mirrors the autolobby's + -- CreateLocalPlayer and the legacy lobby's GetLocalPlayerData. The host preserves all of these + -- when it seats a joining peer (it only reassigns StartSpot / colors) — see ProcessAddPlayer. + + -- rating + game count + player.MEAN = GetCommandLineNumber("/mean", 1500) + player.DEV = GetCommandLineNumber("/deviation", 500) + player.NG = GetCommandLineNumber("/numgames", 0) + player.PL = math.floor(player.MEAN - 3 * player.DEV) + + -- faction + team + player.Faction = GetCommandLineFaction(player.Faction) + player.Team = GetCommandLineNumber("/team", player.Team) + + -- identity + league standing + player.PlayerClan = GetCommandLineString("/clan", player.PlayerClan or "") + player.Country = GetCommandLineString("/country", player.Country or "") + player.DIV = GetCommandLineString("/division", player.DIV or "") + player.SUBDIV = GetCommandLineString("/subdivision", player.SUBDIV or "") + + return player end --#endregion @@ -359,6 +424,7 @@ function ProcessSetSessionState(instance, data) local session = CustomLobbySessionModel.GetSingleton() session.SlotCount:Set(data.SlotCount or session.SlotCount()) session.ClosedSlots:Set(data.ClosedSlots or {}) + session.SlotsPinned:Set(data.SlotsPinned and true or false) end --- Host flips a peer's ready flag and re-broadcasts. @@ -372,10 +438,15 @@ function ProcessSetReady(instance, data) end end ---- Host moves a requesting client into the open slot it asked for. +--- Host moves a requesting client into the open slot it asked for. Ignored while seating +--- is pinned — only the host may change slots then (the host's own take goes through +--- RequestTakeSlot, which is exempt). ---@param instance UICustomLobbyInstance ---@param data UICustomLobbyTakeSlotMessage function ProcessTakeSlot(instance, data) + if CustomLobbySessionModel.GetSingleton().SlotsPinned() then + return + end TakeSlot(instance, data.SenderID, data.Slot) end @@ -440,6 +511,7 @@ function BroadcastSessionState(instance) Type = 'SetSessionState', SlotCount = session.SlotCount(), ClosedSlots = session.ClosedSlots(), + SlotsPinned = session.SlotsPinned(), }) end @@ -509,6 +581,10 @@ function RequestTakeSlot(slot) if localModel.IsHost() then TakeSlot(instance, localModel.LocalPeerId(), slot) else + -- pinned seating is host-only: don't bother the host with a request it will reject + if CustomLobbySessionModel.GetSingleton().SlotsPinned() then + return + end instance:SendData(localModel.HostID(), { Type = 'TakeSlot', Slot = slot }) end end @@ -774,6 +850,86 @@ function RequestSwapSlots(slotA, slotB) SwapSlots(instance, slotA, slotB) end +--- The host pins or unpins seating. Host-only — backs the slots header's pin button. While +--- pinned, the host rejects client slot-takes (ProcessTakeSlot), so only the host can change +--- who sits where; the host itself is unaffected. Synced via the session-state snapshot. +---@param pinned boolean +function RequestSetSlotsPinned(pinned) + local instance = LobbyInstance + if not instance then + return + end + + if not CustomLobbyLocalModel.GetSingleton().IsHost() then + WARN("CustomLobby: only the host can pin the slots") + return + end + + CustomLobbySessionModel.SetSlotsPinned(CustomLobbySessionModel.GetSingleton(), pinned) + BroadcastSessionState(instance) +end + +--- The host asks the lobby to auto-balance the seated players across teams. Host-only — +--- backs the slots header's auto-balance button. +--- +--- TODO: implement the balancer (group seated players into even teams by rating, re-seat / +--- set Team via the launch model, then BroadcastPlayers). Stubbed for now. +function RequestAutoBalance() + local instance = LobbyInstance + if not instance then + return + end + + if not CustomLobbyLocalModel.GetSingleton().IsHost() then + WARN("CustomLobby: only the host can auto-balance") + return + end + + LOG("CustomLobby: RequestAutoBalance — not implemented yet") +end + +--- Re-broadcasts the closed slots, then after a short delay opens every one of them. The +--- close→open pulse pushes two fresh session snapshots so a client that drifted out of sync +--- re-renders its closed slots correctly. Host-only — backs the slots header's reopen button. +function RequestReopenClosedSlots() + local instance = LobbyInstance + if not instance then + return + end + + if not CustomLobbyLocalModel.GetSingleton().IsHost() then + WARN("CustomLobby: only the host can reopen the closed slots") + return + end + + local session = CustomLobbySessionModel.GetSingleton() + local closed = {} + for slot, isClosed in session.ClosedSlots() do + if isClosed then + table.insert(closed, slot) + end + end + if table.empty(closed) then + return + end + + instance.Trash:Add(ForkThread(function() + -- re-assert the closed state to everyone + BroadcastSessionState(instance) + WaitSeconds(ReopenClosedSlotsDelay) + if IsDestroyed(instance) then + return + end + -- then open the slots that were closed + local opened = table.copy(session.ClosedSlots()) + for _, slot in closed do + opened[slot] = nil + end + session.ClosedSlots:Set(opened) + BroadcastSessionState(instance) + end)) +end + --- Ejects the player in `slot`: a human is dropped from the network (the resulting --- PeerDisconnected clears the slot + re-broadcasts), an AI is just cleared. Host-only. --- Slot-keyed so a chat command (`/eject `) can call it too; whether the caller diff --git a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua index 02dbfea6ff6..741dac09ff8 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua @@ -29,7 +29,7 @@ -- module-level `Debug` flag to tint each so the regions are visible while iterating. Targeted at -- the 1024x768 minimum resolution: -- --- ┌ TitleArea ─ title · leave ─────────────────────────────────────────────────┐ +-- ┌ TitleArea ─ title ─────────────────────────────────────────────────────────┐ -- ├──────────────────────────────────────┬─────────────────────────────────────┤ -- │ SlotsArea (slots — one or two team │ RightArea (the map preview + facts │ -- │ columns, top-left, up to 16) │ line + read-only options summary) │ @@ -37,16 +37,16 @@ -- │ BottomLeftArea (Chat / Observers │ │ -- │ — tabs) │ │ -- ├──────────────────────────────────────┴─────────────────────────────────────┤ --- │ ActionArea (status · … · Settings · Launch) ─ full width │ +-- │ ActionArea (Leave · status · … · Settings · Launch) ─ full width │ -- └────────────────────────────────────────────────────────────────────────────┘ -- -- The LEFT column splits vertically: the slots on top (CustomLobbySlotsInterface — one column, or -- two team columns for the binary auto-team modes, with the team-rating indicator atop the cards), -- the chat/observers tabs (CustomLobbyTabs) below. The RIGHT column is the map + options -- (CustomLobbyConfigInterface — a bound map preview, a name/size/players/version facts line, and the --- read-only options summary). A full-width action bar at the bottom holds the global actions (status --- + the generic Settings button, which opens the options editor, + the host-only Launch). The title --- bar is just the title + Leave. +-- read-only options summary). A full-width action bar at the bottom holds the global actions: Leave +-- + status on the left, the generic Settings button (opens the options editor) + the host-only +-- Launch on the right. The title bar is just the title. -- -- The per-domain edit buttons (change-map, mod-select) are removed for now — only the generic -- Settings button remains — and will be reintegrated once the rework is complete. @@ -58,6 +58,7 @@ local EscapeHandler = import("/lua/ui/dialogs/eschandler.lua") local Group = import("/lua/maui/group.lua").Group local Bitmap = import("/lua/maui/bitmap.lua").Bitmap +local Button = import("/lua/maui/button.lua").Button local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") local CustomLobbySessionModel = import("/lua/ui/lobby/customlobby/customlobbysessionmodel.lua") local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/customlobbylocalmodel.lua") @@ -69,10 +70,40 @@ local CustomLobbyChatPanel = import("/lua/ui/lobby/customlobby/social/customlobb local CustomLobbyObserversPanel = import("/lua/ui/lobby/customlobby/social/customlobbyobserverspanel.lua") local CustomLobbyOptionSelect = import("/lua/ui/lobby/customlobby/optionselect/customlobbyoptionselect.lua") +local LazyVarCreate = import("/lua/lazyvar.lua").Create local LazyVarDerive = import("/lua/lazyvar.lua").Derive local Layouter = LayoutHelpers.ReusedLayoutFor +-- the per-tab config gear (inside the tab, left of the label) — skinned button (up/down/over/dis). +-- Local copy of the config column's gear (drift-is-fine; see ../CLAUDE.md "On sharing"). +local GearTextures = { + up = UIUtil.SkinnableFile('/game/menu-btns/config_btn_up.dds'), + down = UIUtil.SkinnableFile('/game/menu-btns/config_btn_down.dds'), + over = UIUtil.SkinnableFile('/game/menu-btns/config_btn_over.dds'), + dis = UIUtil.SkinnableFile('/game/menu-btns/config_btn_dis.dds'), +} + +--- Builds a tab `Action` (a config gear) for `CustomLobbyTabs`: a skinned button that runs `onOpen` +--- on click, with a tooltip. (The chat/observer settings dialogs don't exist yet — these are +--- placeholders, so `onOpen` is a no-op for now.) +---@param onOpen fun() +---@param title string +---@param body string +---@return UICustomLobbyTabAction +local function GearAction(onOpen, title, body) + return { + Create = function(parent) + local gear = Button(parent, GearTextures.up, GearTextures.down, GearTextures.over, GearTextures.dis) + gear.OnClick = function(button, modifiers) + onOpen() + end + Tooltip.AddControlTooltipManual(gear, title, body) + return gear + end, + } +end + -- flip to tint each layout area so the regions are visible while iterating local Debug = true @@ -114,6 +145,8 @@ end ---@field Slots UICustomLobbySlotsInterface ---@field BottomLeftArea Group ---@field BottomLeftTabs UICustomLobbyTabs +---@field ChatBadge LazyVar # dummy count pill for the Chat tab (until the chat slice lands) +---@field ObserversBadge LazyVar # observer count pill for the Observers tab ---@field RightArea Group ---@field Config UICustomLobbyConfigInterface ---@field ActionArea Group @@ -145,27 +178,44 @@ local CustomLobbyInterface = Class(Group) { self.ActionArea = CreateArea(self.Content, "ActionArea", 'ff808080') --#endregion - --#region title bar (title · leave) + --#region title bar (title) self.Title = UIUtil.CreateText(self.TitleArea, "Custom game", 20, UIUtil.titleFont) self.Title:DisableHitTest() + --#endregion - self.LeaveButton = UIUtil.CreateButtonWithDropshadow(self.TitleArea, '/BUTTON/medium/', "Leave") + -- Leave lives in the action bar (bottom-left, beside the status text); created here so it's + -- always available regardless of host status + self.LeaveButton = UIUtil.CreateButtonWithDropshadow(self.ActionArea, '/BUTTON/medium/', "Leave") self.LeaveButton.OnClick = function(button, modifiers) -- leaving disconnects + returns to the menu via the escape handler lobby.lua -- registered (one teardown, shared with the Esc key) EscapeHandler.HandleEsc(false) end - --#endregion --#region slots (top-left region — a single column of rows, owns its own drag coordination) self.Slots = CustomLobbySlotsInterface.Create(self.SlotsArea) --#endregion --#region bottom-left: chat / observers tabs + -- each tab gets a config gear (left) + a count pill (right), mirroring the config column. + -- Chat's count is a dummy until the chat slice lands; Observers shows the live observer count. + self.ChatBadge = self.Trash:Add(LazyVarCreate("0")) -- TODO: real unread count with the chat slice + self.ObserversBadge = self.Trash:Add(LazyVarCreate()) + self.ObserversBadge:Set(function() + local count = table.getn(CustomLobbyLaunchModel.GetSingleton().Observers()) + return count > 0 and tostring(count) or "" + end) + self.BottomLeftTabs = CustomLobbyTabs.Create(self.BottomLeftArea, { Tabs = { - { Label = "Chat", Create = CustomLobbyChatPanel.Create }, - { Label = "Observers", Create = CustomLobbyObserversPanel.Create }, + { + Label = "Chat", Create = CustomLobbyChatPanel.Create, Badge = self.ChatBadge, + Action = GearAction(function() end, "Chat settings", "Chat settings — coming soon."), + }, + { + Label = "Observers", Create = CustomLobbyObserversPanel.Create, Badge = self.ObserversBadge, + Action = GearAction(function() end, "Observer settings", "Observer settings — coming soon."), + }, }, }) --#endregion @@ -240,9 +290,8 @@ local CustomLobbyInterface = Class(Group) { self.BottomLeftArea.Right:Set(function() return self.RightArea.Left() - LayoutHelpers.ScaleNumber(Pad) end) --#endregion - --#region title bar (title · leave) + --#region title bar (title) Layouter(self.Title):AtLeftIn(self.TitleArea, 8):AtVerticalCenterIn(self.TitleArea):End() - Layouter(self.LeaveButton):AtRightIn(self.TitleArea):AtVerticalCenterIn(self.TitleArea):End() --#endregion --#region slots fill their area (the component stacks the rows + coordinates dragging) @@ -257,8 +306,9 @@ local CustomLobbyInterface = Class(Group) { Layouter(self.Config):Fill(self.RightArea):End() --#endregion - --#region action bar: status on the left, Settings + launch on the right - Layouter(self.StatusLabel):AtLeftIn(self.ActionArea, 8):AtVerticalCenterIn(self.ActionArea):End() + --#region action bar: Leave + status on the left, Settings + launch on the right + Layouter(self.LeaveButton):AtLeftIn(self.ActionArea):AtVerticalCenterIn(self.ActionArea):End() + Layouter(self.StatusLabel):AnchorToRight(self.LeaveButton, 8):AtVerticalCenterIn(self.ActionArea):End() Layouter(self.LaunchButton):AtRightIn(self.ActionArea):AtVerticalCenterIn(self.ActionArea):End() Layouter(self.SettingsButton):AnchorToLeft(self.LaunchButton, 8):AtVerticalCenterIn(self.ActionArea):End() --#endregion diff --git a/lua/ui/lobby/customlobby/CustomLobbyLaunchModel.lua b/lua/ui/lobby/customlobby/CustomLobbyLaunchModel.lua index 2e09fae8c1a..567162a1fac 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyLaunchModel.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyLaunchModel.lua @@ -58,6 +58,8 @@ MaxSlots = 16 ---@field MEAN? number ---@field DEV? number ---@field NG? number # number of games +---@field DIV? string # league division (e.g. "gold") +---@field SUBDIV? string # league subdivision (e.g. "III") ---@field PlayerClan? string ---@field Country? string ---@field AIPersonality? string diff --git a/lua/ui/lobby/customlobby/CustomLobbyMessages.lua b/lua/ui/lobby/customlobby/CustomLobbyMessages.lua index 66abb93df91..3155e7354b9 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyMessages.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyMessages.lua @@ -119,6 +119,7 @@ CustomLobbyMessages = { ---@class UICustomLobbySetSessionStateMessage : UILobbyReceivedMessage ---@field SlotCount number ---@field ClosedSlots table + ---@field SlotsPinned boolean ---@param data UICustomLobbySetSessionStateMessage Validate = function(lobby, data) diff --git a/lua/ui/lobby/customlobby/CustomLobbySessionModel.lua b/lua/ui/lobby/customlobby/CustomLobbySessionModel.lua index c2d49128890..810f12c917a 100644 --- a/lua/ui/lobby/customlobby/CustomLobbySessionModel.lua +++ b/lua/ui/lobby/customlobby/CustomLobbySessionModel.lua @@ -41,6 +41,7 @@ local Create = import("/lua/lazyvar.lua").Create ---@class UICustomLobbySessionModel ---@field SlotCount LazyVar # player slots the current map supports ---@field ClosedSlots LazyVar> +---@field SlotsPinned LazyVar # host locked seating: only the host may change slots ---@type UICustomLobbySessionModel | nil local ModelInstance = nil @@ -53,6 +54,7 @@ function SetupSingleton(slotCount) local model = { SlotCount = Create(slotCount or 8), ClosedSlots = Create({}), + SlotsPinned = Create(false), } ModelInstance = model @@ -90,6 +92,13 @@ function SetClosed(model, slot, closed) model.ClosedSlots:Set(closedSlots) end +--- Sets whether seating is pinned (only the host may change slots while on). +---@param model UICustomLobbySessionModel +---@param pinned boolean +function SetSlotsPinned(model, pinned) + model.SlotsPinned:Set(pinned and true or false) +end + --#endregion ------------------------------------------------------------------------------- @@ -103,6 +112,7 @@ function __moduleinfo.OnReload(newModule) if ModelInstance then local handle = newModule.SetupSingleton(ModelInstance.SlotCount()) handle.ClosedSlots:Set(ModelInstance.ClosedSlots()) + handle.SlotsPinned:Set(ModelInstance.SlotsPinned()) end end diff --git a/lua/ui/lobby/customlobby/CustomLobbyTabs.lua b/lua/ui/lobby/customlobby/CustomLobbyTabs.lua index ff8fb790f1b..690b0d814f8 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyTabs.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyTabs.lua @@ -30,6 +30,14 @@ -- An optional `OnSelect(index, label)` fires on every switch — used when a persistent sibling (e.g. -- the map preview, which can't be churned) must be shown/hidden alongside a tab. -- +-- The tabs **divide the strip evenly** across the available width. A tab may carry an optional +-- `Badge` LazyVar (a string): when non-empty it renders a small grey count pill to the right of the +-- label; and an optional `Action` (`{ Create, Visible? }`): a small button the owner builds **inside +-- the tab, left of the label** (e.g. a config gear that opens that tab's editor). Its `Visible` +-- LazyVar hides it (and collapses it out of the layout) when it doesn't apply — e.g. for non-hosts. +-- The action, label and pill are centred together as one cluster, so nothing collides with the tab +-- edge. The container only observes the LazyVars — the owner decides what the count + action mean. +-- -- The parent sizes this control and calls `Initialize()` after mounting (so the first panel reads a -- concrete height — three-phase init, /lua/ui/CLAUDE.md § 1). @@ -39,26 +47,58 @@ local LayoutHelpers = import("/lua/maui/layouthelpers.lua") local Group = import("/lua/maui/group.lua").Group local Bitmap = import("/lua/maui/bitmap.lua").Bitmap +local LazyVarDerive = import("/lua/lazyvar.lua").Derive local Layouter = LayoutHelpers.ReusedLayoutFor local TabHeight = 26 -local TabWidth = 92 +local TabGap = 2 local TabIdleColor = 'ff141a20' local TabHoverColor = 'ff1f262e' local TabActiveColor = 'ff2c3e48' +-- the count badge: a grey pill (rounded-look solid) with a number, sitting to the right of a label +local BadgeHeight = 16 +local BadgeMinWidth = 18 -- keeps a single digit roughly square +local BadgePadH = 6 -- horizontal text padding inside the pill +local BadgeGap = 5 -- between cluster items (action / label / pill) +local BadgeColor = 'ff454c56' +local BadgeTextColor = 'ffd0d4d8' + +-- the per-tab action button (e.g. a config gear), square, sitting left of the label +local ActionSize = 18 + +---@class UICustomLobbyTabAction +---@field Create fun(parent: Control): Control # builds the action control (the owner wires its click) +---@field Visible? LazyVar # boolean; false hides the action + collapses it from the cluster (default: always shown) + +---@class UICustomLobbyTab +---@field Label string +---@field Create fun(parent: Control): Control +---@field Badge? LazyVar # optional count-badge text (a string; "" hides the pill) +---@field Action? UICustomLobbyTabAction # optional button inside the tab, left of the label + ---@class UICustomLobbyTabsOptions ----@field Tabs { Label: string, Create: fun(parent: Control): Control }[] +---@field Tabs UICustomLobbyTab[] ---@field OnSelect? fun(index: number, label: string) +---@class UICustomLobbyTabBadge : Group +---@field Bg Bitmap +---@field Text Text + +---@class UICustomLobbyTabButton : Group +---@field Bg Bitmap +---@field Label Text +---@field Badge? UICustomLobbyTabBadge # present only when the tab defines a Badge LazyVar +---@field Action? Control # present only when the tab defines an Action + ---@class UICustomLobbyTabs : Group ---@field Trash TrashBag ----@field Tabs { Label: string, Create: fun(parent: Control): Control }[] +---@field Tabs UICustomLobbyTab[] ---@field OnSelectCb? fun(index: number, label: string) ---@field TabStripArea Group ---@field TabContentArea Group ----@field TabButtons Group[] +---@field TabButtons UICustomLobbyTabButton[] ---@field ActiveTab number ---@field CurrentPanel Control | false local CustomLobbyTabs = ClassUI(Group) { @@ -92,21 +132,87 @@ local CustomLobbyTabs = ClassUI(Group) { :AnchorToBottom(self.TabStripArea, 6):AtBottomIn(self) :End() - for index = 1, table.getn(self.TabButtons) do + -- the tabs divide the strip evenly: each is (stripWidth - gaps) / count wide, pinned at its + -- own slot. Width/Left are bound to the strip so they re-flow with the column. + local count = table.getn(self.TabButtons) + for index = 1, count do local button = self.TabButtons[index] - local builder = Layouter(button):AtTopIn(self.TabStripArea):Width(TabWidth):Height(TabHeight) - if index == 1 then - builder:AtLeftIn(self.TabStripArea) - else - builder:AnchorToRight(self.TabButtons[index - 1], 2) - end - builder:End() + local slot = index + Layouter(button):AtTopIn(self.TabStripArea):Height(TabHeight):End() + button.Width:Set(function() + local gap = LayoutHelpers.ScaleNumber(TabGap) + return (self.TabStripArea.Width() - gap * (count - 1)) / count + end) + button.Left:Set(function() + local gap = LayoutHelpers.ScaleNumber(TabGap) + local width = (self.TabStripArea.Width() - gap * (count - 1)) / count + return self.TabStripArea.Left() + (slot - 1) * (width + gap) + end) Layouter(button.Bg):Fill(button):End() - Layouter(button.Label):AtHorizontalCenterIn(button):AtVerticalCenterIn(button):End() + self:LayoutButtonContent(button) button.Bg:SetSolidColor(index == self.ActiveTab and TabActiveColor or TabIdleColor) end end, + --- Lays out a button's content — an optional action button, the label, and an optional count + --- pill — centred together as one `[action] [label] [pill]` cluster, so nothing collides with + --- the tab edge for a long label. Each side piece collapses (contributes 0 width) when absent + --- or hidden, re-centring the rest. Private. + ---@param self UICustomLobbyTabs + ---@param button UICustomLobbyTabButton + LayoutButtonContent = function(self, button) + local action = button.Action + local badge = button.Badge + + -- vertical placement for every piece; the action's Width is owned by its visibility wiring + -- (CreateTabButton), the pill's by its text. The action is hit-enabled and overlaps the + -- tab's solid Bg, so lift it a depth above Bg — otherwise (equal default depth) the click + -- could land on the tab background instead of the gear. + Layouter(button.Label):AtVerticalCenterIn(button):End() + if action then + Layouter(action):AtVerticalCenterIn(button):Height(ActionSize):Over(button.Bg, 1):End() + end + if badge then + Layouter(badge):AtVerticalCenterIn(button):Height(BadgeHeight):End() + badge.Width:Set(function() + local textWidth = badge.Text.Width() + if textWidth <= 0 then + return 0 + end + return math.max(LayoutHelpers.ScaleNumber(BadgeMinWidth), textWidth + LayoutHelpers.ScaleNumber(BadgePadH) * 2) + end) + Layouter(badge.Bg):Fill(badge):End() + Layouter(badge.Text):AtCenterIn(badge):End() + end + + -- cluster maths: each item contributes its width plus a leading gap only when it has width + local function gapFor(width) + return width > 0 and LayoutHelpers.ScaleNumber(BadgeGap) or 0 + end + local function actionWidth() + return action and action.Width() or 0 + end + local function badgeWidth() + return badge and badge.Width() or 0 + end + local function clusterLeft() + local cluster = actionWidth() + gapFor(actionWidth()) + + button.Label.Width() + + gapFor(badgeWidth()) + badgeWidth() + return button.Left() + (button.Width() - cluster) / 2 + end + + if action then + action.Left:Set(function() return clusterLeft() end) + end + button.Label.Left:Set(function() + return clusterLeft() + actionWidth() + gapFor(actionWidth()) + end) + if badge then + badge.Left:Set(function() return button.Label.Right() + gapFor(badgeWidth()) end) + end + end, + --- Opens the initial tab. Called by the parent after it has sized this control (the content --- needs a concrete height — three-phase init). ---@param self UICustomLobbyTabs @@ -145,11 +251,12 @@ local CustomLobbyTabs = ClassUI(Group) { end end, - --- Builds one clickable tab button (a tinted group + label). Private. + --- Builds one clickable tab button (a tinted group + label, plus a count pill when the tab + --- defines a `Badge` LazyVar). Private. ---@param self UICustomLobbyTabs ---@param label string ---@param index number - ---@return Group + ---@return UICustomLobbyTabButton CreateTabButton = function(self, label, index) local button = Group(self.TabStripArea, "CustomLobbyTabButton") @@ -160,6 +267,46 @@ local CustomLobbyTabs = ClassUI(Group) { button.Label:SetColor('ffc8ccd0') button.Label:DisableHitTest() + -- optional count pill: created only when the tab supplies a Badge LazyVar; the container + -- just mirrors that LazyVar's string (the owner decides what the count means) + local badgeLazy = self.Tabs[index].Badge + if badgeLazy then + local badge = Group(button, "CustomLobbyTabBadge") --[[@as UICustomLobbyTabBadge]] + badge:DisableHitTest() + badge.Bg = Bitmap(badge) + badge.Bg:SetSolidColor(BadgeColor) + badge.Bg:DisableHitTest() + badge.Text = UIUtil.CreateText(badge, "", 11, UIUtil.bodyFont) + badge.Text:SetColor(BadgeTextColor) + badge.Text:DisableHitTest() + badge:Hide() + button.Badge = badge + + -- the badge Derive fires synchronously on creation (before TabButtons[index] is set), + -- so operate on the captured `badge`, not a lookup by index + self.Trash:Add(LazyVarDerive(badgeLazy, function(badgeTextLazy) + self:SetBadge(badge, badgeTextLazy() or "") + end)) + end + + -- optional per-tab action (e.g. a config gear): the owner builds it (and wires its click), + -- the container places it and drives its visibility from the optional Visible LazyVar. The + -- action's width is owned here — full when shown, 0 when hidden — so the cluster re-centres. + local actionDef = self.Tabs[index].Action + if actionDef then + local action = actionDef.Create(button) + button.Action = action + if actionDef.Visible then + -- fires synchronously on creation (before TabButtons[index] is set) — operate on + -- the captured `action`, not a lookup by index + self.Trash:Add(LazyVarDerive(actionDef.Visible, function(visibleLazy) + self:SetActionVisible(action, visibleLazy() and true or false) + end)) + else + action.Width:Set(LayoutHelpers.ScaleNumber(ActionSize)) + end + end + button.Bg.HandleEvent = function(control, event) if event.Type == 'ButtonPress' then self:SelectTab(index) @@ -181,6 +328,37 @@ local CustomLobbyTabs = ClassUI(Group) { return button end, + --- Updates a count pill from its Badge LazyVar: shows the pill with `text`, or hides it (and + --- clears the text so the cluster re-centres on the bare label) when empty. Private. + ---@param self UICustomLobbyTabs + ---@param badge UICustomLobbyTabBadge + ---@param text string + SetBadge = function(self, badge, text) + if text == "" then + badge.Text:SetText("") + badge:Hide() + else + badge.Text:SetText(text) + badge:Show() + end + end, + + --- Shows or hides an action button. Hiding sets its width to 0 so the label/pill cluster + --- re-centres as if the action weren't there (used to drop a host-only action for clients). + --- Private. + ---@param self UICustomLobbyTabs + ---@param action Control + ---@param shown boolean + SetActionVisible = function(self, action, shown) + if shown then + action:Show() + action.Width:Set(LayoutHelpers.ScaleNumber(ActionSize)) + else + action:Hide() + action.Width:Set(0) + end + end, + ---@param self UICustomLobbyTabs OnDestroy = function(self) if self.CurrentPanel then diff --git a/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua b/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua index 6b7f14a9958..4910f3ab4f2 100644 --- a/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua +++ b/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua @@ -47,16 +47,22 @@ local Tooltip = import("/lua/ui/game/tooltip.lua") local Group = import("/lua/maui/group.lua").Group local Bitmap = import("/lua/maui/bitmap.lua").Bitmap +local Button = import("/lua/maui/button.lua").Button local CustomLobbyMapPreview = import("/lua/ui/lobby/customlobby/customlobbymappreview.lua") local CustomLobbyTabs = import("/lua/ui/lobby/customlobby/customlobbytabs.lua") local CustomLobbyOptionsPanel = import("/lua/ui/lobby/customlobby/config/customlobbyoptionspanel.lua") local CustomLobbyModsPanel = import("/lua/ui/lobby/customlobby/config/customlobbymodspanel.lua") local CustomLobbyUnitsPanel = import("/lua/ui/lobby/customlobby/config/customlobbyunitspanel.lua") local CustomLobbyMapSelect = import("/lua/ui/lobby/customlobby/mapselect/customlobbymapselect.lua") +local CustomLobbyOptionSelect = import("/lua/ui/lobby/customlobby/optionselect/customlobbyoptionselect.lua") +local CustomLobbyModSelect = import("/lua/ui/lobby/customlobby/modselect/customlobbymodselect.lua") local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/customlobbylocalmodel.lua") local CustomLobbyMapCatalog = import("/lua/ui/lobby/customlobby/mapselect/customlobbymapcatalog.lua") +local OptionUtil = import("/lua/ui/optionutil.lua") +local ModUtilities = import("/lua/ui/modutilities.lua") +local LazyVarCreate = import("/lua/lazyvar.lua").Create local LazyVarDerive = import("/lua/lazyvar.lua").Derive local Layouter = LayoutHelpers.ReusedLayoutFor @@ -80,6 +86,37 @@ local ResourceIcon = '/game/build-ui/icon-mass_bmp.dds' local WaterIcon = '/game/camera-btn/pinned_btn_up.dds' local ConfigIcon = '/game/menu-btns/config_btn_up.dds' +-- the per-tab config gear (inside the tab, left of the label): a fully-skinned button +-- (up/down/over/dis) that opens that tab's editor dialog +local GearTextures = { + up = UIUtil.SkinnableFile('/game/menu-btns/config_btn_up.dds'), + down = UIUtil.SkinnableFile('/game/menu-btns/config_btn_down.dds'), + over = UIUtil.SkinnableFile('/game/menu-btns/config_btn_over.dds'), + dis = UIUtil.SkinnableFile('/game/menu-btns/config_btn_dis.dds'), +} + +--- Builds a tab `Action` (a config gear) for `CustomLobbyTabs`: a skinned button that opens +--- `onOpen` on click, with a tooltip. `visibleLazy` (optional) hides the gear when it doesn't apply +--- — e.g. the host-only Options gear is hidden for clients (the tab's label re-centres). +---@param onOpen fun() +---@param title string +---@param body string +---@param visibleLazy? LazyVar +---@return UICustomLobbyTabAction +local function GearAction(onOpen, title, body, visibleLazy) + return { + Create = function(parent) + local gear = Button(parent, GearTextures.up, GearTextures.down, GearTextures.over, GearTextures.dis) + gear.OnClick = function(button, modifiers) + onOpen() + end + Tooltip.AddControlTooltipManual(gear, title, body) + return gear + end, + Visible = visibleLazy, + } +end + -- A small square icon button used in the preview tool strip. A toggle flips Active on click and -- calls `OnToggle(active)`; an action button (isToggle = false) just calls `OnPress`. The look is -- the tab convention: idle / hover / lit-when-active background, with the icon dimmed when off. @@ -200,6 +237,9 @@ end ---@field WaterToggle UICustomLobbyPreviewTool ---@field ConfigButton UICustomLobbyPreviewTool ---@field Tabs UICustomLobbyTabs +---@field OptionsBadge LazyVar # count of non-default options (Options tab pill) +---@field ModsBadge LazyVar # "sim / ui" mod counts (Mods tab pill) +---@field RestrictionsBadge LazyVar # restriction count (Restrictions tab pill) ---@field ScenarioObserver LazyVar ---@field IsHostObserver LazyVar local CustomLobbyConfigInterface = ClassUI(Group) { @@ -242,12 +282,54 @@ local CustomLobbyConfigInterface = ClassUI(Group) { Tooltip.AddControlTooltipManual(self.ConfigButton.Bg, "Change map", "Pick a different scenario (host only).") --#endregion - -- the read-only config tabs below the preview (created-on-select / destroyed-on-switch) + -- count badges for the tab strip: computed LazyVars over the launch model (the tabs + -- container observes them and renders the grey pills). Built before the tabs so each + -- button can subscribe at creation. + local launch = CustomLobbyLaunchModel.GetSingleton() + + self.OptionsBadge = self.Trash:Add(LazyVarCreate()) + self.OptionsBadge:Set(function() + local count = OptionUtil.CountNonDefault(launch.ScenarioFile(), launch.GameMods(), launch.GameOptions()) + return count > 0 and tostring(count) or "" + end) + + -- "sim / ui" — sim mods are the synced GameMods; UI mods are this peer's prefs (not a + -- reactive field, so the count refreshes whenever the sim mods change, which is good enough + -- until the mod-select dialog is rewired). + self.ModsBadge = self.Trash:Add(LazyVarCreate()) + self.ModsBadge:Set(function() + local sim = table.getsize(launch.GameMods()) + local ui = table.getsize(ModUtilities.GetSelectedUIMods()) + if sim == 0 and ui == 0 then + return "" + end + return sim .. " / " .. ui + end) + + -- TODO: unit restrictions aren't modelled yet (the Restrictions panel is a placeholder); + -- point this at the restriction count once that slice lands. Empty → no pill for now. + self.RestrictionsBadge = self.Trash:Add(LazyVarCreate()) + self.RestrictionsBadge:Set("") + + -- the read-only config tabs below the preview (created-on-select / destroyed-on-switch), + -- each with a config gear (inside the tab, left of the label) opening that tab's editor. + -- Options is host-only — its gear hides for clients (the host gate is the IsHost LazyVar); + -- Mods is open to everyone (UI mods are local, the sim portion is host-gated in the dialog); + -- Restrictions has no editor yet, so no gear. + local isHost = CustomLobbyLocalModel.GetSingleton().IsHost self.Tabs = CustomLobbyTabs.Create(self, { Tabs = { - { Label = "Options", Create = CustomLobbyOptionsPanel.Create }, - { Label = "Mods", Create = CustomLobbyModsPanel.Create }, - { Label = "Restrictions", Create = CustomLobbyUnitsPanel.Create }, + { + Label = "Options", Create = CustomLobbyOptionsPanel.Create, Badge = self.OptionsBadge, + Action = GearAction(function() CustomLobbyOptionSelect.Open(GetFrame(0)) end, + "Edit options", "Open the game options editor (host only).", isHost), + }, + { + Label = "Mods", Create = CustomLobbyModsPanel.Create, Badge = self.ModsBadge, + Action = GearAction(function() CustomLobbyModSelect.Open(GetFrame(0)) end, + "Manage mods", "Pick the game's sim mods (host) and your own UI mods."), + }, + { Label = "Restrictions", Create = CustomLobbyUnitsPanel.Create, Badge = self.RestrictionsBadge }, }, }) @@ -256,6 +338,7 @@ local CustomLobbyConfigInterface = ClassUI(Group) { scenarioFileLazy() self:RefreshFacts() end)) + -- the change-map button is host-only (the gears are gated per-tab via their Visible LazyVar) self.IsHostObserver = self.Trash:Add( LazyVarDerive(CustomLobbyLocalModel.GetSingleton().IsHost, function(isHostLazy) if isHostLazy() then diff --git a/lua/ui/lobby/customlobby/slots/CustomLobbySlotsInterface.lua b/lua/ui/lobby/customlobby/slots/CustomLobbySlotsInterface.lua index df8eae60b3b..ae0fb7bc5b7 100644 --- a/lua/ui/lobby/customlobby/slots/CustomLobbySlotsInterface.lua +++ b/lua/ui/lobby/customlobby/slots/CustomLobbySlotsInterface.lua @@ -36,6 +36,7 @@ local UIUtil = import("/lua/ui/uiutil.lua") local LayoutHelpers = import("/lua/maui/layouthelpers.lua") +local Tooltip = import("/lua/ui/game/tooltip.lua") local Group = import("/lua/maui/group.lua").Group local Bitmap = import("/lua/maui/bitmap.lua").Bitmap @@ -52,15 +53,112 @@ local Layouter = LayoutHelpers.ReusedLayoutFor local HeaderHeight = 24 -- the "Players" header + gap above the layout body +-- the header tool buttons (right of the "Players" label): small square icon buttons, the same +-- idle/hover/lit look as the config column's preview tools (drift-fine local copy — see CLAUDE.md). +local ToolSize = 18 +local ToolGap = 4 +local ToolIconInset = 3 +local ToolIdle = 'ff141a20' +local ToolHover = 'ff1f262e' +local ToolActive = 'ff2c4a5e' -- a lit toggle's background (pin on) +local ToolIconDim = 0.45 -- icon alpha when a toggle is off + +-- icon textures (skin-relative, resolved through CreateBitmap). Pin reuses the camera pin glyph, +-- balance the lobby auto-balance button art, reopen the circular recall ("refresh") icon. +local PinIcon = '/game/camera-btn/pinned_btn_up.dds' +local BalanceIcon = '/BUTTON/autobalance/_btn_up.dds' +local ReopenIcon = '/game/recall-panel/icon-recall_bmp.dds' + +--- A small square icon button for the header tool strip: a solid background that lights on hover, +--- plus an `Active` state (driven externally for the pin toggle) that lights it the "on" colour and +--- un-dims the icon. Clicking calls `OnPress`; the owner decides what that means (fire an intent, +--- flip a synced flag, …). Mirrors the config column's `PreviewTool`. +---@class UICustomLobbySlotTool : Group +---@field Bg Bitmap +---@field Icon Bitmap +---@field Active boolean +---@field Hovered boolean +---@field OnPress? fun() +local SlotTool = Class(Group) { + + ---@param self UICustomLobbySlotTool + ---@param parent Control + ---@param texture FileName + __init = function(self, parent, texture) + Group.__init(self, parent, "CustomLobbySlotTool") + + self.Active = false + self.Hovered = false + + self.Bg = Bitmap(self) + self.Bg:SetSolidColor(ToolIdle) + + self.Icon = UIUtil.CreateBitmap(self, texture) + self.Icon:DisableHitTest() + + self.Bg.HandleEvent = function(control, event) + if event.Type == 'ButtonPress' then + if self.OnPress then + self.OnPress() + end + return true + elseif event.Type == 'MouseEnter' then + self.Hovered = true + self:ApplyVisual() + return true + elseif event.Type == 'MouseExit' then + self.Hovered = false + self:ApplyVisual() + return true + end + return false + end + end, + + ---@param self UICustomLobbySlotTool + __post_init = function(self) + Layouter(self.Bg):Fill(self):End() + Layouter(self.Icon):AtCenterIn(self):Width(ToolSize - 2 * ToolIconInset):Height(ToolSize - 2 * ToolIconInset):End() + self:ApplyVisual() + end, + + --- Repaints the background + icon for the current active/hover state. + ---@param self UICustomLobbySlotTool + ApplyVisual = function(self) + local bg = ToolIdle + if self.Active then + bg = ToolActive + elseif self.Hovered then + bg = ToolHover + end + self.Bg:SetSolidColor(bg) + self.Icon:SetAlpha(self.Active and 1.0 or ToolIconDim) + end, + + --- Sets the lit/active state (the pin toggle drives this from the synced model). + ---@param self UICustomLobbySlotTool + ---@param active boolean + SetActive = function(self, active) + self.Active = active + self:ApplyVisual() + end, +} + ---@alias UICustomLobbySlotsBody UICustomLobbyOneColumnSlots | UICustomLobbyTwoColumnSlots ---@class UICustomLobbySlotsInterface : Group, UICustomLobbySlotCoordinator ---@field Trash TrashBag ---@field Header Text +---@field Tools Group # host-only tool strip (right of the header) +---@field PinButton UICustomLobbySlotTool +---@field BalanceButton UICustomLobbySlotTool +---@field ReopenButton UICustomLobbySlotTool ---@field Body UICustomLobbySlotsBody | false # the active layout body ---@field LayoutKind "one" | "two" | false # which layout Body currently is ---@field Mounted boolean # true once __post_init has laid us out ---@field GameOptionsObserver LazyVar +---@field IsHostObserver LazyVar +---@field SlotsPinnedObserver LazyVar ---@field HighlightedSlot number | false # slot currently shown as a drop target ---@field DragGhost Group | false # floating label following the cursor mid-drag local CustomLobbySlotsInterface = Class(Group) { @@ -81,6 +179,47 @@ local CustomLobbySlotsInterface = Class(Group) { self.Header:SetColor('ff9aa0a8') self.Header:DisableHitTest() + -- host-only tool strip, right-aligned in the header band: pin seating · auto-balance · + -- reopen closed slots. Grouped so a single Show/Hide gates them all on host status. + self.Tools = Group(self, "CustomLobbySlotTools") + + self.PinButton = SlotTool(self.Tools, PinIcon) + self.PinButton.OnPress = function() + CustomLobbyController.RequestSetSlotsPinned(not CustomLobbySessionModel.GetSingleton().SlotsPinned()) + end + Tooltip.AddControlTooltipManual(self.PinButton.Bg, "Pin slots", + "Lock seating so only you (the host) can move players between slots.") + + self.BalanceButton = SlotTool(self.Tools, BalanceIcon) + self.BalanceButton.OnPress = function() + CustomLobbyController.RequestAutoBalance() + end + Tooltip.AddControlTooltipManual(self.BalanceButton.Bg, "Auto balance", + "Balance the seated players across the teams.") + + self.ReopenButton = SlotTool(self.Tools, ReopenIcon) + self.ReopenButton.OnPress = function() + CustomLobbyController.RequestReopenClosedSlots() + end + Tooltip.AddControlTooltipManual(self.ReopenButton.Bg, "Reopen closed slots", + "Close, then re-open every closed slot to refresh the lobby for everyone.") + + -- the tool strip is a host action set; hide it for clients + self.IsHostObserver = self.Trash:Add( + LazyVarDerive(CustomLobbyLocalModel.GetSingleton().IsHost, function(isHostLazy) + if isHostLazy() then + self.Tools:Show() + else + self.Tools:Hide() + end + end)) + + -- light the pin button while seating is pinned (synced session state) + self.SlotsPinnedObserver = self.Trash:Add( + LazyVarDerive(CustomLobbySessionModel.GetSingleton().SlotsPinned, function(pinnedLazy) + self.PinButton:SetActive(pinnedLazy()) + end)) + -- the active layout body, picked by the AutoTeams mode (created now, laid out on mount) self:RebuildBody() @@ -96,6 +235,21 @@ local CustomLobbySlotsInterface = Class(Group) { ---@param self UICustomLobbySlotsInterface __post_init = function(self) Layouter(self.Header):AtLeftIn(self, 4):AtTopIn(self):End() + + -- the tool strip: a fixed-width band pinned top-right, the three square buttons inside it + -- laid out right-to-left (Pin · Balance · Reopen, reading left-to-right), centred on the header + Layouter(self.Tools) + :AtRightIn(self, 4) + :AtVerticalCenterIn(self.Header) + :Width(3 * ToolSize + 2 * ToolGap):Height(ToolSize) + :End() + local function placeTool(tool) + return Layouter(tool):AtTopIn(self.Tools):Width(ToolSize):Height(ToolSize) + end + placeTool(self.ReopenButton):AtRightIn(self.Tools):End() + placeTool(self.BalanceButton):LeftOf(self.ReopenButton, ToolGap):End() + placeTool(self.PinButton):LeftOf(self.BalanceButton, ToolGap):End() + self.Mounted = true self:LayoutBody() end, diff --git a/lua/ui/optionutil.lua b/lua/ui/optionutil.lua index cef6daa8b03..6d9a227f461 100644 --- a/lua/ui/optionutil.lua +++ b/lua/ui/optionutil.lua @@ -241,6 +241,29 @@ function ValueLabels(option) return labels end +--- Counts the options across all three sources (lobby + scenario + selected mods) whose current +--- value differs from its default — i.e. how many options the host has changed. This is the same +--- union the options dialog / Options panel render (mod options are deduped by key); it backs the +--- Options tab's count badge. +---@param scenarioFile FileName | false +---@param gameMods table +---@param values table +---@return number +function CountNonDefault(scenarioFile, gameMods, values) + local count = 0 + local function tally(options) + for _, option in options do + if not IsDefault(option, values) then + count = count + 1 + end + end + end + tally(GetLobbyOptions()) + tally(GetScenarioOptions(scenarioFile)) + tally(GetModOptions(gameMods)) + return count +end + --- Fills `values` (a copy) with the default for every option in `options` that has no value yet, --- so the result is a complete set ready to launch with. Returns the copy. ---@param options ScenarioOption[] diff --git a/scripts/LaunchCustomLobby.ps1 b/scripts/LaunchCustomLobby.ps1 index 2bca45a1c50..056dfbf7dc2 100644 --- a/scripts/LaunchCustomLobby.ps1 +++ b/scripts/LaunchCustomLobby.ps1 @@ -63,6 +63,44 @@ $hostProtocol = "udp" $hostPlayerName = "HostPlayer_1" $gameName = "DevLobby" +# Random pools to draw per-player info from (mirrors scripts\LaunchFAInstances.ps1). +$factions = @("uef", "aeon", "cybran", "seraphim") +$clans = @("Yps", "Nom", "Cly", "Mad", "Gol", "Kur", "Row", "Jip", "Bal", "She") +$countries = @("us", "gb", "de", "fr", "nl", "ru", "ca", "au", "br", "pl") +$divisions = @("bronze", "silver", "gold", "diamond", "master", "grandmaster", "unlisted") +$subdivisions = @("I", "II", "III", "IV", "V") + +# Returns a randomised set of per-player arguments, mirroring what the FAF client passes for a +# real player. The custom lobby reads these when it builds the local player (see +# CustomLobbyController.CreateLocalPlayer). The host preserves them when seating a joining peer. +# * rating: the displayed rating (PL) is derived as mean - 3 * deviation; the ranges below keep +# it positive and realistic for a FAF player. +# * faction: a flag arg (/uef, /aeon, …) — no value. +# * team: 2 or 3 (shown as T1 / T2), so the team-aware layouts have a split to render. +# * grandmaster/unlisted have no subdivision (mirrors LaunchFAInstances). +function Get-PlayerArgs { + $mean = Get-Random -Minimum 800 -Maximum 2400 + $deviation = Get-Random -Minimum 50 -Maximum 250 + $numGames = Get-Random -Minimum 0 -Maximum 2000 + $team = Get-Random -Minimum 2 -Maximum 4 + + $playerArgs = @( + "/mean", $mean, "/deviation", $deviation, "/numgames", $numGames, + "/$($factions | Get-Random)", + "/team", $team, + "/clan", ($clans | Get-Random), + "/country", ($countries | Get-Random) + ) + + $division = $divisions | Get-Random + $playerArgs += @("/division", $division) + if ($division -ne "unlisted" -and $division -ne "grandmaster") { + $playerArgs += @("/subdivision", ($subdivisions | Get-Random)) + } + + return $playerArgs +} + # Arguments shared by every instance. Note: NO `/players` argument — that is what keeps us # on the regular lobby instead of the autolobby. $commonArgs = @( @@ -104,7 +142,7 @@ function Launch-LobbyInstance { $hostArgs = @( "/log", "host_lobby_1.log", "/hostgame", $hostProtocol, $port, $hostPlayerName, $gameName, $map -) + $commonArgs +) + $commonArgs + (Get-PlayerArgs) Launch-LobbyInstance -instanceNumber 1 -xPos 0 -yPos 0 -arguments $hostArgs # Joining client instances. Positional order for /joingame is protocol, address, name. @@ -118,7 +156,7 @@ for ($i = 1; $i -lt $players; $i++) { $clientArgs = @( "/log", "client_lobby_$($i + 1).log", "/joingame", $hostProtocol, "localhost:$port", $clientName - ) + $commonArgs + ) + $commonArgs + (Get-PlayerArgs) Launch-LobbyInstance -instanceNumber ($i + 1) -xPos $xPos -yPos $yPos -arguments $clientArgs } From 5a7bae4751ad6a06a7ca597f368d4afac8a59b49 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Thu, 25 Jun 2026 07:46:32 +0200 Subject: [PATCH 51/98] Add a basic logs panel, for debugging --- lua/ui/lobby/customlobby/CLAUDE.md | 9 +- .../lobby/customlobby/CustomLobbyInstance.lua | 4 + .../customlobby/CustomLobbyInterface.lua | 8 + lua/ui/lobby/customlobby/CustomLobbyLog.lua | 165 ++++++++++++++++++ lua/ui/lobby/customlobby/CustomLobbyTabs.lua | 104 +++++++++-- .../slots/CustomLobbySlotsInterface.lua | 64 +++++-- .../social/CustomLobbyLogsPanel.lua | 165 ++++++++++++++++++ lua/ui/lobby/lobby.lua | 3 + 8 files changed, 485 insertions(+), 37 deletions(-) create mode 100644 lua/ui/lobby/customlobby/CustomLobbyLog.lua create mode 100644 lua/ui/lobby/customlobby/social/CustomLobbyLogsPanel.lua diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index 73a3605e283..bfa7318095e 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -44,14 +44,15 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | [CustomLobbySessionModel.lua](CustomLobbySessionModel.lua) | shared, lobby-room-only state (slot count, closed slots). | | [CustomLobbyLocalModel.lua](CustomLobbyLocalModel.lua) | per-peer state, never synced: identity + CPU benchmarks. | | [CustomLobbyPerformancePopover.lua](CustomLobbyPerformancePopover.lua) | hover popover over the CPU column; hand-built bitmap bar chart of a peer's `PerformanceTrackingV2` history, with a yellow recommended-unit-cap line. | -| [CustomLobbyInstance.lua](CustomLobbyInstance.lua) | thin `moho.lobby_methods` shell; validates/dispatches traffic, forwards callbacks to the controller. | +| [CustomLobbyInstance.lua](CustomLobbyInstance.lua) | thin `moho.lobby_methods` shell; validates/dispatches traffic, forwards callbacks to the controller. Also feeds [`CustomLobbyLog`](CustomLobbyLog.lua) from its three network choke points (BroadcastData / SendData / DataReceived). | +| [CustomLobbyLog.lua](CustomLobbyLog.lua) | the **network traffic log** — a reactive, per-peer, never-synced ring buffer (`Entries` LazyVar, capped) of every message this peer broadcasts / sends / receives, fed by the instance and rendered by the **Logs** tab. Each peer logs only its own traffic, so host and client views differ naturally. Not one of the three models (no game state); a diagnostic feed. | | [CustomLobbyController.lua](CustomLobbyController.lua) | host-authority logic (free functions): seating, `Process*` handlers, intents (`RequestSetReady`, `RequestTakeSlot`, `RequestSwapSlots`, `RequestEject`, `RequestMoveToObserver`, `RequestSetScenario`, `RequestSetGameMods/Options`, `RequestResetGameOptions`, `RequestSetSlotsPinned`, `RequestAutoBalance` (stub), `RequestReopenClosedSlots`, `RequestLaunch` — all keyed by slot/bool/file so a chat command can call them too; permission is gated separately), sharing the stored CPU benchmark. **Launch:** `RequestLaunch` (host-only, readiness-validated) → `BuildGameConfiguration` (seed option defaults + scenario, resolve random factions, assign army numbers + push to server, stamp ratings/clan tags, resolve sim mods via `Mods.GetGameMods`) → broadcast `LaunchGame` + `instance:LaunchGame`; clients run `ProcessLaunchGame`. | | [CustomLobbyRules.lua](CustomLobbyRules.lua) | game-rule derivations from lobby state (not view, not networking): `RecommendedUnitCap()` (per-player cap by map size, memoised scenario lookup). | | [CustomLobbySession.lua](CustomLobbySession.lua) | the lobby session's **main trash bag**. The lobby lives in the persistent front-end Lua state, which is *not* reset when the game launches in its own state — so anything left reachable (a running thread, a cache, a singleton) leaks for the whole match. This module owns one session-lifetime `TrashBag` (`GetTrash()`); every lobby-scoped `Destroyable` registers in it, and one `Teardown()` (clean-slate in `CreateLobby`, on leave, on `OnGameLaunched`) frees the lot. Works despite the bag being weak-valued because each singleton is pinned by its own module `Instance` local. **Rollout in progress** — the map catalog is the first resource converted; models / interface / instance follow. | | [CustomLobbyMessages.lua](CustomLobbyMessages.lua) | message registry: `AddPlayer`, `SetPlayers` (launch model: players + observers), `SentLaunchInfo` (launch model: scenario / options / mods / teams / spawn mex), `SetSessionState` (session model: slot count / closed slots), `SetReady`, `TakeSlot`, `DisconnectPeer`, `LaunchGame` (host's final game config → `instance:LaunchGame`), `ReportCpuBenchmark`, `SetCpuBenchmarks`. | | [CustomLobbyContextMenu.lua](CustomLobbyContextMenu.lua) | generic framed floating menu; `Show(entries, x, y)` renders any `{label, action, enabled}` list, dismisses on item click / click-outside / Esc. Knows nothing about the lobby. | | [CustomLobbyMenus.lua](CustomLobbyMenus.lua) | declarative menu **definitions**: entry lists with `when(ctx)`/`action(ctx)` filtered by lobby state (`BuildSlotMenu`). Adding/state-gating an item is a one-liner here. | -| [slots/](slots/) | the **slot subsystem** (its own folder, a sub-folder per layout). [`CustomLobbySlotsInterface`](slots/CustomLobbySlotsInterface.lua) is the entry the composition root mounts: a "Players" header over the **active layout body**, picked by the AutoTeams mode — one-column for the non-team modes, the two-column team layout for the binary modes (left/right, top/bottom, even/odd). It observes `GameOptions` and swaps the body when the kind flips (create-on-mode / destroy-on-switch); a change *within* the binary modes is handled by the two-column body's own re-layout. It is the rows' **drag coordinator** (`UICustomLobbySlotCoordinator`: hit-test which row a point is in, drop-highlight, drag ghost → `RequestSwapSlots`) for *every* layout — it alone needs to hit-test across rows — so the layout bodies stay pure build/place/reveal and never duplicate the drag logic; it also exposes `PreferredHeight()` (computed from the mode + model, swap-order-independent) so the root sizes the slot area. The header band carries a **host-only tool strip** (right-aligned: pin seating · auto-balance · reopen closed slots) of small icon buttons (local `SlotTool`, mirroring the config column's `PreviewTool`); the pin button is lit from the synced `SlotsPinned` and the strip is hidden for clients. [`CustomLobbySlotBase`](slots/CustomLobbySlotBase.lua) holds **all slot behaviour** (the slot + CPU subscriptions, the CPU-cap math, click/right-click/drag-to-swap + intents, the background/highlight/click overlays) and the default `RenderPlayer`/`RenderCpu` that paint the standard named controls from normalised views; a presentation subclasses it and implements just `CreateContents` / `LayoutContents`. [`onecolumn/`](slots/onecolumn/): [`CustomLobbyOneColumnSlots`](slots/onecolumn/CustomLobbyOneColumnSlots.lua) (stacks thin rows, reveals 1..count) + [`CustomLobbySlotRow`](slots/onecolumn/CustomLobbySlotRow.lua) (thin one-line presentation). [`twocolumn/`](slots/twocolumn/): [`CustomLobbyTwoColumnSlots`](slots/twocolumn/CustomLobbyTwoColumnSlots.lua) (splits slots into two team columns via [`CustomLobbyRules.BuildSideResolver`](CustomLobbyRules.lua), with a [`CustomLobbyTeamScore`](CustomLobbyTeamScore.lua) strip across the top as the Left/Right indicator, and the "two columns, unresolved" fallback — parity fill + the score self-hides until a positional map's start positions load) + [`CustomLobbySlotCard`](slots/twocolumn/CustomLobbySlotCard.lua) (fat half-width / two-line presentation; `SetMirrored` flips the right column's cards so the two teams face each other). Each body exports `HeightForCount`. | +| [slots/](slots/) | the **slot subsystem** (its own folder, a sub-folder per layout). [`CustomLobbySlotsInterface`](slots/CustomLobbySlotsInterface.lua) is the entry the composition root mounts: a "Players" header over the **active layout body**, picked by the AutoTeams mode — one-column for the non-team modes, the two-column team layout for the binary modes (left/right, top/bottom, even/odd). It observes `GameOptions` and swaps the body when the kind flips (create-on-mode / destroy-on-switch); a change *within* the binary modes is handled by the two-column body's own re-layout. It is the rows' **drag coordinator** (`UICustomLobbySlotCoordinator`: hit-test which row a point is in, drop-highlight, drag ghost → `RequestSwapSlots`) for *every* layout — it alone needs to hit-test across rows — so the layout bodies stay pure build/place/reveal and never duplicate the drag logic; it also exposes `PreferredHeight()` (computed from the mode + model, swap-order-independent) so the root sizes the slot area. The header band carries a **host-only tool strip** (right-aligned: pin seating · auto-balance · reopen closed slots) of small icon buttons (local `SlotTool`, mirroring the config column's `PreviewTool`); the pin button is lit from the synced `SlotsPinned` and the strip is hidden for clients. A **"Locked" notice** (lock glyph + label) sits right of the "Players" label and is shown to **everyone** while `SlotsPinned` is on, so a client can see seating is host-controlled before clicking an open slot to no effect. [`CustomLobbySlotBase`](slots/CustomLobbySlotBase.lua) holds **all slot behaviour** (the slot + CPU subscriptions, the CPU-cap math, click/right-click/drag-to-swap + intents, the background/highlight/click overlays) and the default `RenderPlayer`/`RenderCpu` that paint the standard named controls from normalised views; a presentation subclasses it and implements just `CreateContents` / `LayoutContents`. [`onecolumn/`](slots/onecolumn/): [`CustomLobbyOneColumnSlots`](slots/onecolumn/CustomLobbyOneColumnSlots.lua) (stacks thin rows, reveals 1..count) + [`CustomLobbySlotRow`](slots/onecolumn/CustomLobbySlotRow.lua) (thin one-line presentation). [`twocolumn/`](slots/twocolumn/): [`CustomLobbyTwoColumnSlots`](slots/twocolumn/CustomLobbyTwoColumnSlots.lua) (splits slots into two team columns via [`CustomLobbyRules.BuildSideResolver`](CustomLobbyRules.lua), with a [`CustomLobbyTeamScore`](CustomLobbyTeamScore.lua) strip across the top as the Left/Right indicator, and the "two columns, unresolved" fallback — parity fill + the score self-hides until a positional map's start positions load) + [`CustomLobbySlotCard`](slots/twocolumn/CustomLobbySlotCard.lua) (fat half-width / two-line presentation; `SetMirrored` flips the right column's cards so the two teams face each other). Each body exports `HeightForCount`. | | [CustomLobbyObserversInterface.lua](CustomLobbyObserversInterface.lua) | observer strip; subscribes to the model's `Observers` list and shows the count + names (read-only). | | [CustomLobbyScenarioPreview.lua](CustomLobbyScenarioPreview.lua) | **shared** map-preview *surface*: the scenario's map texture + overlays (start spots, resource/wreck markers, plus a **dummy translucent `WaterMask`** placeholder until a real mask exists) with aspect-correct positioning, texture-leak-safe icon sharing, per-group visibility (`SetOverlayVisible('spawns'\|'resources'\|'wrecks'\|'water', …)`), and three-phase init. Chrome-free; `CustomLobbyMapPreview` wraps it with the frame. Spawn appearance is the owner's via a `CreateSpawnIcon` factory. | | [CustomLobbyMapPreview.lua](CustomLobbyMapPreview.lua) | the map preview **as one whole** — the chrome (glow border on top + dark backdrop, surface inset by `Padding`), the surface, and the faction spawn icon (local `MapPreviewSpawn`). Used by **both** consumers: created `Bound = true` it subscribes to the launch model and renders the committed `ScenarioFile` with per-slot faction spawns (no reload on take/swap); created unbound (the map-select dialog) it does no model wiring and the owner drives `preview.Surface` directly (numbered-dot spawns). Exposes `.Surface` for owners to drive / anchor overlays to. | @@ -60,8 +61,8 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | [optionselect/](optionselect/CLAUDE.md) | the **options dialog**: three columns (lobby / scenario / mod options) over the selected scenario + mods, with search + hide-defaults filters; non-default options are marked. Derives the option *schema* per-peer (reference data) via [`/lua/ui/optionutil.lua`](/lua/ui/optionutil.lua); edits a working copy of the *values* and on OK routes the reconciled set through `RequestSetGameOptions` (synced via `GameOptions`). Host-only. See [optionselect/CLAUDE.md](optionselect/CLAUDE.md). | | [CustomLobbyInterface.lua](CustomLobbyInterface.lua) | composition root, laid out in **areas** (flip the module `Debug` flag to tint them), sized for the **1024×768** floor. A **one-column** layout (the two-column variant was reverted by community request): a **title bar** (title · team score) over a **left column** split vertically — the **slots** on top (a single column of rows, up to 16, height tracking the slot count) and the **Chat / Observers** tabs below — beside a fixed-width **right column** (the config component: a pinned map preview + facts line over Options / Mods / Restrictions tabs). A full-width **action bar** at the bottom holds the global actions: **Leave** + status on the left, the host-only **Settings** button (opens the options editor) + the host-only **Launch** on the right. Reads IsHost (action-bar buttons) + SlotCount (to size the slot area via `CustomLobbySlotsInterface.HeightForSlots`); Leave (Esc handler) + **Become observer** (`RequestMoveToObserver`, in the Observers tab). The slot rows + their drag coordination now live in [`CustomLobbySlotsInterface`](CustomLobbySlotsInterface.lua), which fills the slot area. `OpenDebug()` / hot-reload. | | [CustomLobbyTeamScore.lua](CustomLobbyTeamScore.lua) | the **accumulated team rating** side indicator — `Side A N · M Side B`. Hosted in the strip atop the two-column slot layout (it doubles as the columns' Left/Right header). Shown only for the binary auto-team formations; **hidden** for `none`/`manual` or until a positional map's start positions load. Reads the mode + side split from [`CustomLobbyRules`](CustomLobbyRules.lua) (`AutoTeamMode` / `SideLabels` / `BuildSideResolver`) and the ratings from each slot's `PL`. Reference data; never writes. | -| [CustomLobbyTabs.lua](CustomLobbyTabs.lua) | a **generic tabbed panel** (strip + content; one panel alive, created on select / destroyed on switch). Tabs **divide the strip evenly** across its width. Construct with a `{ Label, Create, Badge?, Action? }` list + optional `OnSelect`. A tab's optional `Badge` LazyVar drives a grey **count pill** to the right of the label; its optional `Action` (`{ Create, Visible? }`) is a small button the owner builds **inside the tab, left of the label** (e.g. a config gear), whose `Visible` LazyVar hides it (collapsing it from the layout) when it doesn't apply. The action, label and pill are centred together as one cluster; any absent/hidden/empty piece contributes 0 width so the rest re-centres — the container just mirrors the LazyVars, the owner decides what they mean. Used for the bottom-left (Chat / Observers) and the config interface's Options / Mods / Restrictions. | -| [social/](social/) | the lobby's **bottom-left** column (the `CustomLobbyTabs` content): [`CustomLobbyChatPanel`](social/CustomLobbyChatPanel.lua) (the **Chat** tab — placeholder until the chat slice lands) and [`CustomLobbyObserversPanel`](social/CustomLobbyObserversPanel.lua) (the **Observers** tab — the shared observer list + a host-authoritative **Become observer** button → `RequestMoveToObserver`). Each is a tab content component (`Create(parent)`, created on select / destroyed on switch). Both tabs mirror the config column's shape — a per-tab **config gear** (`CustomLobbyInterface`'s local `GearAction`; both no-ops with a "coming soon" tooltip for now) + a right-side **count pill**: Observers shows the live observer count, Chat a dummy until the chat slice lands. | +| [CustomLobbyTabs.lua](CustomLobbyTabs.lua) | a **generic tabbed panel** (strip + content; one panel alive, created on select / destroyed on switch). Tabs **divide the strip evenly** across its width. Construct with a `{ Label, Create, Badge?, Action?, Icon?, Compact? }` list + optional `OnSelect`. A tab's optional `Badge` LazyVar drives a grey **count pill** to the right of the label; its optional `Action` (`{ Create, Visible? }`) is a small button the owner builds **inside the tab, left of the label** (e.g. a config gear), whose `Visible` LazyVar hides it (collapsing it from the layout) when it doesn't apply. The action, label and pill are centred together as one cluster; any absent/hidden/empty piece contributes 0 width so the rest re-centres. A tab can instead be **`Compact`** (a fixed narrow width, excluded from the even division — the flexible tabs share what's left) and/or show an **`Icon`** centred instead of its label (an icon-only utility tab); the default active tab is the first non-compact one. The container just mirrors the LazyVars, the owner decides what they mean. Used for the bottom-left (Logs / Chat / Observers) and the config interface's Options / Mods / Restrictions. | +| [social/](social/) | the lobby's **bottom-left** column (the `CustomLobbyTabs` content): [`CustomLobbyChatPanel`](social/CustomLobbyChatPanel.lua) (the **Chat** tab — placeholder until the chat slice lands) and [`CustomLobbyObserversPanel`](social/CustomLobbyObserversPanel.lua) (the **Observers** tab — the shared observer list + a host-authoritative **Become observer** button → `RequestMoveToObserver`). Each is a tab content component (`Create(parent)`, created on select / destroyed on switch). The Chat / Observers tabs mirror the config column's shape — a per-tab **config gear** (`CustomLobbyInterface`'s local `GearAction`; both no-ops with a "coming soon" tooltip for now) + a right-side **count pill**: Observers shows the live observer count, Chat a dummy until the chat slice lands. A third **compact, icon-only [`CustomLobbyLogsPanel`](social/CustomLobbyLogsPanel.lua)** sits left of Chat (the **Logs** tab — a live scrolling view of this peer's network traffic from [`CustomLobbyLog`](CustomLobbyLog.lua)). | | [config/](config/) | the lobby's **right** column. [`CustomLobbyConfigInterface.lua`](config/CustomLobbyConfigInterface.lua) is the column **composition**: a bound square `CustomLobbyMapPreview` **pinned** at the top with a vertical **preview tool strip** to its right (local `PreviewTool` icon buttons — toggles for army/start icons, mass+hydro deposits and the dummy water mask, driving `preview.Surface:SetOverlayVisible`, + a host-only **change-map** config icon at the bottom → the map-select dialog), a name + size/players/version facts line under it, and a [`CustomLobbyTabs`](CustomLobbyTabs.lua) (**Options / Mods / Restrictions**) filling the rest. Each tab carries its own **config gear** (`CustomLobbyTabs`' per-tab `Action`, built by the interface's `GearAction` helper) — a skinned button **inside the tab, left of the label** — that opens that tab's editor: Options → `CustomLobbyOptionSelect`, Mods → `CustomLobbyModSelect`, Restrictions → none yet (no gear). The Options gear is **host-only**: its `Visible` LazyVar is the `IsHost` field, so it's **hidden** for clients and the label re-centres; the Mods gear shows for everyone (UI mods are local; the sim portion is host-gated inside the dialog). The interface also owns the tabs' **count badges** as computed LazyVars over the launch model — Options shows the non-default-option count (`OptionUtil.CountNonDefault`), Mods shows `sim / ui` (synced sim mods / this peer's UI-mod prefs), Restrictions is wired but empty until the restrictions slice lands. All three tab panels are now **read-only**: their per-domain action buttons (open editor / reset / manage mods) are removed — the grid/content fills the whole panel — and the action-bar **Settings** button (now joined by this gear) is the edit entry point. [`CustomLobbyOptionsPanel`](config/CustomLobbyOptionsPanel.lua) (options grouped **Lobby / Scenario / Mods** + hide-defaults toggle; map/mod options gold-flagged with an origin tooltip — schema via [`/lua/ui/optionutil.lua`](/lua/ui/optionutil.lua)), [`CustomLobbyModsPanel`](config/CustomLobbyModsPanel.lua) (enabled mods in **Game / UI** sections), [`CustomLobbyUnitsPanel`](config/CustomLobbyUnitsPanel.lua) (the **Restrictions** placeholder). Each self-subscribes to the model and exposes `Initialize()` + `Create(parent)`. **Parked** (built, but unwired): [`CustomLobbyMapPanel`](config/CustomLobbyMapPanel.lua) (the full Map tab — preview + label/value details + Change-map), now superseded by the pinned preview. Churning a preview is safe here because the lobby shows only **one** current map and the engine caches map textures by name (the texture-leak rule only bites the *map-select dialog* — see [mapselect/CLAUDE.md](mapselect/CLAUDE.md)). | | [/lua/ui/lobby/lobby.lua](../lobby.lua) | engine entry wrapper (`CreateLobby`/`HostGame`/`JoinGame`) → CustomLobby. Old lobby preserved at `lobby-old.lua`. | diff --git a/lua/ui/lobby/customlobby/CustomLobbyInstance.lua b/lua/ui/lobby/customlobby/CustomLobbyInstance.lua index 1cd188e9aaa..3366af13bf6 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyInstance.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyInstance.lua @@ -33,6 +33,7 @@ local MohoLobbyMethods = moho.lobby_methods local CustomLobbyMessages = import("/lua/ui/lobby/customlobby/customlobbymessages.lua").CustomLobbyMessages local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") +local CustomLobbyLog = import("/lua/ui/lobby/customlobby/customlobbylog.lua") ---@class UICustomLobbyInstance : moho.lobby_methods ---@field Trash TrashBag @@ -59,6 +60,7 @@ CustomLobbyInstance = Class(MohoLobbyMethods) { WARN("CustomLobby: blocked broadcast of malformed message " .. tostring(data.Type)) return end + CustomLobbyLog.Broadcast(data) return MohoLobbyMethods.BroadcastData(self, data) end, @@ -76,6 +78,7 @@ CustomLobbyInstance = Class(MohoLobbyMethods) { WARN("CustomLobby: blocked send of malformed message " .. tostring(data.Type)) return end + CustomLobbyLog.Send(peerId, data) return MohoLobbyMethods.SendData(self, peerId, data) end, @@ -165,6 +168,7 @@ CustomLobbyInstance = Class(MohoLobbyMethods) { WARN("CustomLobby: ignoring unknown message type " .. tostring(data.Type) .. " from " .. tostring(data.SenderID)) return end + CustomLobbyLog.Received(data) if not message.Validate(self, data) then WARN("CustomLobby: ignoring malformed message " .. tostring(data.Type) .. " from " .. tostring(data.SenderID)) return diff --git a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua index 741dac09ff8..63a9e6fb228 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua @@ -68,6 +68,7 @@ local CustomLobbyConfigInterface = import("/lua/ui/lobby/customlobby/config/cust local CustomLobbyTabs = import("/lua/ui/lobby/customlobby/customlobbytabs.lua") local CustomLobbyChatPanel = import("/lua/ui/lobby/customlobby/social/customlobbychatpanel.lua") local CustomLobbyObserversPanel = import("/lua/ui/lobby/customlobby/social/customlobbyobserverspanel.lua") +local CustomLobbyLogsPanel = import("/lua/ui/lobby/customlobby/social/customlobbylogspanel.lua") local CustomLobbyOptionSelect = import("/lua/ui/lobby/customlobby/optionselect/customlobbyoptionselect.lua") local LazyVarCreate = import("/lua/lazyvar.lua").Create @@ -84,6 +85,10 @@ local GearTextures = { dis = UIUtil.SkinnableFile('/game/menu-btns/config_btn_dis.dds'), } +-- the icon for the compact Logs tab (the game's "log" button glyph; a plain UIFile path like the +-- config column's PreviewTool icons, not a skinnable callable — CreateBitmap resolves it via UIFile) +local LogsIcon = '/BUTTON/log/_btn_up.dds' + --- Builds a tab `Action` (a config gear) for `CustomLobbyTabs`: a skinned button that runs `onOpen` --- on click, with a tooltip. (The chat/observer settings dialogs don't exist yet — these are --- placeholders, so `onOpen` is a no-op for now.) @@ -208,6 +213,9 @@ local CustomLobbyInterface = Class(Group) { self.BottomLeftTabs = CustomLobbyTabs.Create(self.BottomLeftArea, { Tabs = { + -- a compact, icon-only tab: a live feed of this peer's network traffic (host and + -- clients each see their own broadcasts / sends / receives) + { Label = "Logs", Create = CustomLobbyLogsPanel.Create, Icon = LogsIcon, Compact = true }, { Label = "Chat", Create = CustomLobbyChatPanel.Create, Badge = self.ChatBadge, Action = GearAction(function() end, "Chat settings", "Chat settings — coming soon."), diff --git a/lua/ui/lobby/customlobby/CustomLobbyLog.lua b/lua/ui/lobby/customlobby/CustomLobbyLog.lua new file mode 100644 index 00000000000..5439b49f57b --- /dev/null +++ b/lua/ui/lobby/customlobby/CustomLobbyLog.lua @@ -0,0 +1,165 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- The **network traffic log**: a per-peer, never-synced record of every lobby message this peer +-- broadcasts, sends or receives. The instance ([CustomLobbyInstance.lua]) feeds it from its three +-- network choke points (BroadcastData / SendData / DataReceived); the Logs tab +-- ([social/CustomLobbyLogsPanel.lua]) renders it. Because each peer logs only its *own* traffic, the +-- host's log (lots of authoritative broadcasts) and a client's log (its requests + received +-- snapshots) naturally differ — no special host/client branching here. +-- +-- It is reactive (an `Entries` LazyVar) like the lobby models, but it is *not* one of the three +-- lobby models: it carries no launch/session/local game state, just a diagnostic feed. Capped to a +-- ring of the most recent `MaxEntries` so it can't grow without bound across a long session. + +local Create = import("/lua/lazyvar.lua").Create + +--- Most recent entries kept; older ones drop off the front. +local MaxEntries = 200 + +---@alias UICustomLobbyLogKind +---| 'broadcast' # an outgoing broadcast to every peer +---| 'send' # an outgoing send to a single peer +---| 'recv' # an incoming message from a peer + +---@class UICustomLobbyLogEntry +---@field Kind UICustomLobbyLogKind +---@field Type string # the message Type (e.g. "SetPlayers") +---@field Peer? UILobbyPeerId # the other peer (send target / receive sender); nil for a broadcast +---@field Time number # seconds since the log started (for a relative clock) + +------------------------------------------------------------------------------- +--#region Reactive store + +---@class UICustomLobbyLog +---@field Entries LazyVar + +---@type UICustomLobbyLog | nil +local ModelInstance = nil + +--- The baseline for relative timestamps (set when the store is created). +---@type number | nil +local StartTime = nil + +--- Allocates a fresh log singleton, replacing any existing one. +---@return UICustomLobbyLog +function SetupSingleton() + ---@type UICustomLobbyLog + local model = { + Entries = Create({}), + } + ModelInstance = model + StartTime = GetSystemTimeSeconds() + return model +end + +--- Returns the log singleton, creating it on first access. +---@return UICustomLobbyLog +function GetSingleton() + if not ModelInstance then + SetupSingleton() + end + return ModelInstance --[[@as UICustomLobbyLog]] +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Write helpers +-- +-- Entries is a LazyVar value, so a write builds a NEW array and `:Set`s it (mutating in place never +-- marks dependents dirty — see /lua/ui/CLAUDE.md § 2). + +--- Appends one entry (copy-then-Set), trimming the oldest beyond the cap. +---@param kind UICustomLobbyLogKind +---@param messageType? string +---@param peer? UILobbyPeerId +local function Append(kind, messageType, peer) + local model = GetSingleton() + local entries = table.copy(model.Entries()) + table.insert(entries, { + Kind = kind, + Type = messageType or "?", + Peer = peer, + Time = GetSystemTimeSeconds() - (StartTime or GetSystemTimeSeconds()), + }) + while table.getn(entries) > MaxEntries do + table.remove(entries, 1) + end + model.Entries:Set(entries) +end + +--- Logs an outgoing broadcast to every peer. +---@param data table # the message ({ Type = ... }) +function Broadcast(data) + Append('broadcast', data and data.Type, nil) +end + +--- Logs an outgoing send to a single peer. +---@param peerId UILobbyPeerId +---@param data table +function Send(peerId, data) + Append('send', data and data.Type, peerId) +end + +--- Logs an incoming message (its sender is `data.SenderID`). +---@param data table +function Received(data) + Append('recv', data and data.Type, data and data.SenderID) +end + +--- Empties the log entirely and restarts the relative clock (called when a lobby is exited, so the +--- next lobby starts with a clean feed timed from its own start). +function Clear() + GetSingleton().Entries:Set({}) + StartTime = GetSystemTimeSeconds() +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Debugging + +--- Hot-reload hook: rebuilds the singleton and copies the entries across. +--- +--- NOTE: the instance ([CustomLobbyInstance.lua]) holds its import of this module from load time and +--- does not hot-reload, so after reloading *this* file new traffic logs into the previous singleton +--- until the lobby is recreated. Dev-only; restart the lobby if the feed goes quiet after an edit. +---@param newModule any +function __moduleinfo.OnReload(newModule) + if ModelInstance then + local handle = newModule.SetupSingleton() + handle.Entries:Set(ModelInstance.Entries()) + end +end + +--- Hot-reload hook: re-imports this module after a couple of frames. +function __moduleinfo.OnDirty() + ForkThread( + function() + WaitFrames(2) + import(__moduleinfo.name) + end + ) +end + +--#endregion diff --git a/lua/ui/lobby/customlobby/CustomLobbyTabs.lua b/lua/ui/lobby/customlobby/CustomLobbyTabs.lua index 690b0d814f8..a343aa0a0c5 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyTabs.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyTabs.lua @@ -30,13 +30,18 @@ -- An optional `OnSelect(index, label)` fires on every switch — used when a persistent sibling (e.g. -- the map preview, which can't be churned) must be shown/hidden alongside a tab. -- --- The tabs **divide the strip evenly** across the available width. A tab may carry an optional --- `Badge` LazyVar (a string): when non-empty it renders a small grey count pill to the right of the --- label; and an optional `Action` (`{ Create, Visible? }`): a small button the owner builds **inside --- the tab, left of the label** (e.g. a config gear that opens that tab's editor). Its `Visible` --- LazyVar hides it (and collapses it out of the layout) when it doesn't apply — e.g. for non-hosts. --- The action, label and pill are centred together as one cluster, so nothing collides with the tab --- edge. The container only observes the LazyVars — the owner decides what the count + action mean. +-- The **flexible** tabs divide the strip evenly across the available width. A tab may instead be +-- `Compact = true`: a fixed narrow width, excluded from the division (the flexible tabs share what's +-- left) — for a small utility tab. A tab may show an `Icon` (a texture) centred *instead* of its +-- label (paired with Compact, a tidy icon-only tab with no distracting text). +-- +-- A flexible tab may carry an optional `Badge` LazyVar (a string): when non-empty it renders a small +-- grey count pill to the right of the label; and an optional `Action` (`{ Create, Visible? }`): a +-- small button the owner builds **inside the tab, left of the label** (e.g. a config gear that opens +-- that tab's editor). Its `Visible` LazyVar hides it (and collapses it out of the layout) when it +-- doesn't apply — e.g. for non-hosts. The action, label and pill are centred together as one +-- cluster, so nothing collides with the tab edge. The container only observes the LazyVars — the +-- owner decides what the count + action mean. -- -- The parent sizes this control and calls `Initialize()` after mounting (so the first panel reads a -- concrete height — three-phase init, /lua/ui/CLAUDE.md § 1). @@ -68,6 +73,10 @@ local BadgeTextColor = 'ffd0d4d8' -- the per-tab action button (e.g. a config gear), square, sitting left of the label local ActionSize = 18 +-- a compact (utility) tab: fixed narrow width, and the size of its centred icon +local CompactWidth = 28 +local TabIconSize = 16 + ---@class UICustomLobbyTabAction ---@field Create fun(parent: Control): Control # builds the action control (the owner wires its click) ---@field Visible? LazyVar # boolean; false hides the action + collapses it from the cluster (default: always shown) @@ -77,6 +86,8 @@ local ActionSize = 18 ---@field Create fun(parent: Control): Control ---@field Badge? LazyVar # optional count-badge text (a string; "" hides the pill) ---@field Action? UICustomLobbyTabAction # optional button inside the tab, left of the label +---@field Icon? FileName # optional centred icon (a UIFile path) shown instead of the label (tidy with Compact) +---@field Compact? boolean # fixed narrow width, excluded from the even division (a utility tab) ---@class UICustomLobbyTabsOptions ---@field Tabs UICustomLobbyTab[] @@ -88,7 +99,8 @@ local ActionSize = 18 ---@class UICustomLobbyTabButton : Group ---@field Bg Bitmap ----@field Label Text +---@field Label? Text # present only for a text tab (absent when the tab uses an Icon) +---@field Icon? Bitmap # present only when the tab defines an Icon ---@field Badge? UICustomLobbyTabBadge # present only when the tab defines a Badge LazyVar ---@field Action? Control # present only when the tab defines an Action @@ -112,7 +124,15 @@ local CustomLobbyTabs = ClassUI(Group) { self.Trash = TrashBag() self.Tabs = options.Tabs self.OnSelectCb = options.OnSelect + -- open on the first non-compact tab — a compact utility tab (e.g. Logs) shouldn't be the + -- default view just because it sits leftmost self.ActiveTab = 1 + for index = 1, table.getn(self.Tabs) do + if not self.Tabs[index].Compact then + self.ActiveTab = index + break + end + end self.CurrentPanel = false self.TabStripArea = Group(self, "CustomLobbyTabsStrip") @@ -132,21 +152,42 @@ local CustomLobbyTabs = ClassUI(Group) { :AnchorToBottom(self.TabStripArea, 6):AtBottomIn(self) :End() - -- the tabs divide the strip evenly: each is (stripWidth - gaps) / count wide, pinned at its - -- own slot. Width/Left are bound to the strip so they re-flow with the column. + -- compact tabs take a fixed narrow width; the flexible tabs share what's left of the strip + -- evenly. Width/Left are bound to the strip so they re-flow with the column. local count = table.getn(self.TabButtons) + local flexCount = 0 + for index = 1, count do + if not self.Tabs[index].Compact then + flexCount = flexCount + 1 + end + end + + -- the width of one flexible tab: the strip minus all gaps and all compact tabs, split evenly + local function flexWidth() + local gap = LayoutHelpers.ScaleNumber(TabGap) + local compactW = LayoutHelpers.ScaleNumber(CompactWidth) + local remaining = self.TabStripArea.Width() - gap * (count - 1) - compactW * (count - flexCount) + if flexCount <= 0 then + return 0 + end + return remaining / flexCount + end + local function widthOf(index) + return self.Tabs[index].Compact and LayoutHelpers.ScaleNumber(CompactWidth) or flexWidth() + end + for index = 1, count do local button = self.TabButtons[index] local slot = index Layouter(button):AtTopIn(self.TabStripArea):Height(TabHeight):End() - button.Width:Set(function() - local gap = LayoutHelpers.ScaleNumber(TabGap) - return (self.TabStripArea.Width() - gap * (count - 1)) / count - end) + button.Width:Set(function() return widthOf(slot) end) button.Left:Set(function() local gap = LayoutHelpers.ScaleNumber(TabGap) - local width = (self.TabStripArea.Width() - gap * (count - 1)) / count - return self.TabStripArea.Left() + (slot - 1) * (width + gap) + local x = self.TabStripArea.Left() + for j = 1, slot - 1 do + x = x + widthOf(j) + gap + end + return x end) Layouter(button.Bg):Fill(button):End() self:LayoutButtonContent(button) @@ -161,6 +202,12 @@ local CustomLobbyTabs = ClassUI(Group) { ---@param self UICustomLobbyTabs ---@param button UICustomLobbyTabButton LayoutButtonContent = function(self, button) + -- an icon tab is just a centred glyph (no label/action/pill cluster) + if button.Icon then + Layouter(button.Icon):AtCenterIn(button):Width(TabIconSize):Height(TabIconSize):End() + return + end + local action = button.Action local badge = button.Badge @@ -258,11 +305,20 @@ local CustomLobbyTabs = ClassUI(Group) { ---@param index number ---@return UICustomLobbyTabButton CreateTabButton = function(self, label, index) + local tab = self.Tabs[index] local button = Group(self.TabStripArea, "CustomLobbyTabButton") button.Bg = Bitmap(button) button.Bg:SetSolidColor(TabIdleColor) + if tab.Icon then + -- an icon tab: a centred glyph instead of the text label (and no badge/action cluster) + button.Icon = UIUtil.CreateBitmap(button, tab.Icon) + button.Icon:DisableHitTest() + button.Bg.HandleEvent = self:MakeTabEvent(button, index) + return button + end + button.Label = UIUtil.CreateText(button, label, 13, UIUtil.titleFont) button.Label:SetColor('ffc8ccd0') button.Label:DisableHitTest() @@ -307,7 +363,19 @@ local CustomLobbyTabs = ClassUI(Group) { end end - button.Bg.HandleEvent = function(control, event) + button.Bg.HandleEvent = self:MakeTabEvent(button, index) + + return button + end, + + --- The tab background's event handler: click selects the tab, hover tints it (unless active). + --- Shared by text and icon tabs. Private. + ---@param self UICustomLobbyTabs + ---@param button UICustomLobbyTabButton + ---@param index number + ---@return fun(control: Control, event: KeyEvent): boolean + MakeTabEvent = function(self, button, index) + return function(control, event) if event.Type == 'ButtonPress' then self:SelectTab(index) return true @@ -324,8 +392,6 @@ local CustomLobbyTabs = ClassUI(Group) { end return false end - - return button end, --- Updates a count pill from its Badge LazyVar: shows the pill with `text`, or hides it (and diff --git a/lua/ui/lobby/customlobby/slots/CustomLobbySlotsInterface.lua b/lua/ui/lobby/customlobby/slots/CustomLobbySlotsInterface.lua index ae0fb7bc5b7..69a3a1d588f 100644 --- a/lua/ui/lobby/customlobby/slots/CustomLobbySlotsInterface.lua +++ b/lua/ui/lobby/customlobby/slots/CustomLobbySlotsInterface.lua @@ -61,21 +61,25 @@ local ToolIconInset = 3 local ToolIdle = 'ff141a20' local ToolHover = 'ff1f262e' local ToolActive = 'ff2c4a5e' -- a lit toggle's background (pin on) -local ToolIconDim = 0.45 -- icon alpha when a toggle is off --- icon textures (skin-relative, resolved through CreateBitmap). Pin reuses the camera pin glyph, --- balance the lobby auto-balance button art, reopen the circular recall ("refresh") icon. -local PinIcon = '/game/camera-btn/pinned_btn_up.dds' +-- icon textures (skin-relative, resolved through UIFile). The pin uses the window pin-button glyphs +-- (unpinned vs pinned, same as window.lua's title-bar pin); balance the lobby auto-balance button +-- art; reopen the circular recall ("refresh") icon. +local PinIcon = '/game/menu-btns/pin_btn_up.dds' -- unpinned (toggle off) +local PinnedIcon = '/game/menu-btns/pinned_btn_up.dds' -- pinned (toggle on / the lock-notice glyph) local BalanceIcon = '/BUTTON/autobalance/_btn_up.dds' local ReopenIcon = '/game/recall-panel/icon-recall_bmp.dds' --- A small square icon button for the header tool strip: a solid background that lights on hover, ---- plus an `Active` state (driven externally for the pin toggle) that lights it the "on" colour and ---- un-dims the icon. Clicking calls `OnPress`; the owner decides what that means (fire an intent, ---- flip a synced flag, …). Mirrors the config column's `PreviewTool`. +--- plus an `Active` state (driven externally for the pin toggle) that lights it the "on" colour. A +--- toggle can pass a second `activeTexture` so the icon glyph swaps while active (the pin shows the +--- unpinned vs pinned art, like the window title-bar pin). Clicking calls `OnPress`; the owner decides +--- what that means (fire an intent, flip a synced flag, …). Mirrors the config column's `PreviewTool`. ---@class UICustomLobbySlotTool : Group ---@field Bg Bitmap ---@field Icon Bitmap +---@field IdleTexture FileName +---@field ActiveTexture FileName | nil ---@field Active boolean ---@field Hovered boolean ---@field OnPress? fun() @@ -83,12 +87,15 @@ local SlotTool = Class(Group) { ---@param self UICustomLobbySlotTool ---@param parent Control - ---@param texture FileName - __init = function(self, parent, texture) + ---@param texture FileName # the icon (idle / toggle-off) + ---@param activeTexture? FileName # glyph swapped in while Active (toggle-on); omit for plain actions + __init = function(self, parent, texture, activeTexture) Group.__init(self, parent, "CustomLobbySlotTool") self.Active = false self.Hovered = false + self.IdleTexture = texture + self.ActiveTexture = activeTexture self.Bg = Bitmap(self) self.Bg:SetSolidColor(ToolIdle) @@ -122,7 +129,8 @@ local SlotTool = Class(Group) { self:ApplyVisual() end, - --- Repaints the background + icon for the current active/hover state. + --- Repaints the background (idle / hover / lit-when-active) and, for a toggle with an + --- `ActiveTexture`, swaps the icon glyph for the active/inactive state. ---@param self UICustomLobbySlotTool ApplyVisual = function(self) local bg = ToolIdle @@ -132,7 +140,9 @@ local SlotTool = Class(Group) { bg = ToolHover end self.Bg:SetSolidColor(bg) - self.Icon:SetAlpha(self.Active and 1.0 or ToolIconDim) + if self.ActiveTexture then + self.Icon:SetTexture(UIUtil.UIFile(self.Active and self.ActiveTexture or self.IdleTexture)) + end end, --- Sets the lit/active state (the pin toggle drives this from the synced model). @@ -149,6 +159,8 @@ local SlotTool = Class(Group) { ---@class UICustomLobbySlotsInterface : Group, UICustomLobbySlotCoordinator ---@field Trash TrashBag ---@field Header Text +---@field LockIcon Bitmap # lock glyph shown (with LockLabel) while seating is pinned +---@field LockLabel Text # "Locked" notice, visible to everyone while pinned ---@field Tools Group # host-only tool strip (right of the header) ---@field PinButton UICustomLobbySlotTool ---@field BalanceButton UICustomLobbySlotTool @@ -179,11 +191,22 @@ local CustomLobbySlotsInterface = Class(Group) { self.Header:SetColor('ff9aa0a8') self.Header:DisableHitTest() + -- lock notice (right of the "Players" label): shown to everyone while seating is pinned, so a + -- client can see seating is host-controlled before it clicks an open slot to no effect. The + -- host also has the lit pin button; this reinforces the state for both. + self.LockIcon = UIUtil.CreateBitmap(self, PinnedIcon) + self.LockIcon:DisableHitTest() + self.LockIcon:Hide() + self.LockLabel = UIUtil.CreateText(self, "Locked", 12, UIUtil.bodyFont) + self.LockLabel:SetColor('ffd9c97a') + self.LockLabel:DisableHitTest() + self.LockLabel:Hide() + -- host-only tool strip, right-aligned in the header band: pin seating · auto-balance · -- reopen closed slots. Grouped so a single Show/Hide gates them all on host status. self.Tools = Group(self, "CustomLobbySlotTools") - self.PinButton = SlotTool(self.Tools, PinIcon) + self.PinButton = SlotTool(self.Tools, PinIcon, PinnedIcon) self.PinButton.OnPress = function() CustomLobbyController.RequestSetSlotsPinned(not CustomLobbySessionModel.GetSingleton().SlotsPinned()) end @@ -214,10 +237,19 @@ local CustomLobbySlotsInterface = Class(Group) { end end)) - -- light the pin button while seating is pinned (synced session state) + -- light the host's pin button and show the (everyone-visible) lock notice while seating is + -- pinned (synced session state) self.SlotsPinnedObserver = self.Trash:Add( LazyVarDerive(CustomLobbySessionModel.GetSingleton().SlotsPinned, function(pinnedLazy) - self.PinButton:SetActive(pinnedLazy()) + local pinned = pinnedLazy() + self.PinButton:SetActive(pinned) + if pinned then + self.LockIcon:Show() + self.LockLabel:Show() + else + self.LockIcon:Hide() + self.LockLabel:Hide() + end end)) -- the active layout body, picked by the AutoTeams mode (created now, laid out on mount) @@ -236,6 +268,10 @@ local CustomLobbySlotsInterface = Class(Group) { __post_init = function(self) Layouter(self.Header):AtLeftIn(self, 4):AtTopIn(self):End() + -- the lock notice, just right of the "Players" label (hidden unless seating is pinned) + Layouter(self.LockIcon):CenteredRightOf(self.Header, 8):Width(ToolSize):Height(ToolSize):End() + Layouter(self.LockLabel):CenteredRightOf(self.LockIcon, 3):End() + -- the tool strip: a fixed-width band pinned top-right, the three square buttons inside it -- laid out right-to-left (Pin · Balance · Reopen, reading left-to-right), centred on the header Layouter(self.Tools) diff --git a/lua/ui/lobby/customlobby/social/CustomLobbyLogsPanel.lua b/lua/ui/lobby/customlobby/social/CustomLobbyLogsPanel.lua new file mode 100644 index 00000000000..7d0f77cd0e5 --- /dev/null +++ b/lua/ui/lobby/customlobby/social/CustomLobbyLogsPanel.lua @@ -0,0 +1,165 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- The Logs tab of the lobby's bottom-left tabbed panel: a live, scrolling view of this peer's +-- network traffic (every message it broadcasts / sends / receives), fed by `CustomLobbyLog`. Each +-- line is `mm:ss Type [→/← peer]` — outgoing broadcasts (»»), single sends (»), and +-- incoming messages («). Because each peer logs only its own traffic, the host's and a client's +-- views differ naturally. +-- +-- A bottom-left tab content component: created when its tab is selected and destroyed on switch (see +-- ../CustomLobbyTabs.lua), so it's the live panel for its whole lifetime — the model observer just +-- rebuilds it (Refresh is Ready-gated). `Initialize` (called by the tabs container after sizing it) +-- builds the scrollbar + does the first render (the list needs a concrete height — three-phase init). + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") + +local Group = import("/lua/maui/group.lua").Group +local ItemList = import("/lua/maui/itemlist.lua").ItemList + +local CustomLobbyLog = import("/lua/ui/lobby/customlobby/customlobbylog.lua") + +local LazyVarDerive = import("/lua/lazyvar.lua").Derive +local Layouter = LayoutHelpers.ReusedLayoutFor + +local Inset = 4 +local TextColor = 'ffb0b6be' +local Transparent = '00000000' + +-- per-kind arrow prefix (no colour — ItemList colours are list-wide, so the glyph carries direction) +local KindPrefix = { + broadcast = "»»", + send = "»", + recv = "«", +} + +--- `mm:ss` from a seconds offset (Lua 5.0 — no `%`, use `math.mod`). +---@param seconds number +---@return string +local function FormatClock(seconds) + local whole = math.floor(seconds) + return string.format("%02d:%02d", math.floor(whole / 60), math.mod(whole, 60)) +end + +--- One log line: `mm:ss Type [→/← peer]`. +---@param entry UICustomLobbyLogEntry +---@return string +local function FormatEntry(entry) + local line = FormatClock(entry.Time) .. " " .. (KindPrefix[entry.Kind] or "·") .. " " .. entry.Type + if entry.Peer then + if entry.Kind == 'recv' then + line = line .. " ← " .. tostring(entry.Peer) + elseif entry.Kind == 'send' then + line = line .. " → " .. tostring(entry.Peer) + end + end + return line +end + +---@class UICustomLobbyLogsPanel : Group +---@field Trash TrashBag +---@field Ready boolean +---@field List ItemList +---@field Scrollbar Scrollbar | false +---@field Empty Text +---@field EntriesObserver LazyVar +local CustomLobbyLogsPanel = ClassUI(Group) { + + ---@param self UICustomLobbyLogsPanel + ---@param parent Control + __init = function(self, parent) + Group.__init(self, parent, "CustomLobbyLogsPanel") + + self.Trash = TrashBag() + self.Ready = false + self.Scrollbar = false + + self.List = ItemList(self, "CustomLobbyLogList") + self.List:SetFont(UIUtil.bodyFont, 12) + -- foreground only; background / selection / mouseover transparent (read-only feed) + self.List:SetColors(TextColor, Transparent, TextColor, Transparent, TextColor, Transparent) + self.List:DeleteAllItems() + + self.Empty = UIUtil.CreateText(self, "No messages yet", 13, UIUtil.bodyFont) + self.Empty:SetColor('ff5a606a') + self.Empty:DisableHitTest() + self.Empty:Hide() + + -- created/destroyed with its tab, so always the live panel while it exists — the observer + -- just rebuilds (Refresh is Ready-gated until Initialize sizes the list) + self.EntriesObserver = self.Trash:Add( + LazyVarDerive(CustomLobbyLog.GetSingleton().Entries, function(entriesLazy) + entriesLazy() + self:Refresh() + end)) + end, + + ---@param self UICustomLobbyLogsPanel + __post_init = function(self) + Layouter(self.List) + :AtLeftIn(self, Inset):AtTopIn(self, Inset):AtBottomIn(self, Inset) + :End() + -- leave room for the scrollbar on the right + self.List.Right:Set(function() return self.Right() - LayoutHelpers.ScaleNumber(Inset + 14) end) + Layouter(self.Empty):AtHorizontalCenterIn(self):AtTopIn(self, 8):End() + end, + + --- Builds the scrollbar + does the first render. Called by the tabs container after it has sized + --- the panel (the list needs a concrete height — three-phase init, /lua/ui/CLAUDE.md § 1). + ---@param self UICustomLobbyLogsPanel + Initialize = function(self) + self.Ready = true + self.Scrollbar = UIUtil.CreateVertScrollbarFor(self.List) + self:Refresh() + end, + + --- Rebuilds the list from the current log entries and scrolls to the newest. + ---@param self UICustomLobbyLogsPanel + Refresh = function(self) + if not self.Ready then + return + end + local entries = CustomLobbyLog.GetSingleton().Entries() + self.List:DeleteAllItems() + for _, entry in entries do + self.List:AddItem(FormatEntry(entry)) + end + if table.getn(entries) > 0 then + self.Empty:Hide() + self.List:ScrollToBottom() + else + self.Empty:Show() + end + end, + + ---@param self UICustomLobbyLogsPanel + OnDestroy = function(self) + self.Trash:Destroy() + end, +} + +---@param parent Control +---@return UICustomLobbyLogsPanel +Create = function(parent) + return CustomLobbyLogsPanel(parent) +end diff --git a/lua/ui/lobby/lobby.lua b/lua/ui/lobby/lobby.lua index 059530065d6..99ee75cff7e 100644 --- a/lua/ui/lobby/lobby.lua +++ b/lua/ui/lobby/lobby.lua @@ -40,6 +40,7 @@ local CustomLobbySessionModel = import("/lua/ui/lobby/customlobby/customlobbyses local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/customlobbylocalmodel.lua") local CustomLobbyInterface = import("/lua/ui/lobby/customlobby/customlobbyinterface.lua") local CustomLobbySession = import("/lua/ui/lobby/customlobby/customlobbysession.lua") +local CustomLobbyLog = import("/lua/ui/lobby/customlobby/customlobbylog.lua") local maxConnections = 16 @@ -95,6 +96,8 @@ function CreateLobby(protocol, localPort, desiredPlayerName, localPlayerUID, nat -- Free everything registered in the session trash (the map catalog today; the models, -- interface and instance follow as they are converted to the same pattern). CustomLobbySession.Teardown() + -- Wipe the network traffic log so the next lobby starts with a clean feed. + CustomLobbyLog.Clear() if exitBehavior then exitBehavior() end From 9baa57f09518f76205c089116b8b0a84f45c5942 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Thu, 25 Jun 2026 08:19:39 +0200 Subject: [PATCH 52/98] Uitbreiden werking van de logs --- lua/ui/lobby/customlobby/CLAUDE.md | 2 +- .../lobby/customlobby/CustomLobbyInstance.lua | 33 +++- lua/ui/lobby/customlobby/CustomLobbyLog.lua | 58 +++--- .../lobby/customlobby/CustomLobbyMessages.lua | 101 +++++++--- .../slots/CustomLobbySlotsInterface.lua | 6 +- .../social/CustomLobbyLogsPanel.lua | 172 ++++++++++++------ 6 files changed, 244 insertions(+), 128 deletions(-) diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index bfa7318095e..5889027017b 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -62,7 +62,7 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | [CustomLobbyInterface.lua](CustomLobbyInterface.lua) | composition root, laid out in **areas** (flip the module `Debug` flag to tint them), sized for the **1024×768** floor. A **one-column** layout (the two-column variant was reverted by community request): a **title bar** (title · team score) over a **left column** split vertically — the **slots** on top (a single column of rows, up to 16, height tracking the slot count) and the **Chat / Observers** tabs below — beside a fixed-width **right column** (the config component: a pinned map preview + facts line over Options / Mods / Restrictions tabs). A full-width **action bar** at the bottom holds the global actions: **Leave** + status on the left, the host-only **Settings** button (opens the options editor) + the host-only **Launch** on the right. Reads IsHost (action-bar buttons) + SlotCount (to size the slot area via `CustomLobbySlotsInterface.HeightForSlots`); Leave (Esc handler) + **Become observer** (`RequestMoveToObserver`, in the Observers tab). The slot rows + their drag coordination now live in [`CustomLobbySlotsInterface`](CustomLobbySlotsInterface.lua), which fills the slot area. `OpenDebug()` / hot-reload. | | [CustomLobbyTeamScore.lua](CustomLobbyTeamScore.lua) | the **accumulated team rating** side indicator — `Side A N · M Side B`. Hosted in the strip atop the two-column slot layout (it doubles as the columns' Left/Right header). Shown only for the binary auto-team formations; **hidden** for `none`/`manual` or until a positional map's start positions load. Reads the mode + side split from [`CustomLobbyRules`](CustomLobbyRules.lua) (`AutoTeamMode` / `SideLabels` / `BuildSideResolver`) and the ratings from each slot's `PL`. Reference data; never writes. | | [CustomLobbyTabs.lua](CustomLobbyTabs.lua) | a **generic tabbed panel** (strip + content; one panel alive, created on select / destroyed on switch). Tabs **divide the strip evenly** across its width. Construct with a `{ Label, Create, Badge?, Action?, Icon?, Compact? }` list + optional `OnSelect`. A tab's optional `Badge` LazyVar drives a grey **count pill** to the right of the label; its optional `Action` (`{ Create, Visible? }`) is a small button the owner builds **inside the tab, left of the label** (e.g. a config gear), whose `Visible` LazyVar hides it (collapsing it from the layout) when it doesn't apply. The action, label and pill are centred together as one cluster; any absent/hidden/empty piece contributes 0 width so the rest re-centres. A tab can instead be **`Compact`** (a fixed narrow width, excluded from the even division — the flexible tabs share what's left) and/or show an **`Icon`** centred instead of its label (an icon-only utility tab); the default active tab is the first non-compact one. The container just mirrors the LazyVars, the owner decides what they mean. Used for the bottom-left (Logs / Chat / Observers) and the config interface's Options / Mods / Restrictions. | -| [social/](social/) | the lobby's **bottom-left** column (the `CustomLobbyTabs` content): [`CustomLobbyChatPanel`](social/CustomLobbyChatPanel.lua) (the **Chat** tab — placeholder until the chat slice lands) and [`CustomLobbyObserversPanel`](social/CustomLobbyObserversPanel.lua) (the **Observers** tab — the shared observer list + a host-authoritative **Become observer** button → `RequestMoveToObserver`). Each is a tab content component (`Create(parent)`, created on select / destroyed on switch). The Chat / Observers tabs mirror the config column's shape — a per-tab **config gear** (`CustomLobbyInterface`'s local `GearAction`; both no-ops with a "coming soon" tooltip for now) + a right-side **count pill**: Observers shows the live observer count, Chat a dummy until the chat slice lands. A third **compact, icon-only [`CustomLobbyLogsPanel`](social/CustomLobbyLogsPanel.lua)** sits left of Chat (the **Logs** tab — a live scrolling view of this peer's network traffic from [`CustomLobbyLog`](CustomLobbyLog.lua)). | +| [social/](social/) | the lobby's **bottom-left** column (the `CustomLobbyTabs` content): [`CustomLobbyChatPanel`](social/CustomLobbyChatPanel.lua) (the **Chat** tab — placeholder until the chat slice lands) and [`CustomLobbyObserversPanel`](social/CustomLobbyObserversPanel.lua) (the **Observers** tab — the shared observer list + a host-authoritative **Become observer** button → `RequestMoveToObserver`). Each is a tab content component (`Create(parent)`, created on select / destroyed on switch). The Chat / Observers tabs mirror the config column's shape — a per-tab **config gear** (`CustomLobbyInterface`'s local `GearAction`; both no-ops with a "coming soon" tooltip for now) + a right-side **count pill**: Observers shows the live observer count, Chat a dummy until the chat slice lands. A third **compact, icon-only [`CustomLobbyLogsPanel`](social/CustomLobbyLogsPanel.lua)** sits left of Chat (the **Logs** tab — a live **tail view** of this peer's network traffic from [`CustomLobbyLog`](CustomLobbyLog.lua): the most recent entries that fit, in columns `time · kind · ⚠ · name`, with a malformed/unauthorised message tinted + a ⚠ icon whose tooltip is the failure reason). | | [config/](config/) | the lobby's **right** column. [`CustomLobbyConfigInterface.lua`](config/CustomLobbyConfigInterface.lua) is the column **composition**: a bound square `CustomLobbyMapPreview` **pinned** at the top with a vertical **preview tool strip** to its right (local `PreviewTool` icon buttons — toggles for army/start icons, mass+hydro deposits and the dummy water mask, driving `preview.Surface:SetOverlayVisible`, + a host-only **change-map** config icon at the bottom → the map-select dialog), a name + size/players/version facts line under it, and a [`CustomLobbyTabs`](CustomLobbyTabs.lua) (**Options / Mods / Restrictions**) filling the rest. Each tab carries its own **config gear** (`CustomLobbyTabs`' per-tab `Action`, built by the interface's `GearAction` helper) — a skinned button **inside the tab, left of the label** — that opens that tab's editor: Options → `CustomLobbyOptionSelect`, Mods → `CustomLobbyModSelect`, Restrictions → none yet (no gear). The Options gear is **host-only**: its `Visible` LazyVar is the `IsHost` field, so it's **hidden** for clients and the label re-centres; the Mods gear shows for everyone (UI mods are local; the sim portion is host-gated inside the dialog). The interface also owns the tabs' **count badges** as computed LazyVars over the launch model — Options shows the non-default-option count (`OptionUtil.CountNonDefault`), Mods shows `sim / ui` (synced sim mods / this peer's UI-mod prefs), Restrictions is wired but empty until the restrictions slice lands. All three tab panels are now **read-only**: their per-domain action buttons (open editor / reset / manage mods) are removed — the grid/content fills the whole panel — and the action-bar **Settings** button (now joined by this gear) is the edit entry point. [`CustomLobbyOptionsPanel`](config/CustomLobbyOptionsPanel.lua) (options grouped **Lobby / Scenario / Mods** + hide-defaults toggle; map/mod options gold-flagged with an origin tooltip — schema via [`/lua/ui/optionutil.lua`](/lua/ui/optionutil.lua)), [`CustomLobbyModsPanel`](config/CustomLobbyModsPanel.lua) (enabled mods in **Game / UI** sections), [`CustomLobbyUnitsPanel`](config/CustomLobbyUnitsPanel.lua) (the **Restrictions** placeholder). Each self-subscribes to the model and exposes `Initialize()` + `Create(parent)`. **Parked** (built, but unwired): [`CustomLobbyMapPanel`](config/CustomLobbyMapPanel.lua) (the full Map tab — preview + label/value details + Change-map), now superseded by the pinned preview. Churning a preview is safe here because the lobby shows only **one** current map and the engine caches map textures by name (the texture-leak rule only bites the *map-select dialog* — see [mapselect/CLAUDE.md](mapselect/CLAUDE.md)). | | [/lua/ui/lobby/lobby.lua](../lobby.lua) | engine entry wrapper (`CreateLobby`/`HostGame`/`JoinGame`) → CustomLobby. Old lobby preserved at `lobby-old.lua`. | diff --git a/lua/ui/lobby/customlobby/CustomLobbyInstance.lua b/lua/ui/lobby/customlobby/CustomLobbyInstance.lua index 3366af13bf6..dcd82429cea 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyInstance.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyInstance.lua @@ -54,10 +54,14 @@ CustomLobbyInstance = Class(MohoLobbyMethods) { local message = CustomLobbyMessages[data.Type] if not message then WARN("CustomLobby: blocked broadcast of unknown message type " .. tostring(data.Type)) + CustomLobbyLog.Broadcast(data, "unknown message type") return end - if not message.Validate(self, data) then - WARN("CustomLobby: blocked broadcast of malformed message " .. tostring(data.Type)) + local ok, reason = message.Validate(self, data) + if not ok then + reason = reason or "malformed message" + WARN("CustomLobby: blocked broadcast of message " .. tostring(data.Type) .. " — " .. reason) + CustomLobbyLog.Broadcast(data, reason) return end CustomLobbyLog.Broadcast(data) @@ -72,10 +76,14 @@ CustomLobbyInstance = Class(MohoLobbyMethods) { local message = CustomLobbyMessages[data.Type] if not message then WARN("CustomLobby: blocked send of unknown message type " .. tostring(data.Type)) + CustomLobbyLog.Send(peerId, data, "unknown message type") return end - if not message.Validate(self, data) then - WARN("CustomLobby: blocked send of malformed message " .. tostring(data.Type)) + local ok, reason = message.Validate(self, data) + if not ok then + reason = reason or "malformed message" + WARN("CustomLobby: blocked send of message " .. tostring(data.Type) .. " — " .. reason) + CustomLobbyLog.Send(peerId, data, reason) return end CustomLobbyLog.Send(peerId, data) @@ -166,17 +174,24 @@ CustomLobbyInstance = Class(MohoLobbyMethods) { local message = CustomLobbyMessages[data.Type] if not message then WARN("CustomLobby: ignoring unknown message type " .. tostring(data.Type) .. " from " .. tostring(data.SenderID)) + CustomLobbyLog.Received(data, "unknown message type") return end - CustomLobbyLog.Received(data) - if not message.Validate(self, data) then - WARN("CustomLobby: ignoring malformed message " .. tostring(data.Type) .. " from " .. tostring(data.SenderID)) + local valid, validReason = message.Validate(self, data) + if not valid then + validReason = validReason or "malformed message" + WARN("CustomLobby: ignoring message " .. tostring(data.Type) .. " from " .. tostring(data.SenderID) .. " — " .. validReason) + CustomLobbyLog.Received(data, validReason) return end - if not message.Accept(self, data) then - WARN("CustomLobby: rejected message " .. tostring(data.Type) .. " from " .. tostring(data.SenderID)) + local accepted, acceptReason = message.Accept(self, data) + if not accepted then + acceptReason = acceptReason or "rejected" + WARN("CustomLobby: rejected message " .. tostring(data.Type) .. " from " .. tostring(data.SenderID) .. " — " .. acceptReason) + CustomLobbyLog.Received(data, acceptReason) return end + CustomLobbyLog.Received(data) message.Handler(self, data) end, diff --git a/lua/ui/lobby/customlobby/CustomLobbyLog.lua b/lua/ui/lobby/customlobby/CustomLobbyLog.lua index 5439b49f57b..e28804db023 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyLog.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyLog.lua @@ -46,6 +46,7 @@ local MaxEntries = 200 ---@field Type string # the message Type (e.g. "SetPlayers") ---@field Peer? UILobbyPeerId # the other peer (send target / receive sender); nil for a broadcast ---@field Time number # seconds since the log started (for a relative clock) +---@field Error? string # set when the message was malformed / unauthorised — the reason ------------------------------------------------------------------------------- --#region Reactive store @@ -93,7 +94,8 @@ end ---@param kind UICustomLobbyLogKind ---@param messageType? string ---@param peer? UILobbyPeerId -local function Append(kind, messageType, peer) +---@param error? string +local function Append(kind, messageType, peer, error) local model = GetSingleton() local entries = table.copy(model.Entries()) table.insert(entries, { @@ -101,6 +103,7 @@ local function Append(kind, messageType, peer) Type = messageType or "?", Peer = peer, Time = GetSystemTimeSeconds() - (StartTime or GetSystemTimeSeconds()), + Error = error, }) while table.getn(entries) > MaxEntries do table.remove(entries, 1) @@ -108,23 +111,26 @@ local function Append(kind, messageType, peer) model.Entries:Set(entries) end ---- Logs an outgoing broadcast to every peer. +--- Logs an outgoing broadcast to every peer (`error` set when it was blocked as malformed). ---@param data table # the message ({ Type = ... }) -function Broadcast(data) - Append('broadcast', data and data.Type, nil) +---@param error? string +function Broadcast(data, error) + Append('broadcast', data and data.Type, nil, error) end ---- Logs an outgoing send to a single peer. +--- Logs an outgoing send to a single peer (`error` set when it was blocked as malformed). ---@param peerId UILobbyPeerId ---@param data table -function Send(peerId, data) - Append('send', data and data.Type, peerId) +---@param error? string +function Send(peerId, data, error) + Append('send', data and data.Type, peerId, error) end ---- Logs an incoming message (its sender is `data.SenderID`). +--- Logs an incoming message (its sender is `data.SenderID`; `error` set when it was rejected). ---@param data table -function Received(data) - Append('recv', data and data.Type, data and data.SenderID) +---@param error? string +function Received(data, error) + Append('recv', data and data.Type, data and data.SenderID, error) end --- Empties the log entirely and restarts the relative clock (called when a lobby is exited, so the @@ -136,30 +142,8 @@ end --#endregion -------------------------------------------------------------------------------- ---#region Debugging - ---- Hot-reload hook: rebuilds the singleton and copies the entries across. ---- ---- NOTE: the instance ([CustomLobbyInstance.lua]) holds its import of this module from load time and ---- does not hot-reload, so after reloading *this* file new traffic logs into the previous singleton ---- until the lobby is recreated. Dev-only; restart the lobby if the feed goes quiet after an edit. ----@param newModule any -function __moduleinfo.OnReload(newModule) - if ModelInstance then - local handle = newModule.SetupSingleton() - handle.Entries:Set(ModelInstance.Entries()) - end -end - ---- Hot-reload hook: re-imports this module after a couple of frames. -function __moduleinfo.OnDirty() - ForkThread( - function() - WaitFrames(2) - import(__moduleinfo.name) - end - ) -end - ---#endregion +-- NOTE: this module deliberately has **no hot-reload hooks**. It is fed by the lobby instance +-- ([CustomLobbyInstance.lua]), which holds its import from load time and never hot-reloads — so the +-- store has to be a single stable singleton that survives UI hot-reloads (interface / tabs / panel), +-- or the instance would keep writing into a replaced singleton and the panel would stop updating. +-- The cost is that edits to *this* file need a game restart to take effect. diff --git a/lua/ui/lobby/customlobby/CustomLobbyMessages.lua b/lua/ui/lobby/customlobby/CustomLobbyMessages.lua index 3155e7354b9..3291d06e223 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyMessages.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyMessages.lua @@ -25,28 +25,43 @@ -- unauthorised) and Handler (route to the controller). The instance's DataReceived / -- BroadcastData / SendData run these before anything happens. -- +-- **Validate / Accept return `true` when the message is fine, or `false, reason` when it is not** — +-- a human-readable reason string explaining why (never a bare `false` — always say *why*). The +-- instance logs that reason against the message (see CustomLobbyLog / the Logs tab) and the UI +-- surfaces it as a tooltip, so a bad or unauthorised message is explained rather than silently dropped. +-- -- Each message's payload is typed via a `---@class … : UILobbyReceivedMessage` so the -- handlers (and any future tooling) know its exact shape. local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/customlobbylocalmodel.lua") +--- Authorisation check: passes only for the host. ---@param lobby UICustomLobbyInstance ----@return boolean -local function AmHost(lobby) - return lobby:IsHost() +---@return boolean ok +---@return string? reason +local function RequireHost(lobby) + if lobby:IsHost() then + return true + end + return false, "only the host may act on this message" end +--- Authorisation check: passes only when the message came from the host. ---@param lobby UICustomLobbyInstance ---@param data UILobbyReceivedMessage ----@return boolean -local function IsFromHost(lobby, data) - return data.SenderID == CustomLobbyLocalModel.GetSingleton().HostID() +---@return boolean ok +---@return string? reason +local function RequireFromHost(lobby, data) + if data.SenderID == CustomLobbyLocalModel.GetSingleton().HostID() then + return true + end + return false, "message did not come from the host" end ---@class UICustomLobbyMessageHandler ----@field Validate fun(lobby: UICustomLobbyInstance, data: UILobbyReceivedMessage): boolean ----@field Accept fun(lobby: UICustomLobbyInstance, data: UILobbyReceivedMessage): boolean +---@field Validate fun(lobby: UICustomLobbyInstance, data: UILobbyReceivedMessage): boolean, string? +---@field Accept fun(lobby: UICustomLobbyInstance, data: UILobbyReceivedMessage): boolean, string? ---@field Handler fun(lobby: UICustomLobbyInstance, data: UILobbyReceivedMessage) ---@type table @@ -59,10 +74,13 @@ CustomLobbyMessages = { ---@param data UICustomLobbyAddPlayerMessage Validate = function(lobby, data) - return data.PlayerOptions ~= nil + if data.PlayerOptions == nil then + return false, "AddPlayer is missing its PlayerOptions" + end + return true end, Accept = function(lobby, data) - return AmHost(lobby) + return RequireHost(lobby) end, ---@param data UICustomLobbyAddPlayerMessage Handler = function(lobby, data) @@ -78,10 +96,13 @@ CustomLobbyMessages = { ---@param data UICustomLobbySetPlayersMessage Validate = function(lobby, data) - return type(data.Players) == 'table' + if type(data.Players) ~= 'table' then + return false, "SetPlayers has no Players array" + end + return true end, Accept = function(lobby, data) - return IsFromHost(lobby, data) + return RequireFromHost(lobby, data) end, ---@param data UICustomLobbySetPlayersMessage Handler = function(lobby, data) @@ -102,10 +123,13 @@ CustomLobbyMessages = { ---@param data UICustomLobbySentLaunchInfoMessage Validate = function(lobby, data) - return type(data.GameOptions) == 'table' and type(data.GameMods) == 'table' + if type(data.GameOptions) ~= 'table' or type(data.GameMods) ~= 'table' then + return false, "SentLaunchInfo is missing its GameOptions / GameMods" + end + return true end, Accept = function(lobby, data) - return IsFromHost(lobby, data) + return RequireFromHost(lobby, data) end, ---@param data UICustomLobbySentLaunchInfoMessage Handler = function(lobby, data) @@ -123,10 +147,13 @@ CustomLobbyMessages = { ---@param data UICustomLobbySetSessionStateMessage Validate = function(lobby, data) - return type(data.SlotCount) == 'number' + if type(data.SlotCount) ~= 'number' then + return false, "SetSessionState has no SlotCount" + end + return true end, Accept = function(lobby, data) - return IsFromHost(lobby, data) + return RequireFromHost(lobby, data) end, ---@param data UICustomLobbySetSessionStateMessage Handler = function(lobby, data) @@ -141,10 +168,13 @@ CustomLobbyMessages = { ---@param data UICustomLobbySetReadyMessage Validate = function(lobby, data) - return type(data.Ready) == 'boolean' + if type(data.Ready) ~= 'boolean' then + return false, "SetReady has no Ready flag" + end + return true end, Accept = function(lobby, data) - return AmHost(lobby) + return RequireHost(lobby) end, ---@param data UICustomLobbySetReadyMessage Handler = function(lobby, data) @@ -160,10 +190,13 @@ CustomLobbyMessages = { ---@param data UICustomLobbyTakeSlotMessage Validate = function(lobby, data) - return type(data.Slot) == 'number' + if type(data.Slot) ~= 'number' then + return false, "TakeSlot has no Slot number" + end + return true end, Accept = function(lobby, data) - return AmHost(lobby) + return RequireHost(lobby) end, ---@param data UICustomLobbyTakeSlotMessage Handler = function(lobby, data) @@ -179,10 +212,13 @@ CustomLobbyMessages = { ---@param data UICustomLobbyDisconnectPeerMessage Validate = function(lobby, data) - return data.PeerID ~= nil + if data.PeerID == nil then + return false, "DisconnectPeer is missing its PeerID" + end + return true end, Accept = function(lobby, data) - return IsFromHost(lobby, data) + return RequireFromHost(lobby, data) end, ---@param data UICustomLobbyDisconnectPeerMessage Handler = function(lobby, data) @@ -198,10 +234,13 @@ CustomLobbyMessages = { ---@param data UICustomLobbyLaunchGameMessage Validate = function(lobby, data) - return type(data.GameConfig) == 'table' + if type(data.GameConfig) ~= 'table' then + return false, "LaunchGame has no GameConfig" + end + return true end, Accept = function(lobby, data) - return IsFromHost(lobby, data) + return RequireFromHost(lobby, data) end, ---@param data UICustomLobbyLaunchGameMessage Handler = function(lobby, data) @@ -216,10 +255,13 @@ CustomLobbyMessages = { ---@param data UICustomLobbyReportCpuBenchmarkMessage Validate = function(lobby, data) - return type(data.CpuBenchmark) == 'table' + if type(data.CpuBenchmark) ~= 'table' then + return false, "ReportCpuBenchmark has no CpuBenchmark" + end + return true end, Accept = function(lobby, data) - return AmHost(lobby) + return RequireHost(lobby) end, ---@param data UICustomLobbyReportCpuBenchmarkMessage Handler = function(lobby, data) @@ -234,10 +276,13 @@ CustomLobbyMessages = { ---@param data UICustomLobbySetCpuBenchmarksMessage Validate = function(lobby, data) - return type(data.CpuBenchmarks) == 'table' + if type(data.CpuBenchmarks) ~= 'table' then + return false, "SetCpuBenchmarks has no CpuBenchmarks table" + end + return true end, Accept = function(lobby, data) - return IsFromHost(lobby, data) + return RequireFromHost(lobby, data) end, ---@param data UICustomLobbySetCpuBenchmarksMessage Handler = function(lobby, data) diff --git a/lua/ui/lobby/customlobby/slots/CustomLobbySlotsInterface.lua b/lua/ui/lobby/customlobby/slots/CustomLobbySlotsInterface.lua index 69a3a1d588f..a926f5915f2 100644 --- a/lua/ui/lobby/customlobby/slots/CustomLobbySlotsInterface.lua +++ b/lua/ui/lobby/customlobby/slots/CustomLobbySlotsInterface.lua @@ -55,9 +55,11 @@ local HeaderHeight = 24 -- the "Players" header + gap above the layout -- the header tool buttons (right of the "Players" label): small square icon buttons, the same -- idle/hover/lit look as the config column's preview tools (drift-fine local copy — see CLAUDE.md). -local ToolSize = 18 +-- The icon (ToolSize - 2*ToolIconInset = 18px) matches the config strip; these textures are full +-- title-bar button glyphs, so anything smaller renders the pin tiny. +local ToolSize = 22 local ToolGap = 4 -local ToolIconInset = 3 +local ToolIconInset = 2 local ToolIdle = 'ff141a20' local ToolHover = 'ff1f262e' local ToolActive = 'ff2c4a5e' -- a lit toggle's background (pin on) diff --git a/lua/ui/lobby/customlobby/social/CustomLobbyLogsPanel.lua b/lua/ui/lobby/customlobby/social/CustomLobbyLogsPanel.lua index 7d0f77cd0e5..d66447f0cd0 100644 --- a/lua/ui/lobby/customlobby/social/CustomLobbyLogsPanel.lua +++ b/lua/ui/lobby/customlobby/social/CustomLobbyLogsPanel.lua @@ -20,34 +20,57 @@ --** SOFTWARE. --****************************************************************************************************** --- The Logs tab of the lobby's bottom-left tabbed panel: a live, scrolling view of this peer's --- network traffic (every message it broadcasts / sends / receives), fed by `CustomLobbyLog`. Each --- line is `mm:ss Type [→/← peer]` — outgoing broadcasts (»»), single sends (»), and --- incoming messages («). Because each peer logs only its own traffic, the host's and a client's --- views differ naturally. +-- The Logs tab of the lobby's bottom-left tabbed panel: a live view of this peer's network traffic +-- (every message it broadcasts / sends / receives), fed by `CustomLobbyLog`. It is a **tail view** — +-- the most recent entries that fit the panel height, newest at the bottom — rebuilt as traffic +-- arrives. (No scroll-back; the store keeps the last `MaxEntries`, this shows the tail.) +-- +-- Each row is laid out in columns, **fixed-width columns first then the flexible name** so the names +-- line up: `time · kind · ⚠ · name`. A malformed / unauthorised message (its Validate or Accept +-- returned a reason) tints the name and fills the ⚠ slot with a warning icon whose tooltip is the +-- reason. -- -- A bottom-left tab content component: created when its tab is selected and destroyed on switch (see -- ../CustomLobbyTabs.lua), so it's the live panel for its whole lifetime — the model observer just --- rebuilds it (Refresh is Ready-gated). `Initialize` (called by the tabs container after sizing it) --- builds the scrollbar + does the first render (the list needs a concrete height — three-phase init). +-- rebuilds it (Refresh is Ready-gated). local UIUtil = import("/lua/ui/uiutil.lua") local LayoutHelpers = import("/lua/maui/layouthelpers.lua") +local Tooltip = import("/lua/ui/game/tooltip.lua") local Group = import("/lua/maui/group.lua").Group -local ItemList = import("/lua/maui/itemlist.lua").ItemList local CustomLobbyLog = import("/lua/ui/lobby/customlobby/customlobbylog.lua") local LazyVarDerive = import("/lua/lazyvar.lua").Derive local Layouter = LayoutHelpers.ReusedLayoutFor -local Inset = 4 -local TextColor = 'ffb0b6be' -local Transparent = '00000000' +local RowHeight = 17 +local Pad = 4 +local ColGap = 3 + +-- fixed column widths (left → right); the name column is flexible and fills whatever is left +local TimeWidth = 30 +local KindWidth = 16 +local IconSize = 13 +local NameMax = 28 + +-- left edges of each column, accumulated so every row's name starts at the same x (aligned) +local TimeLeft = Pad +local KindLeft = TimeLeft + TimeWidth + ColGap +local WarnLeft = KindLeft + KindWidth + ColGap +local NameLeft = WarnLeft + IconSize + ColGap --- per-kind arrow prefix (no colour — ItemList colours are list-wide, so the glyph carries direction) -local KindPrefix = { +local TimeColor = 'ff6a707a' +local OutColor = 'ff7aa6c8' -- outgoing kind glyph (broadcast / send) +local RecvColor = 'ff8ac88a' -- incoming kind glyph (recv) +local NameColor = 'ffc8ccd0' +local ErrorColor = 'ffd0824c' -- name tint when the message was bad + +local WarnIcon = '/MODS/mod_type_warning.dds' + +-- the kind glyph (its own column, so the name aligns regardless of direction) +local KindGlyph = { broadcast = "»»", send = "»", recv = "«", @@ -61,26 +84,22 @@ local function FormatClock(seconds) return string.format("%02d:%02d", math.floor(whole / 60), math.mod(whole, 60)) end ---- One log line: `mm:ss Type [→/← peer]`. ----@param entry UICustomLobbyLogEntry +--- Truncates `text` to `maxChars`, appending "…" when it had to cut. +---@param text string +---@param maxChars number ---@return string -local function FormatEntry(entry) - local line = FormatClock(entry.Time) .. " " .. (KindPrefix[entry.Kind] or "·") .. " " .. entry.Type - if entry.Peer then - if entry.Kind == 'recv' then - line = line .. " ← " .. tostring(entry.Peer) - elseif entry.Kind == 'send' then - line = line .. " → " .. tostring(entry.Peer) - end +local function Truncate(text, maxChars) + text = text or "" + if string.len(text) > maxChars then + return string.sub(text, 1, maxChars - 1) .. "…" end - return line + return text end ---@class UICustomLobbyLogsPanel : Group ---@field Trash TrashBag ---@field Ready boolean ----@field List ItemList ----@field Scrollbar Scrollbar | false +---@field Rows Group[] ---@field Empty Text ---@field EntriesObserver LazyVar local CustomLobbyLogsPanel = ClassUI(Group) { @@ -92,13 +111,7 @@ local CustomLobbyLogsPanel = ClassUI(Group) { self.Trash = TrashBag() self.Ready = false - self.Scrollbar = false - - self.List = ItemList(self, "CustomLobbyLogList") - self.List:SetFont(UIUtil.bodyFont, 12) - -- foreground only; background / selection / mouseover transparent (read-only feed) - self.List:SetColors(TextColor, Transparent, TextColor, Transparent, TextColor, Transparent) - self.List:DeleteAllItems() + self.Rows = {} self.Empty = UIUtil.CreateText(self, "No messages yet", 13, UIUtil.bodyFont) self.Empty:SetColor('ff5a606a') @@ -106,7 +119,7 @@ local CustomLobbyLogsPanel = ClassUI(Group) { self.Empty:Hide() -- created/destroyed with its tab, so always the live panel while it exists — the observer - -- just rebuilds (Refresh is Ready-gated until Initialize sizes the list) + -- just rebuilds (Refresh is Ready-gated until Initialize sizes the panel) self.EntriesObserver = self.Trash:Add( LazyVarDerive(CustomLobbyLog.GetSingleton().Entries, function(entriesLazy) entriesLazy() @@ -116,40 +129,97 @@ local CustomLobbyLogsPanel = ClassUI(Group) { ---@param self UICustomLobbyLogsPanel __post_init = function(self) - Layouter(self.List) - :AtLeftIn(self, Inset):AtTopIn(self, Inset):AtBottomIn(self, Inset) - :End() - -- leave room for the scrollbar on the right - self.List.Right:Set(function() return self.Right() - LayoutHelpers.ScaleNumber(Inset + 14) end) Layouter(self.Empty):AtHorizontalCenterIn(self):AtTopIn(self, 8):End() end, - --- Builds the scrollbar + does the first render. Called by the tabs container after it has sized - --- the panel (the list needs a concrete height — three-phase init, /lua/ui/CLAUDE.md § 1). + --- Does the first render once the panel has a concrete height (three-phase init). ---@param self UICustomLobbyLogsPanel Initialize = function(self) self.Ready = true - self.Scrollbar = UIUtil.CreateVertScrollbarFor(self.List) self:Refresh() end, - --- Rebuilds the list from the current log entries and scrolls to the newest. + --- Rebuilds the tail: the most recent entries that fit the panel height, oldest-visible at the + --- top, newest at the bottom. ---@param self UICustomLobbyLogsPanel Refresh = function(self) if not self.Ready then return end - local entries = CustomLobbyLog.GetSingleton().Entries() - self.List:DeleteAllItems() - for _, entry in entries do - self.List:AddItem(FormatEntry(entry)) + + for _, row in self.Rows do + row:Destroy() end - if table.getn(entries) > 0 then - self.Empty:Hide() - self.List:ScrollToBottom() - else + self.Rows = {} + + local entries = CustomLobbyLog.GetSingleton().Entries() + local total = table.getn(entries) + if total == 0 then self.Empty:Show() + return + end + self.Empty:Hide() + + local rowHeight = LayoutHelpers.ScaleNumber(RowHeight) + local available = self.Height() - LayoutHelpers.ScaleNumber(2 * Pad) + local capacity = math.max(1, math.floor(available / rowHeight)) + local first = math.max(1, total - capacity + 1) + + ---@type Group | false + local previous = false + for i = first, total do + local row = self:CreateRow(entries[i]) + local builder = Layouter(row):AtLeftIn(self, Pad):AtRightIn(self, Pad):Height(RowHeight) + if previous then + builder:AnchorToBottom(previous) + else + builder:AtTopIn(self, Pad) + end + builder:End() + previous = row + table.insert(self.Rows, row) + end + end, + + --- Builds one log row: time · kind · (warn) · name. The warn icon only appears for a bad + --- message; its column slot is still reserved (a fixed width before the flexible name) so names + --- stay aligned. Private. + ---@param self UICustomLobbyLogsPanel + ---@param entry UICustomLobbyLogEntry + ---@return Group + CreateRow = function(self, entry) + local row = Group(self, "CustomLobbyLogRow") + + local time = UIUtil.CreateText(row, FormatClock(entry.Time), 11, UIUtil.bodyFont) + time:SetColor(TimeColor) + time:DisableHitTest() + Layouter(time):AtLeftIn(row, TimeLeft):AtVerticalCenterIn(row):End() + + local kind = UIUtil.CreateText(row, KindGlyph[entry.Kind] or "·", 12, UIUtil.titleFont) + kind:SetColor(entry.Kind == 'recv' and RecvColor or OutColor) + kind:DisableHitTest() + Layouter(kind):AtLeftIn(row, KindLeft):AtVerticalCenterIn(row):End() + + local label = entry.Type + if entry.Peer then + label = label .. (entry.Kind == 'recv' and " ← " or " → ") .. tostring(entry.Peer) end + local name = UIUtil.CreateText(row, Truncate(label, NameMax), 12, UIUtil.bodyFont) + name:SetColor(entry.Error and ErrorColor or NameColor) + name:DisableHitTest() + Layouter(name):AtLeftIn(row, NameLeft):AtRightIn(row, Pad):AtVerticalCenterIn(row):End() + + if entry.Error then + local warn = UIUtil.CreateBitmap(row, WarnIcon) + warn:DisableHitTest() + Layouter(warn):AtLeftIn(row, WarnLeft):AtVerticalCenterIn(row):Width(IconSize):Height(IconSize):End() + -- a hit-test-enabled overlay so the tooltip (the failure reason) shows on hover + local hover = Group(row, "CustomLobbyLogWarnHover") + Layouter(hover):AtLeftIn(row, WarnLeft):AtVerticalCenterIn(row):Width(IconSize):Height(IconSize):End() + Tooltip.AddControlTooltipManual(hover, "Invalid message", entry.Error) + end + + return row end, ---@param self UICustomLobbyLogsPanel From 8509126dd5569196e53c819c921ed2f66d43d11c Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Thu, 25 Jun 2026 11:38:18 +0200 Subject: [PATCH 53/98] Refinement of existing elements --- lua/ui/CLAUDE.md | 24 ++ .../customlobby/CustomLobbyInterface.lua | 2 +- lua/ui/lobby/customlobby/CustomLobbyTabs.lua | 39 ++-- .../config/CustomLobbyModsPanel.lua | 3 +- .../config/CustomLobbyOptionsPanel.lua | 3 +- .../mapselect/CustomLobbyMapSelect.lua | 3 +- .../modselect/CustomLobbyModSelect.lua | 3 + .../optionselect/CustomLobbyOptionColumn.lua | 3 +- .../optionselect/CustomLobbyOptionSelect.lua | 2 +- .../social/CustomLobbyLogsPanel.lua | 206 ++++++++++++++---- lua/ui/uiutil.lua | 25 +++ 11 files changed, 247 insertions(+), 66 deletions(-) diff --git a/lua/ui/CLAUDE.md b/lua/ui/CLAUDE.md index 4abd43add8a..99adcc944a2 100644 --- a/lua/ui/CLAUDE.md +++ b/lua/ui/CLAUDE.md @@ -224,6 +224,30 @@ self.SomeLine.Top:Set(scaled(20)) If you find yourself reaching for `ScaleNumber` a lot, that's usually a sign you should be using `Layouter` instead. +### Scrollbars — reserve the gutter on the content, not the bar + +`UIUtil.CreateVertScrollbarFor(content, offset_right)` sets `bar.Left = content.Right + offset_right` (`AnchorToRight`). The convention here: **inset the content's right edge by the gutter and attach the bar at offset 0** — the bar then sits in that reserved strip. + +```lua +Layouter(self.Content):AtLeftIn(self, 6):AtRightIn(self, 32):End() -- 32px gutter on the right +self.Scrollbar = UIUtil.CreateVertScrollbarFor(self.Content) -- offset 0 → bar sits in it +``` + +The standard gutter is **32px** (`/lua/ui/lobby/customlobby` is aligned to this). For a fixed-width grid the same reservation goes into the width math (`width = panelW - leftInset - 32`); right-aligned cell content (a value dropdown) then clears the bar automatically. + +- **Don't double-reserve.** Use *either* a content inset *or* a negative `offset_right` (e.g. `-32` on a full-width control), never both — combined they push the bar over the content. +- **Pooled/virtualised lists** that attach the bar to themselves (`CreateVertScrollbarFor(self)`, rows full-width) put the bar just *outside* the list's right edge instead — a deliberate different idiom; a negative `offset_right` is the only way to inset those. +- **TextArea**: bind `Width` in `Initialize` (post-mount), not `__post_init` — see the TextArea gotcha in the lobby CLAUDE.md. + +**The wheel only scrolls over the bar by default.** The engine routes `WheelRotation` to the scrollbar itself, so spinning the wheel over the *content* does nothing unless the content forwards it. Use `UIUtil.ForwardWheelToScroll(content, scrollable)` (chains any existing `HandleEvent`) right after creating the bar: + +```lua +self.Scrollbar = UIUtil.CreateVertScrollbarFor(self.Grid) +UIUtil.ForwardWheelToScroll(self.Grid, self.Grid) -- wheel over the rows now scrolls too +``` + +It forwards with the explicit `"Vert"` axis: a native `Grid` indexes its scroll state by axis (a `nil` axis errors in [grid.lua](../maui/grid.lua) `ScrollLines`); single-axis scrollables (`ItemList`/`TextArea`, custom lists) ignore the argument. Wheel events bubble from rows/cells to the parent `Grid`/`ItemList`, so attaching to the scrollable itself is enough — and a child that consumes the wheel (e.g. a `Combo` adjusting its value) still wins, which is what you want. + ### Reusing layout files `LayoutHelpers.*RelativeTo` reads positions from a layout `.lua` table — used by older skinned screens. New code generally prefers fluent anchors against siblings; reach for layout files only when matching an existing skinned design. diff --git a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua index 63a9e6fb228..027f125669f 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua @@ -110,7 +110,7 @@ local function GearAction(onOpen, title, body) end -- flip to tint each layout area so the regions are visible while iterating -local Debug = true +local Debug = false -- the lobby content is designed for the 1024x768 floor; the root fills the frame (full-screen -- backdrop) but the content is centered and capped to this size, so it never stretches on a diff --git a/lua/ui/lobby/customlobby/CustomLobbyTabs.lua b/lua/ui/lobby/customlobby/CustomLobbyTabs.lua index a343aa0a0c5..06e9fc85904 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyTabs.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyTabs.lua @@ -335,7 +335,6 @@ local CustomLobbyTabs = ClassUI(Group) { badge.Text = UIUtil.CreateText(badge, "", 11, UIUtil.bodyFont) badge.Text:SetColor(BadgeTextColor) badge.Text:DisableHitTest() - badge:Hide() button.Badge = badge -- the badge Derive fires synchronously on creation (before TabButtons[index] is set), @@ -394,35 +393,35 @@ local CustomLobbyTabs = ClassUI(Group) { end end, - --- Updates a count pill from its Badge LazyVar: shows the pill with `text`, or hides it (and - --- clears the text so the cluster re-centres on the bare label) when empty. Private. + --- Updates a count pill from its Badge LazyVar by setting its text. An empty string collapses + --- the pill to width 0 (its `Width` binding returns 0 when the text has no width), so the cluster + --- re-centres on the bare label and the grey `Bg` paints nothing. + --- + --- Drives *only* the text, never `Hide()`/`Show()`: the badge is often empty at construction + --- (the model populates after) and a control hidden before it is laid out doesn't reliably come + --- back on `Show()` — the hide-before-layout gotcha (see SetActionVisible / /lua/ui/CLAUDE.md). + --- Private. ---@param self UICustomLobbyTabs ---@param badge UICustomLobbyTabBadge ---@param text string SetBadge = function(self, badge, text) - if text == "" then - badge.Text:SetText("") - badge:Hide() - else - badge.Text:SetText(text) - badge:Show() - end + badge.Text:SetText(text) end, - --- Shows or hides an action button. Hiding sets its width to 0 so the label/pill cluster - --- re-centres as if the action weren't there (used to drop a host-only action for clients). - --- Private. + --- Shows or hides an action button by collapsing its width to 0 (the label/pill cluster then + --- re-centres as if the action weren't there) — used to drop a host-only action for clients. + --- + --- Deliberately drives *only* the width, never `Hide()`/`Show()`: this derive fires once at + --- construction (in a real lobby `IsHost` is still false then, before `__post_init` lays the + --- button out) and again when `IsHost` flips true. A `Button` hidden before it is laid out does + --- not reliably come back on `Show()` (the Hide/Show-before-layout gotcha — see /lua/ui/CLAUDE.md), + --- so the gear stayed invisible for the host. A 0-width button renders nothing and can't be + --- clicked, so width alone is a complete collapse. Private. ---@param self UICustomLobbyTabs ---@param action Control ---@param shown boolean SetActionVisible = function(self, action, shown) - if shown then - action:Show() - action.Width:Set(LayoutHelpers.ScaleNumber(ActionSize)) - else - action:Hide() - action.Width:Set(0) - end + action.Width:Set(shown and LayoutHelpers.ScaleNumber(ActionSize) or 0) end, ---@param self UICustomLobbyTabs diff --git a/lua/ui/lobby/customlobby/config/CustomLobbyModsPanel.lua b/lua/ui/lobby/customlobby/config/CustomLobbyModsPanel.lua index 430445d7472..7beb2181895 100644 --- a/lua/ui/lobby/customlobby/config/CustomLobbyModsPanel.lua +++ b/lua/ui/lobby/customlobby/config/CustomLobbyModsPanel.lua @@ -44,7 +44,7 @@ local LazyVarDerive = import("/lua/lazyvar.lua").Derive local Layouter = LayoutHelpers.ReusedLayoutFor local RowHeight = 22 -local ScrollGap = 18 +local ScrollGap = 32 -- standard lobby scrollbar gutter (see ModSelect) local GridContentWidth = 360 - 6 - ScrollGap local LabelMaxChars = 30 local NormalColor = 'ffc8ccd0' @@ -109,6 +109,7 @@ local CustomLobbyModsPanel = ClassUI(Group) { Initialize = function(self) self.Ready = true self.Scrollbar = UIUtil.CreateVertScrollbarFor(self.ModsGrid) + UIUtil.ForwardWheelToScroll(self.ModsGrid, self.ModsGrid) self:Refresh() end, diff --git a/lua/ui/lobby/customlobby/config/CustomLobbyOptionsPanel.lua b/lua/ui/lobby/customlobby/config/CustomLobbyOptionsPanel.lua index b6761c3e60b..9bc68bc31cc 100644 --- a/lua/ui/lobby/customlobby/config/CustomLobbyOptionsPanel.lua +++ b/lua/ui/lobby/customlobby/config/CustomLobbyOptionsPanel.lua @@ -51,7 +51,7 @@ local LazyVarDerive = import("/lua/lazyvar.lua").Derive local Layouter = LayoutHelpers.ReusedLayoutFor local RowHeight = 22 -local ScrollGap = 18 +local ScrollGap = 32 -- standard lobby scrollbar gutter (see ModSelect) local GridContentWidth = 360 - 6 - ScrollGap local LabelMaxChars = 26 @@ -136,6 +136,7 @@ local CustomLobbyOptionsPanel = ClassUI(Group) { Initialize = function(self) self.Ready = true self.Scrollbar = UIUtil.CreateVertScrollbarFor(self.OptionsGrid) + UIUtil.ForwardWheelToScroll(self.OptionsGrid, self.OptionsGrid) self:Refresh() end, diff --git a/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapSelect.lua b/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapSelect.lua index ed1e7ce9c2c..e34eafed022 100644 --- a/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapSelect.lua +++ b/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapSelect.lua @@ -620,8 +620,9 @@ local CustomLobbyMapSelect = ClassUI(Group) { -- the description's left/right are fixed here so its Width can be bound in Initialize (the -- TextArea reflows on the Width bind, which reads Left/Right); top/bottom set by LayoutSections - Layouter(self.Description):AtLeftIn(self.PreviewArea, 6):AtRightIn(self.PreviewArea, 24):End() + Layouter(self.Description):AtLeftIn(self.PreviewArea, 6):AtRightIn(self.PreviewArea, 32):End() self.DescriptionScrollbar = UIUtil.CreateVertScrollbarFor(self.Description) + UIUtil.ForwardWheelToScroll(self.Description, self.Description) --#endregion --#region actions diff --git a/lua/ui/lobby/customlobby/modselect/CustomLobbyModSelect.lua b/lua/ui/lobby/customlobby/modselect/CustomLobbyModSelect.lua index 53f29cc8cf5..3ae123aeaa2 100644 --- a/lua/ui/lobby/customlobby/modselect/CustomLobbyModSelect.lua +++ b/lua/ui/lobby/customlobby/modselect/CustomLobbyModSelect.lua @@ -513,11 +513,13 @@ local CustomLobbyModSelect = ClassUI(Group) { :End() Layouter(self.DetailsLabel):AtLeftIn(self.PreviewArea, 6):AnchorToTop(self.DetailsText, 2):End() self.DetailsScrollbar = UIUtil.CreateVertScrollbarFor(self.DetailsText) + UIUtil.ForwardWheelToScroll(self.DetailsText, self.DetailsText) -- Description: left/right + height fixed here (width is bound in Initialize); its top + the -- Author section above it are placed by LayoutDetail so Author can collapse when absent Layouter(self.Description):AtLeftIn(self.PreviewArea, 6):AtRightIn(self.PreviewArea, 32):Height(120):End() self.DescriptionScrollbar = UIUtil.CreateVertScrollbarFor(self.Description) + UIUtil.ForwardWheelToScroll(self.Description, self.Description) -- Dependencies: fills the gap between the description and the details block; scrolls Layouter(self.DepsLabel):AtLeftIn(self.PreviewArea, 6):AnchorToBottom(self.Description, 10):End() @@ -526,6 +528,7 @@ local CustomLobbyModSelect = ClassUI(Group) { :AnchorToBottom(self.DepsLabel, 4):AnchorToTop(self.DetailsLabel, 8) :End() self.DepsScrollbar = UIUtil.CreateVertScrollbarFor(self.DepsText) + UIUtil.ForwardWheelToScroll(self.DepsText, self.DepsText) --#endregion --#region actions diff --git a/lua/ui/lobby/customlobby/optionselect/CustomLobbyOptionColumn.lua b/lua/ui/lobby/customlobby/optionselect/CustomLobbyOptionColumn.lua index 5253b4ef397..37b78c9e7ca 100644 --- a/lua/ui/lobby/customlobby/optionselect/CustomLobbyOptionColumn.lua +++ b/lua/ui/lobby/customlobby/optionselect/CustomLobbyOptionColumn.lua @@ -48,7 +48,7 @@ local Layouter = LayoutHelpers.ReusedLayoutFor local HeaderHeight = 24 local RowHeight = 30 -local ScrollbarGap = 20 -- space reserved on the column's right for the scrollbar +local ScrollbarGap = 32 -- space reserved on the column's right for the scrollbar (standard lobby gutter) local ComboWidth = 116 local MarkerWidth = 14 @@ -143,6 +143,7 @@ local CustomLobbyOptionColumn = ClassUI(Group) { return end self.Scrollbar = UIUtil.CreateVertScrollbarFor(self.Grid) + UIUtil.ForwardWheelToScroll(self.Grid, self.Grid) end, --- Rebuilds the visible rows applying the search + hide-defaults filter, updates the count and diff --git a/lua/ui/lobby/customlobby/optionselect/CustomLobbyOptionSelect.lua b/lua/ui/lobby/customlobby/optionselect/CustomLobbyOptionSelect.lua index ae89f3b2422..534bbcda565 100644 --- a/lua/ui/lobby/customlobby/optionselect/CustomLobbyOptionSelect.lua +++ b/lua/ui/lobby/customlobby/optionselect/CustomLobbyOptionSelect.lua @@ -67,7 +67,7 @@ local ActionHeight = 48 -- three equal columns spanning the inner width, each reserving room on its right for a scrollbar local ColTotalWidth = math.floor((DialogWidth - 2 * Pad - 2 * ColGap) / 3) -local ColContentWidth = ColTotalWidth - 20 +local ColContentWidth = ColTotalWidth - 32 -- reserve the standard 32px scrollbar gutter local PrefsKey = "customlobby_optionselect" diff --git a/lua/ui/lobby/customlobby/social/CustomLobbyLogsPanel.lua b/lua/ui/lobby/customlobby/social/CustomLobbyLogsPanel.lua index d66447f0cd0..e55d7a766dd 100644 --- a/lua/ui/lobby/customlobby/social/CustomLobbyLogsPanel.lua +++ b/lua/ui/lobby/customlobby/social/CustomLobbyLogsPanel.lua @@ -21,15 +21,21 @@ --****************************************************************************************************** -- The Logs tab of the lobby's bottom-left tabbed panel: a live view of this peer's network traffic --- (every message it broadcasts / sends / receives), fed by `CustomLobbyLog`. It is a **tail view** — --- the most recent entries that fit the panel height, newest at the bottom — rebuilt as traffic --- arrives. (No scroll-back; the store keeps the last `MaxEntries`, this shows the tail.) +-- (every message it broadcasts / sends / receives), fed by `CustomLobbyLog`. A small **toolbar** +-- (a Copy icon-button that puts the whole log on the clipboard + a `Logs (N)` title) sits over a +-- **scrollable list** of rows. -- -- Each row is laid out in columns, **fixed-width columns first then the flexible name** so the names -- line up: `time · kind · ⚠ · name`. A malformed / unauthorised message (its Validate or Accept -- returned a reason) tints the name and fills the ⚠ slot with a warning icon whose tooltip is the -- reason. -- +-- The list is a `Grid` (one column, a row Group per cell) + a vertical scrollbar — the same +-- scrollable-rows pattern the config column's option/mod panels use; the Grid hides off-window rows +-- so it scrolls without needing clip-to-bounds (MAUI has none). It sticks to the newest entry unless +-- you've scrolled up. The Grid is built in `Initialize` because its cell width needs the panel's +-- concrete (post-mount) width. +-- -- A bottom-left tab content component: created when its tab is selected and destroyed on switch (see -- ../CustomLobbyTabs.lua), so it's the live panel for its whole lifetime — the model observer just -- rebuilds it (Refresh is Ready-gated). @@ -39,21 +45,43 @@ local LayoutHelpers = import("/lua/maui/layouthelpers.lua") local Tooltip = import("/lua/ui/game/tooltip.lua") local Group = import("/lua/maui/group.lua").Group +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap +local Grid = import("/lua/maui/grid.lua").Grid local CustomLobbyLog = import("/lua/ui/lobby/customlobby/customlobbylog.lua") local LazyVarDerive = import("/lua/lazyvar.lua").Derive local Layouter = LayoutHelpers.ReusedLayoutFor -local RowHeight = 17 +local ToolbarHeight = 26 -- the Copy button + title band at the top +-- the standard scrollbar gutter (matches ModSelect): the grid reserves these 32px on its right by +-- being that much narrower, and CreateVertScrollbarFor(grid) hangs the bar on grid.Right (offset 0), +-- so it lands in the strip. Reservation lives in the content width, NOT the scrollbar call — never +-- also pass -32 to CreateVertScrollbarFor or it double-reserves and the bar overlaps the text. +local ScrollGap = 32 +local RowHeight = 21 local Pad = 4 local ColGap = 3 +-- the Copy icon-button (idle / hover background + a centred icon), mirroring the config column's +-- PreviewTool / the slot tool strip +local ButtonSize = 20 +local ButtonIdle = 'ff141a20' +local ButtonHover = 'ff1f262e' +local ButtonIconInset = 4 +local ButtonIconColor = 'ffc8ccd0' -- TODO: temporary 1-colour placeholder until a copy/clipboard icon exists + +-- font sizes per column (the message type is the one you read, so it's the largest) +local TimeFont = 12 +local KindFont = 14 +local NameFont = 14 +local TitleFont = 14 + -- fixed column widths (left → right); the name column is flexible and fills whatever is left local TimeWidth = 30 local KindWidth = 16 local IconSize = 13 -local NameMax = 28 +local NameMax = 40 -- left edges of each column, accumulated so every row's name starts at the same x (aligned) local TimeLeft = Pad @@ -76,6 +104,13 @@ local KindGlyph = { recv = "«", } +-- plain-text kind label for the clipboard copy (glyphs don't paste usefully) +local KindText = { + broadcast = "BROADCAST", + send = "SEND", + recv = "RECV", +} + --- `mm:ss` from a seconds offset (Lua 5.0 — no `%`, use `math.mod`). ---@param seconds number ---@return string @@ -96,10 +131,30 @@ local function Truncate(text, maxChars) return text end +--- One entry as a plain-text clipboard line: `mm:ss RECV Type <- peer [ERROR: reason]`. +---@param entry UICustomLobbyLogEntry +---@return string +local function EntryToText(entry) + local line = FormatClock(entry.Time) .. " " .. (KindText[entry.Kind] or "?") .. " " .. entry.Type + if entry.Peer then + line = line .. (entry.Kind == 'recv' and " <- " or " -> ") .. tostring(entry.Peer) + end + if entry.Error then + line = line .. " [ERROR: " .. entry.Error .. "]" + end + return line +end + ---@class UICustomLobbyLogsPanel : Group ---@field Trash TrashBag ---@field Ready boolean ----@field Rows Group[] +---@field CopyButton Group # icon-button (Bg + Icon); copies the log to the clipboard +---@field CopyBg Bitmap +---@field CopyIcon Bitmap +---@field Title Text +---@field Grid Grid | false +---@field RowWidth number # unscaled cell width (set in Initialize from the panel width) +---@field Scrollbar Scrollbar | false ---@field Empty Text ---@field EntriesObserver LazyVar local CustomLobbyLogsPanel = ClassUI(Group) { @@ -111,7 +166,35 @@ local CustomLobbyLogsPanel = ClassUI(Group) { self.Trash = TrashBag() self.Ready = false - self.Rows = {} + self.Grid = false + self.Scrollbar = false + + --#region toolbar: Copy icon-button + title + self.CopyButton = Group(self, "CustomLobbyLogCopy") + self.CopyBg = Bitmap(self.CopyButton) + self.CopyBg:SetSolidColor(ButtonIdle) + self.CopyIcon = Bitmap(self.CopyButton) + self.CopyIcon:SetSolidColor(ButtonIconColor) + self.CopyIcon:DisableHitTest() + self.CopyBg.HandleEvent = function(control, event) + if event.Type == 'ButtonPress' then + self:CopyAll() + return true + elseif event.Type == 'MouseEnter' then + self.CopyBg:SetSolidColor(ButtonHover) + return true + elseif event.Type == 'MouseExit' then + self.CopyBg:SetSolidColor(ButtonIdle) + return true + end + return false + end + Tooltip.AddControlTooltipManual(self.CopyBg, "Copy log", + "Copy the whole traffic log to the clipboard (handy when something looks wrong).") + + self.Title = UIUtil.CreateText(self, "Logs (0)", TitleFont, UIUtil.titleFont) + self.Title:DisableHitTest() + --#endregion self.Empty = UIUtil.CreateText(self, "No messages yet", 13, UIUtil.bodyFont) self.Empty:SetColor('ff5a606a') @@ -119,7 +202,7 @@ local CustomLobbyLogsPanel = ClassUI(Group) { self.Empty:Hide() -- created/destroyed with its tab, so always the live panel while it exists — the observer - -- just rebuilds (Refresh is Ready-gated until Initialize sizes the panel) + -- just rebuilds (Refresh is Ready-gated until Initialize builds the grid) self.EntriesObserver = self.Trash:Add( LazyVarDerive(CustomLobbyLog.GetSingleton().Entries, function(entriesLazy) entriesLazy() @@ -129,56 +212,98 @@ local CustomLobbyLogsPanel = ClassUI(Group) { ---@param self UICustomLobbyLogsPanel __post_init = function(self) - Layouter(self.Empty):AtHorizontalCenterIn(self):AtTopIn(self, 8):End() + Layouter(self.CopyButton):AtLeftIn(self, Pad):AtTopIn(self, Pad):Width(ButtonSize):Height(ButtonSize):End() + Layouter(self.CopyBg):Fill(self.CopyButton):End() + Layouter(self.CopyIcon) + :AtCenterIn(self.CopyButton) + :Width(ButtonSize - 2 * ButtonIconInset):Height(ButtonSize - 2 * ButtonIconInset) + :End() + Layouter(self.Title):AnchorToRight(self.CopyButton, 8):AtVerticalCenterIn(self.CopyButton):End() + Layouter(self.Empty):AtHorizontalCenterIn(self):AtTopIn(self, ToolbarHeight + 8):End() end, - --- Does the first render once the panel has a concrete height (three-phase init). + --- Builds the scrollable grid (its cell width needs the panel's concrete width) + scrollbar, and + --- does the first render. Three-phase init (/lua/ui/CLAUDE.md § 1). ---@param self UICustomLobbyLogsPanel Initialize = function(self) self.Ready = true + + -- Grid itemWidth is unscaled (Grid scales it); the panel width is concrete/scaled, so divide + -- back out the ui scale. Reserve the scrollbar gap on the right. + local scale = LayoutHelpers.GetPixelScaleFactor() + self.RowWidth = math.floor(self.Width() / scale) - ScrollGap + self.Grid = Grid(self, self.RowWidth, RowHeight) + Layouter(self.Grid) + :AtLeftIn(self, 0):Width(self.RowWidth) + :AnchorToBottom(self.CopyButton, 6):AtBottomIn(self, Pad) + :End() + self.Scrollbar = UIUtil.CreateVertScrollbarFor(self.Grid) + -- let the wheel scroll the grid when the cursor is over the rows, not just over the scrollbar + UIUtil.ForwardWheelToScroll(self.Grid, self.Grid) + self:Refresh() end, - --- Rebuilds the tail: the most recent entries that fit the panel height, oldest-visible at the - --- top, newest at the bottom. + --- Rebuilds the list from the current entries, keeping the view pinned to the newest entry + --- unless the user has scrolled up. ---@param self UICustomLobbyLogsPanel Refresh = function(self) - if not self.Ready then + if not self.Ready or not self.Grid then return end - for _, row in self.Rows do - row:Destroy() - end - self.Rows = {} - local entries = CustomLobbyLog.GetSingleton().Entries() local total = table.getn(entries) + self.Title:SetText("Logs (" .. total .. ")") + + -- were we at (or near) the bottom before the rebuild? if so, stick to the bottom after + local _, rangeMax, _, visibleMax = self.Grid:GetScrollValues("Vert") + local stick = visibleMax >= rangeMax - 1 + + self.Grid:DeleteAndDestroyAll(true) if total == 0 then self.Empty:Show() + self.Grid:EndBatch() + self:UpdateScrollbar() return end self.Empty:Hide() - local rowHeight = LayoutHelpers.ScaleNumber(RowHeight) - local available = self.Height() - LayoutHelpers.ScaleNumber(2 * Pad) - local capacity = math.max(1, math.floor(available / rowHeight)) - local first = math.max(1, total - capacity + 1) - - ---@type Group | false - local previous = false - for i = first, total do - local row = self:CreateRow(entries[i]) - local builder = Layouter(row):AtLeftIn(self, Pad):AtRightIn(self, Pad):Height(RowHeight) - if previous then - builder:AnchorToBottom(previous) - else - builder:AtTopIn(self, Pad) - end - builder:End() - previous = row - table.insert(self.Rows, row) + self.Grid:AppendCols(1, true) + self.Grid:AppendRows(total, true) + for index, entry in entries do + self.Grid:SetItem(self:CreateRow(entry), 1, index, true) + end + self.Grid:EndBatch() + + if stick then + self.Grid:ScrollSetTop("Vert", total) + end + self:UpdateScrollbar() + end, + + --- Shows the scrollbar only when the list overflows. + ---@param self UICustomLobbyLogsPanel + UpdateScrollbar = function(self) + if not self.Scrollbar then + return + end + if self.Grid and self.Grid:IsScrollable("Vert") then + self.Scrollbar:Show() + else + self.Scrollbar:Hide() + end + end, + + --- Copies the whole log to the clipboard as plain text (the engine's global `CopyToClipboard`). + ---@param self UICustomLobbyLogsPanel + CopyAll = function(self) + local entries = CustomLobbyLog.GetSingleton().Entries() + local lines = {} + for index, entry in entries do + lines[index] = EntryToText(entry) end + CopyToClipboard(table.concat(lines, "\n")) end, --- Builds one log row: time · kind · (warn) · name. The warn icon only appears for a bad @@ -188,14 +313,15 @@ local CustomLobbyLogsPanel = ClassUI(Group) { ---@param entry UICustomLobbyLogEntry ---@return Group CreateRow = function(self, entry) - local row = Group(self, "CustomLobbyLogRow") + local row = Group(self.Grid, "CustomLobbyLogRow") + LayoutHelpers.SetDimensions(row, self.RowWidth, RowHeight) - local time = UIUtil.CreateText(row, FormatClock(entry.Time), 11, UIUtil.bodyFont) + local time = UIUtil.CreateText(row, FormatClock(entry.Time), TimeFont, UIUtil.bodyFont) time:SetColor(TimeColor) time:DisableHitTest() Layouter(time):AtLeftIn(row, TimeLeft):AtVerticalCenterIn(row):End() - local kind = UIUtil.CreateText(row, KindGlyph[entry.Kind] or "·", 12, UIUtil.titleFont) + local kind = UIUtil.CreateText(row, KindGlyph[entry.Kind] or "·", KindFont, UIUtil.titleFont) kind:SetColor(entry.Kind == 'recv' and RecvColor or OutColor) kind:DisableHitTest() Layouter(kind):AtLeftIn(row, KindLeft):AtVerticalCenterIn(row):End() @@ -204,7 +330,7 @@ local CustomLobbyLogsPanel = ClassUI(Group) { if entry.Peer then label = label .. (entry.Kind == 'recv' and " ← " or " → ") .. tostring(entry.Peer) end - local name = UIUtil.CreateText(row, Truncate(label, NameMax), 12, UIUtil.bodyFont) + local name = UIUtil.CreateText(row, Truncate(label, NameMax), NameFont, UIUtil.bodyFont) name:SetColor(entry.Error and ErrorColor or NameColor) name:DisableHitTest() Layouter(name):AtLeftIn(row, NameLeft):AtRightIn(row, Pad):AtVerticalCenterIn(row):End() diff --git a/lua/ui/uiutil.lua b/lua/ui/uiutil.lua index 427f5f79ce7..fcbba286cc1 100644 --- a/lua/ui/uiutil.lua +++ b/lua/ui/uiutil.lua @@ -980,6 +980,31 @@ function CreateLobbyVertScrollbar(attachto, offset_right, offset_bottom, offset_ return CreateVertScrollbarFor(attachto, offset_right, "/SCROLLBAR_VERT/", offset_bottom, offset_top) end +--- Makes the mouse wheel scroll a region when the cursor is over its **content**, not just over the +--- scrollbar (the engine only routes wheel events to the bar itself). Forwards `WheelRotation` on +--- `control` to `scrollable:ScrollLines` — `scrollable` is whatever the scrollbar drives (a `Grid`, +--- `ItemList`, `TextArea`, or a custom control implementing `ScrollLines(axis, delta)`); usually the +--- same control. Chains any existing `HandleEvent` so it is safe to add on top of click handling. +---@param control Control # the control whose wheel events to capture (the content / its container) +---@param scrollable Control # the scrollable the scrollbar drives (often `control` itself) +---@param linesPerNotch? number # lines to scroll per wheel notch (default 3) +function ForwardWheelToScroll(control, scrollable, linesPerNotch) + linesPerNotch = linesPerNotch or 3 + local previous = control.HandleEvent + control.HandleEvent = function(c, event) + if event.Type == 'WheelRotation' then + -- pass the explicit "Vert" axis: the native Grid indexes its state by axis (a nil axis + -- errors in grid.lua); single-axis scrollables ignore the argument + scrollable:ScrollLines("Vert", event.WheelRotation > 0 and -linesPerNotch or linesPerNotch) + return true + end + if previous then + return previous(c, event) + end + return false + end +end + -- cause a dialog to get input focus, optional functions to perform when the user hits enter or escape -- functions signature is: function() function MakeInputModal(control, onEnterFunc, onEscFunc) From 3f98bca9662a35fb35a541f82f507acba9f41f75 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Thu, 25 Jun 2026 14:23:39 +0200 Subject: [PATCH 54/98] Fix popover for sim performance --- .../customlobby/CustomLobbyInterface.lua | 42 +++++++------------ .../CustomLobbyPerformancePopover.lua | 9 ++-- 2 files changed, 19 insertions(+), 32 deletions(-) diff --git a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua index 027f125669f..e0cc0a581ab 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua @@ -37,7 +37,7 @@ -- │ BottomLeftArea (Chat / Observers │ │ -- │ — tabs) │ │ -- ├──────────────────────────────────────┴─────────────────────────────────────┤ --- │ ActionArea (Leave · status · … · Settings · Launch) ─ full width │ +-- │ ActionArea (Leave · status · … · Launch) ─ full width │ -- └────────────────────────────────────────────────────────────────────────────┘ -- -- The LEFT column splits vertically: the slots on top (CustomLobbySlotsInterface — one column, or @@ -45,11 +45,10 @@ -- the chat/observers tabs (CustomLobbyTabs) below. The RIGHT column is the map + options -- (CustomLobbyConfigInterface — a bound map preview, a name/size/players/version facts line, and the -- read-only options summary). A full-width action bar at the bottom holds the global actions: Leave --- + status on the left, the generic Settings button (opens the options editor) + the host-only --- Launch on the right. The title bar is just the title. +-- + status on the left, the host-only Launch on the right. The title bar is just the title. -- --- The per-domain edit buttons (change-map, mod-select) are removed for now — only the generic --- Settings button remains — and will be reintegrated once the rework is complete. +-- Options/mods are edited from the per-tab config gears in the right column; the action bar's old +-- generic Settings button (and the per-domain change-map / mod-select buttons) are gone. local UIUtil = import("/lua/ui/uiutil.lua") local LayoutHelpers = import("/lua/maui/layouthelpers.lua") @@ -69,7 +68,6 @@ local CustomLobbyTabs = import("/lua/ui/lobby/customlobby/customlobbytabs.lua") local CustomLobbyChatPanel = import("/lua/ui/lobby/customlobby/social/customlobbychatpanel.lua") local CustomLobbyObserversPanel = import("/lua/ui/lobby/customlobby/social/customlobbyobserverspanel.lua") local CustomLobbyLogsPanel = import("/lua/ui/lobby/customlobby/social/customlobbylogspanel.lua") -local CustomLobbyOptionSelect = import("/lua/ui/lobby/customlobby/optionselect/customlobbyoptionselect.lua") local LazyVarCreate = import("/lua/lazyvar.lua").Create local LazyVarDerive = import("/lua/lazyvar.lua").Derive @@ -121,7 +119,7 @@ local LobbyHeight = 768 local Pad = 8 local TitleHeight = 48 local RightWidth = 360 -- the right column (map preview + options summary); the left fills the rest -local ActionHeight = 52 -- the full-width action bar at the very bottom (status + Settings + launch) +local ActionHeight = 52 -- the full-width action bar at the very bottom (status + launch) --- Creates a layout area (an invisible Group with an optional debug tint). ---@param parent Control @@ -156,7 +154,6 @@ end ---@field Config UICustomLobbyConfigInterface ---@field ActionArea Group ---@field StatusLabel Text ----@field SettingsButton Button ---@field LaunchButton Button ---@field IsHostObserver LazyVar local CustomLobbyInterface = Class(Group) { @@ -232,19 +229,11 @@ local CustomLobbyInterface = Class(Group) { self.Config = CustomLobbyConfigInterface.Create(self.RightArea) --#endregion - --#region action bar (full-width, bottom): status + Settings + launch + --#region action bar (full-width, bottom): status + launch self.StatusLabel = UIUtil.CreateText(self.ActionArea, "", 13, UIUtil.bodyFont) self.StatusLabel:SetColor('ff9aa0a8') self.StatusLabel:DisableHitTest() - -- the single generic settings button (host-only); opens the options editor. The per-domain - -- edit buttons (change-map, mod-select) are removed until the rework is complete. - self.SettingsButton = UIUtil.CreateButtonWithDropshadow(self.ActionArea, '/BUTTON/medium/', "Settings") - self.SettingsButton.OnClick = function(button, modifiers) - CustomLobbyOptionSelect.Open(GetFrame(0)) - end - Tooltip.AddControlTooltipManual(self.SettingsButton, "Settings", "Open the game options (host only).") - self.LaunchButton = UIUtil.CreateButtonWithDropshadow(self.ActionArea, '/BUTTON/large/', "Launch") self.LaunchButton.OnClick = function(button, modifiers) CustomLobbyController.RequestLaunch() @@ -314,11 +303,10 @@ local CustomLobbyInterface = Class(Group) { Layouter(self.Config):Fill(self.RightArea):End() --#endregion - --#region action bar: Leave + status on the left, Settings + launch on the right + --#region action bar: Leave + status on the left, launch on the right Layouter(self.LeaveButton):AtLeftIn(self.ActionArea):AtVerticalCenterIn(self.ActionArea):End() Layouter(self.StatusLabel):AnchorToRight(self.LeaveButton, 8):AtVerticalCenterIn(self.ActionArea):End() Layouter(self.LaunchButton):AtRightIn(self.ActionArea):AtVerticalCenterIn(self.ActionArea):End() - Layouter(self.SettingsButton):AnchorToLeft(self.LaunchButton, 8):AtVerticalCenterIn(self.ActionArea):End() --#endregion -- size-dependent children build their scrollbars / first render now that they're sized @@ -327,19 +315,17 @@ local CustomLobbyInterface = Class(Group) { self.Config:Initialize() end, - --- Tracks host status: updates the status line and shows the host-only action-bar buttons - --- (Settings + Launch) only to the host. (The options editor the Settings button opens is - --- host-gated regardless; the right-column options summary stays read-only-visible to everyone.) + --- Tracks host status: updates the status line and shows the host-only Launch button only to the + --- host. (Options editing lives on the right-column config gears, host-gated there; the + --- right-column options summary stays read-only-visible to everyone.) ---@param self UICustomLobbyInterface ---@param isHost boolean OnIsHostChanged = function(self, isHost) self.StatusLabel:SetText(isHost and "You are the host." or "The host controls the game.") - for _, button in { self.SettingsButton, self.LaunchButton } do - if isHost then - button:Show() - else - button:Hide() - end + if isHost then + self.LaunchButton:Show() + else + self.LaunchButton:Hide() end end, diff --git a/lua/ui/lobby/customlobby/CustomLobbyPerformancePopover.lua b/lua/ui/lobby/customlobby/CustomLobbyPerformancePopover.lua index 15d5379f026..d8965fc13c1 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyPerformancePopover.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyPerformancePopover.lua @@ -370,11 +370,12 @@ function Show(anchor, metrics, unitCap) local frame = GetFrame(0) - -- float beside the hovered control, vertically centred on it. If the control sits on the - -- right half of the screen, open to its LEFT so the popover doesn't run off the edge. + -- float beside the hovered control, vertically centred on it. Prefer opening to its RIGHT, but + -- flip to the LEFT when the popover's own width would run it off the right screen edge (the old + -- screen-centre test ignored the popover width, so it clipped — worse at higher ui_scale). local builder = Layouter(popover):AtVerticalCenterIn(anchor) - local anchorCenter = (anchor.Left() + anchor.Right()) / 2 - if anchorCenter > frame.Width() / 2 then + local pad = LayoutHelpers.ScaleNumber(12) + if anchor.Right() + pad + popover.Width() > frame.Width() then builder:AnchorToLeft(anchor, 12) else builder:AnchorToRight(anchor, 12) From aed967c436e2fba093ddb15cd0724a3e6b68b753 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Thu, 25 Jun 2026 16:23:50 +0200 Subject: [PATCH 55/98] Add support for unit restrictions --- lua/ui/lobby/customlobby/CLAUDE.md | 4 +- .../customlobby/CustomLobbyController.lua | 25 ++ .../customlobby/CustomLobbyLaunchModel.lua | 11 + .../lobby/customlobby/CustomLobbyMessages.lua | 5 +- .../config/CustomLobbyConfigInterface.lua | 21 +- .../config/CustomLobbyUnitsPanel.lua | 128 +++++- .../unitselect/CustomLobbyUnitSelect.lua | 408 ++++++++++++++++++ 7 files changed, 582 insertions(+), 20 deletions(-) create mode 100644 lua/ui/lobby/customlobby/unitselect/CustomLobbyUnitSelect.lua diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index 5889027017b..f3608755ecf 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -23,7 +23,7 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` - **[CustomLobbyLaunchModel.lua](CustomLobbyLaunchModel.lua)** — shared **and** launched: the launch payload (players, observers, scenario, game options, mods, auto-teams, spawn - mex) + `MaxSlots` + the `UICustomLobbyPlayer` shape. `Players` is an **array of per-slot + mex, unit restrictions) + `MaxSlots` + the `UICustomLobbyPlayer` shape. `Players` is an **array of per-slot LazyVars** so one slot's change re-fires only that row. Write helpers (`SetPlayer`, `SetPlayerField`, `AddObserver`, …) keep the copy-then-`Set` discipline. - **[CustomLobbySessionModel.lua](CustomLobbySessionModel.lua)** — shared but **not** @@ -63,7 +63,7 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | [CustomLobbyTeamScore.lua](CustomLobbyTeamScore.lua) | the **accumulated team rating** side indicator — `Side A N · M Side B`. Hosted in the strip atop the two-column slot layout (it doubles as the columns' Left/Right header). Shown only for the binary auto-team formations; **hidden** for `none`/`manual` or until a positional map's start positions load. Reads the mode + side split from [`CustomLobbyRules`](CustomLobbyRules.lua) (`AutoTeamMode` / `SideLabels` / `BuildSideResolver`) and the ratings from each slot's `PL`. Reference data; never writes. | | [CustomLobbyTabs.lua](CustomLobbyTabs.lua) | a **generic tabbed panel** (strip + content; one panel alive, created on select / destroyed on switch). Tabs **divide the strip evenly** across its width. Construct with a `{ Label, Create, Badge?, Action?, Icon?, Compact? }` list + optional `OnSelect`. A tab's optional `Badge` LazyVar drives a grey **count pill** to the right of the label; its optional `Action` (`{ Create, Visible? }`) is a small button the owner builds **inside the tab, left of the label** (e.g. a config gear), whose `Visible` LazyVar hides it (collapsing it from the layout) when it doesn't apply. The action, label and pill are centred together as one cluster; any absent/hidden/empty piece contributes 0 width so the rest re-centres. A tab can instead be **`Compact`** (a fixed narrow width, excluded from the even division — the flexible tabs share what's left) and/or show an **`Icon`** centred instead of its label (an icon-only utility tab); the default active tab is the first non-compact one. The container just mirrors the LazyVars, the owner decides what they mean. Used for the bottom-left (Logs / Chat / Observers) and the config interface's Options / Mods / Restrictions. | | [social/](social/) | the lobby's **bottom-left** column (the `CustomLobbyTabs` content): [`CustomLobbyChatPanel`](social/CustomLobbyChatPanel.lua) (the **Chat** tab — placeholder until the chat slice lands) and [`CustomLobbyObserversPanel`](social/CustomLobbyObserversPanel.lua) (the **Observers** tab — the shared observer list + a host-authoritative **Become observer** button → `RequestMoveToObserver`). Each is a tab content component (`Create(parent)`, created on select / destroyed on switch). The Chat / Observers tabs mirror the config column's shape — a per-tab **config gear** (`CustomLobbyInterface`'s local `GearAction`; both no-ops with a "coming soon" tooltip for now) + a right-side **count pill**: Observers shows the live observer count, Chat a dummy until the chat slice lands. A third **compact, icon-only [`CustomLobbyLogsPanel`](social/CustomLobbyLogsPanel.lua)** sits left of Chat (the **Logs** tab — a live **tail view** of this peer's network traffic from [`CustomLobbyLog`](CustomLobbyLog.lua): the most recent entries that fit, in columns `time · kind · ⚠ · name`, with a malformed/unauthorised message tinted + a ⚠ icon whose tooltip is the failure reason). | -| [config/](config/) | the lobby's **right** column. [`CustomLobbyConfigInterface.lua`](config/CustomLobbyConfigInterface.lua) is the column **composition**: a bound square `CustomLobbyMapPreview` **pinned** at the top with a vertical **preview tool strip** to its right (local `PreviewTool` icon buttons — toggles for army/start icons, mass+hydro deposits and the dummy water mask, driving `preview.Surface:SetOverlayVisible`, + a host-only **change-map** config icon at the bottom → the map-select dialog), a name + size/players/version facts line under it, and a [`CustomLobbyTabs`](CustomLobbyTabs.lua) (**Options / Mods / Restrictions**) filling the rest. Each tab carries its own **config gear** (`CustomLobbyTabs`' per-tab `Action`, built by the interface's `GearAction` helper) — a skinned button **inside the tab, left of the label** — that opens that tab's editor: Options → `CustomLobbyOptionSelect`, Mods → `CustomLobbyModSelect`, Restrictions → none yet (no gear). The Options gear is **host-only**: its `Visible` LazyVar is the `IsHost` field, so it's **hidden** for clients and the label re-centres; the Mods gear shows for everyone (UI mods are local; the sim portion is host-gated inside the dialog). The interface also owns the tabs' **count badges** as computed LazyVars over the launch model — Options shows the non-default-option count (`OptionUtil.CountNonDefault`), Mods shows `sim / ui` (synced sim mods / this peer's UI-mod prefs), Restrictions is wired but empty until the restrictions slice lands. All three tab panels are now **read-only**: their per-domain action buttons (open editor / reset / manage mods) are removed — the grid/content fills the whole panel — and the action-bar **Settings** button (now joined by this gear) is the edit entry point. [`CustomLobbyOptionsPanel`](config/CustomLobbyOptionsPanel.lua) (options grouped **Lobby / Scenario / Mods** + hide-defaults toggle; map/mod options gold-flagged with an origin tooltip — schema via [`/lua/ui/optionutil.lua`](/lua/ui/optionutil.lua)), [`CustomLobbyModsPanel`](config/CustomLobbyModsPanel.lua) (enabled mods in **Game / UI** sections), [`CustomLobbyUnitsPanel`](config/CustomLobbyUnitsPanel.lua) (the **Restrictions** placeholder). Each self-subscribes to the model and exposes `Initialize()` + `Create(parent)`. **Parked** (built, but unwired): [`CustomLobbyMapPanel`](config/CustomLobbyMapPanel.lua) (the full Map tab — preview + label/value details + Change-map), now superseded by the pinned preview. Churning a preview is safe here because the lobby shows only **one** current map and the engine caches map textures by name (the texture-leak rule only bites the *map-select dialog* — see [mapselect/CLAUDE.md](mapselect/CLAUDE.md)). | +| [config/](config/) | the lobby's **right** column. [`CustomLobbyConfigInterface.lua`](config/CustomLobbyConfigInterface.lua) is the column **composition**: a bound square `CustomLobbyMapPreview` **pinned** at the top with a vertical **preview tool strip** to its right (local `PreviewTool` icon buttons — toggles for army/start icons, mass+hydro deposits and the dummy water mask, driving `preview.Surface:SetOverlayVisible`, + a host-only **change-map** config icon at the bottom → the map-select dialog), a name + size/players/version facts line under it, and a [`CustomLobbyTabs`](CustomLobbyTabs.lua) (**Options / Mods / Restrictions**) filling the rest. Each tab carries its own **config gear** (`CustomLobbyTabs`' per-tab `Action`, built by the interface's `GearAction` helper) — a skinned button **inside the tab, left of the label** — that opens that tab's editor: Options → `CustomLobbyOptionSelect`, Mods → `CustomLobbyModSelect`, Restrictions → `CustomLobbyUnitSelect` (see [unitselect/](unitselect/)). The Options + Restrictions gears are **host-only**: their `Visible` LazyVar is the `IsHost` field, so they're **hidden** for clients and the label re-centres; the Mods gear shows for everyone (UI mods are local; the sim portion is host-gated inside the dialog). The interface also owns the tabs' **count badges** as computed LazyVars over the launch model — Options shows the non-default-option count (`OptionUtil.CountNonDefault`), Mods shows `sim / ui` (synced sim mods / this peer's UI-mod prefs), Restrictions shows the active restriction count (preset keys in the launch model's `Restrictions`). All three tab panels are now **read-only**: their per-domain action buttons (open editor / reset / manage mods) are removed — the grid/content fills the whole panel — and the action-bar **Settings** button (now joined by this gear) is the edit entry point. [`CustomLobbyOptionsPanel`](config/CustomLobbyOptionsPanel.lua) (options grouped **Lobby / Scenario / Mods** + hide-defaults toggle; map/mod options gold-flagged with an origin tooltip — schema via [`/lua/ui/optionutil.lua`](/lua/ui/optionutil.lua)), [`CustomLobbyModsPanel`](config/CustomLobbyModsPanel.lua) (enabled mods in **Game / UI** sections), [`CustomLobbyUnitsPanel`](config/CustomLobbyUnitsPanel.lua) (the **Restrictions** read-only list — the active restrictions' names, from the launch model's `Restrictions`). Each self-subscribes to the model and exposes `Initialize()` + `Create(parent)`. **Parked** (built, but unwired): [`CustomLobbyMapPanel`](config/CustomLobbyMapPanel.lua) (the full Map tab — preview + label/value details + Change-map), now superseded by the pinned preview. Churning a preview is safe here because the lobby shows only **one** current map and the engine caches map textures by name (the texture-leak rule only bites the *map-select dialog* — see [mapselect/CLAUDE.md](mapselect/CLAUDE.md)). | | [/lua/ui/lobby/lobby.lua](../lobby.lua) | engine entry wrapper (`CreateLobby`/`HostGame`/`JoinGame`) → CustomLobby. Old lobby preserved at `lobby-old.lua`. | Working today: host + clients see each other (host-authoritative player sync), the diff --git a/lua/ui/lobby/customlobby/CustomLobbyController.lua b/lua/ui/lobby/customlobby/CustomLobbyController.lua index 732503714de..a450204db87 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyController.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyController.lua @@ -414,6 +414,7 @@ function ProcessSentLaunchInfo(instance, data) launch.GameMods:Set(data.GameMods or {}) launch.AutoTeams:Set(data.AutoTeams or {}) launch.SpawnMex:Set(data.SpawnMex or {}) + launch.Restrictions:Set(data.Restrictions or {}) end --- Everyone applies the host's session state (slot count / closed slots) — lobby-room @@ -500,6 +501,7 @@ function BroadcastLaunchInfo(instance) GameMods = launch.GameMods(), AutoTeams = launch.AutoTeams(), SpawnMex = launch.SpawnMex(), + Restrictions = launch.Restrictions(), }) end @@ -657,6 +659,26 @@ function RequestSetGameMods(gameMods) BroadcastLaunchInfo(instance) end +--- The host sets the unit restrictions. Host-only — backs the unit-select dialog and a +--- `/restrict ` chat command. Stores the preset-key list in the launch model and broadcasts, +--- so every peer sees the same restrictions. The keys are folded into the launch config's +--- `GameOptions.RestrictedCategories` at launch (`BuildGameConfiguration`); the sim expands them. +---@param keys string[] +function RequestSetRestrictions(keys) + local instance = LobbyInstance + if not instance then + return + end + + if not CustomLobbyLocalModel.GetSingleton().IsHost() then + WARN("CustomLobby: only the host can change the unit restrictions") + return + end + + CustomLobbyLaunchModel.SetRestrictions(CustomLobbyLaunchModel.GetSingleton(), keys) + BroadcastLaunchInfo(instance) +end + --- The host sets the game options. Host-only — backs the options dialog. Replaces the whole --- `GameOptions` value table in the launch model and broadcasts so every peer sees the same --- options. The dialog already seeds defaults + drops stale keys, so this is the reconciled set. @@ -759,6 +781,9 @@ local function BuildGameConfiguration(instance) for _, option in OptionUtil.GetModOptions(launch.GameMods()) do table.insert(schema, option) end local gameOptions = OptionUtil.SeedDefaults(schema, launch.GameOptions()) gameOptions.ScenarioFile = launch.ScenarioFile() + -- the unit restrictions live in their own launch-model field (so the options dialog can't wipe + -- them); fold them in here as the preset-key array the sim expands (see simInit.lua) + gameOptions.RestrictedCategories = launch.Restrictions() or {} -- seated players (fresh copies; random faction resolved to a concrete one) local playerOptions = {} diff --git a/lua/ui/lobby/customlobby/CustomLobbyLaunchModel.lua b/lua/ui/lobby/customlobby/CustomLobbyLaunchModel.lua index 567162a1fac..c9ba8fe9537 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyLaunchModel.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyLaunchModel.lua @@ -77,6 +77,7 @@ MaxSlots = 16 ---@field AutoTeams LazyVar> ---@field GameOptions LazyVar
---@field GameMods LazyVar
+---@field Restrictions LazyVar # unit-restriction preset keys (folded into GameOptions.RestrictedCategories at launch) ---@field ScenarioFile LazyVar ---@type UICustomLobbyLaunchModel | nil @@ -98,6 +99,7 @@ function SetupSingleton() AutoTeams = Create({}), GameOptions = Create({}), GameMods = Create({}), + Restrictions = Create({}), ScenarioFile = Create(false), } @@ -185,6 +187,14 @@ function SetGameMods(model, gameMods) model.GameMods:Set(table.copy(gameMods)) end +--- Sets the unit-restriction preset keys (a list of strings). Folded into the launch config's +--- `GameOptions.RestrictedCategories` at launch (the sim expands the keys — see simInit.lua). +---@param model UICustomLobbyLaunchModel +---@param keys string[] +function SetRestrictions(model, keys) + model.Restrictions:Set(table.copy(keys)) +end + --- Appends a player to the observer list (copy-then-Set). ---@param model UICustomLobbyLaunchModel ---@param player UICustomLobbyPlayer @@ -235,6 +245,7 @@ function __moduleinfo.OnReload(newModule) handle.AutoTeams:Set(ModelInstance.AutoTeams()) handle.GameOptions:Set(ModelInstance.GameOptions()) handle.GameMods:Set(ModelInstance.GameMods()) + handle.Restrictions:Set(ModelInstance.Restrictions()) handle.ScenarioFile:Set(ModelInstance.ScenarioFile()) end end diff --git a/lua/ui/lobby/customlobby/CustomLobbyMessages.lua b/lua/ui/lobby/customlobby/CustomLobbyMessages.lua index 3291d06e223..61b60b1072f 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyMessages.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyMessages.lua @@ -111,8 +111,8 @@ CustomLobbyMessages = { }, -- The host's launch configuration — the launch-state fields that aren't the player - -- list (scenario, options, mods, teams, spawn mex). Broadcast on any change and to - -- each peer as it joins; the whole snapshot is sent rather than per-field deltas. + -- list (scenario, options, mods, teams, spawn mex, unit restrictions). Broadcast on any + -- change and to each peer as it joins; the whole snapshot is sent rather than per-field deltas. SentLaunchInfo = { ---@class UICustomLobbySentLaunchInfoMessage : UILobbyReceivedMessage ---@field ScenarioFile FileName | false @@ -120,6 +120,7 @@ CustomLobbyMessages = { ---@field GameMods table ---@field AutoTeams table ---@field SpawnMex table + ---@field Restrictions string[] ---@param data UICustomLobbySentLaunchInfoMessage Validate = function(lobby, data) diff --git a/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua b/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua index 4910f3ab4f2..1e8187ab80e 100644 --- a/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua +++ b/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua @@ -56,6 +56,7 @@ local CustomLobbyUnitsPanel = import("/lua/ui/lobby/customlobby/config/customlob local CustomLobbyMapSelect = import("/lua/ui/lobby/customlobby/mapselect/customlobbymapselect.lua") local CustomLobbyOptionSelect = import("/lua/ui/lobby/customlobby/optionselect/customlobbyoptionselect.lua") local CustomLobbyModSelect = import("/lua/ui/lobby/customlobby/modselect/customlobbymodselect.lua") +local CustomLobbyUnitSelect = import("/lua/ui/lobby/customlobby/unitselect/customlobbyunitselect.lua") local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/customlobbylocalmodel.lua") local CustomLobbyMapCatalog = import("/lua/ui/lobby/customlobby/mapselect/customlobbymapcatalog.lua") @@ -306,16 +307,18 @@ local CustomLobbyConfigInterface = ClassUI(Group) { return sim .. " / " .. ui end) - -- TODO: unit restrictions aren't modelled yet (the Restrictions panel is a placeholder); - -- point this at the restriction count once that slice lands. Empty → no pill for now. + -- the count of active unit restrictions (preset keys in the launch model's Restrictions) self.RestrictionsBadge = self.Trash:Add(LazyVarCreate()) - self.RestrictionsBadge:Set("") + self.RestrictionsBadge:Set(function() + local count = table.getn(launch.Restrictions()) + return count > 0 and tostring(count) or "" + end) -- the read-only config tabs below the preview (created-on-select / destroyed-on-switch), -- each with a config gear (inside the tab, left of the label) opening that tab's editor. - -- Options is host-only — its gear hides for clients (the host gate is the IsHost LazyVar); - -- Mods is open to everyone (UI mods are local, the sim portion is host-gated in the dialog); - -- Restrictions has no editor yet, so no gear. + -- Options + Restrictions are host-only — their gears hide for clients (the host gate is the + -- IsHost LazyVar); Mods is open to everyone (UI mods are local, the sim portion is host-gated + -- in the dialog). local isHost = CustomLobbyLocalModel.GetSingleton().IsHost self.Tabs = CustomLobbyTabs.Create(self, { Tabs = { @@ -329,7 +332,11 @@ local CustomLobbyConfigInterface = ClassUI(Group) { Action = GearAction(function() CustomLobbyModSelect.Open(GetFrame(0)) end, "Manage mods", "Pick the game's sim mods (host) and your own UI mods."), }, - { Label = "Restrictions", Create = CustomLobbyUnitsPanel.Create, Badge = self.RestrictionsBadge }, + { + Label = "Restrictions", Create = CustomLobbyUnitsPanel.Create, Badge = self.RestrictionsBadge, + Action = GearAction(function() CustomLobbyUnitSelect.Open(GetFrame(0)) end, + "Edit restrictions", "Pick the units and presets to restrict (host only).", isHost), + }, }, }) diff --git a/lua/ui/lobby/customlobby/config/CustomLobbyUnitsPanel.lua b/lua/ui/lobby/customlobby/config/CustomLobbyUnitsPanel.lua index 28ac68f8c2b..4954e6701ef 100644 --- a/lua/ui/lobby/customlobby/config/CustomLobbyUnitsPanel.lua +++ b/lua/ui/lobby/customlobby/config/CustomLobbyUnitsPanel.lua @@ -20,18 +20,39 @@ --** SOFTWARE. --****************************************************************************************************** --- The Units tab panel of the config interface: a placeholder until the unit-restrictions slice --- lands. A config-interface tab panel: the host creates it when the Units tab is selected and --- destroys it on switch, and calls `Initialize` after sizing it (same interface as the others). +-- The Units tab panel of the config interface: a **read-only** view of the active unit +-- restrictions. A config-interface tab panel — the host creates it when the Units tab is selected +-- and destroys it on switch, and calls `Initialize` after sizing it (same interface as the others). +-- +-- It subscribes to the launch model's `Restrictions` (the preset-key list, host-dictated + synced) +-- and lists each restriction's name. Editing happens in the host-only `CustomLobbyUnitSelect` dialog +-- behind this tab's config gear (see CustomLobbyConfigInterface). Step 1 lists names only; the +-- preset icons land with the dialog's icon grid. local UIUtil = import("/lua/ui/uiutil.lua") local LayoutHelpers = import("/lua/maui/layouthelpers.lua") local Group = import("/lua/maui/group.lua").Group +local Grid = import("/lua/maui/grid.lua").Grid + +local UnitsRestrictions = import("/lua/ui/lobby/unitsrestrictions.lua") +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") + +local LazyVarDerive = import("/lua/lazyvar.lua").Derive local Layouter = LayoutHelpers.ReusedLayoutFor +local RowHeight = 24 +local ScrollbarGap = 32 +local LabelColor = 'ffc8ccd0' +local DimColor = 'ff8a909a' + ---@class UICustomLobbyUnitsPanel : Group ----@field Info Text +---@field Trash TrashBag +---@field Grid Grid +---@field Scrollbar Scrollbar | false +---@field Empty Text +---@field Ready boolean +---@field RestrictionsObserver LazyVar local CustomLobbyUnitsPanel = ClassUI(Group) { ---@param self UICustomLobbyUnitsPanel @@ -39,19 +60,108 @@ local CustomLobbyUnitsPanel = ClassUI(Group) { __init = function(self, parent) Group.__init(self, parent, "CustomLobbyUnitsPanel") - self.Info = UIUtil.CreateText(self, "Unit restrictions — coming soon.", 14, UIUtil.bodyFont) - self.Info:SetColor('ff8a909a') - self.Info:DisableHitTest() + self.Trash = TrashBag() + self.Ready = false + self.Scrollbar = false + + -- a single-column list: the column width is nominal (rows bind their own Width to the grid), + -- only RowHeight drives the vertical layout / scroll. Grid scales these itself (pass unscaled). + self.Grid = Grid(self, 200, RowHeight) + + self.Empty = UIUtil.CreateText(self, "No unit restrictions.", 14, UIUtil.bodyFont) + self.Empty:SetColor(DimColor) + self.Empty:DisableHitTest() + self.Empty:Hide() + + -- gated behind Ready so the immediate fire on creation (hot-reload, model already populated) + -- doesn't rebuild rows before the parent has sized us — see ../CLAUDE.md layout gotchas + self.RestrictionsObserver = self.Trash:Add( + LazyVarDerive(CustomLobbyLaunchModel.GetSingleton().Restrictions, function(restrictionsLazy) + restrictionsLazy() + if self.Ready then + self:Refresh() + end + end)) end, ---@param self UICustomLobbyUnitsPanel __post_init = function(self) - Layouter(self.Info):AtHorizontalCenterIn(self):AtTopIn(self, 16):End() + Layouter(self.Grid):AtLeftIn(self, 6):AtTopIn(self, 6):AtBottomIn(self, 6):End() + self.Grid.Right:Set(function() return self.Right() - LayoutHelpers.ScaleNumber(ScrollbarGap) end) + Layouter(self.Empty):AtHorizontalCenterIn(self):AtTopIn(self, 16):End() end, - --- Nothing deferred (no grid); kept for a uniform panel interface (the host calls it). + --- Three-phase init: the tabs container calls this after sizing the panel (the Grid needs a + --- concrete height). Builds the scrollbar and renders the first list. ---@param self UICustomLobbyUnitsPanel Initialize = function(self) + self.Ready = true + if not self.Scrollbar then + self.Scrollbar = UIUtil.CreateVertScrollbarFor(self.Grid) + UIUtil.ForwardWheelToScroll(self.Grid, self.Grid) + end + self:Refresh() + end, + + --- Rebuilds the list of active restriction names from the launch model. + ---@param self UICustomLobbyUnitsPanel + Refresh = function(self) + local presets = UnitsRestrictions.GetPresetsData() + local keys = CustomLobbyLaunchModel.GetSingleton().Restrictions() + local count = table.getn(keys) + + self.Grid:DeleteAndDestroyAll(true) + + if count == 0 then + self.Empty:Show() + self:UpdateScrollbar() + return + end + self.Empty:Hide() + + self.Grid:AppendCols(1, true) + self.Grid:AppendRows(count, true) + for row, key in keys do + local preset = presets[key] + local name = (preset and preset.name and LOC(preset.name)) or key + self.Grid:SetItem(self:CreateRow(name), 1, row, true) + end + self.Grid:EndBatch() + self:UpdateScrollbar() + end, + + --- Builds one read-only row: the restriction's name. Private. + ---@param self UICustomLobbyUnitsPanel + ---@param name string + ---@return Group + CreateRow = function(self, name) + local row = Group(self.Grid) + LayoutHelpers.SetDimensions(row, 10, RowHeight) + row.Width:Set(function() return self.Grid.Width() end) + + local label = UIUtil.CreateText(row, name, 13, UIUtil.bodyFont) + label:SetColor(LabelColor) + label:DisableHitTest() + Layouter(label):AtLeftIn(row, 4):AtVerticalCenterIn(row):End() + return row + end, + + --- Shows the scrollbar only when the grid actually overflows. + ---@param self UICustomLobbyUnitsPanel + UpdateScrollbar = function(self) + if not self.Scrollbar then + return + end + if self.Grid:IsScrollable("Vert") then + self.Scrollbar:Show() + else + self.Scrollbar:Hide() + end + end, + + ---@param self UICustomLobbyUnitsPanel + OnDestroy = function(self) + self.Trash:Destroy() end, } diff --git a/lua/ui/lobby/customlobby/unitselect/CustomLobbyUnitSelect.lua b/lua/ui/lobby/customlobby/unitselect/CustomLobbyUnitSelect.lua new file mode 100644 index 00000000000..23055a3c596 --- /dev/null +++ b/lua/ui/lobby/customlobby/unitselect/CustomLobbyUnitSelect.lua @@ -0,0 +1,408 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- The unit-restriction dialog (step 1: the minimal core). A transient `Popup` modelled on the +-- mod-select dialog (see ../modselect/CLAUDE.md): a scrollable list of restriction-preset toggles, +-- with Clear / OK / Cancel. It is NOT a model component — it owns a *working selection* (a set of +-- preset keys) and, on OK, hands the **array of keys** to an `onConfirm` callback. Where that goes +-- is the opener's decision: `Open` routes it through the host-authoritative `RequestSetRestrictions` +-- intent (synced via the launch model's `Restrictions`). +-- +-- The preset list needs NO blueprint analysis — `UnitsRestrictions.GetPresetsData()` already gives +-- the name + tooltip per preset, and the sim expands the keys at launch (see simInit.lua). The +-- preset icon grid and the per-unit (UnitsAnalyzer-backed) grid are later steps; they plug into the +-- same working-selection / `onConfirm` contract, so this file's openers stay unchanged. +-- +-- `isHost = false` → read-only: the checkboxes are disabled and the OK / Clear buttons are hidden +-- (Cancel reads "Close"), matching the legacy UnitsManager `isHost` contract. + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") +local Tooltip = import("/lua/ui/game/tooltip.lua") + +local Group = import("/lua/maui/group.lua").Group +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap +local Grid = import("/lua/maui/grid.lua").Grid +local Popup = import("/lua/ui/controls/popups/popup.lua").Popup + +local UnitsRestrictions = import("/lua/ui/lobby/unitsrestrictions.lua") +local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") +local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/customlobbylocalmodel.lua") + +local Layouter = LayoutHelpers.ReusedLayoutFor + +-- flip to tint each layout area so the regions are visible while iterating +local Debug = true + +-- sized for the lobby's 1024×768 floor (the Popup centres it, leaving a small margin) +local DialogWidth = 960 +local DialogHeight = 710 +local Pad = 12 +local TitleHeight = 32 +local StatsHeight = 22 +local ActionHeight = 44 +local RowHeight = 26 +local ScrollbarGap = 32 -- standard lobby gutter reserved on the list's right +local Columns = 3 -- the preset checkboxes flow across this many columns to fill the width + +local DimColor = 'ff9aa0a8' + +-- the width of one preset column (the Grid's cell width); the rows fill it +local ColumnWidth = math.floor((DialogWidth - 2 * Pad - ScrollbarGap) / Columns) + +--- Creates a layout area (an invisible Group with an optional debug tint). +---@param parent Control +---@param name string +---@param color string +---@return Group +local function CreateArea(parent, name, color) + local area = Group(parent, name) + local bg = Bitmap(area) + bg:SetSolidColor(color) + bg:SetAlpha(Debug and 0.18 or 0.0) + bg:DisableHitTest() + Layouter(bg):Fill(area):End() + area.Bg = bg + return area +end + +--- The ordered list of selectable preset keys: `GetPresetsOrder()` minus the `""` separators and +--- any key that has no visible preset (name) in the data table. +---@return string[] +local function SelectablePresetKeys() + local presets = UnitsRestrictions.GetPresetsData() + local keys = {} + for _, key in UnitsRestrictions.GetPresetsOrder() do + if key ~= "" and presets[key] and presets[key].name then + table.insert(keys, key) + end + end + return keys +end + +---@class UICustomLobbyUnitSelect : Group +---@field Trash TrashBag +---@field Editable boolean +---@field Selection table +---@field OnConfirmCb fun(keys: string[]) +---@field OnCancelCb fun() +---@field Keys string[] +---@field Checkboxes table +---@field TitleArea Group +---@field ListArea Group +---@field StatsArea Group +---@field ActionArea Group +---@field Title Text +---@field Grid Grid +---@field Scrollbar Scrollbar | false +---@field Count Text +---@field SelectButton Button +---@field CancelButton Button +---@field ClearButton Button +local CustomLobbyUnitSelect = ClassUI(Group) { + + ---@param self UICustomLobbyUnitSelect + ---@param parent Control + ---@param options { initial: string[], editable: boolean, onConfirm: fun(keys: string[]), onCancel: fun() } + __init = function(self, parent, options) + Group.__init(self, parent, "CustomLobbyUnitSelect") + + self.Trash = TrashBag() + self.OnConfirmCb = options.onConfirm + self.OnCancelCb = options.onCancel + self.Editable = options.editable ~= false + self.Scrollbar = false + self.Checkboxes = {} + self.Keys = SelectablePresetKeys() + + -- working selection: a set built from the initial key array + self.Selection = {} + for _, key in (options.initial or {}) do + self.Selection[key] = true + end + + -- areas + self.TitleArea = CreateArea(self, "TitleArea", 'ffcc4040') + self.ListArea = CreateArea(self, "ListArea", 'ff4060cc') + self.StatsArea = CreateArea(self, "StatsArea", 'ff40cccc') + self.ActionArea = CreateArea(self, "ActionArea", 'ff808080') + + self.Title = UIUtil.CreateText(self.TitleArea, "Unit restrictions", 22, UIUtil.titleFont) + self.Title:DisableHitTest() + + -- a multi-column grid (cell = one preset column); Grid scales itemWidth / itemHeight + -- itself, so pass unscaled values + self.Grid = Grid(self.ListArea, ColumnWidth, RowHeight) + + self.Count = UIUtil.CreateText(self.StatsArea, "", 13, UIUtil.bodyFont) + self.Count:SetColor(DimColor) + self.Count:DisableHitTest() + + --#region actions + self.SelectButton = UIUtil.CreateButtonStd(self.ActionArea, '/scx_menu/small-btn/small', "OK", 16, 2) + self.SelectButton.OnClick = function(button, modifiers) + self:Confirm() + end + + self.CancelButton = UIUtil.CreateButtonStd(self.ActionArea, '/scx_menu/small-btn/small', + self.Editable and "Cancel" or "Close", 16, 2) + self.CancelButton.OnClick = function(button, modifiers) + self.OnCancelCb() + end + + self.ClearButton = UIUtil.CreateButtonStd(self.ActionArea, '/scx_menu/small-btn/small', "Clear", 14, 2) + self.ClearButton.OnClick = function(button, modifiers) + self:ClearSelection() + end + Tooltip.AddControlTooltipManual(self.ClearButton, "Clear", "Remove every unit restriction.") + + if not self.Editable then + self.SelectButton:Hide() + self.ClearButton:Hide() + end + --#endregion + end, + + ---@param self UICustomLobbyUnitSelect + __post_init = function(self) + self.Width:Set(LayoutHelpers.ScaleNumber(DialogWidth)) + self.Height:Set(LayoutHelpers.ScaleNumber(DialogHeight)) + + Layouter(self.TitleArea):AtLeftIn(self, Pad):AtRightIn(self, Pad):AtTopIn(self, Pad):Height(TitleHeight):End() + Layouter(self.ActionArea):AtLeftIn(self, Pad):AtRightIn(self, Pad):AtBottomIn(self, Pad):Height(ActionHeight):End() + Layouter(self.StatsArea):AtLeftIn(self, Pad):AtRightIn(self, Pad):AnchorToTop(self.ActionArea, Pad):Height(StatsHeight):End() + Layouter(self.ListArea) + :AtLeftIn(self, Pad):AtRightIn(self, Pad) + :AnchorToBottom(self.TitleArea, Pad):AnchorToTop(self.StatsArea, Pad) + :End() + + Layouter(self.Title):AtHorizontalCenterIn(self.TitleArea):AtVerticalCenterIn(self.TitleArea):End() + + Layouter(self.Grid):AtLeftIn(self.ListArea):AtTopIn(self.ListArea):AtBottomIn(self.ListArea):End() + self.Grid.Right:Set(function() return self.ListArea.Right() - LayoutHelpers.ScaleNumber(ScrollbarGap) end) + + Layouter(self.Count):AtLeftIn(self.StatsArea):AtVerticalCenterIn(self.StatsArea):End() + + -- one row (the wide dialog has room): Clear on the left, the Cancel / OK pair on the right + Layouter(self.ClearButton):AtLeftIn(self.ActionArea):AtVerticalCenterIn(self.ActionArea):End() + Layouter(self.SelectButton):AtRightIn(self.ActionArea):AtVerticalCenterIn(self.ActionArea):End() + Layouter(self.CancelButton):AnchorToLeft(self.SelectButton, 12):AtVerticalCenterIn(self.SelectButton):End() + end, + + --- Builds the row pool + scrollbar; called by the opener after Popup mounts + centres the dialog + --- (three-phase init — the Grid needs a concrete height). + ---@param self UICustomLobbyUnitSelect + Initialize = function(self) + self:Populate() + if not self.Scrollbar then + self.Scrollbar = UIUtil.CreateVertScrollbarFor(self.Grid) + UIUtil.ForwardWheelToScroll(self.Grid, self.Grid) + end + self:UpdateScrollbar() + self:UpdateStats() + end, + + --- Builds one checkbox cell per selectable preset, flowed across `Columns` columns. + ---@param self UICustomLobbyUnitSelect + Populate = function(self) + local presets = UnitsRestrictions.GetPresetsData() + local count = table.getn(self.Keys) + + self.Grid:DeleteAndDestroyAll(true) + self.Checkboxes = {} + if count == 0 then + return + end + + -- flow the presets left-to-right then down, filling `Columns` columns + local rows = math.floor((count - 1) / Columns) + 1 + self.Grid:AppendCols(Columns, true) + self.Grid:AppendRows(rows, true) + for i, key in self.Keys do + local col = math.mod(i - 1, Columns) + 1 + local row = math.floor((i - 1) / Columns) + 1 + self.Grid:SetItem(self:CreateRow(presets[key]), col, row, true) + end + self.Grid:EndBatch() + end, + + --- Builds one preset cell: a labelled checkbox wired to the working selection. Private. + ---@param self UICustomLobbyUnitSelect + ---@param preset table # an entry from UnitsRestrictions.GetPresetsData() + ---@return Group + CreateRow = function(self, preset) + local key = preset.key + local row = Group(self.Grid) + LayoutHelpers.SetDimensions(row, ColumnWidth, RowHeight) + + local checkbox = UIUtil.CreateCheckbox(row, '/CHECKBOX/', LOC(preset.name) or key, true, 13) + checkbox:SetCheck(self.Selection[key] == true, true) + if self.Editable then + checkbox.OnCheck = function(control, checked) + self:TogglePreset(key, checked) + end + else + checkbox:Disable() + end + if preset.tooltip then + Tooltip.AddControlTooltipManual(checkbox, LOC(preset.name) or key, LOC(preset.tooltip) or "") + end + self.Checkboxes[key] = checkbox + + Layouter(checkbox):AtLeftIn(row):AtVerticalCenterIn(row):End() + return row + end, + + --- Adds or removes a preset key from the working selection and refreshes the count. + ---@param self UICustomLobbyUnitSelect + ---@param key string + ---@param checked boolean + TogglePreset = function(self, key, checked) + if checked then + self.Selection[key] = true + else + self.Selection[key] = nil + end + self:UpdateStats() + end, + + --- Clears the whole working selection and unticks every checkbox. + ---@param self UICustomLobbyUnitSelect + ClearSelection = function(self) + self.Selection = {} + for _, checkbox in self.Checkboxes do + checkbox:SetCheck(false, true) + end + self:UpdateStats() + end, + + --- Updates the "N restrictions" footer. + ---@param self UICustomLobbyUnitSelect + UpdateStats = function(self) + local count = table.getsize(self.Selection) + if count == 0 then + self.Count:SetText("No restrictions") + elseif count == 1 then + self.Count:SetText("1 restriction") + else + self.Count:SetText(count .. " restrictions") + end + end, + + --- Shows the scrollbar only when the grid actually overflows. + ---@param self UICustomLobbyUnitSelect + UpdateScrollbar = function(self) + if not self.Scrollbar then + return + end + if self.Grid:IsScrollable("Vert") then + self.Scrollbar:Show() + else + self.Scrollbar:Hide() + end + end, + + --- Commits the working selection (as a key array) via the opener's callback. + ---@param self UICustomLobbyUnitSelect + Confirm = function(self) + local keys = {} + for key in self.Selection do + table.insert(keys, key) + end + self.OnConfirmCb(keys) + end, + + ---@param self UICustomLobbyUnitSelect + OnDestroy = function(self) + self.Trash:Destroy() + end, +} + +------------------------------------------------------------------------------- +--#region Singleton + open / close + +---@type Popup | false +local Instance = false + +--- Opens the unit-restriction dialog. Seeds from the synced `Restrictions`; the host can edit and +--- on OK the new key list routes through the host-authoritative `RequestSetRestrictions` intent. A +--- non-host opens it read-only (a window into the host's choice). +---@param parent? Control +function Open(parent) + parent = parent or GetFrame(0) + + if Instance then + Instance:Close() + end + + local launch = CustomLobbyLaunchModel.GetSingleton() + local isHost = CustomLobbyLocalModel.GetSingleton().IsHost() + + local popup + local content = CustomLobbyUnitSelect(parent, { + initial = launch.Restrictions() or {}, + editable = isHost, + onConfirm = function(keys) + CustomLobbyController.RequestSetRestrictions(keys) + if popup then + popup:Close() + end + end, + onCancel = function() + if popup then + popup:Close() + end + end, + }) + + popup = Popup(parent, content) + local baseOnClosed = popup.OnClosed + popup.OnClosed = function(self) + baseOnClosed(self) + Instance = false + end + Instance = popup + + -- now that Popup has mounted + centred the content, it's safe to build the rows + scrollbar + -- (both read concrete geometry) + content:Initialize() +end + +--- Closes the dialog if open. +function Close() + if Instance then + Instance:Close() + Instance = false + end +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Debugging + +function __moduleinfo.OnDirty() + Close() +end + +--#endregion From d541a8a21acd1d855f4837698c84333202545b46 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Thu, 25 Jun 2026 19:03:50 +0200 Subject: [PATCH 56/98] Extend support for unit restrictions --- lua/ui/CLAUDE.md | 26 + .../customlobby/CustomLobbyController.lua | 8 + .../customlobby/CustomLobbyInterface.lua | 52 -- lua/ui/lobby/customlobby/CustomLobbyTabs.lua | 14 +- .../unitselect/CustomLobbyUnitCatalog.lua | 178 ++++ .../unitselect/CustomLobbyUnitSelect.lua | 812 +++++++++++++++--- 6 files changed, 908 insertions(+), 182 deletions(-) create mode 100644 lua/ui/lobby/customlobby/unitselect/CustomLobbyUnitCatalog.lua diff --git a/lua/ui/CLAUDE.md b/lua/ui/CLAUDE.md index 99adcc944a2..077ae6a3f36 100644 --- a/lua/ui/CLAUDE.md +++ b/lua/ui/CLAUDE.md @@ -184,6 +184,32 @@ end, `TrashBag` is a weak table for values, so an item already destroyed elsewhere drops out automatically — `Destroy()` should always be idempotent. See [trashbag.lua:30-50](../system/trashbag.lua#L1-L50) for the contract. +### ⚠️ A `Trash:Add(Derive(...))` with no strong reference gets garbage-collected + +This is the load-bearing reason the canonical pattern assigns to `self.Observer` **and** passes through `Trash:Add` — the two references do different jobs: + +- `self.Observer = …` is the **strong** reference that keeps the derived LazyVar alive. +- `self.Trash:Add(…)` registers it for **teardown**, nothing more. + +`Trash:Add` does **not** keep its contents alive: `TrashBag` is `__mode = 'v'` (weak *values*, [trashbag.lua:40](../system/trashbag.lua#L40)), and a LazyVar's `used_by` (the subscriber list a source walks on `:Set`) is `__mode = 'k'` (weak *keys*, [lazyvar.lua:306](../lazyvar.lua#L306)). So a derived observer reachable **only** through those two tables has no strong referrer and is eligible for collection. + +```lua +-- ❌ BUG: nothing holds the derived LazyVar strongly. It fires once on creation, +-- then a GC pass collects it and it silently stops reacting to source changes. +self.Trash:Add(LazyVarDerive(model.IsHost, function(isHostLazy) + self:OnHostChanged(isHostLazy()) +end)) + +-- ✅ CORRECT: the self field is the strong ref; Trash:Add still handles teardown. +self.IsHostObserver = self.Trash:Add(LazyVarDerive(model.IsHost, function(isHostLazy) + self:OnHostChanged(isHostLazy()) +end)) +``` + +The failure mode is nasty because it's **timing-dependent and silent**: the observer's first synchronous fire works (before any GC), so initial render looks correct; only a *later* `source:Set` — after a GC cycle — fails to propagate, with no error. (Real example: a tab's host-only gear that stayed hidden because its visibility observer was collected before `IsHost` flipped to `true` at hosting time. The source's own `OnDirty` still fired — the source was strongly held by its model singleton — but the orphaned derived observer was already gone.) + +If you build observers in a loop (per row / per tab) and have no natural `self.X` name for each, keep them in a strong list field — `self.Observers = self.Observers or {}; table.insert(self.Observers, self.Trash:Add(Derive(...)))` — so the list holds them and the `Trash` still tears them down. + --- ## 4. Layout — Fluent Layouter diff --git a/lua/ui/lobby/customlobby/CustomLobbyController.lua b/lua/ui/lobby/customlobby/CustomLobbyController.lua index a450204db87..f5a3271c33d 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyController.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyController.lua @@ -278,14 +278,19 @@ end --- Called when we become the host. ---@param instance UICustomLobbyInstance function OnHosting(instance) + LOG("OnHosting") LobbyInstance = instance local localModel = CustomLobbyLocalModel.GetSingleton() local id = instance:GetLocalPlayerID() localModel.LocalPeerId:Set(id) localModel.HostID:Set(id) + localModel.IsHost.OnDirty = function() + LOG("CustomLobby: IsHost changed to " .. tostring(localModel.IsHost())) + end localModel.IsHost:Set(true) + local launch = CustomLobbyLaunchModel.GetSingleton() local player = CreateLocalPlayer(instance) player.OwnerID = id @@ -667,6 +672,8 @@ end function RequestSetRestrictions(keys) local instance = LobbyInstance if not instance then + WARN("CustomLobby: RequestSetRestrictions ignored — no lobby instance (UI-only, or the " + .. "controller was hot-reloaded; re-host to restore it)") return end @@ -676,6 +683,7 @@ function RequestSetRestrictions(keys) end CustomLobbyLaunchModel.SetRestrictions(CustomLobbyLaunchModel.GetSingleton(), keys) + LOG("CustomLobby: restrictions set (" .. table.getn(keys) .. ")") BroadcastLaunchInfo(instance) end diff --git a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua index e0cc0a581ab..0fe9bf3c67f 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua @@ -371,58 +371,6 @@ end ------------------------------------------------------------------------------- --#region Debugging ---- Standalone entry point: mounts the lobby against a model populated with fake ---- players so the UI can be inspected without networking. From the console: ---- `UI_Lua import("/lua/ui/lobby/customlobby/customlobbyinterface.lua").OpenDebug()` -function OpenDebug() - local slotCount = 6 - local launch = CustomLobbyLaunchModel.SetupSingleton() - CustomLobbySessionModel.SetupSingleton(slotCount) - - local localModel = CustomLobbyLocalModel.SetupSingleton() - localModel.LocalPeerId:Set("1") - localModel.IsHost:Set(true) - - -- four players in the first four slots, last two left open - for slot = 1, 4 do - CustomLobbyLaunchModel.SetPlayer(launch, slot, { - PlayerName = "Player " .. slot, - OwnerID = tostring(slot), - Human = slot ~= 4, -- slot 4 is an AI, to show the AI colour - Faction = math.mod(slot - 1, 4) + 1, - PlayerColor = slot, - ArmyColor = slot, - Team = math.mod(slot - 1, 2) + 2, -- alternating teams 1/2 - StartSpot = slot, - Ready = slot <= 2, - PL = 1000 + slot * 100, - AIPersonality = slot == 4 and "rush" or nil, - }) - end - - -- a couple of observers so the observer strip shows something - launch.Observers:Set({ - { PlayerName = "Zock", OwnerID = "10", Human = true }, - { PlayerName = "Spag", OwnerID = "11", Human = true }, - }) - - -- an auto-team mode so the title's team score shows (pvsi splits by start-spot parity, so it - -- needs no map); the four debug players already carry a PL rating + StartSpot - launch.GameOptions:Set({ AutoTeams = 'pvsi' }) - - -- a stock map so the preview renders; swap to any installed scenario if this - -- one isn't present (an unknown path just leaves the preview frame empty) - CustomLobbyLaunchModel.SetScenario(launch, "/maps/scmp_009/scmp_009_scenario.lua") - - SetupSingleton() -end - ---- Tears down the debug lobby. -function CloseDebug() - ModuleTrash:Destroy() - Instance = false -end - --- Called by the module manager when this module is reloaded. The rebuild is driven by --- OnDirty's deferred thread (below), so there's nothing to do here. ---@param newModule any diff --git a/lua/ui/lobby/customlobby/CustomLobbyTabs.lua b/lua/ui/lobby/customlobby/CustomLobbyTabs.lua index 06e9fc85904..dc2cef3a1f9 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyTabs.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyTabs.lua @@ -102,7 +102,9 @@ local TabIconSize = 16 ---@field Label? Text # present only for a text tab (absent when the tab uses an Icon) ---@field Icon? Bitmap # present only when the tab defines an Icon ---@field Badge? UICustomLobbyTabBadge # present only when the tab defines a Badge LazyVar +---@field BadgeObserver? LazyVar # strong ref to the badge observer (kept alive; see /lua/ui/CLAUDE.md § 3) ---@field Action? Control # present only when the tab defines an Action +---@field ActionObserver? LazyVar # strong ref to the action-visibility observer (kept alive) ---@class UICustomLobbyTabs : Group ---@field Trash TrashBag @@ -338,8 +340,10 @@ local CustomLobbyTabs = ClassUI(Group) { button.Badge = badge -- the badge Derive fires synchronously on creation (before TabButtons[index] is set), - -- so operate on the captured `badge`, not a lookup by index - self.Trash:Add(LazyVarDerive(badgeLazy, function(badgeTextLazy) + -- so operate on the captured `badge`, not a lookup by index. Stored on the button (a + -- strong ref) so the observer isn't garbage-collected — Trash:Add alone is weak, see + -- /lua/ui/CLAUDE.md § 3. + button.BadgeObserver = self.Trash:Add(LazyVarDerive(badgeLazy, function(badgeTextLazy) self:SetBadge(badge, badgeTextLazy() or "") end)) end @@ -353,8 +357,10 @@ local CustomLobbyTabs = ClassUI(Group) { button.Action = action if actionDef.Visible then -- fires synchronously on creation (before TabButtons[index] is set) — operate on - -- the captured `action`, not a lookup by index - self.Trash:Add(LazyVarDerive(actionDef.Visible, function(visibleLazy) + -- the captured `action`, not a lookup by index. Stored on the button (a strong ref) + -- so the observer isn't garbage-collected — Trash:Add alone is weak, see + -- /lua/ui/CLAUDE.md § 3. + button.ActionObserver = self.Trash:Add(LazyVarDerive(actionDef.Visible, function(visibleLazy) self:SetActionVisible(action, visibleLazy() and true or false) end)) else diff --git a/lua/ui/lobby/customlobby/unitselect/CustomLobbyUnitCatalog.lua b/lua/ui/lobby/customlobby/unitselect/CustomLobbyUnitCatalog.lua new file mode 100644 index 00000000000..54ba50890b7 --- /dev/null +++ b/lua/ui/lobby/customlobby/unitselect/CustomLobbyUnitCatalog.lua @@ -0,0 +1,178 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- The unit catalog: the custom lobby's per-faction unit blueprints for the restriction dialog — +-- the unit-side counterpart to CustomLobbyModCatalog. *Reference data*, NOT a model: it is built +-- from the player's own game files + the lobby's sim mods and never goes on the wire (only the +-- host's restriction *choice* — the launch model's `Restrictions` — syncs). +-- +-- It wraps the heavy `UnitsAnalyzer` blueprint pipeline (the same one the legacy UnitsManager uses) +-- behind a small reactive surface: `EnsureLoaded(activeMods)` kicks off the async fetch, `Progress` +-- / `ProgressText` stream the load progress, and `Factions` fires once with the grouped result — +-- a list of `{ Name, Order, Blueprints, Units }` per faction, where `Units` is the type grouping +-- (ACU / SCU / AIR / LAND / NAVAL / CONSTRUCT / ECONOMIC / SUPPORT / CIVILIAN / DEFENSES) from +-- `UnitsAnalyzer.GetUnitsGroups`. The dialog renders one faction tab per entry. + +local Create = import("/lua/lazyvar.lua").Create +local UnitsAnalyzer = import("/lua/ui/lobby/unitsanalyzer.lua") + +-- left-to-right tab order; an unknown faction is appended after these +local FactionOrder = { SERAPHIM = 1, UEF = 2, CYBRAN = 3, AEON = 4, NOMADS = 5 } + +---@class UICustomLobbyFaction +---@field Name string +---@field Order number +---@field Blueprints table[] # the faction's blueprints (list) +---@field Units table # type group -> { [unitId] = blueprint } + +--- The grouped factions, fired once when the load completes (empty until then). +---@type LazyVar +local Factions = Create({}) + +--- Load progress in [0, 1] and the current task label, streamed while loading. +local Progress = Create(0) +local ProgressText = Create("") + +local Loading = false +local Loaded = false + +------------------------------------------------------------------------------- +--#region Build + +--- Groups the analyzer's flat blueprint list into per-faction entries (each with its type groups), +--- sorted by `FactionOrder`, and publishes them on the `Factions` LazyVar. +local function PublishFactions() + local blueprints = UnitsAnalyzer.GetBlueprintsList() + local byName = {} + for _, bp in blueprints.All do + if bp.Faction then + local faction = byName[bp.Faction] + if not faction then + faction = { Name = bp.Faction, Blueprints = {}, Units = {} } + byName[bp.Faction] = faction + end + table.insert(faction.Blueprints, bp) + end + end + + local list = {} + for name, faction in byName do + UnitsAnalyzer.GetUnitsGroups(faction.Blueprints, faction) + faction.Order = FactionOrder[name] or (table.getsize(FactionOrder) + table.getn(list) + 1) + table.insert(list, faction) + end + table.sort(list, function(a, b) return a.Order < b.Order end) + + Factions:Set(list) +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Public API + +--- Kicks off the async blueprint fetch for the given sim mods if it hasn't run yet. Idempotent — +--- safe to call on every open; once loaded it's a no-op and the cached factions stay. +---@param activeMods table[] # resolved sim-mod list (e.g. `Mods.GetGameMods(launch.GameMods())`) +function EnsureLoaded(activeMods) + if Loading or Loaded then + return + end + Loading = true + Progress:Set(0) + ProgressText:Set("Loading blueprints…") + Factions:Set({}) + + local notifier = import("/lua/ui/lobby/tasknotifier.lua").Create() + notifier:Reset() + notifier.OnProgressCallback = function(task) + Progress:Set(notifier.totalProgress or 0) + if task and task.name then + ProgressText:Set(task.name .. " …") + end + end + notifier.OnCompleteCallback = function() + PublishFactions() + Progress:Set(1) + ProgressText:Set("") + Loaded = true + Loading = false + end + + UnitsAnalyzer.FetchBlueprints(activeMods or {}, false, notifier) +end + +--- The factions LazyVar — subscribe (via `Derive`) to react when the load completes. +---@return LazyVar +function GetFactionsVar() + return Factions +end + +--- The current (possibly empty, until loaded) list of factions. +---@return UICustomLobbyFaction[] +function GetFactions() + return Factions() +end + +--- The load-progress LazyVar in [0, 1]. +---@return LazyVar +function GetProgressVar() + return Progress +end + +--- The current load-task label LazyVar. +---@return LazyVar +function GetProgressTextVar() + return ProgressText +end + +--- Whether the blueprints have finished loading. +---@return boolean +function IsLoaded() + return Loaded +end + +--- Drops the cache so the next `EnsureLoaded` re-fetches (e.g. the sim mods changed). +function Refresh() + UnitsAnalyzer.StopBlueprints() + Loaded = false + Loading = false + Factions:Set({}) +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Debugging + +--- Hot-reload hook: re-imports this module after a couple of frames. The factions rebuild on the +--- next access, so dropping the cache is harmless. +function __moduleinfo.OnDirty() + ForkThread( + function() + WaitFrames(2) + import(__moduleinfo.name) + end + ) +end + +--#endregion diff --git a/lua/ui/lobby/customlobby/unitselect/CustomLobbyUnitSelect.lua b/lua/ui/lobby/customlobby/unitselect/CustomLobbyUnitSelect.lua index 23055a3c596..526f9605be5 100644 --- a/lua/ui/lobby/customlobby/unitselect/CustomLobbyUnitSelect.lua +++ b/lua/ui/lobby/customlobby/unitselect/CustomLobbyUnitSelect.lua @@ -20,24 +20,27 @@ --** SOFTWARE. --****************************************************************************************************** --- The unit-restriction dialog (step 1: the minimal core). A transient `Popup` modelled on the --- mod-select dialog (see ../modselect/CLAUDE.md): a scrollable list of restriction-preset toggles, --- with Clear / OK / Cancel. It is NOT a model component — it owns a *working selection* (a set of --- preset keys) and, on OK, hands the **array of keys** to an `onConfirm` callback. Where that goes --- is the opener's decision: `Open` routes it through the host-authoritative `RequestSetRestrictions` --- intent (synced via the launch model's `Restrictions`). +-- The unit-restriction dialog: the custom-lobby rebuild of the legacy UnitsManager, laid out as +-- * a shared **presets strip** on top — the faction-agnostic restriction presets (No T1, No +-- Nukes, …), which restrict by category for everyone; +-- * a row of **faction tabs** (Seraphim / UEF / Cybran / Aeon / …, from the loaded blueprints); +-- * the active faction's **unit grid** below — every unit as an icon you can individually +-- restrict (grouped by type: land / air / naval / construction / economy / support / defenses). -- --- The preset list needs NO blueprint analysis — `UnitsRestrictions.GetPresetsData()` already gives --- the name + tooltip per preset, and the sim expands the keys at launch (see simInit.lua). The --- preset icon grid and the per-unit (UnitsAnalyzer-backed) grid are later steps; they plug into the --- same working-selection / `onConfirm` contract, so this file's openers stay unchanged. +-- It is a transient `Popup`, NOT a model component: it owns a *working selection* (a set of keys) +-- and on OK hands the **array of keys** to `onConfirm`. A key is either a preset key +-- (UnitsRestrictions) or a raw unit blueprint id — the sim expands both at launch (a non-preset key +-- is treated as a custom category/id expression — see simInit.lua). `Open` routes the result through +-- the host-authoritative `RequestSetRestrictions` intent (synced via the launch model's +-- `Restrictions`). `editable = false` → read-only (tiles can't toggle; OK / Clear hidden). -- --- `isHost = false` → read-only: the checkboxes are disabled and the OK / Clear buttons are hidden --- (Cancel reads "Close"), matching the legacy UnitsManager `isHost` contract. +-- The unit blueprints come from `CustomLobbyUnitCatalog` (reference data, streamed in by +-- `UnitsAnalyzer` for the lobby's sim mods); a progress label shows while they load. local UIUtil = import("/lua/ui/uiutil.lua") local LayoutHelpers = import("/lua/maui/layouthelpers.lua") local Tooltip = import("/lua/ui/game/tooltip.lua") +local Mods = import("/lua/mods.lua") local Group = import("/lua/maui/group.lua").Group local Bitmap = import("/lua/maui/bitmap.lua").Bitmap @@ -45,30 +48,167 @@ local Grid = import("/lua/maui/grid.lua").Grid local Popup = import("/lua/ui/controls/popups/popup.lua").Popup local UnitsRestrictions = import("/lua/ui/lobby/unitsrestrictions.lua") +local UnitsAnalyzer = import("/lua/ui/lobby/unitsanalyzer.lua") +local UnitsTooltip = import("/lua/ui/lobby/unitstooltip.lua") +local CustomLobbyUnitCatalog = import("/lua/ui/lobby/customlobby/unitselect/customlobbyunitcatalog.lua") local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/customlobbylocalmodel.lua") +local LazyVarDerive = import("/lua/lazyvar.lua").Derive local Layouter = LayoutHelpers.ReusedLayoutFor -- flip to tint each layout area so the regions are visible while iterating -local Debug = true +local Debug = false -- sized for the lobby's 1024×768 floor (the Popup centres it, leaving a small margin) local DialogWidth = 960 local DialogHeight = 710 local Pad = 12 local TitleHeight = 32 -local StatsHeight = 22 +local TabsHeight = 34 local ActionHeight = 44 -local RowHeight = 26 -local ScrollbarGap = 32 -- standard lobby gutter reserved on the list's right -local Columns = 3 -- the preset checkboxes flow across this many columns to fill the width +local ScrollbarGap = 32 -- standard lobby gutter reserved on a list's right local DimColor = 'ff9aa0a8' +local TabActiveColor = 'ffffffff' +local TabIdleColor = 'ff7a828c' + +-- icon tiles: a square icon in a clickable cell; the tile is TileSize, the Grid cell TileStride +-- (so there's a small gap between tiles). Used for both presets and units. +local TileSize = 48 +local TileGap = 4 +local TileStride = TileSize + TileGap +local TileIconInset = 2 +local TileIdle = 'ff141a20' -- an unselected tile's background +local TileHover = 'ff1f262e' +local TileSelected = 'ff7a2d2d' -- a restricted preset/unit is lit red (forbidden) +local TileIconDim = 0.55 -- icon alpha when not restricted + +-- the presets strip shows this many rows of preset icons (it scrolls if there are more) +local PresetRows = 3 +local PresetAreaHeight = PresetRows * TileStride + 22 -- + the "Presets" label row + +-- the order the unit-type groups are shown in within a faction's grid +local UnitGroupOrder = { 'LAND', 'AIR', 'NAVAL', 'CONSTRUCT', 'ECONOMIC', 'SUPPORT', 'DEFENSES', 'CIVILIAN', 'SCU', 'ACU' } + +-- the per-faction "disable entire faction" presets — keyed by the blueprint faction name, which +-- matches the UnitsRestrictions preset key. These are NOT shown in the presets strip; instead each +-- faction tab's icon toggles its own (so a whole faction is banned from its tab). +local FactionPresetKeys = { + UEF = true, + CYBRAN = true, + AEON = true, + SERAPHIM = true, + NOMADS = true, +} + +-- faction-tab chrome +local TabBgIdle = 'ff10151b' +local TabBgActive = 'ff223344' +local TabBgHover = 'ff1a2128' + +--- The faction's icon path (reused from its UnitsRestrictions "disable faction" preset), or nil. +---@param factionName string +---@return FileName | nil +local function FactionIcon(factionName) + local preset = UnitsRestrictions.GetPresetsData()[factionName] + return preset and preset.Icon +end --- the width of one preset column (the Grid's cell width); the rows fill it -local ColumnWidth = math.floor((DialogWidth - 2 * Pad - ScrollbarGap) / Columns) +--- One icon tile (preset or unit): a background + icon. Clicking toggles the restriction (when +--- editable), lighting the tile red and brightening the icon. Mirrors the config column's +--- `PreviewTool` look. The owner wires `OnToggle` (and, for units, `OnHover`/`OnHoverEnd` to drive +--- the unit tooltip). +---@class UICustomLobbyIconTile : Group +---@field Editable boolean +---@field Selected boolean +---@field Hovered boolean +---@field DimWhenSelected boolean +---@field OnToggle? fun(selected: boolean) +---@field OnHover? fun() +---@field OnHoverEnd? fun() +---@field Bg Bitmap +---@field Icon Bitmap +local IconTile = ClassUI(Group) { + + ---@param self UICustomLobbyIconTile + ---@param parent Control + ---@param texture FileName + ---@param selected boolean + ---@param editable boolean + ---@param dimWhenSelected boolean # units: bright by default, dim (disabled-looking) when restricted + __init = function(self, parent, texture, selected, editable, dimWhenSelected) + Group.__init(self, parent, "CustomLobbyIconTile") + + self.Editable = editable + self.Selected = selected and true or false + self.Hovered = false + self.DimWhenSelected = dimWhenSelected and true or false + + self.Bg = Bitmap(self) + self.Bg:SetSolidColor(TileIdle) + + self.Icon = Bitmap(self) + if texture then + self.Icon:SetTexture(texture) + end + self.Icon:DisableHitTest() + + self.Bg.HandleEvent = function(control, event) + if event.Type == 'MouseEnter' then + self.Hovered = true + self:ApplyVisual() + if self.OnHover then self.OnHover() end + return true + elseif event.Type == 'MouseExit' then + self.Hovered = false + self:ApplyVisual() + if self.OnHoverEnd then self.OnHoverEnd() end + return true + elseif event.Type == 'ButtonPress' and self.Editable then + self.Selected = not self.Selected + self:ApplyVisual() + if self.OnToggle then self.OnToggle(self.Selected) end + return true + end + return false + end + end, + + ---@param self UICustomLobbyIconTile + __post_init = function(self) + Layouter(self.Bg):Fill(self):End() + Layouter(self.Icon):AtCenterIn(self):Width(TileSize - 2 * TileIconInset):Height(TileSize - 2 * TileIconInset):End() + self:ApplyVisual() + end, + + ---@param self UICustomLobbyIconTile + ApplyVisual = function(self) + local bg = TileIdle + if self.Selected then + bg = TileSelected + elseif self.Hovered then + bg = TileHover + end + self.Bg:SetSolidColor(bg) + -- presets light up when selected (active rule); units dim when selected (the unit is + -- disabled), so a restricted unit reads as greyed-out + if self.DimWhenSelected then + self.Icon:SetAlpha(self.Selected and TileIconDim or 1.0) + else + self.Icon:SetAlpha(self.Selected and 1.0 or TileIconDim) + end + end, + + --- Sets the selected state without firing `OnToggle` (initial paint + Clear). + ---@param self UICustomLobbyIconTile + ---@param selected boolean + SetSelected = function(self, selected) + self.Selected = selected and true or false + self:ApplyVisual() + end, +} --- Creates a layout area (an invisible Group with an optional debug tint). ---@param parent Control @@ -86,44 +226,181 @@ local function CreateArea(parent, name, color) return area end ---- The ordered list of selectable preset keys: `GetPresetsOrder()` minus the `""` separators and ---- any key that has no visible preset (name) in the data table. ----@return string[] -local function SelectablePresetKeys() - local presets = UnitsRestrictions.GetPresetsData() - local keys = {} - for _, key in UnitsRestrictions.GetPresetsOrder() do - if key ~= "" and presets[key] and presets[key].name then - table.insert(keys, key) - end +--- Collects a unit-group map ({ [id] = bp }) into an id-sorted list of blueprints. +---@param units table +---@return table[] +local function SortedUnits(units) + local list = {} + for _, bp in units do + table.insert(list, bp) end - return keys + table.sort(list, function(a, b) return (a.ID or "") < (b.ID or "") end) + return list end +--- One faction tab: a full-cell clickable strip (click → view that faction's units) carrying the +--- faction icon, name and a unit-restriction count badge. The icon doubles as the "disable entire +--- faction" toggle (click → ban every unit of the faction); when the faction is disabled the icon +--- goes red + dim. The owner wires `OnSelect` / `OnToggleDisable`. +---@class UICustomLobbyFactionTab : Group +---@field Name string +---@field Editable boolean +---@field Active boolean +---@field Disabled boolean +---@field Hovered boolean +---@field OnSelect? fun() +---@field OnToggleDisable? fun() +---@field Bg Bitmap +---@field IconBg Bitmap +---@field Icon Bitmap +---@field Label Text +---@field Badge Text +local FactionTab = ClassUI(Group) { + + ---@param self UICustomLobbyFactionTab + ---@param parent Control + ---@param faction UICustomLobbyFaction + ---@param editable boolean + __init = function(self, parent, faction, editable) + Group.__init(self, parent, "CustomLobbyFactionTab") + + self.Name = faction.Name + self.Editable = editable + self.Active = false + self.Disabled = false + self.Hovered = false + + self.Bg = Bitmap(self) + self.Bg:SetSolidColor(TabBgIdle) + self.Bg.HandleEvent = function(control, event) + if event.Type == 'ButtonPress' then + if self.OnSelect then self.OnSelect() end + return true + elseif event.Type == 'MouseEnter' then + self.Hovered = true + self:ApplyVisual() + return true + elseif event.Type == 'MouseExit' then + self.Hovered = false + self:ApplyVisual() + return true + end + return false + end + + -- the faction icon doubles as the "disable entire faction" toggle + self.IconBg = Bitmap(self) + self.IconBg:SetSolidColor('00000000') + self.Icon = Bitmap(self.IconBg) + local icon = FactionIcon(self.Name) + if icon then + self.Icon:SetTexture(icon) + end + self.Icon:DisableHitTest() + self.IconBg.HandleEvent = function(control, event) + if event.Type == 'ButtonPress' and self.Editable then + if self.OnToggleDisable then self.OnToggleDisable() end + return true + end + return false + end + Tooltip.AddControlTooltipManual(self.IconBg, "Disable faction", "Restrict every unit of this faction.") + + self.Label = UIUtil.CreateText(self, self.Name, 15, UIUtil.titleFont) + self.Label:DisableHitTest() + self.Badge = UIUtil.CreateText(self, "", 12, UIUtil.bodyFont) + self.Badge:SetColor(DimColor) + self.Badge:DisableHitTest() + end, + + ---@param self UICustomLobbyFactionTab + __post_init = function(self) + local iconSize = 24 + Layouter(self.Bg):Fill(self):End() + -- the icon doubles as the disable toggle, so it must sit *above* the tab's Bg (which handles + -- the select-click) to receive its own clicks + Layouter(self.IconBg):AtLeftIn(self, 8):AtVerticalCenterIn(self):Width(iconSize):Height(iconSize):Over(self, 10):End() + Layouter(self.Icon):Fill(self.IconBg):Over(self.IconBg, 1):End() + Layouter(self.Label):AnchorToRight(self.IconBg, 8):AtVerticalCenterIn(self):End() + Layouter(self.Badge):AnchorToRight(self.Label, 6):AtVerticalCenterIn(self):End() + self:ApplyVisual() + end, + + ---@param self UICustomLobbyFactionTab + ApplyVisual = function(self) + local bg = TabBgIdle + if self.Active then + bg = TabBgActive + elseif self.Hovered then + bg = TabBgHover + end + self.Bg:SetSolidColor(bg) + self.Label:SetColor(self.Active and TabActiveColor or TabIdleColor) + -- the icon button shows the faction-disabled state (red + dimmed icon = banned) + self.IconBg:SetSolidColor(self.Disabled and TileSelected or '00000000') + self.Icon:SetAlpha(self.Disabled and TileIconDim or 1.0) + end, + + ---@param self UICustomLobbyFactionTab + ---@param active boolean + SetActive = function(self, active) + self.Active = active and true or false + self:ApplyVisual() + end, + + ---@param self UICustomLobbyFactionTab + ---@param disabled boolean + SetDisabled = function(self, disabled) + self.Disabled = disabled and true or false + self:ApplyVisual() + end, + + ---@param self UICustomLobbyFactionTab + ---@param count number + SetBadge = function(self, count) + self.Badge:SetText(count > 0 and ("(" .. count .. ")") or "") + end, +} + ---@class UICustomLobbyUnitSelect : Group ---@field Trash TrashBag ---@field Editable boolean +---@field ActiveMods table[] ---@field Selection table ---@field OnConfirmCb fun(keys: string[]) ---@field OnCancelCb fun() ----@field Keys string[] ----@field Checkboxes table +---@field Ready boolean +---@field PresetTiles table +---@field UnitTiles table +---@field Factions UICustomLobbyFaction[] +---@field FactionIds table> # faction name -> set of its unit ids +---@field ActiveFaction string | false +---@field FactionTabs { name: string, tab: UICustomLobbyFactionTab }[] +---@field PresetPerRow number +---@field UnitPerRow number ---@field TitleArea Group ----@field ListArea Group ----@field StatsArea Group +---@field PresetArea Group +---@field TabsArea Group +---@field UnitArea Group ---@field ActionArea Group ---@field Title Text ----@field Grid Grid ----@field Scrollbar Scrollbar | false ----@field Count Text +---@field Stats Text +---@field PresetLabel Text +---@field PresetGrid Grid +---@field PresetScrollbar Scrollbar | false +---@field UnitGrid Grid +---@field UnitScrollbar Scrollbar | false +---@field ProgressLabel Text ---@field SelectButton Button ---@field CancelButton Button ---@field ClearButton Button +---@field FactionsObserver LazyVar +---@field ProgressObserver LazyVar local CustomLobbyUnitSelect = ClassUI(Group) { ---@param self UICustomLobbyUnitSelect ---@param parent Control - ---@param options { initial: string[], editable: boolean, onConfirm: fun(keys: string[]), onCancel: fun() } + ---@param options { initial: string[], editable: boolean, activeMods: table[], onConfirm: fun(keys: string[]), onCancel: fun() } __init = function(self, parent, options) Group.__init(self, parent, "CustomLobbyUnitSelect") @@ -131,9 +408,18 @@ local CustomLobbyUnitSelect = ClassUI(Group) { self.OnConfirmCb = options.onConfirm self.OnCancelCb = options.onCancel self.Editable = options.editable ~= false - self.Scrollbar = false - self.Checkboxes = {} - self.Keys = SelectablePresetKeys() + self.ActiveMods = options.activeMods or {} + self.Ready = false + self.PresetTiles = {} + self.UnitTiles = {} + self.Factions = {} + self.FactionIds = {} + self.ActiveFaction = false + self.FactionTabs = {} + self.PresetPerRow = 1 + self.UnitPerRow = 1 + self.PresetScrollbar = false + self.UnitScrollbar = false -- working selection: a set built from the initial key array self.Selection = {} @@ -143,37 +429,39 @@ local CustomLobbyUnitSelect = ClassUI(Group) { -- areas self.TitleArea = CreateArea(self, "TitleArea", 'ffcc4040') - self.ListArea = CreateArea(self, "ListArea", 'ff4060cc') - self.StatsArea = CreateArea(self, "StatsArea", 'ff40cccc') + self.PresetArea = CreateArea(self, "PresetArea", 'ff40cc60') + self.TabsArea = CreateArea(self, "TabsArea", 'ffcc8040') + self.UnitArea = CreateArea(self, "UnitArea", 'ff4060cc') self.ActionArea = CreateArea(self, "ActionArea", 'ff808080') self.Title = UIUtil.CreateText(self.TitleArea, "Unit restrictions", 22, UIUtil.titleFont) self.Title:DisableHitTest() + self.Stats = UIUtil.CreateText(self.TitleArea, "", 14, UIUtil.bodyFont) + self.Stats:SetColor(DimColor) + self.Stats:DisableHitTest() + + self.PresetLabel = UIUtil.CreateText(self.PresetArea, "Presets", 13, UIUtil.titleFont) + self.PresetLabel:SetColor(DimColor) + self.PresetLabel:DisableHitTest() - -- a multi-column grid (cell = one preset column); Grid scales itemWidth / itemHeight - -- itself, so pass unscaled values - self.Grid = Grid(self.ListArea, ColumnWidth, RowHeight) + -- two icon grids; Grid scales itemWidth / itemHeight itself (pass unscaled) + self.PresetGrid = Grid(self.PresetArea, TileStride, TileStride) + self.UnitGrid = Grid(self.UnitArea, TileStride, TileStride) - self.Count = UIUtil.CreateText(self.StatsArea, "", 13, UIUtil.bodyFont) - self.Count:SetColor(DimColor) - self.Count:DisableHitTest() + self.ProgressLabel = UIUtil.CreateText(self.UnitArea, "Loading blueprints…", 16, UIUtil.bodyFont) + self.ProgressLabel:SetColor(DimColor) + self.ProgressLabel:DisableHitTest() --#region actions self.SelectButton = UIUtil.CreateButtonStd(self.ActionArea, '/scx_menu/small-btn/small', "OK", 16, 2) - self.SelectButton.OnClick = function(button, modifiers) - self:Confirm() - end + self.SelectButton.OnClick = function(button, modifiers) self:Confirm() end self.CancelButton = UIUtil.CreateButtonStd(self.ActionArea, '/scx_menu/small-btn/small', self.Editable and "Cancel" or "Close", 16, 2) - self.CancelButton.OnClick = function(button, modifiers) - self.OnCancelCb() - end + self.CancelButton.OnClick = function(button, modifiers) self.OnCancelCb() end self.ClearButton = UIUtil.CreateButtonStd(self.ActionArea, '/scx_menu/small-btn/small', "Clear", 14, 2) - self.ClearButton.OnClick = function(button, modifiers) - self:ClearSelection() - end + self.ClearButton.OnClick = function(button, modifiers) self:ClearSelection() end Tooltip.AddControlTooltipManual(self.ClearButton, "Clear", "Remove every unit restriction.") if not self.Editable then @@ -181,6 +469,23 @@ local CustomLobbyUnitSelect = ClassUI(Group) { self.ClearButton:Hide() end --#endregion + + -- react to the catalog streaming in (gated by Ready so the immediate fire on creation — + -- e.g. when already loaded from a previous open — doesn't build grids before we're sized) + self.FactionsObserver = self.Trash:Add( + LazyVarDerive(CustomLobbyUnitCatalog.GetFactionsVar(), function(factionsLazy) + local factions = factionsLazy() + if self.Ready and table.getn(factions) > 0 then + self:OnFactionsLoaded(factions) + end + end)) + self.ProgressObserver = self.Trash:Add( + LazyVarDerive(CustomLobbyUnitCatalog.GetProgressTextVar(), function(textLazy) + local text = textLazy() + if self.Ready and not CustomLobbyUnitCatalog.IsLoaded() then + self.ProgressLabel:SetText(text ~= "" and text or "Loading blueprints…") + end + end)) end, ---@param self UICustomLobbyUnitSelect @@ -189,136 +494,387 @@ local CustomLobbyUnitSelect = ClassUI(Group) { self.Height:Set(LayoutHelpers.ScaleNumber(DialogHeight)) Layouter(self.TitleArea):AtLeftIn(self, Pad):AtRightIn(self, Pad):AtTopIn(self, Pad):Height(TitleHeight):End() + Layouter(self.PresetArea):AtLeftIn(self, Pad):AtRightIn(self, Pad):AnchorToBottom(self.TitleArea, Pad):Height(PresetAreaHeight):End() + Layouter(self.TabsArea):AtLeftIn(self, Pad):AtRightIn(self, Pad):AnchorToBottom(self.PresetArea, Pad):Height(TabsHeight):End() Layouter(self.ActionArea):AtLeftIn(self, Pad):AtRightIn(self, Pad):AtBottomIn(self, Pad):Height(ActionHeight):End() - Layouter(self.StatsArea):AtLeftIn(self, Pad):AtRightIn(self, Pad):AnchorToTop(self.ActionArea, Pad):Height(StatsHeight):End() - Layouter(self.ListArea) + Layouter(self.UnitArea) :AtLeftIn(self, Pad):AtRightIn(self, Pad) - :AnchorToBottom(self.TitleArea, Pad):AnchorToTop(self.StatsArea, Pad) + :AnchorToBottom(self.TabsArea, Pad):AnchorToTop(self.ActionArea, Pad) :End() - Layouter(self.Title):AtHorizontalCenterIn(self.TitleArea):AtVerticalCenterIn(self.TitleArea):End() + Layouter(self.Title):AtLeftIn(self.TitleArea):AtVerticalCenterIn(self.TitleArea):End() + Layouter(self.Stats):AtRightIn(self.TitleArea):AtVerticalCenterIn(self.TitleArea):End() - Layouter(self.Grid):AtLeftIn(self.ListArea):AtTopIn(self.ListArea):AtBottomIn(self.ListArea):End() - self.Grid.Right:Set(function() return self.ListArea.Right() - LayoutHelpers.ScaleNumber(ScrollbarGap) end) + Layouter(self.PresetLabel):AtLeftIn(self.PresetArea):AtTopIn(self.PresetArea):End() + Layouter(self.PresetGrid):AtLeftIn(self.PresetArea):AnchorToBottom(self.PresetLabel, 4):AtBottomIn(self.PresetArea):End() + self.PresetGrid.Right:Set(function() return self.PresetArea.Right() - LayoutHelpers.ScaleNumber(ScrollbarGap) end) - Layouter(self.Count):AtLeftIn(self.StatsArea):AtVerticalCenterIn(self.StatsArea):End() + Layouter(self.UnitGrid):AtLeftIn(self.UnitArea):AtTopIn(self.UnitArea):AtBottomIn(self.UnitArea):End() + self.UnitGrid.Right:Set(function() return self.UnitArea.Right() - LayoutHelpers.ScaleNumber(ScrollbarGap) end) + Layouter(self.ProgressLabel):AtHorizontalCenterIn(self.UnitArea):AtVerticalCenterIn(self.UnitArea):End() - -- one row (the wide dialog has room): Clear on the left, the Cancel / OK pair on the right + -- one row: Clear on the left, the Cancel / OK pair on the right Layouter(self.ClearButton):AtLeftIn(self.ActionArea):AtVerticalCenterIn(self.ActionArea):End() Layouter(self.SelectButton):AtRightIn(self.ActionArea):AtVerticalCenterIn(self.ActionArea):End() Layouter(self.CancelButton):AnchorToLeft(self.SelectButton, 12):AtVerticalCenterIn(self.SelectButton):End() end, - --- Builds the row pool + scrollbar; called by the opener after Popup mounts + centres the dialog - --- (three-phase init — the Grid needs a concrete height). + --- Three-phase init: the opener calls this after Popup mounts + centres the dialog (the Grids + --- need a concrete width to know how many tiles fit a row). Builds the presets strip, kicks off + --- the blueprint load, and renders the factions if they're already cached. ---@param self UICustomLobbyUnitSelect Initialize = function(self) - self:Populate() - if not self.Scrollbar then - self.Scrollbar = UIUtil.CreateVertScrollbarFor(self.Grid) - UIUtil.ForwardWheelToScroll(self.Grid, self.Grid) + self.Ready = true + + self.PresetPerRow = math.max(1, self.PresetGrid:GetVisible()) + self.UnitPerRow = math.max(1, self.UnitGrid:GetVisible()) + + self:BuildPresetGrid() + if not self.PresetScrollbar then + self.PresetScrollbar = UIUtil.CreateVertScrollbarFor(self.PresetGrid) + UIUtil.ForwardWheelToScroll(self.PresetGrid, self.PresetGrid) + end + if not self.UnitScrollbar then + self.UnitScrollbar = UIUtil.CreateVertScrollbarFor(self.UnitGrid) + UIUtil.ForwardWheelToScroll(self.UnitGrid, self.UnitGrid) + end + + CustomLobbyUnitCatalog.EnsureLoaded(self.ActiveMods) + if CustomLobbyUnitCatalog.IsLoaded() then + self:OnFactionsLoaded(CustomLobbyUnitCatalog.GetFactions()) + else + self.ProgressLabel:Show() end - self:UpdateScrollbar() + self:UpdateStats() + self:UpdatePresetLabel() + self:UpdateScrollbar(self.PresetGrid, self.PresetScrollbar) end, - --- Builds one checkbox cell per selectable preset, flowed across `Columns` columns. + --------------------------------------------------------------------------- + --#region Presets strip + + --- Builds the presets strip: every restriction preset as an icon tile, flowing left-to-right and + --- starting a fresh row at each preset-group separator. (All presets — they're faction-agnostic.) ---@param self UICustomLobbyUnitSelect - Populate = function(self) + BuildPresetGrid = function(self) local presets = UnitsRestrictions.GetPresetsData() - local count = table.getn(self.Keys) + local order = UnitsRestrictions.GetPresetsOrder() + local perRow = self.PresetPerRow + + self.PresetGrid:DeleteAndDestroyAll(true) + self.PresetTiles = {} + + local placements = {} + local col, row, maxRow = 1, 1, 1 + local placedAny = false + local pendingBreak = false + for _, key in order do + if key == "" then + pendingBreak = true + elseif presets[key] and presets[key].name and not FactionPresetKeys[key] then + -- faction presets are excluded here — each faction tab's icon toggles its own + if pendingBreak and placedAny and col > 1 then + row = row + 1 + col = 1 + end + pendingBreak = false + table.insert(placements, { key = key, col = col, row = row }) + placedAny = true + if row > maxRow then maxRow = row end + col = col + 1 + if col > perRow then col = 1; row = row + 1 end + end + end - self.Grid:DeleteAndDestroyAll(true) - self.Checkboxes = {} - if count == 0 then + if table.getn(placements) == 0 then return end - -- flow the presets left-to-right then down, filling `Columns` columns - local rows = math.floor((count - 1) / Columns) + 1 - self.Grid:AppendCols(Columns, true) - self.Grid:AppendRows(rows, true) - for i, key in self.Keys do - local col = math.mod(i - 1, Columns) + 1 - local row = math.floor((i - 1) / Columns) + 1 - self.Grid:SetItem(self:CreateRow(presets[key]), col, row, true) + self.PresetGrid:AppendCols(perRow, true) + self.PresetGrid:AppendRows(maxRow, true) + for _, placement in placements do + self.PresetGrid:SetItem(self:CreatePresetTile(presets[placement.key]), placement.col, placement.row, true) end - self.Grid:EndBatch() + self.PresetGrid:EndBatch() end, - --- Builds one preset cell: a labelled checkbox wired to the working selection. Private. + --- Builds one preset cell: an icon tile wired to the working selection. Private. ---@param self UICustomLobbyUnitSelect - ---@param preset table # an entry from UnitsRestrictions.GetPresetsData() + ---@param preset table ---@return Group - CreateRow = function(self, preset) + CreatePresetTile = function(self, preset) local key = preset.key - local row = Group(self.Grid) - LayoutHelpers.SetDimensions(row, ColumnWidth, RowHeight) - - local checkbox = UIUtil.CreateCheckbox(row, '/CHECKBOX/', LOC(preset.name) or key, true, 13) - checkbox:SetCheck(self.Selection[key] == true, true) - if self.Editable then - checkbox.OnCheck = function(control, checked) - self:TogglePreset(key, checked) + local cell = Group(self.PresetGrid) + LayoutHelpers.SetDimensions(cell, TileStride, TileStride) + + local tile = IconTile(cell, preset.Icon, self.Selection[key] == true, self.Editable, false) + tile.OnToggle = function(selected) self:ToggleKey(key, selected) end + Layouter(tile):AtLeftIn(cell):AtTopIn(cell):Width(TileSize):Height(TileSize):End() + + if preset.tooltip then + Tooltip.AddControlTooltipManual(tile.Bg, LOC(preset.name) or key, LOC(preset.tooltip) or "") + end + self.PresetTiles[key] = tile + return cell + end, + + --#endregion + + --------------------------------------------------------------------------- + --#region Faction tabs + unit grid + + --- The catalog finished loading: cache the factions, build the faction tabs, and render the + --- first faction's units. + ---@param self UICustomLobbyUnitSelect + ---@param factions UICustomLobbyFaction[] + OnFactionsLoaded = function(self, factions) + self.Factions = factions + self.ProgressLabel:Hide() + + -- per-faction set of unit ids (for the tab badges) + self.FactionIds = {} + for _, faction in factions do + local ids = {} + for _, bp in faction.Blueprints do + if bp.ID then ids[bp.ID] = true end end - else - checkbox:Disable() + self.FactionIds[faction.Name] = ids end - if preset.tooltip then - Tooltip.AddControlTooltipManual(checkbox, LOC(preset.name) or key, LOC(preset.tooltip) or "") + + self:BuildFactionTabs() + if table.getn(factions) > 0 then + self.ActiveFaction = factions[1].Name + self:PaintTabs() + self:BuildUnitGrid(factions[1]) + end + end, + + --- (Re)builds the faction tabs, dividing the tabs area into equal full-width cells. Each tab + --- selects its faction on click; its icon toggles "disable entire faction". + ---@param self UICustomLobbyUnitSelect + BuildFactionTabs = function(self) + for _, entry in self.FactionTabs do + entry.tab:Destroy() + end + self.FactionTabs = {} + + local count = table.getn(self.Factions) + if count == 0 then + return + end + + for index, faction in self.Factions do + local name = faction.Name + local tab = FactionTab(self.TabsArea, faction, self.Editable) + tab.OnSelect = function() self:SetActiveFaction(name) end + tab.OnToggleDisable = function() self:ToggleFaction(name) end + -- equal full-width cells: cell `index` of `count` across the tabs area + local i = index + tab.Left:Set(function() return self.TabsArea.Left() + (i - 1) * (self.TabsArea.Width() / count) end) + tab.Right:Set(function() return self.TabsArea.Left() + i * (self.TabsArea.Width() / count) end) + tab.Top:Set(function() return self.TabsArea.Top() end) + tab.Bottom:Set(function() return self.TabsArea.Bottom() end) + table.insert(self.FactionTabs, { name = name, tab = tab }) + end + end, + + --- Switches the active faction and rebuilds its unit grid (a no-op if already active). + ---@param self UICustomLobbyUnitSelect + ---@param name string + SetActiveFaction = function(self, name) + if self.ActiveFaction == name then + return + end + self.ActiveFaction = name + self:PaintTabs() + for _, faction in self.Factions do + if faction.Name == name then + self:BuildUnitGrid(faction) + break + end + end + end, + + --- Refreshes every faction tab: which is active, which faction is fully disabled, and each + --- tab's restricted-unit-count badge. + ---@param self UICustomLobbyUnitSelect + PaintTabs = function(self) + for _, entry in self.FactionTabs do + entry.tab:SetActive(entry.name == self.ActiveFaction) + entry.tab:SetDisabled(self.Selection[entry.name] == true) + entry.tab:SetBadge(self:CountForFaction(entry.name)) + end + end, + + --- Toggles "disable entire faction" — the faction's UnitsRestrictions preset key (its own name). + --- Driven by the faction tab's icon. Not shown in the presets strip; reflected on the tab. + ---@param self UICustomLobbyUnitSelect + ---@param name string + ToggleFaction = function(self, name) + self.Selection[name] = (not self.Selection[name]) and true or nil + self:UpdateStats() + self:PaintTabs() + end, + + --- How many of a faction's units are currently in the selection (its tab badge count). + ---@param self UICustomLobbyUnitSelect + ---@param name string + ---@return number + CountForFaction = function(self, name) + local ids = self.FactionIds[name] + if not ids then + return 0 + end + local count = 0 + for key in self.Selection do + if ids[key] then count = count + 1 end + end + return count + end, + + --- Builds the active faction's unit grid: each type group (land / air / naval / …) flows its + --- units left-to-right and starts on a fresh row. + ---@param self UICustomLobbyUnitSelect + ---@param faction UICustomLobbyFaction + BuildUnitGrid = function(self, faction) + local perRow = self.UnitPerRow + + self.UnitGrid:DeleteAndDestroyAll(true) + self.UnitTiles = {} + + -- gather the placements group by group + local placements = {} + local col, row, maxRow = 1, 1, 1 + local placedAny = false + for _, group in UnitGroupOrder do + local units = faction.Units[group] + if units and not table.empty(units) then + if placedAny and col > 1 then -- start each group on a fresh row + row = row + 1 + col = 1 + end + for _, bp in SortedUnits(units) do + table.insert(placements, { bp = bp, col = col, row = row }) + placedAny = true + if row > maxRow then maxRow = row end + col = col + 1 + if col > perRow then col = 1; row = row + 1 end + end + end + end + + if table.getn(placements) == 0 then + self:UpdateScrollbar(self.UnitGrid, self.UnitScrollbar) + return end - self.Checkboxes[key] = checkbox - Layouter(checkbox):AtLeftIn(row):AtVerticalCenterIn(row):End() - return row + self.UnitGrid:AppendCols(perRow, true) + self.UnitGrid:AppendRows(maxRow, true) + for _, placement in placements do + self.UnitGrid:SetItem(self:CreateUnitTile(placement.bp, faction.Name), placement.col, placement.row, true) + end + self.UnitGrid:EndBatch() + self:UpdateScrollbar(self.UnitGrid, self.UnitScrollbar) + end, + + --- Builds one unit cell: an icon tile wired to the working selection (keyed by blueprint id), + --- with the unit tooltip on hover. Private. + ---@param self UICustomLobbyUnitSelect + ---@param bp table + ---@param factionName string + ---@return Group + CreateUnitTile = function(self, bp, factionName) + local id = bp.ID + local cell = Group(self.UnitGrid) + LayoutHelpers.SetDimensions(cell, TileStride, TileStride) + + local texture = UnitsAnalyzer.GetImagePath(bp, factionName) + local tile = IconTile(cell, texture, self.Selection[id] == true, self.Editable, true) + tile.OnToggle = function(selected) self:ToggleKey(id, selected) end + tile.OnHover = function() UnitsTooltip.Create(tile, bp) end + tile.OnHoverEnd = function() UnitsTooltip.Destroy() end + Layouter(tile):AtLeftIn(cell):AtTopIn(cell):Width(TileSize):Height(TileSize):End() + + self.UnitTiles[id] = tile + return cell end, - --- Adds or removes a preset key from the working selection and refreshes the count. + --#endregion + + --------------------------------------------------------------------------- + --#region Selection + + --- Adds or removes a key (preset key or unit id) from the working selection and refreshes the + --- stats + tab badges. Keeps any other tile showing the same key in sync. ---@param self UICustomLobbyUnitSelect ---@param key string - ---@param checked boolean - TogglePreset = function(self, key, checked) - if checked then + ---@param selected boolean + ToggleKey = function(self, key, selected) + if selected then self.Selection[key] = true else self.Selection[key] = nil end + -- a key can appear in both grids (e.g. a preset and a unit are distinct keys, but a unit id + -- shown again after a faction switch); keep the matching tiles consistent + if self.PresetTiles[key] then self.PresetTiles[key]:SetSelected(selected) end + if self.UnitTiles[key] then self.UnitTiles[key]:SetSelected(selected) end self:UpdateStats() + self:UpdatePresetLabel() + self:PaintTabs() end, - --- Clears the whole working selection and unticks every checkbox. + --- Clears the whole working selection and unlights every tile. ---@param self UICustomLobbyUnitSelect ClearSelection = function(self) self.Selection = {} - for _, checkbox in self.Checkboxes do - checkbox:SetCheck(false, true) - end + for _, tile in self.PresetTiles do tile:SetSelected(false) end + for _, tile in self.UnitTiles do tile:SetSelected(false) end self:UpdateStats() + self:UpdatePresetLabel() + self:PaintTabs() end, - --- Updates the "N restrictions" footer. + --- Updates the "N restrictions" stat (total selected keys). ---@param self UICustomLobbyUnitSelect UpdateStats = function(self) local count = table.getsize(self.Selection) if count == 0 then - self.Count:SetText("No restrictions") + self.Stats:SetText("No restrictions") elseif count == 1 then - self.Count:SetText("1 restriction") + self.Stats:SetText("1 restriction") else - self.Count:SetText(count .. " restrictions") + self.Stats:SetText(count .. " restrictions") end end, - --- Shows the scrollbar only when the grid actually overflows. + --- Updates the presets-strip label with the count of selected presets (excluding the faction + --- ones, which live on the tabs). ---@param self UICustomLobbyUnitSelect - UpdateScrollbar = function(self) - if not self.Scrollbar then + UpdatePresetLabel = function(self) + local presets = UnitsRestrictions.GetPresetsData() + local count = 0 + for key in self.Selection do + if presets[key] and not FactionPresetKeys[key] then + count = count + 1 + end + end + self.PresetLabel:SetText(count > 0 and ("Presets (" .. count .. ")") or "Presets") + end, + + --- Shows a grid's scrollbar only when it actually overflows. + ---@param self UICustomLobbyUnitSelect + ---@param grid Grid + ---@param scrollbar Scrollbar | false + UpdateScrollbar = function(self, grid, scrollbar) + if not scrollbar then return end - if self.Grid:IsScrollable("Vert") then - self.Scrollbar:Show() + if grid:IsScrollable("Vert") then + scrollbar:Show() else - self.Scrollbar:Hide() + scrollbar:Hide() end end, @@ -332,8 +888,11 @@ local CustomLobbyUnitSelect = ClassUI(Group) { self.OnConfirmCb(keys) end, + --#endregion + ---@param self UICustomLobbyUnitSelect OnDestroy = function(self) + UnitsTooltip.Destroy() self.Trash:Destroy() end, } @@ -362,6 +921,7 @@ function Open(parent) local content = CustomLobbyUnitSelect(parent, { initial = launch.Restrictions() or {}, editable = isHost, + activeMods = Mods.GetGameMods(launch.GameMods()), onConfirm = function(keys) CustomLobbyController.RequestSetRestrictions(keys) if popup then @@ -383,8 +943,8 @@ function Open(parent) end Instance = popup - -- now that Popup has mounted + centred the content, it's safe to build the rows + scrollbar - -- (both read concrete geometry) + -- now that Popup has mounted + centred the content, it's safe to build the grids (they read + -- concrete geometry) content:Initialize() end From c3a739cccc9e9bd2f9f463a612e70e2f11200304 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Thu, 25 Jun 2026 19:19:46 +0200 Subject: [PATCH 57/98] Always show the badges, even when there's 0 --- .../customlobby/CustomLobbyInterface.lua | 3 +- lua/ui/lobby/customlobby/CustomLobbyTabs.lua | 35 ++++++++++++------- .../config/CustomLobbyConfigInterface.lua | 12 +++---- .../unitselect/CustomLobbyUnitSelect.lua | 2 +- 4 files changed, 29 insertions(+), 23 deletions(-) diff --git a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua index 0fe9bf3c67f..d2967aec4fe 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua @@ -204,8 +204,7 @@ local CustomLobbyInterface = Class(Group) { self.ChatBadge = self.Trash:Add(LazyVarCreate("0")) -- TODO: real unread count with the chat slice self.ObserversBadge = self.Trash:Add(LazyVarCreate()) self.ObserversBadge:Set(function() - local count = table.getn(CustomLobbyLaunchModel.GetSingleton().Observers()) - return count > 0 and tostring(count) or "" + return tostring(table.getn(CustomLobbyLaunchModel.GetSingleton().Observers())) end) self.BottomLeftTabs = CustomLobbyTabs.Create(self.BottomLeftArea, { diff --git a/lua/ui/lobby/customlobby/CustomLobbyTabs.lua b/lua/ui/lobby/customlobby/CustomLobbyTabs.lua index dc2cef3a1f9..d69d5e076fa 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyTabs.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyTabs.lua @@ -63,9 +63,9 @@ local TabHoverColor = 'ff1f262e' local TabActiveColor = 'ff2c3e48' -- the count badge: a grey pill (rounded-look solid) with a number, sitting to the right of a label -local BadgeHeight = 16 -local BadgeMinWidth = 18 -- keeps a single digit roughly square -local BadgePadH = 6 -- horizontal text padding inside the pill +local BadgeHeight = 13 +local BadgeMinWidth = 14 -- keeps a single digit roughly square +local BadgePadH = 4 -- horizontal text padding inside the pill local BadgeGap = 5 -- between cluster items (action / label / pill) local BadgeColor = 'ff454c56' local BadgeTextColor = 'ffd0d4d8' @@ -88,6 +88,7 @@ local TabIconSize = 16 ---@field Action? UICustomLobbyTabAction # optional button inside the tab, left of the label ---@field Icon? FileName # optional centred icon (a UIFile path) shown instead of the label (tidy with Compact) ---@field Compact? boolean # fixed narrow width, excluded from the even division (a utility tab) +---@field Weight? number # flexible tabs share width in proportion to this (default 1); ignored for Compact ---@class UICustomLobbyTabsOptions ---@field Tabs UICustomLobbyTab[] @@ -155,27 +156,35 @@ local CustomLobbyTabs = ClassUI(Group) { :End() -- compact tabs take a fixed narrow width; the flexible tabs share what's left of the strip - -- evenly. Width/Left are bound to the strip so they re-flow with the column. + -- in proportion to their `Weight` (default 1) — so a tab can claim more room than its peers. + -- Width/Left are bound to the strip so they re-flow with the column. local count = table.getn(self.TabButtons) - local flexCount = 0 + local flexWeight = 0 + local compactCount = 0 for index = 1, count do - if not self.Tabs[index].Compact then - flexCount = flexCount + 1 + if self.Tabs[index].Compact then + compactCount = compactCount + 1 + else + flexWeight = flexWeight + (self.Tabs[index].Weight or 1) end end - -- the width of one flexible tab: the strip minus all gaps and all compact tabs, split evenly - local function flexWidth() + -- the width of one unit of weight: the strip minus all gaps and all compact tabs, split by + -- total flexible weight + local function flexUnit() local gap = LayoutHelpers.ScaleNumber(TabGap) local compactW = LayoutHelpers.ScaleNumber(CompactWidth) - local remaining = self.TabStripArea.Width() - gap * (count - 1) - compactW * (count - flexCount) - if flexCount <= 0 then + local remaining = self.TabStripArea.Width() - gap * (count - 1) - compactW * compactCount + if flexWeight <= 0 then return 0 end - return remaining / flexCount + return remaining / flexWeight end local function widthOf(index) - return self.Tabs[index].Compact and LayoutHelpers.ScaleNumber(CompactWidth) or flexWidth() + if self.Tabs[index].Compact then + return LayoutHelpers.ScaleNumber(CompactWidth) + end + return flexUnit() * (self.Tabs[index].Weight or 1) end for index = 1, count do diff --git a/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua b/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua index 1e8187ab80e..8e9b19ae872 100644 --- a/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua +++ b/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua @@ -288,10 +288,10 @@ local CustomLobbyConfigInterface = ClassUI(Group) { -- button can subscribe at creation. local launch = CustomLobbyLaunchModel.GetSingleton() + -- the badges always render, even at 0 (an empty string would collapse the pill) self.OptionsBadge = self.Trash:Add(LazyVarCreate()) self.OptionsBadge:Set(function() - local count = OptionUtil.CountNonDefault(launch.ScenarioFile(), launch.GameMods(), launch.GameOptions()) - return count > 0 and tostring(count) or "" + return tostring(OptionUtil.CountNonDefault(launch.ScenarioFile(), launch.GameMods(), launch.GameOptions())) end) -- "sim / ui" — sim mods are the synced GameMods; UI mods are this peer's prefs (not a @@ -301,17 +301,13 @@ local CustomLobbyConfigInterface = ClassUI(Group) { self.ModsBadge:Set(function() local sim = table.getsize(launch.GameMods()) local ui = table.getsize(ModUtilities.GetSelectedUIMods()) - if sim == 0 and ui == 0 then - return "" - end return sim .. " / " .. ui end) -- the count of active unit restrictions (preset keys in the launch model's Restrictions) self.RestrictionsBadge = self.Trash:Add(LazyVarCreate()) self.RestrictionsBadge:Set(function() - local count = table.getn(launch.Restrictions()) - return count > 0 and tostring(count) or "" + return tostring(table.getn(launch.Restrictions())) end) -- the read-only config tabs below the preview (created-on-select / destroyed-on-switch), @@ -334,6 +330,8 @@ local CustomLobbyConfigInterface = ClassUI(Group) { }, { Label = "Restrictions", Create = CustomLobbyUnitsPanel.Create, Badge = self.RestrictionsBadge, + -- the longest label (+ gear + badge); give it more of the strip than Options/Mods + Weight = 1.4, Action = GearAction(function() CustomLobbyUnitSelect.Open(GetFrame(0)) end, "Edit restrictions", "Pick the units and presets to restrict (host only).", isHost), }, diff --git a/lua/ui/lobby/customlobby/unitselect/CustomLobbyUnitSelect.lua b/lua/ui/lobby/customlobby/unitselect/CustomLobbyUnitSelect.lua index 526f9605be5..64ab3256df5 100644 --- a/lua/ui/lobby/customlobby/unitselect/CustomLobbyUnitSelect.lua +++ b/lua/ui/lobby/customlobby/unitselect/CustomLobbyUnitSelect.lua @@ -358,7 +358,7 @@ local FactionTab = ClassUI(Group) { ---@param self UICustomLobbyFactionTab ---@param count number SetBadge = function(self, count) - self.Badge:SetText(count > 0 and ("(" .. count .. ")") or "") + self.Badge:SetText("(" .. count .. ")") end, } From 97bf770603f532773a54da8c1c51ab9d4fa781b3 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Thu, 25 Jun 2026 22:11:17 +0200 Subject: [PATCH 58/98] Add default background for the lobby --- .../ui/common/lobby/backgrounds/aeon-omen.png | Bin 0 -> 124950 bytes .../common/lobby/backgrounds/cybran-galaxy.png | Bin 0 -> 161110 bytes .../lobby/backgrounds/seraphim-hauthuun.png | Bin 0 -> 181319 bytes .../ui/common/lobby/backgrounds/uef-summit.png | Bin 0 -> 169560 bytes 4 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 textures/ui/common/lobby/backgrounds/aeon-omen.png create mode 100644 textures/ui/common/lobby/backgrounds/cybran-galaxy.png create mode 100644 textures/ui/common/lobby/backgrounds/seraphim-hauthuun.png create mode 100644 textures/ui/common/lobby/backgrounds/uef-summit.png diff --git a/textures/ui/common/lobby/backgrounds/aeon-omen.png b/textures/ui/common/lobby/backgrounds/aeon-omen.png new file mode 100644 index 0000000000000000000000000000000000000000..d7e2052c0100a083dd3bdc1c7d73f7ee8273f3f7 GIT binary patch literal 124950 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˴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

u^#(imgk9YWKU0T(3_6cG%ttdB9ZLV_B^Dbd* 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

r4nELJI`0 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ⅆCkot|?2s_kPJ2o7UQ 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∈ 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 z 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 z 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 L^?B*18~*mcSn!$5 literal 0 HcmV?d00001 diff --git a/textures/ui/common/lobby/backgrounds/seraphim-hauthuun.png b/textures/ui/common/lobby/backgrounds/seraphim-hauthuun.png new file mode 100644 index 0000000000000000000000000000000000000000..f630b447f10ba562ec31c86236c4f1d54abe93f9 GIT binary patch literal 181319 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 z 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

+d1`~s3{8UC)B#vi{`C8!(9D(D%Vy`zmKCPx;t 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 diff --git a/textures/ui/common/lobby/backgrounds/cybran-galaxy.png b/textures/ui/common/lobby/backgrounds/cybran-galaxy.png deleted file mode 100644 index 68918abc4e8b2047c64c97fc40057c85c46162da..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 161110 zcmZsCcU;oz`#!dZ20ReE<7Vz65;c{oLcaulspFx?+84&#r^JAP~qN^ULSq z5Xkm;2xN=Fj&0zZU%emAz^|RbmmR|(5RIdvABm^}Xax9j`(1OZ3)^S5NlQUapuMVz z5XfH;^YdrzqK0Ni8KH;F(WX=SRn`X2?G(Kum_qecK@jO?ViO}TZ#rw*!D5RxUjlLg z4lfD+Z%0b^zN?29AeGP4)3^S_xqGidQxw1iq8GZMc<07D2ztAO*2Y_s&S8jXb>J1H ze9?;Jp;i#vPear^(1X+pHXA=%VED@l!5>$2>>!)P%^=46Hr?2G7w>ZpBDwK)-tuWO zA>sGfJ$z}1i%{Yz3Vc1EwCn%Za7VnvMj!N}tNS1gR|q}_3*T4UuuPaQGSbfQDLpYU zDZPP`s*3BT;&x-SEhLDhomJ~FKfG;0n9STS*!yB>ljv`xOzo|@q}^e*AJ?1YaY|{pb4v0jH8LTj!$5NHZ{opYB_y;+VTXS3 zc*Eu=Sf&(w95O0AD_S8rnNdWbYss>?$qy_H)0Ay!X+*(#W(VzgN+gf{gSTDW+vXlb zX6&JpW>~_)Zvu3CaXnt8>C_Il3?;mil#IBAX(DKmm%u14& zH*}G-K0s0#esU+lPwlq2@rvB08}U9f$lqdp=2@-Y1Z(+(B_z666-@5ssQq@6_@LsU zRuIYgb7^qx?l3#`35iAr6#IWPdMx@JWA72SnfW& zMKnXa%^#4>#Nv{lF(^#7zpE_273J(9C!e%E!8+oyF)*gGrOBzuE z((^WL+!kz6WCC?@^OR|;=vfYg#9D_0TbVEON*x%b^e^|eibsc@1uKKkA~f`+i_EO3 ztkg>4DqNF(K%i`{uEZnvMASFLaM~8cdK3@68T&O{PGUunKqK4_J?C}lDi`0cJtkF$-x2_dr&Y1$-Y7QJO%`-{5wUW<`u zu?MmtCjAtPJb!2MT{_&>*P<|B6J#C>v!8; z&=hx)12&5;8ty0JpnUr<1Z$d5ke2NPJDt-xe$M;shTv*X`E82F&b&J}R|H!w{8V$S zNo#5`G$-0nl8qlx!xE}0=T=pHiwH{CVoFa6MM^Ov1D)Y3v)I&s!A&UeU#^dXnJ-Us zhiG#q)&z^kVjmGD5FO%X?0k)KQTSKC>?*g6H2B_emM!sme~MF(NpcSyu{iT{@m*sG zD)Q`!P&e^v-L$|K_k}whCM1%=drsf-xfh2^J3-Ra7b=IsbVnc|Ruuu1(Gs@}UBq zIBF86p1-FBmE=F3p`DMFKk+a%C3HO_l+IHeQq7+$C`^m6ts4=3rZpRRI%ce|&}>#) zpaNvohY)pE`Q3H7l2;PwiSUl>S!$m}ed9C~GRD~&PlN`?33MOAA>EUkzQrJ8Ic7Zw zSfewNcYB!^m*RP_IbeWc*%Y-;8AIN>;Se$}0u8VPXW1E(`rd5OG3eYkK7pA($u}Bg zm8Ul?D@niW*~^3O@u<$}YQuQ4$+et~ekJbbQOSjVDzK#AoA{O)=_beZRD z_!(c1mc)C?CAb9EIF{=twk)|0`NlnluFLD0TQE7~7M1`<;Ey9s?RebKA8lBqfGOl= zM5EaQM1h`=LlwG5)Vn(Eg}%v5G|Zq=h~4~!2}rX55LWkMnDcuz%cw*|wHU z=Es%qs+F6&`GG%(_M|!?*B7u2gRVZ2gmzPP9z~msNM_A##JI85g*kmN7y=H1`-I@d7ACnh~A_^87l7$vj z)WCK+hTYAM-pI~U>~cvuDv)gkHTUXJD|6`TVXtg#0GT1#L@1`fgZ{Cx-V?E- zb)a1>$1DA&jO~YQViYnK4Sakq`>(i;6Bag3jMec)HM1gFNh<0sH-mzJ*%aRV8bRoa zaf8Fmt+e+u#R^Db>-zlzb$GDF*n^<|EVwx^kx1aK2iJ9KKg%jqy@Sg8_#3j#hd@Z%L_- zSWS82Y6J6kEHupV%7Z_)7W*Jlm2UB^e=^%wQEn9ZXBlbv-e0xI4Idi58YGzy%M&XK zRa#ZFJ8T{n6r`o~rNQAAR%e(WE8KIDvAnOWBKMpNnGqw&w8UvuEMHB}oHrI`z^5!f zqNCQew0*@48n*J40Pj)+;FJqOTkg?vq(oPEe3?>v zr6-!sRda9->RzkMjE%;#6sLI^6@38%IZr1?ok#_YAo2E$r2s_Jv4?e6S|&njVaJJf zCs=9-=V2e21ebuCEc$ADl7+fz|Nfp0VW@yeB&%lPn4#3Ji%mmQwvebjl_v)S$g(r9 zcCCbw9p*Y0#4X2Scg9O}U2OLq9gbEAts{Ij?k;Ni_<`b7RqRBZsUkQ#8w|R$K(H)v z2{^5P$8m;$>pR1Ez@hYDR{MrGmKOKNmUtZ<$yVcTFkiv`16b-eCO!?p8Z0~Q*x7ES zG>&X_;v5ccKnou;h-C0K>+v9* z9^2T*L^JXeweaWCaLsC9rjvj5uvKAn&tDr;?|!2Ui8_fz1n(UW^ydYXX@rJXSh3Wb zItTpyid3mZGPMe*ZmZ+`fXC0Jn6Z8>M#lyQh&~4s)|Z+}*15uUlK~zdLB)8S8Ob(T z`#a>ccvPRuTeV1rlFcR{jtmA?XDRo;r8Zqj>`PnXn=+zXjV)#2=&NI5&r7m62EzF4 zy%iY)w^IF2RMy)i&&yV-N;qa%NJZy$MxKY*3?Vkpfd6`tM$7%gsqLiQeTlB+qy>&r zSzec|tXgB-w3BRSLb`koZzM-H->;QRxcGg43~|>+7UsW4SCH2yu^63XGr#G?v5I2_ z*BBk9{|)=g55RS-%mnOmC|$Tt6qa{1Js&N@PK-KzWhwqW02S}0D|y$A_)B)MRzJ5& zOWS6nV>qR&tGv-VXr|UsPkX36xFTutThnD4&Zf$^wdZ+AVnF10hBTdSy8xu2PfNnxFBuf`pyPNGKi5^ z!iR7?#NM`356ev)HA^geUygD#!r$ump5KiWyCMT0V0GI=tH?nBBC47ZvS3jJH!p&t zc2Z(@`Yi)fX>5F?V@ByfP8vGRx zr{#rpgDmdqLVrnsLy3#KJM}@M{mO2UP)M@CRmXamMBb<7z{behdp-Q*D=7hcKeC?e z-N(43VWy68!z|oPM@y;}nE?M*Y01u`DgAFs7hqV8_tBmw^~_Z3Mgn^yc{y5FIjm?k z{yX(v0M7H5M=ZxTGAp`Y-xi3}o6)v#n*(nc9HShLGF@ghBW9!oIaHr1AEA)sXkI7+ zB<0jb^I?|M9i{i;urOv>1XRP?$-nN0o7^^ciJz$;5zO#1f4xf_qT|`i{Rc>ssk^VE z5zQmB!fUc~u}l2>FR;k5Qr-u3qTXoxm!h+J;BN%aZL$EG}_o z9d(sRU722^bD^VDcaD2G$~{;(fMR0!k%EJ$XT&aO3#-5cMXM1=X;ujh&p;IZZautW zV#PPM@;KeD<{{s;bc8IJxWK%(gbgwL(9mEfv0(;|Rok>ko9kT15aA(NF_I`o)>I<) z@o-8eVcUF*8m7g!qbCz&JvBj8W$%RzTq_C06ZQ3MwF*1xtpI08ozLXrLw^LGC;%Yq z2qCiN578mVSLJVd88qYJA7MHcwBA-uTsgY@d6?C}CBF9rUa7@hknNS->txZ&Y#X~| z{TqT`9JeyWCSZE{d%CJ3(?hQxS^be3!vb;Ge6d#wI>92YhpKD|oa9wgd!Z=w8cQGf zL9ohS!?3UM43XWe)nO=)wXP@dL-AOvP$E5Ooh%&pT817VL8MOAE_;^hF*wS-S`(#a&PF16UaG0T;aJinbi5OLo=fw%q zWF->y>vKmQkfa&R?ZgcLtB?vh+!JN!nf5TowCf@-vFq-^UlaT`X38-f{Z>`0?*BN2 zv4*FUsgt)cZT}Ka_uaN>*g1Ty+8r^$N6gIZMhU*7x`KuNHdE1EAX9J}E>Kognr>NG zTqurbp*0JZnzBE8S*kX5-linooGp5bN0olSqeeM2?>!?uDIjj=OR3@u(-yz$g$50= z?w#ZCTCkb6J7Q~~xI#lHVeJ%(HvnB+>txBcOzWU*g=HZB z9KMdGvbkI$A4;c3&asknc>-A!lSmhaVu%CXYl#e6`>2pUO1!ukLeg)T;CRX;0<(TP zbv?>~vOA$yr`tBet95wPdb8M@1poPYAB5`nx5>5#9HBcen#DQGcW0Z0^*3 z1%_N3Z<#HR1iHe^o0%Iv9Bj+kh-i`jZIT%~w5PBHL^^BPBh!eTFjdt8&*OS}0899s zv>%fa%|&_xKw4~JGH$%ytz5^d_vVc|{5SqBiya#ICbIC*icxPlt5WfM9R3zGJf zjJRPeIh(jXsVVE1($j;mP;I$Uo2KK$~d*RBocFR$}4#C%CT4w-$%1Y=twDci_e5X^2~!6(xl-Q((~ z_drnY@q(skd0}B3B$$sEE@KYuN`o|BVVQ)#bu1qcA)K_OWsUu+7ye%EvFuIjj7a6g z;7*BT0*TrW!i8qvvO1m&Kx+p(4c`7LG+nd)uk;i+3|3^LW*npxS=@jPX^W^2`CBX< zaTn|CmKJV3VhK?t^T5n0n+|Yvsw+RgidP}HqaYVW}cuKt`_L4IGa5y^&l4=8=*$s#;uDQDt} zdTgohqmoM3AeEGX7DQoI~9TwGlzKQP%5k+`ek?oq*N z@e+cL8_|-{h@iR;A7Hq|@vwJ`nu;OW%`q}9O-`hi#v=wBG3_vjn}XT+L6I@V5UkZF zmA{^7VS21xI*rEKx*spd$D>1JDqV3p-C;0jEajQlCLzZA0lda;YeW7hyxCFGUa}Nt zG0aRo|FmWM9$+X+ID9?_1c%N_BfG6NO?~6yRn7mlg#=N&8hFRHXw_Y~fB^~6GINU2 zS|7ji{w%}9mtSa;hF)J{cLTl<7~I+hWiE9d$ZV$>EW`KD`NdV~K{!0r#Vn@gOsV21 z6(_a9fm5>~GEo(6fO#2R`)}C{vJR5^0^tA5wqha4Hm)~Vqgvmbx&i(>`9!dhN8Se% z%=$eztT5X2K}0di^=0EZmI)3INK%j;l9M;$+1aPX&*L+vY~N6V*9)tOT_6=rhPrvl zSPTUUKeB=*8A$3Ie3Ng?PumOZg_tWzh>@V0md&OWyD6+CLDjhSRaOE#0;e^J@Tntf6)~aF=qhUcNAd9SL846M;BWq&f;FQiE$^z* zIm>qE$7)uMIDVto!RAuBQm@v_Z3jh-yZzj|T{ zeLKMUJniiqumHX`KRtgsUOaxRGFbk10aK&BQbaiT*7MtUpb&goa&&DW%f#2D#J9`a zu^{)Kn^qeKdrbz)xyZ(P=P#|Wk;cY!0SDJz?&TN?KukrSBtDo|ve`XqyVs_8 z;F#WY3q5StJqyIQ1qK0g4F)rZnb?uYi!H<&)9+JI=JIs}EU5K)!`irZ&~nh?@UZ?M zR!K=VFzNmRb!8`D4fm}j3)oRiEMH=NZG8Ib%+}!dCoKw{Z5KY7eIS-CRi2b>x!NS& z$-Mm*66uM3;2rZyH=MRL4e?RV_yNy<-7P>I!r6wTi6+^uUhSn5zz;8}a1hEulxC5xuJ=WdWmua;*$#gi~7r_rN! zZ$r^6H{BSUoH3#4dgGl6(o&pQdmnKJZa)l~HshCYJ$5uWVblCf{KQK#%@HlBErbD} z4MC)SNFs6d!;E`l4P`A)Xw&qZ)6l@Evj1&E%P=~~;t#+4siDYUEnJ&eGbWuNl;TH4 zb@3V9b3$LaTKU;!3Y%8a9&p;iiHcp$K%4D|4fE;P-G0yJu!mtr0rC!K8WVk2v*qu` zV+D#r^~sfb3|FEyEY1iY&QQgB9>ls3hNjk7K~+R#PLD-Q-LL{-;F3wQdrbJE1<@2W z!?_lsxMxjsZM*3Fx~qJNErF;3pJ2f&VVt=TFd)k%TSkU?HxZ-M}28EER}}u zSJz({dJ$C63Ju$J6*Yb+?$|h-u|`n8>+a-#t5F=tlutaxV=zDrC?IDKN%fNmT)P9Q zxi5xNm%z$FuE7#3gl2l=zxRAhNTUi98a2Zwd((PS;a17*y;oyIb4fO-3_t=21)Rd~;?{0& zRfyYyuada=oW;uIa=K8fTUZFIl>79KkHmOoi&UYH0#C?a`m?FUgLmcfpcF<^U6TVD zb*I)Wqb2pOF-m5g+ckx2K4si`Zs08g`}fdvJj#7-h0e(wfDUmNhHQ8<(6#Mrm}N8J zchoxnmGB=>yowZby9;?Amaj@k8av}^(#Sf=wx`=?$RzAqrpnT}`m{cfx${o8=YmR& zt*OzT_jsJxG~7;0Xw3(3MokIaj;5FG?G}4J_5P}voEvq;3b_;zC>=`dXDZfe+M>nH zYcD~-_&hS8<2W(tzA_KIj8(b!$Kw2D+n;w$TR=G? zB)ELHjm?QfW}%JZW(oxX;D~Xj>KCsK3{~F{1{Op!0LVd-_TQ-}?YZs58lijA1sZ`H zP@Qwep2)>M-3WnR0J(Pi{C$Gc^}Y>2l!JT6%Q-0MH#h#=h5wc!C5|Q|Tm&6l;N*cH z9)oYFi@Jwi+%bA-lmIQ{4YdqS%H;Ngu-=pmxHoef7{go@BOi#oBePcI*Rl3!^LkMW z%1>yzypq$Bo|4kk_sOiS^X{ifnd8WEznsO&g}z}> zV|wt4C2^7JmsseD{&O{KR@_%;$dUM5A z9t`pjG=xO^&NYksM4bX|w^!kF z(#>nr<}6XP0wBUS2H`JUl;co(mqomgU7|}sbynj4!h~p&c}T>G^*eVkoXWQTFBixq z_7Oqlwn6siv-f7xUn~9@XnJztTMUfh*!KJhN7+L*P;G*>!I3sd0;g?$#!gWE)a$Fa zqn83;KKtMg8jV?v?l2(6Mzm+vRB54MLG63-@)|rTO54z-Iq@}|QgR7E>`!UWdz}0# zeyM6soTEtHoY(GjEL3e$S!>kWa6D*zp!)ggWbMAgnK^gJ?J4ik{8NxWH$j_dxME(D zG#mXC@2%IFWiPQY{_Qu)5Ogsd0ZWN4b#$=paZbr!M!y%dsm6;$ro5v6{ELbTlsUCx)0#gSy}R7J7YPe^j@3J+O6O zW^nIkrZ}|3)6M-h8Ozo)7_-au;C!ESo;HVf`0ZA-8ycUe{qW&KSeYGtBQjT=F5a}M z%T?7}GjaM3$kC*r>o4K3W~3Z3rheZRu8MYP|0M+!qDa-3W&bcE$36{;M>Xl6Y1Aj; z`f}om6j%8+UeD+))ixHaR^$Hll^HdZa8l8ifX$?hx~tK};zTR41e zo#9`*ua(2wZ$2ElHRH2lOScJq3qiN2LR|=Ly3c$E>T_7))s98W>PZB5`RyI=2Q_d@ zTbN%=(6sNVox1C-?6)e@>g@(zdYW~Olmg#BMJE&QW4=`@IAVz48F^9<@VJsis?6V< z57_?Qw0*Q}oxl%am;j6n+-}o+A<#$3JF#v{8A3Z z76GV`0EZJkzl$1^bi8)m9;@el`|yT;or9=fi>yK9fZWT))#cb7Oj?sPB8aTPZ*FCz$_h^W+RxUcd2kOOms>VeXj%5r~dvM_{PsR7xx%ZXzU5bY}`A?oRt5 z5fy# zSSLQ(1yen^xA7IzJN1@Ym)VOgr@9ezl1djjP<<2+r(h6;0JbO~> z=x^8O#k}wQ-JS9GszP?fv^cR9p+Y54@px+%L(I9DuHX-8%5+;vmIz{*NDtqsW>{5H zu|_rG6{EKTS9IISFrWscoLN&Hw3jit=<1rn3We*c9Lr@hJeR8r3hyQ1)r`)=GN$s| z|EDaeKv@(m1Uo^;j@o)X}|01~C}tdx#ZSy9F_Po8;9`dUS6&QFd%D=5rJ0jZ=D zBX8HujidMz9L4d?fK&6sf$|Io7&Z{>tG&}a0_QSupFbo_`#T%LC(cjX$#!rTUf$y|AG?1P(ws7sdUd@n*JY)M>m~76uf8oL50KCt zSE3nAhSq~SPPV)d^&ij|n=^hYWR$~UZ3J~g%ck68%ohVullQvnp$4V+J{LDPWpgBt z&m2#9%JbK4|0TBSu52##-X+_Z6p-VFgk0aeETdIq8vBuduf>-~>)cqSUg#u!cvk8S}BKW9?s*1SH z5;2WR%$Q8#I!99$dkV*duGG2>t03Q6?HL;l ziBagno&u9u8j-x2C2V;d!B2BN3OVRt1Xe^ooBXsJNlsMv%>;!CiNf)pP~VBby(}0rDm3o;jyX-{u5cBECUYnMgBwWqsp^2g1mL=^j`}NrsG3OiH2t2|4I!7K?cdReFy}iAoH#bx=1E#KjC^&(> zaB6ncEuh!gCLPrKMO)DWW^n^X#CzXPDPdk{cB&#aTC)mYOiF23pKGq_L3BspTXn5A zO1@(q&$1yaWXh=HqQ-=4r$seGi6gU~fnIV*4Lz~LVas}4dc?`^fr+?d)7+$1MU(YI zRu7#tB-gK_VH!gy=T(X%vme6nyt{>CJQ+kXlwm^TwFv}>+1JArcf ztTbSD%%7CZhU5w%5H~2qr>h=My02!Em2j%W*BY027@Lu8B$=%E?#1w7{&+k-?8ok| zZ^)W<14&UaG-2F2e50;P2gHx@OWVJ7`wufZ*XHZsM90bF^ZeOmo+O}7L86lz*tNj3 zC2SZ_tw?aSJ5hoz)f-(1B3WDC;yCj@%b6-0}0S}kb4z|zz(c0 zZvQa1pzEe3*@U2TZ)^}J@l{8GeAYKa1;8SL((vz+dbg4W9e2g`U#EzOVkfT zJf@3@vR8WtSyszDPmRrdp8KB<5^QFTp$g?r{)N$P#@DSj&`Iu7^27sd3737jD{xao z3Sf0JHc0yR=WtjIEArELZyIqjwIl(l_H_SAl&i4Fe61o+iczo77#zS|e8+T#hVHk3 zj{JSaFM6ONO1EiyR}FCE5t#s^5x6cwz7dmfbhU-f0ynD`@HEzdsjx*sNI)*FjjQs= zd~QJv$Wb7{NRY=<0W|#|=N`M_!Y({+TrUksmBvib)wIrtlB;6zc0B+-$=k_a)~_?B zs?{L-&p*+8LsaP;wRAvso2KeUn|qP|_ks#*(17_MI2F8Ie@I7^QM2ckbieTY@Qv(K z;#SFKj}+#umMH9*88r}@6X3luk%>DoxZ0pGUpXvXJ3k+9_TvoTGFqO18ZVtWT%*b# zP3eJX-n10mhc!@HlpQO{BMS&W0;xFu@E1TWf4RKEyPNE+ssu_yCoK|Q841PCTHys) zv+`-ivw3coYQf0h*qgWl6|hx6Eb3A%H{*a7gI6pHWIj*Q;h>5xAdCb`VcsumHgqif z1+HZ1>;EHeGH(EG659OmxYoa?0NX#|SKnHh-4n?>MqTVmnfeRWEwGc~7rnP7&QJ@8 z>7lt74a`BH&d7-ym*llpI4{u-8|AThaTc&pz>AK~8I`*e3GBFT+^&@hOmeZ`lDGh6 zdU31P{1N_c1S{jve3Nh|rLbqO;<4C_LH)mf9ckRo6;3)38Y^pXm%k zU8`a2O~o>i4`1T*%qjLu$lC6{1_CmN>=asjd;lfx*mwf?6MF%FxZL*+rlh3AGTc&= zRrv2#w%Rv-^V&eFvWbqP$RY+V~P?spm@u7zz&0=`D-;xWLrP;6XxcI zmRmK~HqB{%1>}XFwC2>weWUf5)%n#Tz74a8kWWU#*G}O}R5?vj z>*RqaQqL5z-6Kp{8}^FH5=+>F5mvN}maV9RsBRX)i;J;I2>Ht43^yQo%0yyZDudyw z{gf5>*W1Wsu>wJoF98kuYwv-)#4R(xYU&yrt^Ii}ff}H8k+Z$)3*s=Z=3h5rCCfl9 zxr~c;b(#@X0H~C8G>X5*Y8Yh#G0T7Gs=(akz7U&FF5v&?Zv>Jh)s+fV1_tWanTjY- zR07HDW!z+8@%xLSLvOOG~5S z32EyB9z*;y-&6vm4sWyS$A;e+=e=#o(HtW9t@l1VBwVLM*~^_d!1A#J+2rMe;zV?8 z=Wh_Ued*!O(Y*@`ntNJ8KT@c;eQQwSx=F7AxKbhl7AA^?36z=$mlG>j4!a~T%-8F| zzI>T4u=Za^TYuK>TcYUL*t!9+=@W^qL?l15temS)54W+vB_7gi!x)Z>w;ZJlw50yF zQW2l>zCtOwp4c^va-|Opl(Ruy2r%6;&3&&F)b^<#kfgeRXCI~EdnkuPajwc zwv=uML_-yM>BZJIC| zi!$%QRfvyq{+K9R3*DvUpE3X*WSHv=r!)#oa+u1D=$^kd&p&(ITOJ-%$4q-QwHA{( zKop`;0v^@3MGtAiz4~%-L!i-DD4TOx5WT+t#Rgx*!v5H2;5w$NY~raqL}={Xe!-v2 z4dmeR79{s@3vHXk$JCI6RhGr5asOGaI^b54%p8dPY-%}onjo)+vUdjUli#9lnEwTV zH0A;y8$gJwZ#Tw?{B<;1s`tmP;;|>L?yO2Gi%qkE7Q~o=qv`sa|8@D(QcNVGhzYJH z?s50plv@xYS9Q{&ImFVxFg_v3H=BMe@eGqiy#wSwZ_A>hG=8KJe%Wj&fZB6sljPH$_~Z{}p^cOjVB{FC-I+VohQNU+~>X)JV(_ zg(`$V+}jV5NP~v_qIxHswhg=DTnJ`Ax0HnGPmK*o0Wc(5uF5jZY1oIg4dlryg7QI& z$`dAh^0O=@z251q{M{8c-Qhul1u8`#XPDJ9n=YE1=Yp0PpOc#ojJ+69jEh0(ce4H5 zLhA+#kNMuoj6?~^xpNuFc|OC2w}1gxSxQS>+Cob7gA8H^kinL!2mxw&F2DZ8G4o!I z%Ij_M_oZI^?)cq4PC71=D-{0cX4hZT@wFp3vd5gHY+tuqCmv$3PY$ve*L61PTb@in zeD>o<#V}+)SADsfC)Dfe{_&$b=a6xca<*Xd8h?N)EVQfpFcL&S*7-ocqabLCU=ch& zHP-ZnSUtO45;gVwu|?+9rQ|ATzj^UU_>o>Bihp5W-N|E$K3k;`7_S#c9#0ewofED^ zzY3J}Oj6INLf)znHdh&9c+x`m8Y%4ELPco_ zpE2NWw}##;auM@w=gP>Ic@}rH@vZ%JCvr++pDTRGIx*5;*UbM5-#;%Lba{9$3^cSk zr@4!iQ&)WNy{f&riVC(eMd^T&tc9tB=ETy{`Bc9nI_~}Br@8%+`$1z;yOz!cRega8 zc>iwqA0)_fTj+9-p7MV?V((?%^sfBlvCGoxQfiOGmm4WWQG3SmskWPsZ#~{|A$IKd zZ#SFnJ7<8e&lLUG3Za}V>#g07bM4nL;omK`QNe0w83$DTJ3aGg%ReLy>X~O$!mDMr z*6v#7na&)i-(QtJFZVlFZgP)x7>qFAz-Rkg$f3rN5hK4vj=5@OEN+90^@nh_&QC3j z_Dp|&WbNIO9doM%QZD;6Q8&j@52xJmLBw^qnP^!*)=EGAct`qi#QYKs-{tWKNf5VD?T?JZN;+1`>B!YZ zd;4NfZ#A#$%QKV#uwGlPdNH1LbF~bEqAt#QE3+4Fo-X`LiSR9;4uBX>H|O(jLkGU!Eu_(CH{-YY ziXL92Zi^sSL$C;NRRj^c>_`|J)eEz){anYZ&6`y zQHs>gn-D~wq1K@cA57=OT;(6JHX>7LI0zIO0;xYNAvrd=S^toNgZb}{Q?CXCUV{R2 zhhVGjNtDBnJwbtWvtgkFca8czwXcp(iD|1Eh2{oLBG|$ z*dr0;>y}W_WeD>5-rl2mNF#-eH1+5mvq#D<<~P8rmU@ofXxmK`mUZjv)a4($rmZ;B z09#yuq)AhuIrkcJ+DFqo&8&u^7$yzW7NHWA_*58v$eW?4Xr&i4Q^NA8{UruFHakC` zOKJ6dyf{BGg_uL}_AF4>ma}E=rvqUq47c2phD3EX7sAY2s*ZB3&U_}ku9kY2NgY`5 z?mK30WN7jtS!Y@OrK+eBW}Mk_>>hNa@zIqnI&VO4^gmm);;-4(O|z#Tc~=ekMW09^ zURU!xz=fSJO(EGA>*jPOIP(1EcNmH2Uee~lDedmQYJ$hLiXiYgqM6_f(Nt{sGXF9^ zznA7QS!NGA(CQxG_S`^zdAaswe(IZ}b@j#u4xlXreJ$-89bO{IB(O7gr5!3w@-R0XP0@ zZ)J(WzxnjgI-l0qdWGQcS0rAZ5xI)wv#gz@Cl=bB%Dw%THz3=W_@ceCi5F$?6K9KQ%d^6G2TDosp?-}+B$aOQ0C+;)mQJ;M~S`HRWI1vPe&Q`dcu7X z-$7*_^dXu2PAd0bb>aRq2#{uq}GK{CpG2N&4#Yiv$)Igu-|X_%Nvr!Pjo zIAfJ;vozT*ZB_gTMwJrYm^72aBsa!QHqxIR9mynS9xd5#YfW2pue?ubLXNSBuBnEq zMRzak!~D{8Z=C5?r1oc4W^Z-rnEdc+o56q-MYwsnoWf&sy1LzeCMJ&xr+C7~VZ}1k zk|Je^U7kYpD6f0)>73HJ-!FKVeKqUp?eGk&yyd}nZe6NwXEpo~I9oiHKI9;~xKQ3{ zR(PuG-=;wK&s?q<7itnCHUD;Pc}00&Cwn!e6R>w- z!Ssh;4KM6XNdNXd&5;D!ku*LfT&iFwT?g;uU7tg?&ByL(dr7K#o>ZulC#Tqc4*m$& zJoQF1=;7pQrLm98H9R_4rr2(pF;JduRoJFfWhUx)srf8dK-oT|Ik0c6sq&jblZhx1 zHLj$JJnTquTEf9sQ)&grk4#vH-QVt#_D>cm@knNAnoHH7<-T%*K+qNv@yC?fNHK7n zQGYHKLyX@XDLQUnMoY~)at{7tPj7rs6vW_15Mo--yo@$@KDBjX262Z5R0TrHBQpfI z)XDEUp+E%lQA@fK#x@?>T>^`KEuMaz|7F1+7301V(2s&f#r!MhuiD!i8KEmhE+am% zvgF?P-=mp6C$H`U`1Xdr{Rb7pD*r@Aqmew9fE)@jZ_RH)3Y>`o=g1$dGpL?>D1j># zN?73fx4yZo?D*0Y6WcRA{ar1})dr&T)3bZduvYb!!NvB(V_CEIL&V9|I%BhuSdQU{ zrSc(Eiveo+=YvNJyhdY^M*4W74HlcC#Agp3tG#BIiVxscjQ_ZKca$pZ$Kj=tU zG_P2Tmffem8JpSpZ{z5Kd-H3?!ao*mW-d43mtmFqe8DV&w@9I3mYS6km*a$}rIqn- z-O!(?AxzDBEQ`O!`OXrCK;=nQhDN46-xppOD@AOZ@4~4BtLOXLPfz^1{3m?pF7e%@ z0awiAp~*FBT;G8gk6k`i{s1!V{>SY=yiZuHuDw&WnuDjpHO}A909Hl>hwJ#^rpog{ z_<8fD=tyO$XLM<|K$K#=V;0(n9Df)VUK8&nx)Xs;0^U~T$-mFGB-fXJ(mT3vzs2RL zf&1R!T9?u3=^|xS)qf;J*nm`Z7Z1H%dj1F?EN&cNgI5zxfIZNCjM%N>|qXia0anBX(Jg_DszWwY67zmlpA|dqzlLc1WOfwIMCM z?zQ#z-&Epi2j9Iowt5Bf@;cxR$G*Rmki1cI@w031^m%lw-sYEU7*dO;cecYmE3B=O z?9MpQUG$3D46M;O3y@y{taPu#~o-;Pq6+1LhZuc!s)v(gsAP<&4p%} zGiiUMrGd$#wuw687G&5)8%P5H%q=>Cc&Xba-x-JPQy&%fJ_>NzH1t$Y^!QS|Yhcsw zq9>ZiPR-#Y)_deFjoz__Unx>+1nc#U!mv0&iBn_Fn_wtPP&yc(-da^Wx%TeAWnAjH z4(s(XW-nSviOY{|SjJHV=$!mNQV!Tpt*x(C4OREPzLVCGrYreJUGdi3TI4tACdd#p zSkU-$wVTVpaWU?kH_V$lE>Fze$^=4!qM6PiKS3x`ab%gmT}fO{Whb+r(7IPYuEzaK zSJVL;D!Q4SpD&6MY87UX((?~o;9h0mqm^>MT3siIKoH2^G{Cm6g~dkQ=73L-h_k?V zrOGK{p095WMr=Q-1l%QX_HDK28_tW7Dk;1@>SmnRF`d+XB11Ob2|#PIgMHI1mGh$Y zeZMdMC4NF^B>yEDRv6o`B0A=7tOyXi9rP?N^u2pG=KWed z=dm4>RmbRUeN-IMy24Q!1>-?Ry3DlqBy-)QqfEvDkc#*D zWl^~sVr+Ny*HXKR6}H#DGO$kaeQK{^11;;xP__LW_vrV7r;Q(sfBm|ADfRGb&cv}esgAXd z_9MREPtU-J*DMw^f?}5nB&$++CtD}C@VB#8Ry^^ckScMcVT26fh(}>>!O&wNY8PcU z)jmrv$$C`;TLTmd?4URA;{HLrPVKF)=>+`yOd@zqRb zl@^O~F{GQ@Tg?k0cmAw_7W0Ec)~V#Us(D$Df0JVc^ZXmaO}WI{iu(<}x14OkCGJhq ztvbW|n&|wxHQ~+^8HLC8>Nj*URVM-~5kY^=^9NbW^fxuA_rkGt2?L=!isvg28EH@T zH_F>9?EXx+pAJ8G@mc*ey?b|f-8F|1%v;piNJ0%ZztFc&_t~zhcqw$2zfqQ-c3$x! z@7izwHH)r#nErG8ZY+@T{JL6%gj-LX{tCnj)>?eDV3DXi zV%Mph9bwCHRnbW;*R()3MI(Iv_@OXBF`Iyx4E`1MCMI50A+)o+Q%mUg-O9*rdc>#1qc#5Q9ZCT_f|^%33};=vi@*>uxWP4 zw9yT3GX&g98Rl#eg?RS~3*M3OBHGyIv*eB0&mJv$Up)r& zM%qoviNoy3IAJD5kcmW2ydM1i0=c?=A=OTm%Wp3E<1sUu)Q~>5Co(TDuZVd@wJu?1F zmlq#4nckyK30v4NEk$U|ssY~maFCdI&>;CuE4}kvUHA6X=Z{X2BK7fiN~-Uq%Ss?? z>ElteBfa0s^7B1&@OjKfch+o&gf)%G*)>5BKUO4Mf7X?e*TZ(rTSPtqoghn!8vJeQ zfZvb6k7laQb!KZP8T)Zp!zX<^r3lyD2RcjT0YuYYWfm)ab6pPdp-r zEz-EVj1zx-*;93Y)Y7|phTF)U_c0kf^!tb?IQ{s&Y4^vSPo23;5Z4P+i(nd08{)SA z8IFTLo``tfQgz5j!gl-2J4e;U=fG#d(H z`TR~1t!SJ)gY*Ay&NR`J>W-pvskG}mv$Blqj@-gU&{kL#{OQF}%_yp5KaTF6Gk%E6 zeAYnwleN4~rw9gOkuUDX=%!w9c>DC;46Vy3<0;ayrr*N#|MB$R@ofF?`#7a^p-WLM zrAF3X=2m?*M&v1> zqDKy|!d{gtj<~Iz&H7sO=9Nn+Y245#F77S6i|Ouu?>71BZ-s(Sh=_Z+@@mo4iPutj zjp@xN>A2(b?&A{q-@u>)y9NqkJp=WG4?nC2-2;fhFQ0h|S@HOMJdePM4WqOYUMpX7 zaiT0OgGU_toGVner4g{&g<9P!0RqH>to_MT|0(eia_gvLPpx(gobBhJ)lUUqk`^W6 zei-F%YcG@h{wQUEph9+tI-)#o$mRWZzysiuUj-Sqxbil&Z?CpXM;=YD&tu%csbCIR zpMae19>)^z19y=OYiKSn=bQCaN5#5KdbgrEcPahv(_THW=k!BPFHB9q`o*`hb8>V5 zjj+P|6tPRGr`;mn3^=JfI*_V#AR);FE52~kXSM-quh)!8<%Gf%NZ2Zmy?F@I-PpDGHr*9X3B6 z8WStSYA^Akgi`Z?=m%sr=G3aUZ8>R7aGl1wdk3U6tJ1*}ihzDn@>FS#_1sa>d1o%A zqJgroJ^B|HXa~2SaQXodgZ3bYsozex#^0}7GRQ=6c@tMS-M}Vd~2SOF2C&Ikq}k2ibYN zXUhzcw@I(rGD{p=-O{mJWaIMt+K0SBYr0>Iv&@8$ktc2#V6Qgb{~ITuf9A{ack|s*>1^7 zWpw10f<1(ULn*Jp0MAP)Y_NyjC=>A3gqtD2&YT_r4{~$`yRhAd*ul3=xj6ZVkL(wI zXa#*_iwDff%J!67AqA@@icky?Z&KE%PmD&a^f8E(gOO0lX&n9E`gtGECNz+6?(NXj z+m12fOlOgB96$M=ex=`S*|Eb#>RNv6YytcUZNK(h(K_iXzeR5J%Akt`D(F_ERM&dW z7b28;v{mx`&K@>$TeibKgycXxm96>~8fyFSiLXb!cd;U$eF~5!mHr^nJfVA#$-oML z`bY$qCPmk?Pk9Sp)Ccfw^b@>?Ip{yfOp7 zb2+)F9_?JeK0;MDF%w}o4GK`xex&Ph!jc*{0#6ymp6qG6N8z|km@o%dll5_>E zC+fM?2Kz(AM>3#iV^*tdD$|mRv))HS?p+c;-QC@ZgJNwjU}clS&ld}!e@_Bh(83U| zHTB`YMYvZH5r`~^)hZmMp8rD=Qb!?I+L3aj7b52>&-tlZIO{6!kt;a58;|{~*}tHZ zA@4p7Z)_mOFR|u%QZW$+eMv+>xTwD(k={AakJHT z{4jxaD(ym7o16B6dEwqRfu|nRYA`ggWX^3b;kZ}x#Lw9Hx*B7vZaCoUXPW}PVgK5O zu_M16OX17HId{7uF&TxOkoRE_7n_oty2~5-J-xZw2uIgE2#P|D#KQP+{0MpUxxTu!?F1FW8Uu_1*LP<=S^b7 zta?k~4^~w-y1;N;qh?GxL;X|dXNoAjz}Xk_*&g!Q1M{WypCKc8^K@74g^?PzPg3jI z6aOG$b5$(xy|MH50dp3k|Fkq@Sd4$b=s5E+=fB;BZN88*GL+OSF9Pi^Yc>zWB(T!j z8$ShwNJcEEB5RUdU1pmCG0Ds)B!d%(x-@NoKh5cqd^wNa${t^@Fai>1P8M!@fV zGoGjep4mXp6pBh)7JOU5^*s?bZQL%@S6G7BW=cG3tsrlHF9EoOCtgeY0*#pa^pfpw zOk$hH&>_I8+aE5qNTi$!yx0Cmu}7n5ALoE_A(Q&wk>1Q5kM$P|lfhCXmP`Q4w+IJV zIMm!Zx+od>?-u%fTU`(ReJ9_){O-V3{!ho=ccC|;Ni&WlZ6J9=0ARA!WL^dXesjkR zg-<%VZ{`FCaj-f+TR8rp3Kq%GwX?M^8*ZH=3t9$28iv=)?tdCS8s&YjhtE%bzkR&& zl*7Wc06EKP;PCT&w~Ab5rt`~bq2yf!Iv&1F?jS5}L|y>M&KIm#`tZFl9bPqaHNj=* zt>DtM&KRfk)3g5wMu?kjgAuNup%TUJbk`{W%o?M(uq^T(+l_vHy7(R-Z^-S5`J^-Q zH*PiPUVVut-dW^1mDquu&{mVZH>T$4KMa$@+^P#~>Ukgq*2dhFki?@bW`hROXGGd{ zH%a#TdrSCU*0vixp=YO4vyFsI0gQDl>5i!0VA4_f0*yN46sn2 zv+;RveU^8+n!G1Y=J$i$c;39e&r68I83Rzh*Zxim6=GQfCw}-NI<>)xxr|1pc7wRolTs`U55cR)Kb&=(NFkW@pDooNZBq zuJPozA{SfjmK!{6?9R?g#d4KBr85N%gYsj3Fz*+PI42+P_D;vuM((~&l$v9!0;s5R zN+-~G8}`xqx7NS(dvuJ5VoB`f^&p{B*^1e#V$q{u!33paX#V zc_6RXuNyz9^pvtIHc4{?FvP!~ore?r-6-nTw zvL{Y)5J@V>r>^%7Z1m*Y{+5cm3!n}qu7=m&Ifi=?7U+I?lE&2ruB4~e#6+B*m2SQ& z)0PVL1C^?)LF>%&3A2UA4b4pw#G zYG#*`3-9HXyXJa8>AZfOWW}u>c~2T1+t^2jFtAf>@j^)-S%W$Y>f&FF?BEgGAyv&!K#!4a+2#8}<sz)+`_{RH++I@lKbWROs_<2~SKLtre~0 zaa#OtcIJJO@X}lmLgaz_3@p4oiC5$A1&V}=cf{$AQ0#6MDJDMp zx0k=1XnJZt*S6aP;2Mj)uq?9Zya6^JN5W6|f#SxaIxS#pik0DNzZ9DS0Y~MZ zwz+Ebkm2oNxZLv)Z#ut}FK`_Pel7VNGonPMK>teOr*g9`ofH;}cPl9ckD@cpl>U<` z92LrZ24)^SJg{_2t%=jdJ~#y+$3LanzM*SdyU;~s%euDTj9Kzsl&9puZ<-cmfNV)l zRL^G|He8!bWXM|qyDey0`23y5oOq_zZfR6ZuaC!JbqD2AZ6qJOj-NR9+9n!!W9&RL zfvep&Q8O_HrGtHvVaJ1&ZCikHWLK9xV^Sc&bET``aboXv79@0|MtDWv7%zE@UrCHm zQkv8T^>5n?#0-sryx1Z{F1LKnl|CQgK%3m$i#3BX2U%YuOy(1O_8+H|>yx~KOz;u@=i&xMIJJ@DJ+gaIKZ+b?zZFe-SUxF?dvt(%5 zZP=YF0@1hg062Z#((?0V#+FLNX<2DA48>Q&b=p{dE4)kZc`Q7+bpM)`)Skjnm$;?q zuHDX>iz0AN)PWQW4+87HvxQkCi;9X#W%Hm^$WOwzGg=83)kryW3;3fp{K+mLq12Pn zHx|6n3q*J8Ay1%vvbLblVzkb`r&7$*e*u<-@(o=k$YLV% z;dCDg#HsFu?VKbK;{h1fHq>J<&g%a3krV4*_8Stq5?9k*r)6aWKjPJHC7#P?3mSY$ z`aaoElxm^Av;0TB#@v5W%P?87K)95#lwq?so-@N-yKTr#K5#tMqv&A(FUpJv$2C-F zsd^TEeB-i$=#4DdQa~(%Vmf2Z08pc&ZDI~8Ftc?N4d~}~IRN_$V3??6cTVC@Sc18z zKU;W5amU1<6bsO|flT^D3MF~OXo(5T&M30t4GzE#3U=hr$ekP)(qkbW&(mKJaTc0T zd*?_pyAQ9F8IeAGJbU1t4FqR&s#(wL4~TEd`?b|yeGpOR$gs7+js;;95J_`NnJ%{! zaa~qCqW}@5*SJhr=v};%!VeoVc_#R|axUDvnd{Vv?`ZX=-ILkTLe9jULKmw;N6tzm z9|ePUh;JlMDZ(>QPDuPe;P@@ZF^E4OX%$RIo8Kb$_FD|sB&iA7-$u5Kf&*GM+%hy@ zgZ?uMWKhR*_?Ey7$Zt$p)%1-{qK zFi>y>5yqQ9+tXW*KK8%_cI_yLq=vX{;B>9(;%3mPab4Ble{4~QubjMCm>5{D|7UFT z9UO9X+sd>q6(a7gm^ttz^Q}5cYNTGC8yMS{PwqExFS8>qG!TFN7CdzHJ2yY;_nKhY zgZSc6-dV3j7~tz{BtSb*yrKKw^6=B!@6$!s0^Sz@unc}|)qP2HzvBYBe#O_}@N38? ziHY;Ln!5}w<>cloR5&Z---UzQoPq7{8Aw0N0h$2l4EOcjyCRCn9rWIjb9z>JNN@al zyf4^L<~NH2QiU-2RXhbmAEmy__bZat{+Y?MHTd1C!L%PkE^b}z*h}wwGTL+Ml*K1N zPoykxFz_#+R|0$7I~qW)MNPEaMq!mplsz|oXs{`(!zO~P5(PI+@+fEL#Rid-VEx`z zry<)KSB9nh)Et$uCvo{{E}}9PNiAh@_pT&=v`H~iHbbm7DH`h?R#--E53xb^!ao5g z{5#}cd!~T##H#&Kk@>uIn7dNgVn^qPZ!Np>Qg+Sa+o?|n26udQ)gR2)SBS2qTF9{W z(C_CVdin6qb~~`avR#327GMc-j8nYeYo3bS3$=NgzA^dBNyoFrhCoPvNsO`)km;7q z){9fC&pZ$EM<`ZI2z{U?EkFP7R;^KPUPA>~SAR8=I6L&uYdre8{KCT1skQHeUFE$X z`m~Jpzn8cce}{4umh377+BMwGYt6vx-u!z?pvX2ir2z$)rn$-p`$#HWE7@vfZCB@a zK0*#h-4fub>rF=%qVn|(b#Q6T4Qibq5H#;WHEMto6l1=aj5!##ZEPP~+Se3tI}Fd< z_n;A@P-1+QJ12#X39(nn8=e}0lhW4D@<)|iX+bCip>bKnrUu^;ai^(v&RiU$ zra{4zo6p-KNe+U7hbBGfe3kXzWob;6>}cZW?A|SkZW8EG7W4AZbbj-NmEFI)Mepj< zv6f3un;hADX?;+<;%(vN{7k~=p={`FxdUDR3p|{J)(+S=C4zk!P)azS`^5D~{~!Xk zSN4L6Q{#ab?{R9SgVce?#zNb{qdtMm)RQ<)Os+txcNeFCinFePH!jQhh~qZ8zA zpVo`EAw9X6e9y_C3b$S@a4@Ri$-+CU$!82`hv+(JNEYzVo9wT78(!}Ig2561Aq7uX z38N&O19R0XR3PF4=>jHWoa^hv-ZR@&tBjEkGg;<(G4_IaRvCTZVrN6Z-b@iIwEbWk zXD5`e2obcpA=T@)cbR|HU}yAwIoi@SVFoua8jXwNg#?6oxNjOtn$1CK)5g zlyI^Jg-njVQ<%!PIfJyByGL_7AhdDpRX$(L`N>i7IiYwoqknaE>lW{k0^t{w)DUl= zA)1@L;Z^-}dbO~Isefn0uC>Ry4=kY89j4*Z&qMfX)r2QE;Ji}mM{hZoR)1YFkGsVf zW0tZT!YgCbs#BdO>T8}b$Lw=OB=Hw)?zWEyxOhdtEDYN?x?zs2C-o@R|>C_ z17WDG#svSyPwk-B0BoZHs2SvBkM#W9x!7ojzy z$T-PyUt81)-1L!ZVfkX)BVm`E39T0k1o=^`tA-vTa9wRVotplk{3j!}_Y;A|XBJ}Ao?ivF+q72^V5=0gt5_(6k)mbdrCXkh zVH&#CKmx@0(OgjSx+iW(PdR-Pg4u|S$SS=mkO>bl{xY%ix#ItM0gSW(P9#;nT6UG! zDNCwFNh?gU2z@|yvb9%G6Zg?daM|lZoao$eebjclt6udk%N){3DjO(CO$Nf7Jb8ob zQR6KL(7Ld$Nl@wMPtM!5;o(@p7~)Yr)=+tKf;YbLfbIXqUZwO-CPaK9o-~^Pm^M8hi2Z701lTYGikdv?Elb1XS z6!&w6*8`-yzkmFSVv_04e|-IFd$aS}K*DOy#GTD@@4#Py1CC(cWM!8%L2(`$+phnV z#55|pt2{n#&J2lAg=Cex%1idpu1hoZOYuM-vM_holvTzIJQkAT#RDO`T(p34eqJm} zvgb`V$>{rVZHowBU`y1ovdYuUixd^br;eLu7wF^+HqxG((kyWG3rP;^7PnDaezrAP zgLDM{m?Sv{Az&#-e$h|MwEuv0yS(gd_L?hX_kJ@;5B2GDp?H~(;LsN^#ZpNN2Q}~h zEh5)18=35_Ik=dK`!ZTAkCoJ;j^eZEt3&74*|1rhfml{$R@2~$BJxvLX;aq0&r8tlhw$yNKyQW5E7HqFOYEQgH+KP$3Y4yH-@6nZ zC0&98VelR&zJFTi_o)$tnu6akYd{!D6Bd`Wy1ZJk_&rI2=Y`(Z?WH?PfVV2KC%x49 zQOIQHXI&;13x5k=MDDUlm!pZV33R$-F!Y)Anvoq(u7S(m7Rew7R&yx03!&^2C5kNc zD|n$c6B$_%zw}y#2YiMd8g*`qWW0~C6?Flbgh;ilG|qP+_9xwk*9P`HObVSel6jaS zCm^DPiYF_b+X&NnX%B!q2zclK2};VDTRGDug-0fNz|4%{?Za{FPc=8;Q~pPmI@7T+ zAMWzafRJS_u-^LlsygspM$YZMaXq$^9}ipw0Rl08tr+2&>u;Hi(er$NcW08#)E zTWcDc%Ul=3TJ><|^QS`(b0t6bMjfd&<+pb0u$(9>7)Lr?7y zX{&rPi6l!%u&@XpryHZgZhW4Xqjnt`xY0k(FVdoP^1&7#d$LlCWFB1Vb(A73@#iK@0xMCphEX_qCHg$O>> zUMqTZ{N~{NiT?L)r#xjsO`X-Rk6U2h@$;vbSN%0zl;sj2PQ8nEf>!{jj>I9znd#Nn zsiT60=3f1hmd|b2;xz=cj+ze%2y3qH%ucUQlwK;zf?pvi*s1JN%hXO#KQ2Ev8XS#o0VExVPiat ztao0Cny>iCD=Yu1L~V>VnPS##QhgauK{d1dTcG2LmljY^zs1`?3+?gxuTa2N^~>4x3|p`;;Zx9-h0$9tMdb~XWAz+F4MmE865yj+yUJL(ls1ioh&`U zQO;6t5N}|1iq8~ca_GD?<&YZtzDbd`b@NM9P1V>U+=@}QA6g0ME2Ft8wO6PYDv;ij zLAzUXsXtB`7wv@jtfVHx57gK3w$sJVvoaIK*HGr;bt%xd)lap){LDubE}NoINO;iQ zVg4xoDO>*+(}>0zRFmIitR|(g<>%8(Ko)1wj32D7uCld|%9 zXNy6;Xq0G%dE|$e*1XwMMuvolLDY|JV+35Fbqj6bdMnhY@}xuGf4Y(9(5NTmMYP2y z+{ekovu#~*e)iNO&cqeDfhsYJPfJrVmA$(Wi`6p*eLlUL+uA{#Dv{>pUzji30knSR z8Bo4x?C-G;cv^yBA-)0&2AKA*TE{Ujezm-8bvXG5mqn_-p8n?=Bp|DY$IlKUFDGYS z^J*D<%)ztn-v|h*vq;QnB%+pT;Wfy~rPaJ3*-_KxcD7V@2KOnCc4#9S8|Z+(2LA+} zr{8x9w{;5n>->z0B5XzZd!nyWVR7Qmx9hz-FXH*E`_@dwL`xTag4IJz@P zUNYY$JRX!T=o@3Go*{dOShW)IZTFSey|w3NVJ#ZV;Bl}WZx3g*Cm{WXlzCth2GGlK zo!~`D{RN;lUSG`71}CRT%l9Z>8%%va!Lqq?W&+Cakj@mzMNQ`+Q}r|M6EEp2Q`n(< zHs=vab0q(WqM|*Jm+U$QCy%($0?CMeg%#VGY1s)qsw3ea&a+N2dDl*ySYgrGo51uG zxOq2qy|QHUz2o4|H#RuC^d)Z*(U|G@+%7#?P}gA;a$XFDBW{uABc=wCoXsZ`Wujh( zIyT}97BEXbE;Thi&>>rD^2Oo96;jWUCHM-l0eb-_%bRVM9AZZr$oZ%rU(+^t0YbVi zY$ZQ+v{#~3b~$LC)XSkLFiJmD=ZJ60kPmwQno zTe_~n%&y6wgj}u!qz_!r>~?cOo)R@`ng@>*SN0c@uQ&MrGzxu=4O7Yyw1N0bDa_hh zEeo$ctLcM}8UeASHci*O^aHUYIJ>wxt4j<@Ir#biW4DL}&S+MZc|y1wm2>4Pto{Xi zVUo*U{#|}X;v2`Jn|VmwMBu#OBjF&Ws2?`i&%b1^yIOuZ|GN@WPs`@l2NX=Ol8IF9 z3O?&Gop$7cF_ktbXA0we#NO6?pm~A0c8Z?s_iwJK`gvR4*UlxYk7kUhM(+!K8KjF& z&j(VTruW|MJ2m15a>9e*1W&~^9Oqq@WEY)oJmHA5EaFrnGX1{LOpx&L$P-d+J%Kg8?c_t%@83 z5QF>xzg#7COX_w5szw{T{r-F!xz*F(T9nu0D|z<&@nf-g zJSXl2tw_NDl=VN!9&YECy?K!=$IDcT?(A-YYaYP#ZurSVhN;YB_wlX9mx1z13B8k7 zw2QbU1!DR|N`>-j=eFVpb+a4d?r@!Y<(oJOnV`k;l^^@>GDdL|Z6e7CHk9W2JwP;| zp-Xbaz0EE11c_}qt_VP%-z#x~%}t!NZV{o`qtWOj%Di}sXSHR?UQ<2My8U&|=<*I0 zxjV&1wcxJcZqlgzGG8U)%qY_~F5Sb>!z*7C;UpTs?sl@}C^P;2M?K+@pn8QaRfroh zy0Ej;Hbeh-G&TV0>mhO*a2GhPzIQV5H^q%v?H$LT+EP5&?VfGTf1rNCd2q+&?^o6~ zUKi2AxsZRu(?S>l5@29o_My+?WloYyA)uYq)Jl?92Q6M!m~&N){`S3Gv|{}vZcV=e ziTjY5NZtU2Jd4 z6CTM;u<`cM`ls?|==yudVa;K)H}mRLsPqtCT{^UGe8KE9-^=4{GiM3hQ_~Cs5h^6Z{<@t`nD8MCWP?!W9!*( z<=Fn8jTh({0u%f{ZFk+d0cS^b7ZiCW>#l|OsCvvA#3GFW)~5XGkfoiJJdAHG4NQRG zwYOZYFauHxYP4UpeId7C zTYK@;)~5&jQ})u}(Zn2}sm>rwXM@21OxzM901R3&i13I;DXPqj!isL+C+1V$8e#z+;(;P#(kd&6%lIVl`XJw z%8Eb*U5kkCdE(!T``;pF(yk9nPw=V~*aZC1=GBWG+vH?%_kimFyoz_yyK=C}BE`ji zQ+)wk*G$h+LD5b7C?|)N%^-RJP6G-$ADgt^`HbUf01#ALara)80 zi;cHSbv#(E;^mm?6MER+yQ#-O#w2C!e~m($GV!A)NI99;)%^cTTbuHsAco?ALwaGm z0ajHi(%M>tahd=h0;NqMI7B|mbm?AoEaPT?in0?WyJPeQBl>F?J`YEL=H?lL^B5>=^v&htHTEE> z^zsx)kRkk?2?*5nuy9fu9nB{0R+I$+3MP}@k`qlAPVnJ@rk{-F>pfmCHtx}+%uz=mOdycYpqT2nRTvX6Q0Q8tdUINASxIQtUNs4cit4;*#ulJ(A$7B!AFEz@6CmFuG#ViSwtC35YPJx`J#S9Mt!cCzFD!F z&MyI+mY?)iY?FGi+or!} z)lz=AHKRsS^zYsM$6Yqr> zu{-*DjTRnM@PpQ;TY{Cq2^ zglHGuB$t}6Fg;OUW^RLWn_xL#In0j-kM%@$95t_pg)NiSPP890BA>Khdst%9mKw#r!7|&bcQ)C4`r%kWZP<-(J(ySqd*q9yWz==D%Az>TcFK*SUK$+Xv{@1iDpG6vD$N+S*!)r6xMg zc5uRX+y@@OfZTOL4QU?&jE6u;*N($upn@sKX-zEZEu;PNmN9IzcS#MKX&rm1KasmF zoCZ@?5-TB0V>ht%;rs;llS$c&M#;Jt~4*^z(zu7kqr87d%Q*D`&|b4XZk-^Lq2nSe9W40fCI;=Ymcb zRj<>kR=FR(yrx=Zap9O@)v>RO@2c!2`zz$1skF1_*X`tAwdgG>v>M^ru5y?*$zzdi0=4qnE9Zo2h^qn0NCLi~j7NjR{vyH6=00wid0crF zJ0;)_oH;8i;cHFq03Iz!c=4b*lCI;=Lq;oAc!t~n1(kM0^5`}Ku`Qd11IlXb=VO8%SXStp(#MRpoL=dTP~v zRV0WfR6C_PuB8$AXQ~zbV{4s#ZLY8T0!!_eMGUyUllhYmV{v2Y^Q1?&M}17kWwb!( zBPGXX#|QVHC;3XI8(}8jCKvOV=;X#VflWbX`SiEas?EPeXQ8ZzzO(r44{v_RLE?1W zto6DdTw+X8eFsOcJ7a!vAD6M}1W^Q`qjvA9`!2!MvKikzj|1KzrlEQ>Kr$kf@VF=S5=PBce80=*3s~>FwY4x6j@O}7XkmO&)Fst>EWu^Kiet} z3I&!~4KLmyb>HA(?9c1eg}f=b%}fiN-wv5?@+Z`nxV&(j57L}3*kKYxj_k-8x2qXC zES}FsP6yrx>g4o1h1n9Tq>^$qe`3rPebCX?q>$j`{o<us`_1v7Sl!KPqU z<6Udf8RqOqY+~dm_a#0!o8>;^H!NoP81F4&_Uw`!Ch?KMVl-NJCs)<4Z|yQ$@>|-B z&BrY|!ZVX43WX4S%p&i1rgNph<>t-HfgCp6pS~`iMv(Aq|89?~m9`_I+Is`(Rp&kU z*yYK)=0zd##^j-9ang8H2jh2IIA7sQkFkegcPcO?x|`4^nFi_l#nBBcsPVx22vLk8uanJ6DV8C&-3;+vh;tu0e#~S72`?HE*S2niNPO-{+GD4viuV2Sjb#9GsUJGO#@XPQgqKk3S zmaHAMwe}8sz&i}D04G#&Q1wNFLx%;d`dYL2i6>37Z@=J{b7%J3&j!zDo0O+jTnS-m z1L5;%C)l6S;>vVwja9JUq26`c$a{gaM41reVSPbV57qtiYmIlf?iui#mK{LW&b8s+ z`kpcT+scdE+We}1500URnBdIMQ4c}{&w5-<-Z0LfyLvu5s8of@WE-UG@mWR`bU=h2 znzy)IkkN>CFS92K~o6JJ);vo-ubn^Wot%x^}7;SfaH({V(BMr_Ifa`B?kBk}XcrK>VEb z3tM^0{ed?}wYTQZC7~E^ZpIb}B z6drFssjaSTI;e~!Cd|k`&2rq{^i_^j9C#qR1@)?p_6B{n#PJG(AHG-(YF&z@?SdS) z^lp7JJSCEzeD7VL1Z?y(kM~%Y6qnB2$g?k+pWi)PN}JoeWKaJUerfKO9>)p% z|2AY&l^#8PSI^~#Y8(==t1!nt@y8vBqqX>Y>k5Q!CC7hjIHY*K!KZ1Lrp-d~gi?Js z*Gnh)Om0E?WV!6#^8Kvnjf?jbuNjzhTO~eIR;&ttb)B}iF}gx`T>bKxZeNiZ)UVM+QjxtjNwz`|78UZ+(a+*e zmDXz0|1L2H1ZQ}oZi4)s{?0n99ggV!{={Zq56I0m_uW!P`*&70v(Yl=_Q^MEr>`Y1 zQ7sTYuI(;HdG*wI_dDAMl;Ar|69bC2iKL12{W58Gcms z(Lzyh;Eeal)kM8r=i+>}hSM)Ab6Ha+-3@e?&o=R_Kc)O(rFzsFb@U&{e4C>fV-5`I zz1f6FieOwezOGpOpVa_kEKFk)Btt>t$9hwLDsKHrvEvQgJ(C*vE83K^BXL5F&YL2d zy3xivQb`PirN|}!7+qg4@H?be-KSFGcd2s@-%Gc$^S8!K1VQ;w5SK{$e@ALxzr^X% zo40q`hpj47vh)nO)dfQ&hO!B`1@V;@<1LMq6t_%prQ@OvI5VNnWc5{;$w1QPQHnr7 z@XdkKQ#M3rjothy>LdY#?U>|MIs5x?%`2?#!y#w*jb&-reC)v`u4Q0Uu1`cnNiq;c zX0O6@dNzj=n)18-m?yZrvkyXdy0Xo*MJMQ7+?!+ltSXVG%^gZLcecwtMMhPVc?TlT zj%3X1JIyyqy8-y$*RX~?B!rS_a0J?4Gd2L0lxyFbFWqcoVAW<=a!=K-r56~2bL%px z>-E6J;Mg6_xQEbQ!(tWjyuMu{;-psT`v2|xi$Ex5w5Q%Cz0TWuxy47v6Y^yGGh(}n zCZO@7`prCqXGYmfIL@>DR~aJxH-q=A6MYiD4Mqx%UIs^WZ`U3@qh{o2HK#IijPMU7 zjlOvh0!%l4$t}ek2_b#Jre39c+ixC~^w9+LDM`@eP0xGawK6n!GtW7(xOXNokZd{L zD}lqAOt_Z<(^Jwj6TXS=(vizdXhM*izr^<&Y56S~r#1c~|IZ7c_cN=jgPpeH?TYlT zN2lR>JnufqQI-uOxzo9l263{L|HD9+&~*R1rGqU}4!W$QEaSFuW5>hlEGyB}Lfl#! z6#S^L_367JpOS4N9PMj|(c`<7fhhEzRq?8WiU+uyl?i?u$hY&CP{=b7NTMwMs`Iwu z2R1opQ++Dam8EGlJVMRve=oAMm<5jYN`iU>C!Wvh3RUKKwme-$)av zP!s6Leg_8)W}Y(tHp77542(QS~)xaMHK8T{**4kiwV*jX4uU54k^;Vc%)}KOkh4cJvz`&HIaE0)sQdUuMT z`Q00U7a(YWr~_c?13iZ00Y9>Il3uqYFumROcZi&1O?`0bj@lp5 z6&>?lzGBjd^waQ>uaYT}0#d=pP4@_utep@3zVoL4gZvX@cY*<^+ZfSZ@T=hY=}GVh z>rK+<%x?l*C!2zjVjU{pSbr1H-v=dMR1KXHh$O1%|5k_(K-w;#h_;p`iH6mco#^cI zHe~C;d^Kk$qO0+EwPp9(Z|>a2H=kQ0NJ9>8>scR7P|rR2+eNI(MSE)^Vj=>MBB0~T zVP@6H^T=)I(+@|&mvZJgxEe<5!?9On_X9d}yd=vcOTmNkl3U-qz&-68(b{jU+wzC2|ND5L(n5B?dnHKNBb!OnyacOvHy!2 zZ=@2a@7)tN5T8qzORL^eDC&xukP^rUzUUyIJCr`UeoXd*OCU#iKpAC4r5?xZN$E3t z4)W=G8zP)~pKK&&cckqw#@cG8$23>b&l%$!Na*j|O-|QM4JV z!)UpaX4X~Svif`>C>573;az%M!hM68XcU^K(ZR~`4`0FPRjb-QUJ_K3ST4pzJkNL+ zrr>vN*-QRAogj`q1flbAUn7c=fe5$L}rR zo$X$q<8zRE?;i+3GG>dv!~X``Is`eYT`Dd${v)1WvPfIorzG`?mHbwSlXE+aJuP*} zhx!KAL;G9q#{1c$Kk>&@jHfF9yLYh^Umz{2yD!rgxh&#CLr%ujJ5*o3PE%ls75aIT zIsDo5)#OJYJ5!95gnUCAbJy2W)9!v39Xk-cMV^xTcN~B@ob4_Wa_x|rc2mgRv~)g4 zy=!myT_8#VNkgUI{(!# zAFEV%UZbU5=CTG)nNzamyLj03rMI|Dnb>wEelx7N+GMqJKQmeRz#Ju}OD^)u9^(9&yA^riGM(&mpW&gXV%&&B_l2t* zLnHz&_Ljh=n=kB{0Ps8%SPBgaUpY(D$*i~xD6(Ju+z{K%@=}kln|iKUTwf}&|DEEi z2eWYL-?;USd)5I9LzQWnk}vu%^~m`2@P`%8LL@Fc$#2oQWv}nX!>)PS7jlI%d`^E* z4v}KH56|arcD$lHYPy!J}x308@IFx5O+J^4M5gxyxI zywOE4;k2EO)XU!Ke9r*LN%Tcn@_(ypI%<3Jw(nMSZdS?L5#-yd+py#;vNcnR!rG{% zytQ-9KUSi;Px}sdfFa22cBK=OSeWKg8PqtY>dq;WA=ELn&c z;mi{=)bSuhe%enTgLio!CcQe}t@WY1om*YE=vIgEenriwQ#j(QNzg`jA1ka^=Fmh< zwh=e|i0x08M@KZG(Bd)6_4{x4$C|8;js*Jh^%D2T;TAxmDdY0wFPf@o^^Ui83vvfu zY`&U{!yb{qD1WN1o>XI(QEGe2lh9@l?lJ8{tSfli=>@%~!o?93yEDEe3pl z^+h{A-yj)gp?T$l+i=0s*(Ml#?5U#Q8&?|Uj3NPY6jQkig!hx8%M8-h#G;^}nnK&K zr&6XPa}B`%#614iZ}oR+Nusw9Z(DR6RMPbBnme5#n^sh?kp(pJ7Gni@l3k!1sF8ND z!-9EKio&peT2=v|KxIg=alpBMeW4mN`}9y(`E4; ze$sx@knUi9{sNzGo&MK4+T)Aqm&3w+Qk2_7oqawEy=8kV-zKWEKN{3O_Plwz+ehPe z@hF>xs;S!jLs^z5E8iBc2P^2y{8rODW@%d9%~{Q`TpdzbO9gOO1j0j5+?MVz#OIS2 z{*|Rd1jc=a=6+``{L&2(hyOc`g1ZWrzNiZwu8u*dsQWtr)U&cH~KuivpOwDE4! z9w)>RuoJ&th`q6(xen>SQ~&?bbQKOw?q6S|LsGht?oc{xI%R}(i*z?AASEF!(jq7W zMoGsAY1j~ifyC$VFrhpuh-Ut5IxPdWu<#;v< zFxa-HQ-fmXIOwy18mill`uK>cPLJh$7G zBVPQaKIJ4+82GWY$8XcM)L&w5bD+)bzWwbQpj(z79?T#TTDhe2_@R)H0OwtzsuC&R*FH|Z0y2lOYB z$fo-5q3Ar)NT4rtx`7~yc$UZ3d3>Te*&g7hPc&X9MFD%6Fk|&XZdEq;>caG_$8U#! zs-He%j|$HYT|t;mnHJ&Sz;l~IStN~w+zC*-D2eOy6qjn3Zktd*A=oueVdw`6ch5N} zU3t_~38^W5R^tU1DSSr$u+;40G*p1xV?n+;(bVGiIqOEkA6iYBoAYy4dZo4aA_%wd zNv!ljHS2uzBCYPd=G6iZ63uyITfSZT?vX5%s}r1xRYtBBMbgu)`DdwL$pg5{M!fOG z3?9)D(tCTk?KHu`oo%Enm+OhOP#0wC0>$)anovdp`@G_V?bc*hGoFI;Fj#I0xNrOO1PpE&d4iS})aUhk z#?$H*x>vu_wzoUtDBPB%nDvw_^2{>5zSd-vF@^<~8c(;QzY&(Y;%f-GEJ&#Ud1)0F z{!rWR^jQ$njB}TK*CZ7ZnMNiyArjz7op5_yiiUR{yAUV zZ@>qdT9`bc=Q-d;k)?T9l<_#esREz9Qf#brP2LVGD)(mUo~+%5c~%vgc=EYtpv#ZZ zgRR@nYHkogbifEcv^~NY%%~=q(*;@Qrt#0~x2j}!AEHG?D6n;l{+Gsz8=3J4V-9O} z--R=5wUSFc`-(UF+s-V(c&?Fo^T5#(v)_>N#}C+=R_gwR{?=59SsOI@aIDPus8iwn z3k|v&T*sGljbyzwlcch;i`N5pKsLW2Ti)Rr5&}e#vE@&B?-L$+f6xcphg$kgz$=%$ z9qK3Or#Lp%E-vkVuC<2NXby=#dao0W6jio#y87}C<@c#RPJ6IB3Dr`NM0uXF zG5Zojc8z#@s^Wu?%g)}fgr4HC`0m8MP_`&4JosqqnrAibfw7RhfQz1o;oya>+*1vn zRLi_>H(|DRQ4!O`Up5o0NxLP_rrz)NI3SK{_aOHUt%fc{xPCbBcA0`dmr?s>`P{5Y z<+5}>vw9&u2ZM>`9GhkeYiN2+2dHDh!u#4BH2L0r3t88sF*&ms+A(T@?Pe_`9s>Ua zJ!fvpdx0>?4HBqm^&6A~Y2YiRmH0iXVtRsqt9rDd1YWm9y5aU^0p=4@PCsVKPibU5ZF06!w!`jLPUE=-vbygrrii*8D4yisW$TrN z>CAt!M!nfay7;3m=fn2)nHDVTHy%?%j7TR^RMO@O-tO%R2i-_q&iY$rl* zE=iRl5yS zsdFk9CH*Nw#$rIs*w}>H0IZQ>h1TVkL=J!P4I@N*PzdU_D9A*wz&fkHv9$HPBu`Cj8n|yzXA!pTmVA z;#X8@rkF&7I-3)!y&KPD+WtVaUIc+_%&9^u-ixZU381cH>!SfTl<) zXjj3(vC>HMGCc0biCqCjP!TG4luW56Nc>+IeT!k~lc6f#;mE3E%DSI!DX@$&pIppc z-H;*XeWM%jSiCR8f#!+6O*+cj$~1d_K}4!T__(XB?=B)bSJAZbdv12(jXy2+(g_yK zPk!=20a}$s#(hDudG#9h=?LjA?!p@yAz93p4{cIv8dLm>JuCWLi8Vd=f;-PD(_N9U zn1O_Bw3}81<`OQNZajb~5G*=4<>2LO4_=g^51s{ElQTqRTh2PZWD2=cUCkl;P%a`Q z3|}nwOu2FK0I~UkNo<6`uQ%fATvvIcpnd9ArX|daeaX-I-0$7_g`BMz*}oa2d@YwD za>?XKe_RI~2XH-;jUxMO{jm?$TiH)-7>k}qr_T~=TO7e~$s;G)LOz#`IVWG~)Y^s&I$cW~w3 zf>y@Qf5v7aeG&X#QbOD>oC~fCRLlr~g&rmlBm4ec6GqxPxOVa8w8Q}h6LwH-DLJpJ zQ}#_moLzc7!vjkyRywe-;WMvjH7^(D7b8joz*vQ3pLXHDm{n@rZ-mJ69h5D{#5(@^&Olys0wx_wOHe8y-X9dEUWY%^Z>c`G+(dlmeIQqP$!0jr&ZaVe7%%K>xC%S4JJzDxYFHV2+l`${ z;}n4CY-g0mg*hg!J4crOD)yG0N?^~WVN!qdsC#emh&{v4|=(<@ovEtLvY$Nr;1W=CXtg)@#w-ih<6X6bEOH;jv&=)62IWahJ}zacBLT~ zLNj}u6v-w*$g-&4lp9JT1%7c*|bq#AY$xQ=X*C3l1X78_Sl=a@49NqT&#NhMqty z47B-jJ6uCjGOFI?{s(V)Jdu4DWo=d?Q{rY8V-wdc%jL~xQU$@DvT6_;Q58dkzLd$u zrA>d0Y|{tuMBjv+WmcPKY+KlZyb|t}+NFJ9Ec2GVj#?rnPgqg(Wt4jRQq@+V<45k` zUeJCZ5?1>&7xc=kob5r%7$r+ct|NUN)qB=L*U({(VF$Lrw$D-DstpVYQkp?sPHwd!=T$uZQuvoOd!_XUj8D^m9^*W-11-_?H*m+9JX zhDeSX9u4BKb*?D6#>A&hCe`l)LyTseBEdDQ<-ytqVSDIPB$#ZEQwfY_pWA=+r#&1BA zHo653`;X5@tr>fXlZ1#O!?-`DxdgVE9Q!v|J0PmqrbG5N$snRs9CZKm>fI-=`T%w< zadE=#&w^=#2zg$f&d7lq@v%a5S0`FHn=}i!xGBoI`RngsDe?j*!iyrIs_p|wBM;{I z5Y!OZFi7LqZE0*iTROj85+eb+5i>AV*wL}n>#gJe-b3$qiNTZ0_qUkr2%FaeX@qz{x-#Fb zM;D<_-$b-1jkZ_DYO4&_{;cQL+wxi*tG;maQZJ0IM0l&4svd9cUNo>|4}_y*+^Yyl z3POVq@l;2@vx z9(;e#^^(v&njbCq05sKos_Pd3;L$d-4fZ`6oVYtl0np&SaI617chIhmwQ5j^U*Ejs)33h?q#1qY?zSw1 zEnszlT`mD(xZ zY-?Q^n3URTOL~Pdk<@D%ndBlE$_d#}i3s~>+*Ws7Ul0{0QxL&a7Qb`{!H_$#9ee)` z3;g^%z5n?(2oqMjHf*COg5$t{;g^DKn6C|kH6QE*qaOd-a=DLfqdr3doL zp@AtbJveB$)yebrRxu4T=+KoVbGf#^=4SEJf5`V}!y&)4#Cc`Wl(2BPl8Q1KO*?4I z|I~tyH~k9;v7ZPJQ@XUQd(!PCtnd*-t)!W!!>l+5YaUc$9&ZZoL|&eWxkX3`z`{VK zZe~Umw7f+!ENl_KT5ehD%ug?r_OVF2p~LADGee2!kL!1t3kw&#Uz)3+RSRwf?PUy$ znru+!ejA zBz_N)`q6)j2yPyhA9ePo87z2SIsPVuMP2`~vY?S;Zje9o?VlPyn#-kF<5mOd3rb=0 zFJX3Y5739a?HIOfl594ufum{Flx`nDE8BU?-gdp-Vs(qTS(-zYjIMK|T9h2eRZgyg zb}z4{&q-&~k7jR_X!WFiWd=kY-FAkJKntN8o9Q&(h{&q5i3U-pTsUW1e|5N$F1g7@ z=r)@A_%=M{pcYH9a;3a`z1@N^ZojFqieN~hiCSW$7+189Q5-jXw4Zvf9p0>v`(QfF2P{C;@PPyVXPf@R`!i$%Nv;!*Fol!<^-AkligA#g7QCI1gR!^DV(75?P( z07M+Hx$_n#Zp3bKyPG0i^oO6F6^|kd2hjDL2EmD_ipvFE2jdu{&J=QL2D%_P7QZ!y zfF=srvECyYRpulVyw93k9(+E( zF|D7w;5rnZB~(XT*j4L4IOYMsb4S99)MW@R_a+n(9qYD<=kNWZs~qh9N!E0+AIq-u zvSl`qm$v{v1JXxKCEErKu9y6I|4nNrrt@IPZMz|mD8?UQE53s@AZdLI(X^odZe^m5 z&Oz1pH5F_>I)6973`24>=euFFa3pZ)=`2!8tLxpTOW8i?YHG9V7SB!0A=J(NS)u0U z;xeC(^`tw3WmZpyN@^8u_niuFXXI_tmN#b;Z^jFyLm$@uySA$`% zNB*Y;xOqqNw4)d3t|Kfhj}A7g``LGm4qD48Tmk=Y9?5^WseF|pQDhr_D3u`%2+Vb| zf2Zbmjx^czvg=BCRpPW?$I#8fzJ%`~Va_vy((<q zb<^DomS<-L4x4vyB`5&=b8x@XJ4G`%#;@k{vrbMX&+&uz*#*AbPsY_;D$F+ic|Z1r zGsqgQ!Z+#}+nvZZi+2~VySTb-Ne!m-9vG|Iz9ABxWK;&k?zdW?<1JAK68{6opU#m) ziu|gsZdkhBInB`UKv6Kao8X)Ii}90p+ibPGhHba9X=9<+ZVC~(Wj24c-WUiXks9n7 zxPAm4)d-DDfbSpWt(TZX)$Bvv%(5z`cq_IcL2T`Zatc{Eu3uu(w@>S-Zsg^$2d#jz z<3es5Y(g!MmB%_5@NkMVsDz<$)$M7zeBw~(OT}p}kN7AO5LiunY@m2EyMi>BKGCqC z`$1kfI`dCKz800wP3TR6F(@f+bx3_>-BeGqa^gbq`8U~!*$(oY%3+TRIcO89BypAC z_^(4+mM(dtUe%uV-R~qkjQb7+ z;RlO8V#)o+V;QCvB=c;>MaijCgCrw)n1KPbc+S!ny{dvotFPKKWN&UxqmBKv9i#=@ zrElhw!<7O{&B;VO=8`jo@`Yx*plMh9X?ogZ=EuMaSGSL5@nvVYo@o%) zyappJ-t-c$o5dWA9Z$VC%}a(Vptx#Z*K~^Y<<>N;II4xw3>YbAXNo> z&#;0!B8a3{Es~%8a3cx|e2jFilCHCnb5MCml;ed|-%?Nu$W6qUIJAaLmx&OhJU)tNZky?b=OYEpM{Pz83EKH4R-A6aELi zXGA|_!n5LVjrFp<A-J}HqL)UV~|NMXotin zkOAbO^;e+aQ+!=v&jo%8ey)ew&p?^NOR|=cOPF)GC~Yj_koF# zmlQumdihq9C1qC87+Tyufr6jSMunP|`mt;ME>#yn@+H!^?0QWWm?z06WRGy@9mH)q zU7ZP48!mUeYxVOuvaD-ZE5{OLsin9V*wk_T6o zhKA?g{_Feqgsr-z!M;beS&Gs8L6h`ftDS~^msd>5ZeRsk5c()Uw zg5AmVxVnk>bk^+1BK7@%5MY^i!1gy_#hPl^$Z*|!4#nLBPickqwNpH*7o(3GgH4b= zA(t~4%nZf{iA801Ob=|n2E_v$v{z8ji~hk?dBm6?GO_F9V!r!g3ApZ!v3=gol*`1- ztKDKaN^{_L5rM1^kKW8zfy+VJ>Xg~q8d?P>w=?pR{9B%Xdub?ra$+{(v|S5#g3Fy_ zkxq>xn=TrLW6@U1p=%TQ!aU-YR|&Vvgu>`O*07ClZ)K~$|8a~XN%&;1`28SX<+5%A zy1QQik%56`Z$O->;u%(AD#z|q%U|P!W?8={zEAfE3X~ZzcF&v}F}1VrtXJAe`cd1_Vg?Y?U)HG-lcn1s4G9&8kYC!-8ZGuEkicj zs@sIZ8xH~s_MbV1&xl3Ri$FbY^zXi+dAb*mGe8v+a@Tr4cj0F6%o$LPMx0&;&9fgm zg-1j~Y6x0cv#0A|q_`9Cojv#xGM5o*ncbv-w+if=HS|l0W3za< z<<{T#Fv~=WUGoWomRPA$%Q^tyDqGC^?;`DfJsH*02DnlkU7Zpcb4tajOQtA0b8Efz zI_74R245n^pz|vxKfA09b{Oo?Y7s5Y@gOdc7VAD+SVi#iZ;guvor%6*;Za|-GqE$3t zs=o28X$d_-8Y9R#7OhN?>oe7Vj>i?5{2n*q<;Tze0w!!8C2_!{XyI5-!<#EQ$Zwr4f zAx%&D{LGdew%MpTyGQb&eCfn~7M$LJsuumuSa%;N9Xz7C2H=h@>)yQre}0r+mps;6 z%Z{h(N&dOZLE$jGfd#u$mH+N|O>o@*Q>uI(hz5od@Rs_~>zBS>pAGB`-46Lkwz~?i z?=i@W2yK2RpIW-M{Ff%Br78(!5bQdAw|Xb0zAQd*LSg#X%NV`E3*6rZv=Pi_=fBK-Ghp82>A* zibmALk*f|0c7O!>ua+9neJUKy-Qz6o>Il8e1FqNqY!M@woH6rZchf@2eWnBie>5tg z=xd#g0|~+C!VzD~>^n*dD&L*oddsl%7b`L0Ty{DsTaASH zPr3tOf}5 zM|5B^DJp`9UwYntb2{`H`Pul|n+tY(gNS%=hMP&Mu5s{&GtA)lp+*sbn6_9Oib({& zQtGcoSDKzK)ANb?Jm6fJHP;#*)Y9Sz;$b7%UHW$y>IbshpJAAH+yBhCuhTzZ-S{7{ zD2DhN#X%QIB*3%N`WIta6VcqY^it)t@^S);b|miaD5wBG^wZ9S+#|9fYs(b`jXLuE z(e?>^i8lhUi?C;-@0FAS{BH^~hU_EX*!vQ8zY_m^g6g9U%N$J1lr*O*SiE!Ko~yx9 zU$#*R{#^_$A>6acctA(k!WrT4{-giUKw}AC5^VphV?V1mw6b#3z+70~G5ux^tzxi_ z({QsDa_g=zhdA-3bmHkB2=ANg;}P+9Ih^rPct4dSFboA8lg?YtX3m)@L*T_rCOi>jec*%&*Q;Q^A+ZDGvccWS(R}}wrOX?X)g#W0-ddU7jud{j|OzY*dkS5@012;n4xK&oHOqmSe&fco(HfX>ASpR>q3SK!H34ki1vsmxxU%` z*7PLQJCzCIT{CP^MKJ?%>Yj>L88?A0swB$0~oln)oHuDD4E|RlG++Jk$ zy^a5zPC38V`WJ_oDlCSC$ba?Up=Ifi=M#tQ`3rH!TWJt$l-C_H_n*%IqX}UqhSYlh zPx)5}cu)@-n@crO{&(y`KI2YG_||r-GFJ~pG~mJbqpb+%2pr4_9FTVB7ouM@hf!5+ z*RP_c*>qdbZTk$D=|r2_y~L4Z*G~wY+>pjz{eq?$DQ@rqx;n+e1c8iW%+4n=Fxl

u^#(imgk9YWKU0T(3_6cG%ttdB9ZLV_B^Dbd* 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

r4nELJI`0 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ⅆCkot|?2s_kPJ2o7UQ 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∈ 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 z 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 z zl!dFae)-IW!paFbZe~F!?(e8jg|0ep7#HW+-WKP|!47gbt)K?gY9t!vhIuP|F5$eu z6XM!B$rme=t%3HaevL1T_0k7A+g=LQ!^QU=XXjaB4TW|OZ4DA6U*2DY5%vy|FLu{P zkJL9bXN!1`Y6s10vU}Sz*}cyBmBxn)X(@jpL)zReO5wnF^qSU5NNj0Ix8`5mY4dQ( z@t4ND80o0`>ns~W)^&TAMRC8~XkvDwHfic9QZUmqSypemFyo66pEcK-W9dnV1{v;W zQ}f(0Z7VhS!!7y-9V7GJ=PR3k_X*;soK&gkArJToed%)#c8EOv_5CJRo|dkYc{zoo3bP>u ztjy>Crl~tJhz%(+PNMIZ+3M||o7nZ1q&#&dv02H@j$ryYiRTQMUmxhD>rcN82vb&gU-96I&F-EH3yCg#p-0&6=AH&8H9a-;6}s9^cpBuf@~zW5k`QG` zHcEmP&=-3Fc%mX6_EQ|}MeKa)+OZ%=?zUOatRbhSrn3Z;=_zAKEDYoi>eAkbCm}@C}wq9 zlaHQIDQCFX>&lYsej{}#<5pTO2wy_9*8LBMZ?71I+7 z5%}wKrxxrIiFy(N*9R>eI!P`W*jplAnTK(r3HXBKKUU6cn9VR!*Gf|9s7-1FV%#cJuYU9#pDuVW88R+MJDW z4biixVNsul5SqVJ-c9xrOqWPjhE|}SZhVX6XkuC)D<(WFY$`aEg(CUKg@2fg?7D~5 zUe3^?Mhh4jqa2R)vE;Ov^Uj*&N3h+mvG#UW8!l`7`1e{;mo#cn7uuxJs)KvTqFaVq zs0%Ns_e>M#t*LcI>>$!fB-Xie-eQu8otqijt6l*l_w7?}8sF107pP`z?Lx(WI+z|x zNcdA)1A|W%^*V=;x(0XjT(A3#Bi;=?I&{eA!!&4?7T!D8H_!_C_qtt5{RT&l#MpF$ z+P*e$-k~jXYCPssNq1)j%^e11zEhgtZG?1vDI8>B-czexuOY_c)?x(N=4o3K?KVkYOuT%7Wq zU}DKINkq8jWRRrOq;U+tCSEnCsWG}q!v}hlmmxdaMXbx$3*}872^eJ_7FRF<@z*x5 z<`|JzRpCfSt#G9Nb$#w$2crODnxVa)5wAQkZ^f*ZP`LIQwwu&l2R4{_WgL@eio`N{ z4l^9J7pB{-&{H!s&4k{JNc{Ug19^Ec)F27&-Mp=3{V!@ts9Kx$FY=QL(+Gt%aBlhB z_7;*7Cr()Ny7@+W#NTv!{Y|H|mz;!;Y1CmZ-twP+lSgB2Z#!B$z}oc|YT)FZA2{z9 z5*WG>qNp&;&Gg5HntD*7Q-yrz1VQh>#m~bXhsV0lV~m8P-nL91`s1{j4fD$i04{Js zZD#wL`^Uw>OZtno7{d?7daL2Cu^48hJbk$@-%7ZYk(!V<6`3;Mg_56v8E-XF4^vzF z5m<9sp;m)>$Jy5op6%&)Q_&_E94?jU7$j*^1uuMnZ7su)yH0;=C32n`Ov;C@irq;; zcoO}~7YdiK7-w%?5Inm2sHS+(KOFXKOJ7l~fgYmAxE`b%weK~dOJ;S zj1tXJj<3c3v!*4~9P-neglg&diMdTfn8S}GS_gE!o!dO$HVhHjPMF-v7&|wk+`&9Y zELJwfnpC_mY^+yaH`Xq)?>Ry_67Af5fonlb-#L-qnDTP%VYOA1S{futd>7pL!vG^Q zXYEUAw6fy)cBC4lHhb>Op4mt$JVL`BO!10W?_X>kU(`XJ-J{JVS;%%xe? zWbvG%+JcF1x-y2^Mw<0OYFhMn)A%!VGEzA7WEqV5ySg z2j)U^;TOg`0fodQ$fvbpR)1nLo7n=*BV?MB7W+MvyO^|LUFt|Gj(%Ctn{*AUCJJR%;&QojRH_)VBcc98Uv*=kIhCwm~78y_bwq>UyISqlW|!rV6uf16TqcJ2A7fN1-X94ME|NrX~K_Kb9NA`y}p zWs8(#C~cg@`Yy3Ww3Mf%S>jt3$$!(SHlBZZGx*x8hP1Q7anaT254Zq_5kSc$-p*Tm zn2Im!AXTIh=MdsvLkD!Of6=UKb@o&a8~A3q4h;2>n1N1~Wpb7i10z7p@e;dU!Vm)c z$?IK*b$t#|&YPE(WfRW_a^oHxUO=?1$S}=E8y&h)_$Ui_Q_N2g*a+fM{#3A@MR(YU z4yr8i!M|W%V?GY5!RD0hQ90yEXq@7LhBQn98C3J(>s<>~gNmgQo5us7aPUuw^vdST zB0|(;{?R0+SS3QX5hSECPgv(3PJ7Vi%km+5WeAS6ved2S^J@^;Iwc68Cq|seN!G11 zWI-8t%vZaExbONyO=W#Zm>VE%EpU!XjJDu3N138gO__5#hl=l;=7)MyMEDHQ9qBV) zDO&!O)o4fFF zu0idpL`#i&QIUE7d|gokjFA!Pg7WS}MzDLdVXMKgVHVSeIA&0}1+nM8DkWdHC8!Cw zu&^AXn7$9ONMgnUzUvo5I7#{HO`j(lbz3@p@PU7GIrN9Ii~m&%u>R#Lynd-$Yn(zO zC^MEgN^Wp=x^7DuOLgx*nxa$da?jv3kFN*kX6v|=8>l~}!Gd5~5aH^9+F}v07i$!n zVK+*eVYkX2n^}hL{7~jaqZ~H2vZxTIA#Tb_3(p`=HY(s#=$a$$8ia)s66%fzd8N` zs@}W_zkczrpwDtMCRP)rC>WB|FCaFCP8<>RhJ+IsU? zUm2$mgIMNkx|MU!mZJ`rkeEVdfA|3eW-c{!U3!e=-Pf6W{mOZvdiB)C3&Zz^D1r09 zZe%HDa1zauL#BfisA+jd0F=~+SH%EN0h{h+)C!kGVG{~sm8MvEG*Av)oLV{~8^LFh z0Hj=%_=yBLuawQa?oylSN_cfW>0P{4k|F zo_%n5o$8t(Lw3k{i;6cbv!+{Gnc%|+*>X-F8vx%cu^#whE1xC|&Jo^_flMe0h#q93 zfLFbf>0)fdbo5syeSxroD)UsW4!w_nzhJ9I318tl3Ao=?b*O1Bunr2!L6xJD%{=N- z3oNHSva7Ub?KBGJA)GMC*xs|mc_#Zq0T#k`i?YoT>c~{0PB3I3;P$SbI#e*?67J`O zE)Ujq&**jUG@*@pAw&Iov4bJq{`-Tm*xceNg}-TQiBL8!Q1BH?>A*gSJtlJm8Ar)4 z>P<;Aqzt*_h;yUie2>%r=Jd-821%Yy{H%9^8*DBm=4@Z*eAY*Vdq-H5;0VHLIT)!w zT2F$2|GG1Aaz0fkM1v6>D2cgxy$h3H>Lz!VlXJUOroqA3Mv8D25o)Sq9Lj=tzpBqw@>D@Ry8oWO*<{1zY%j0{y0t~H_V#xrM!AL; zy@@d3eXy%LM&Bjl-pVK~=9`MQ$RXmc$*$lR3gea+vJP5XKQTrf;8%<5-ig1hX! z!NSK3->_U*LiU`ux3#2DFf*LhDSJv+bmCV0t>lPOx$S}4QVH?A!Hs`gE9ut?^@5Bs zIv)weV{#3nyc3$Q3*NYCqc)Fu6N`vN!&vte|GJHWFjsa_1v16J9&4YFIW^N0q585= zCVSHkneORJ4XzCgmE)W$8K1Wd2@Cl#v5%kRtgz3z_sJh2h@0l}Whyyj>Wf!*t6aN! zglNtSPK+g%46%k=Npazt9@pR5{`EeckE{jk`ARZI=BS!@;4~a& zghql6OP9oI;hyI~1Ya(zrGgM*h&+YFD{^F?_^GT)`@R}k+Ensx!JE~Vb0us37U<%w zjscsd*p0r>P52v*O{XN!<|s;D1mU74f}K2c08+qwl@4~iEvgV%B6m@>c=a|e%gzwo zK?0wK=KdP)nZHmvFDMh|bu&(Rk-B+&T?>BSicJskZL60!1%Y23k5tV<@sISFvAvd- z%h%$pYK`WE!aZ1oJJ%v9BU3zV4&yD`^H(peZo#}CAuq$nK*cvXM>^jKpv2OxoGiMAcqEggzo}8WHJXF5AGB6`DZd=-Sr<5)sD?T zKdY`&3EM>|nBW@v$^ssNftZ&D(zEAth-Kkj-I@~Ngd-#%$fjFdvZ*{jKer-$ir3>d z7?N8O(K*k_$kRID8OrdQ10CdSLE>WN3XL{OSp2b1CRiz&uPx3d`Z#$Vps?PgLUUoX zImwO=;zp`kQz2Xg(z32D$`@;! zIEr-5r`>N@$&|qzkST16eMLZHPE*_xt|~lYW49aQ# zv-S=Lblb8UsHA%aI#ec?AID}Sw@ZXl79LZXTgr5AF4<#5FihI~V3@p&Y7?8{?JwVj zEP+!3CRcFIYLhBwz)xYNBE6SVzDXH$kt5sCOOrCp(K)c8gl4IR(kpU6n?Ph~jr$M> zy?Zgef%W6Pfdp#CR3GmM{B^=X2&XY8N_DZPkkexTDw15SjQ&g(vQpQaaoYJ%1bRA| z%61#!Flnjj%g;m=l|%goiM!8g53Y&T2u|w9X92`o^wONVx8nAX%}ZOV_tSU^=ennY#(awJ)sc^|?qmEoacn9Xo8tPz?Wi zU85Z61*y4N_y2+#voFs}OUjtr>GqZVEAHmrh14GW)Ba4avrew%2&iG77v(HQ)yHp? zEg3^fhVt&+tE||3)B%ABrVQC3y)1`}J@v}!>u81ARVc=oH`T(FkzuCWu@!nF_WvVQ zpy|aeYEq`qy=Tw5_d4e)ZS6}kSw9%E<`|U0Y7Zifh{FkvN8qA<;zChf!}Z-OHpMwb zE5(9^K);>l!G4F(B&HmV0);1`G}^lIHQV!;zv7QWgXoD*ls^L$Z`HOIlPKA$+J&^L zC`CN}*yPev_c?Z1;?+pC>+z|w9wfFPwp=;vbXVrLB!wvepdP5of zphg+18@W8ibF1=cEb`rHuH_((leo79TN1;v^PIVLQKIEq97gWu1+geC8`@i$xzgL` zOqkg8^GW$}rA7-k4c@kW5`G)?5q}!^5r16nX}PUs&7-peGmPQllAz&0as=BoiVMa} zWT}vV>KGUo>eJ;_$*N!&*oT$`V$B%?l_G`07R{YZNBcu59?r;o;bonBWq-=n^u;7cG!eB!hN9~`Zu$_{9h0L^RRICfPF3VJg?H82{anWD@zO$ zj@_3ry7O6kyn9d$nS4=gKdiLa$dpyokCT9rrMlWe+c@;JCpx%12>&pO?+BEu)TO0a z?mnw;q}pF}Y9Rf8vnl)D+LvlGJANuFrWZU_c4cPzyHw|ZUJ?~z-_`AH6^)mJ3WWYB zaz3}FsM4p}zGAxL7QZQxhgleH$fvpVVGu9A30Cc}Dp|UYuPORAH0j)4b(19Pk`D)j zxy>MWgGs;`BCn)0hnPI%kV9Ot!J4kP5gq+N*KDlkDQ)axBkrgUGd!r*tD2H8%D%`N z?TCZLTY;BZkEF{VjX@(V!|{s0zHwjef06Cgzh!%;faOG^tO^WsyEXB!OVp;9Dd_y5 zJE5k!&Wy>T(L{v-6<%K$J;JSi^(qSlouTFj?B`0jwj9-YeRx7a8L}uaEWBupFMOYZ z^+ICEyb}FGzu>;RXDZV3bhse@M0idBSEmPTr9W0)g?~jUe**W_Cp;U(fa+c4`zhr# z9`68{T0^+Hx+1!9t_|)P?$L|PH?buKl{us6KE>i-w_UvWhyW*7_D8ye_f?bxnvXG| zggKg4qiy}tY-B9PTr3NlG%&S4kimjs-7&}mhNegT3cG(jWN)0eb`ZZp0y&%pbRJPr z*?rt0))@IV$FGeX|EqjSQKlMzw-J`o#jQhz-?;8d(VDwUSPalG_A}L;^NL-4)W|HF zA>+lqRrF~43|~|4jJ*L3B)<6!Ei{KhOE?s_fX@piG9LK<7F)(Rol-oMSySA#t%bJI zs>`U2+isA_YWMOywP^y_?-_;~K~k`F~xX=P#*|eHpOKVz=rcXK^T#xxnsDMhd2q z(S!$sUWdZM0k^Z7eiUqZm&QIbFm{3% zOwFcNp%A5gVWm^6^_vnTUu#ESE5W3A7@C%u&_N}|<3ix8NRVG`gh}`pN#`CU$e_n6 zP;CusqZ$;428O@aFe&x&32PQv5IC!k403DyiT)N0bW@BO&`nl^usuh98)GruOzY1P z3CpR3N00@65Z%o^)O=67%UPm%@qp)F^Fq4{~Ug`GQTnxW`-zqDEf&@buGLbN(0ZBe*b;-eiY?&2kQ0+}kV|JR04NA%~vy%_dY5lS{&8sqJH4QcW!=QiL zBD6+luRUxM1th%N)x;E5YnBh`l6{EP6CvvZ{JlJ)R>!H6kI51utVue zL6x^&&F(VjIwo ziR=k?iAoH$&99vq;izI)*%1-;@tG^K)wdz@}VWGk5X`JspZ9RJ!9>bdAd z^kL<*jSI$&%3u=kmjI|3;yf47@#VTLrU-0IXARN7lqP9?7ta5N*}SR?r#!$i4Y*z;@)^Ih8q_{S$HGqIzoKvr@N0zH5dK-^Yq~oCYU%DM9}a`528&1mYUq2f z^|r(3gW7?@c|h2S%TXeB{KUJC(r++HFJH)&vBqK{txQrh3Fg8@2_%B%DqqG zvl@Yf6;+!pRG zi_!p>7xsRx`o=OVZ2^}cQxdX(>yo~Ld@Wf!UF0q6j^;)mVtD0}HS7!=oZ4bPzHgFh z{5?VoVtM0iAFTPn@>lLoIUKWjd}z+GVxNzDpz#?@n^*PO5PdN2_%t2XA-i|42|MvC zUX?7d0QF^MyLKGFdj!Rjc-WLkt!3;ogN1pz|n2BW$u!dKzAo{*F8sO?Zm0P}eF@6gomF3s+psv!^0m^$lc` z`J^clXkj19ZIR1LG@S%YSOMn`rQ2r|W;cuo6J*o9S%?0^u>(0^t0SAz91Fa9yNl7D zm_$cDP=O}&PB+d^#|={9Rpf1OO>WdnSDvY!sV6e(C(7db~Bz2bYqCN|BJ|R?WurVq)7^v6Wr; zzgMglFEmKHK`vSGPpr<)CNkO#(w&#V=LkGx92KAB-&+c9G9@UXLq*K@MHLeNQ1fCi zA8YDYu6uXkn}Gz)mutoi_2xRT&`GN#kS?*~Gm^Dr1T=vNC?$BSI0&o*Y<;D|tArAE zcOVi(O0`yFTmJsCWy3B@XIbHps?8JSF#Si|=^t?N?NH7Y`Png5|E|;d^vli0F?G%8 zJw^x0b+O&F8pU|4c65T@LHg1QdbY4+Mm%PV*i$`(HOk0aiJjU|a9u%)mD`BK)Ny5B zIm8DDQ1s$;>VN@&EESF5pG-P}QK`unOvw2r;F4JCxg@t@0ztO0OpdMqu%q9BFd+-% zF~YDa+GWk+QIWfa{&maBl0Z{GSFtnW*a8<=)h6V5#|fsafEhA(^p;RYP)%`4L}sS; z$o68bj_>}%kL5$>#}9^!uv-4Q?`LDily%{y^J}lC?J2~6VX}K>IxlB+j2xb^PtqKWU zDG|~(aif}z=lnW9dO4vHTmB86({+3UVH9z?)v78=I z1)k34tn_8g79o8pD6#`RkPcO?z<+E$Pf?`N!8Bv157;Ecl>Ri|`prf=F|=Sor9;CV z^Gx(Po;vV*AcLLBA`NKq408vI2MipRm-w0qQSwtNHb>UTVzC%B$Mhm&40j}J$2l@M zogw-E!F1bNHtGKo(Q<5Tb?yL9SS$!CRfkjwz2zq7>?SU77RLx7VpRklHb;q~Cmnw0n zFPmSqFye~s4^;YBTTXc*i&myGj2mS^z%t5Tu8=ACz?Y%uLm4ham(}^K-uIone@Et< z%{N>gzjr`6P12x&!6pkgY>GL;9O2wOX#glV00Pz=V*!l875oAu)2kFA;B^m8{#*FX zKZ9SMSRvNPn4Mlke-p~u#pG2)_RHcU8jU$(+>!wV!p41UgvV2`dO2mQn-rZV|e7y<86 z?B8{^YUAfXQ=>QJBZ(90zM<8)7&#F4_8EX!zb_I_2OI%Q4sc5_BNxyMpBRT$2FxM~ z1?T=pYgZoE#I^6^(psxeY_X!X82#v#T9;@o7q66zU6fj$qU9lrLabb*vN$42WXTNg z+Iz*Kq!j_Hz_`?lPnK9vVufUA6{CnmMMxn)l!yUB979MZ$t>rMg3J4SxYhgg{FC`4 zC+Gb3^Zm{F&6$ozHLJYa8QB@1Wv?8zN?Gp6E7)|>Q@@xZ4&@x}KhPAgpY9`-hsVSi z`U+8YSMPzJWF?Ve6uM%W*|)kjM&#NyZ^qb1JQPpj*gkCBsyr$0_MAQMtbA`pd^j2^bN`$jI{lPRWmBux zAC8!xujLAO+R46Md^%*R)U;r9Ych6?hpc=%n8UCSTd*g`b9dsWOwEr~^G=Liu|L22 zqnNDek|p;#zNb4#9OI+*HB)`suAFH))1K8HV005i&gXFT!&dw*M{n=;FHZbK8p6K9 zWTW!>$ND%Nu;MUxg2X-Bg~G{;{NBs`YMkrk61+8k1qW&$wxZ>nXM37F(V#_#-CM8r zlF^~FBKm&y)|6?Jg}+!ff9UVRpZVW^N_%8PX@MTY#->KJ1likmjojMz@ekS*zwRrY zW3-cJeKu8y>}Qzfa0s5aVf2PaV{d5uf;U2U(Dn`rYG!VY{*gJe@T~RLp<*~T5{3^k z-P@avD)x`ec~)qfst!#n6+KA#jN1$=@tizsb4g@g=Ip>r19}aPb6v)pyEi=R<~Y|H zg~!>mw?ea4+zbs^{%E@DFSB^B*Y8cfB{&fBH+gO|Y->vReq8Cx7{J{ko ztuAYK{#=2^b43m=9XjiD<=P0E@ZAeq3hm1n?3N=1c?;C4?E*F%cZw8d5wiz1CnS^V zOF+LyI5>;nr-ORuZCJsHYT(|44hou=_NVx} z?^c`AT_)@{@^};8TJiZO|Aj-*|BpFlVGk@MCZ12d6x0Hu zDhS{Az(K!%99Yzjl;1Ti^3x%1DfC~uP272QN4z8wku|l3*rp;j!{DDPX?u7k5q`C~ z6htJtUHDE13as`pTi+h1$Yi)W!zpW#R_zMyGHUPw6Q#YAPH7wN>zqHB3_s~D+aWB( zX6MRAb{jB#9NH4Y7T7GBcnG9g9la|_U6kY>x>FD?qpg_P0Q5PxkEsThaUO5XkRsZ1 zC0@L>C5D%`gGu)sevIa||0hb-zI*V0bg{tsBdeTCn%&}PG5(verPv(0q;y#qtaUSyp)8Go+IBi6){OBztb_OB=cN$jt0hNR6rk$ zlC(1Xz>yvjT5kfE%L!GbGOr@wjIcO7)`ShTGDMx_$!f8tM-Zl zED=ir4~7=gQ~;ynl%W@jFsngUQ;L@(Xwv#9#zFs%)#q&m+isG$YM(5U5As1ghL&CL z6yG#i`c)vyonN2K6{N|CyIS1}1DvA(kd}m_fTdF3h)=HmW8-{ka?=L`5AXEMnFH~G z$IJQlso%V>-O(ij*@+kDXVy-~5i0rCjjLuG+G_D;Z6OaV-bMr3N-W5WW!$;7+8sKp zi3kTPP~XloGPqeARKJrFnpht{L=r{O(XgrtEH;|40PY2`J1K9IIlhsSafJYd5;T50 z*jYj1v>B}{^6u;t(UhV5elaR6>mmy|Y&+2h*>=e&Zp38uPD8>aP@_bwc&l3A8>_hM z$Zue2%8tRmfbd=m?8j^$^ug-9r))gq{%^mqY}Qm5uS?|_i5GQO7s~!@K8oZ)X>3;K zDabqn84qzArnycxyoY}VHHjSDzM7s^e|TbW?tdZ87bjy+S=jEZKplQuGGm@DyvQYs2l{SMgH|wi z^ouKeND`05YK~&}xca+H@vvp%rfhh}T}&^w@uKO4?1&BYDBj1=w%;;Oq1Fu6AM9>_ zYhc<9dmgV(yk`kmxMn0Tqr1^_T5J+?x6Ofq)|D2rtxP;1gz`#7cf zhh_U*%t@DwhLeSrGlb~kJ_q=gA_AqvaVZ|GH+cE56#aD#r918v$j8rd#|j>Y`DdNt z85uS4?gi(>tvr6bw2=SvrgYEz;Jdc3_kEbw*~MYNbhpJ*&UjSYU5ea@7S}GQ{!PW8 zY^tAKke*uPvAj7a@cV8pZ+S1?^195xjpT16c}se2%kim`0WRv&}$UbSzB%3ea}$k7|@TFUP9AmNNumdoV^#Qdx-p6C6~ zF#43pjoWSs-pii8Mb*llTSeImC9JAVSJDk6lBt@2jZ zJMFoz!Kg3U^vjoUx=mAX3TRdaP$TcMGLoY;SAzucK-+!9qaw=>lnz1w-bC!xMd=sk_1}mD2lM)e zC3Ak4i=2OLYl}gAQzhzW1j(uMlIi&=^kTd*6V^o0VfSp@BAta_Hh)d;V0(Ol$1de`OGf1*-rkGgkK*DkUcf6A=NW9$+Hw>gy~4G21qvh#-eo$|3tsu z9b*1E=k_@jw8bIccD_^N#@Gw1B+e@pvJ+CQLCLz7f-E5(LE>Y=S*NVr9ap)hN!!qZ zM0yIyJsonbfppFEbr(JDFCNk{TS12NYkNN8Yf{aS_5p9ylSh{l>uFcN1`@EClkK2K|kBg<3w?zpcvs0PDn|Zie57PDF6-gas5aC;zFdhiAR6MIL&JKV+*ygnGb(AhP?&GC{P1s^>;oVd z3o5tlT0LRuuX~&TTHlZ~YWWA9!?iIhT}O@G9h5nD1n=7?=KIEzfHeLXd+NmIflQ2- zW=VRCv8O%3CQILV;)3I!SkUw*q2SXezB=UaPb}@_1~fO0RmP*Q1)Fao?-ynx7(bN=>@@mL4D~uMm zQX8EFoJ9$Y&-M{wQ|D>o*IA+KKvCN+6J7zFoziyjyS1D`tC%SNVdW}yiyiYp{H0i% z7X64d=tLo{XbK5>tr6VgxFmtKgCZ?_Msf)Sy|#5e=(YQxO#`^26-XAg+TDHly$G9! zBHk3exP?7#XmA)+t@bbms2vop&`6s}{5)d7WSlUpoidZI4TwYxsHN#oLRz5X76S`_ zIvldKi|X1zeF0dD38DbdO7TVt|HWA8@fZldoDErn=9#+3aH2>jj=s6R#A3YADa!97 zRyiWONMCIfP5LI#WD|)i!E&RQoVEoy&a#AkN^je*tWnL6?6a>+0he?=4sv@8$B_|q z5o#&XXz?XTSv?_}Dk>zdU0ia9PY4?m1@|c*2SoSgY>EpN-?ZmiL~7@ zOA;we$>I|FceKDAEmun^%)db`i;So43(>l8+|SPByW>R`sEwUHh{ir2$}2mHMo$YW zX&LBpd*#QEvxo6R`BS=c_StzQ)tSxg?&KHU6S0l&iHyua6OLx!CDl z-Jp`%62KfIEJ-2RjXQusk!p!Zv@AgAVM~nD4Oi&OZ)O zsX;+UsBx=h-=sw_K1yHR&~GfImXk z=Ay}9GI|NrnhTK*-_(4HDwJX6df3WtvN70H$mU{`HRZNi=P8gD5tD}=fEj2J(x8^I z_;q%UOeA3wSugZjRr@Q_ROpwR{Y71o=|sJuRIOCzESZpoV;2NUX89wc*GV#UMSDb= zDZr_=f)CL|uOlV%6nI}BJ10vECsN1~N?qw%ps*^$k{FxJLyB)?w(#mQ&oP!C*gU?>d@Sd=94Svw? zB<=_FBv3+heQ1my<)2#Yjz-3V3g>%AGzmL%bSL5@AQ)Yx#Bb^jjks0RxHjYf3JTs*PXjlcH{^vG*? zyxspI@ICz5j#Vvk=kc1%k8dpujY6Vl*Z74D4mbQce*B`?#XRbAcJljWt1almfmbYB L^?B*18~*mcSn!$5 diff --git a/textures/ui/common/lobby/backgrounds/seraphim-hauthuun.png b/textures/ui/common/lobby/backgrounds/seraphim-hauthuun.png deleted file mode 100644 index f630b447f10ba562ec31c86236c4f1d54abe93f9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 181319 zcmY&=1z3~a`~QQ5FDMvhef=UQTNau!uC`yf-AQL5|m6Q>~ zL`tM32a*HE$c^RmpE|z3>whl29OInx+~>aU^SSf!$(75O_VXO&0RUh>%Mbk*UKa(Y=Ajf*x(f7P*&+kY<9@2B0N zf3_BQqYmX=d5k)~cP9u{vArPu@E#u~YIkrg$U@3)h)tS-g(Wg*)36YOy~58J+-oGi z{imV&y`-K5Ggc0Z8?e6~ZcGmxq9!f!|Hz(DCgIbsgS{+=ph!J%xACk57n@qnDA~#7 zwEYsi?}D*w#jo-|w`1}D#C-SyZ2WA1eQ|AgO{U{i^i z+$jAfn#;{0*yP^9W&oIA9@|TAJ*$yFSy{Y&65XZ47lD@C5&9BDIU!Rw$dz*A%qq2f zq>d5HqU{?+%+B}|W)^$aNy7=a4YC_!3YVozoqV4!ArmS+tS~E5fa}HGoE5g~xS2MX zKtul9Qmf)A@THHL&`M8#x5B92V2H>;an*CA!V$%V1W6y<28%x2eyZgZb0C6|!yGBZ zffI8vJ2Cv`NJSl%5@}6~LhqtNk!boRZi9Afk{QevZA9+VgE@{(UehpTw+TfK=^b3Z z6=%g8hBA?D&n4k`>LgTJosG^*Z%Zj(4`6?6C|^H-I+bp$IXbprhC^6e2EE`hH(cY* zrv1y=DBm)1SFf^&hLP;ep}F!LN2rtz_$~ptyfMIHa;@PRqL)|~GiMaWm`Q?OuR$^{ za?lZE%a#P9;2!l#E00YD2Kg74AqwH)0A|pUYqnq4fy@-2`8VC3C51!UZLKRhbrMO# zB&ZB{&5!D`O9=o%YDXW*8`q^SFE6`@(SjU2DwFuQ2X=CFHmXqRx2iSRQTAlY%$3xS zT+HkV40MpwUM#P(>@^fR)Xuf@FYV2PH~(yxe#=DNk5W5Ie!-LOU8Y7OhS%!8eU{YB zIpIn_4^)d=)junNV84(Z>5EW%h;)r-eQ;AElGQXoJ{?wFXUq%7EG-s#(dR}l0{kH9 z^d@u;pF{9hLq2odX&M&U(uJ(~t-&=N^=h9h2niz(5KRBVEPXHUP2!09*f`Sx$q;Kp zp8;WDJf@s|HI3As!iTxtjFI&WQD$Ub)-LS==nj*Pr)Uw_ndj<{amyiFshZ% zl4!|8+bb@@6L$fC)8jim%=0Mw+0q8Dl+2Q1FSu@%2OqpQX!2JKI`GJjeVqMYJMEu% zQoS>Iu*<$F!h8SYBWf|>@$Ty_KNy*pE`#rSyS^(VDKD=^IsvB?OTt#o96yD&QdW5D?t9|x*W5N(5h@jf=V7VfQo{rSmgKhc!YfJ!+@RT>?y@5N`ZyyNSq_Ngi& z#1p98BRO)5EO?fhPAo)9gP1JV6dwAWN!_`rNF03Lj(t=8FtVmKw1@X``yUzl^mH<7n7k|V#uPHgRtt8VSr)X(l zXV8p#Ll)woz&2 z++JWO5S8$>1xn)2v8toHsma*8p0FO+EW2_w@ZL>x@qAx4mw%6_yFbBo^Xaxer&zZW-V@vfCr_#)Zocxh%ebB9Mni#XS@~d zd*<&}z{Ku9>Omsuer|VoOs}6MfYHW&1`pK&3p0ISgIY3cw|-pf0QPgHV;%wQ8++s{ zw(x}{fm}lz01$_cfA<)9kXzDO(?cOK)nLktx={XIT4REoB_Z6|Z*~At!EGp=%O6As zOvgk|jNt=cha-QUJQ~gC$Lkeby2${`|roz z;ICMpexIAD$A9l;u*@sVng{T!^R-Y*+9Ehw9Ou33?ImGD#C+OA*qXI#Og^|{nC%#( zJ)67ACZajyNC+Du_B<7Bld3*3Mzou8>ss&7tFWp_EV(Lc%Ov0|l#~6^B$5q;|9BO@ z>5dTnji*Z2HpD~4bL;D8ZG@+WvVI7mT!=w=Wj?XJV2J2X&u$+|CI9c)>@gv^i*0wC zTQ<2yM$Pb|Hp8R@kaDD?x>xK{1`0-;ysmCtT?Aj4eY#u_w{49huAK?t51NH}9U0*t z63aM~cIV@((~XGic}DNk7SyPi+2ZnbS$N?$?iu;eFl{&f^l1lfiMpfhG#_xr7Mv;v z;k~-cJt{=?+x%R>wa^%bCmpvzViU8HH#X)@$NZ7(12p=VhNwm^6QS7Zxc6C|cDOok zRRvF1QQH+u%(Fv$uiXF8&t7!(&k1~mP|+W;gqJ=f5mg&ykbnP}&$?Iis-(W@E1!9%LWu<1W-(OZ)e5V? znc;z+yOmBFYy$p_2AdnYYv%nqi%SkPG7uG7W0nPWnUz8t>qkmR_#ls86x{(&{y8u5 zM~b^$U9Q)Q4M)%~447_bf%G7JGmB zLQwO-Zzry~fG7E*LyTILk3J7_$?tQ!*-=bW!a(Uk#z?Taa^Xrc$UlqRwvvMfexp1F z|3RsD@_?o&85*Q{{hwcAgOt;wo9&x%LDr}`=h#!{od$MnuSh@md&TS9c7^U0+6?E1 z<(P=;gqIhrERB#fqOH)FPo^HZQZ;(pnMCFO^`%Z5-Qam0+X`5gmj-CwomZ9#;(4npXmAx8~Anp1|S?u`*UBSHgEm5)LRU` zN9dE-tIaVrR}mDk3IT3O&+3uqMX&65HEh%=l~4b79UC70mHtq!6};Dw&YYZdGpiS4 zC7TJ)SKoFS_pr9c*2&B$)YZ48NWi|D{NM;`W#@0*bUJIA#ur0wS-#Ye$9pJNcW*?b zT~-j_noyCy4zeK@Vj#jlIYg(=!qPvDzX$Tr!ME<0UbTG0 zT1)pd;1kSFbyk){69_G(bZwCO7X>cQbNzr5A$^F{&vEKLV3a+Ex=An~-w#kq{_nUKDrq{_A^5 zqrM^{q4b2v z(u)U6nXG$jP!ds*$y)CjNhs`WWISc)Oj4<6Vr4hZA((!i&hJ!8=_(h2vVw+=aIw9E zuYx@YeA~zR37;wmK1mg&TBKgcFP+@LIfn0F1h8<0Hhl;V&a6-R;OgP{f{R8N#_{Nc zqZK7ovDhMuG_zV02Wj1=g=l|UP*qUth>3P z42`d|wd(fQecQKN!D7;F_M0vxgdDLebpG(qQ0RxvAtWxa0B40^*=|vH52jB!`m*Yl zi0hS2dVDe#^SmutV!yNPjVJv&8VYfAkCy`#Kdfy!{4atQ@waXzdGtVe7*~iv>K{#u4&_+{K z57n-FW9+&ozpMau+-rk*aqP{nf|zZ0;OT|mzWpXmMfrK{M4fJZ#MkGNhV*Qtw7-gHj}j$g5{U1A^u9 zk{$QIX71)}V)I9|x)|n>(azF7wK3_5566zwvW~>%Ud*njsn0E%M=brZQ-2 zE#GgPR8HpOE_A<@G8Lli;H6$9YdykZSkrqscofzD>!37Su9h|4va?xvqcxZj=G(+e zX){)qF2i?{J1`9OzZ3xWDZfUKhCDgD0{}?V-MbMG77>)#};EnsvO-_YuxLeQ*DV+^GQN%lA_bI(!Sl z$NQH&wko^m(!H>NwYVyV9FIi7=B|fv=0~X>|IP5Pw`?j(^8cxWdC3`wojN2^_f$jJ zpr=1jeS<4#vi;MR!Jj^XwbSQmAd)ND^uXTG` z+1n56C$hs)9(TIP!VSzTxgfS2N(~=f$Ar`K&;{UJE@tSig*VPJ?YUI3;Z%eizf7?u z+Fo6YKq^gf%m|{{Q|9c!Z{H)p!rB6sOL&c2cnzi~@=L=i;{kCfn-*S(cjU!`!x;Pz zgnbY-2O)`k%M2mN^&p$ce^?;?qzrO1p;zIOVaz+B9lAl@ddfc4g~>I6!b{a#(=7I_ z`!hNOe%n?h>{Qgis1YSNPT*G1d2C?JBG$rE$Ot7c5c&D+HeL_>#mz%4$9?INm)ezm zEPYHitx(Y>?3hDxFDHO-%8OmaMC}==WbXDcqfXM2ado=>o2rC48&se<)J@%nXKXwe zm*g+)Rw!+fkGSg8&LrEQFdbuem|dELNd~5=J&k_6@{$$#L0f5nB;kiq=VXXS{+)6p zGT{b6Ws}^8V{?@K2Vnl@$%0Q4bQ3~oA=^V1tk<}yi+;6ke~cHZ>Du&__kkZKto+nN z!@NE*l5rTf0n<(U_ZB{RMSbX798GQ3{9~FOrTo5`XrASkv2V?T;H}y*ekD^Wz0v3z ztz_s%*pXwLTj$jH=L#qWigMjao6g4mS2}~B%`pmFH22*5%`(H*`tJ#cw}a!i@aVcE zo}CwF|5Snj&x5EncAGktJiba61A~q@r6mD#hq5NJUyIw&0&9>2FEh@ zynL^EJYwC z-nd(B*h{uQ)gr58cSl$=XT^Z??&Ray;T~GcM>ox-6t5Mk1UH9oWR1Xeep1m;Wp~qRPyL+)s4>4=vWs#dRUcpOSxpZg z!^6BpaiubL0Uw;`vA^DJtTk?|g%X%5Yh5mLP+HdpMguvlPBvre(MlB~LlM41Ou1#n zL5HUIO{3slgIUBhW`bgpxHA7L9c z|M?@oa#GZL0*iJ1+}hh?HmtiAD_w|w`++X?#<70c%X8~QKQ(Ek%ggLtW@F*+aZTgz*#>JI zCe-IjMrX^RTxNx8WP3vq?x zM#@iAw^NQK=h?Q?p~Fq=D{Tprx@YbZt;4D&qblNsB7XfcxW4FH))?_6I=%eb7Hg2j zygr1hgUhDgD#!De7CDq=@$(0cXO3Tfs)BQ&_M0pFw^r&=XuTNqVaReoEs~Mvze(EK zq`aRAXRsXX1E@!yH|ehrt}JSiXg*t-qjx8%;R`GhcKsBgg}Lrh>vP7Bg$V~e$0+!9 zC>RRGXEm|n(SwlnGTa<1BH}hx6iKhH#ichfhw1#I;OTnsKyo&|j|k=51iv&r4}OPW ziD@@2g9kQ*%Y3cQ{pj>JKcn*Z`0J53WA^ zz0hf?{)R#xA}=uCLqB^bZnh}MnKvfIP8A;5wXF$PzSOB!eP==4_eT8en|Zpn-$^!G zZ1Lq@*eq&o@E8sfkkv^?x)Ig#Kb=S+ZG1W;a$~UwS_sa)XfpTt8-_-?{PC+ZOuXy6 z(kn`Ooho5$FUqJlKPC45B1h&ta-Vzx~eFJrp(<^8)Lhtn~YRJk-Z<>{F2gf1$-Ca9M$nsXB7 zby!&iZ6nO6PFH@lDgJ;CR^9t|RFB6RZm8^{F5_kiqs^on(V5pNwM@$1){lbBZ_{_= z-XqKhLH)Q<=#l4OOny1PHkeln@gzC!^9JX#b>%>WIUZWZ+P|DsP8ub3xngi`2(V#; zwf>7_*0PN3Yy`E)tHR@~(~V5ZkCHe9sI<1)(gbGGs3S{s@Gu@~Hij_EZsNt^>}Z>i zdWNY()4#v{RjI(+wsjKFV^n&C?0gKaWp1+!W2)-qZ6surR$m>}?uHW!$mHlr2gFR7 z9nlRgURrq$^Lh=-k6gYy(V`mDX;NQoTlk~Z;s@Gu+X5W?Eg(SKJPl?kX-U{9*maL% zNje;FsI8s!!o0hPPb7AEMJ}%#rEJ|o;)Y1b**W#I$e(r0WN5w)?n!UUcljMoLqdP} zTS*W(8p25GLT+qik{Nl4l=2+aIDs2R2Jj137w9v`UiB{hN)q(g7@^29N5)8+51q#& z7v*Ji@J^@=NHBeM5&R&y@n%jvlm@=eb2;%(@GkUbu@ZQVO&I{H4>>7 z#uBRoBbT>A7wD9v^}!yJ*(DOPvjMDMBZsaM@n32&&$nnM;h=g|WF|K9MFaBoB7*gW zS$>x(Y|r{m#Qe_6J85vGtwWc_E_V3=B#nuKb1QKy4%;Kq#1xL>T8$NF_kqO7q3 zC*d76^A@ClLZ7YyKy$jLgIq-dfS~>8MakaDV*k2e0w0j-$e7Rz^ot z#3hWeI9}$6qusHi$AovL*(fQpbArD|l+B+6bjD~7F>Pnf^z$&_ZA`AHpGxb|{>KqSbWI5tb?QQnuLLKw5V2t8@=_cuU?FqaeCvE$7NEi=A>vbQO_7)ThlVp3f-*;Tqf zW47S{CyRUf2OoB#W<}Vl0D#=X6AuY*&J)WR$&Z(?}s$uExhtZ zfzLy&hv+2?@WEP5>!8MJaD9BRx%pM&p#wKIjsOv1I*SUasm0?N&qs?ww|tOf zy2VG%#QYDxi$_txO8$a*DLjSq;qdX6a5R>qDb0O6BH^r${$_f&X4g;gvCASjaJb87 z8ISzMATsw>jv5&dJbt|}&;9y&2Kgq>5uZ1hz%KOlH0>J&lc56V0>U$AaI}&=T}>PL z_LDuGn-}R!5{-GhE^rE=lY3_ji<@iuSjS55Y-GJeRxQ)~iR^6Nlw$~pruj@VA#MDW zODW~6%ecAgNJkn|g^8q{+USx2Jv;F!&}B0!;sS*OsCgS`KGo*O zcF9dtpSp97nDOt9=H_S7jjTg4r`&^6EmB|~s$K&tfydsYR1XOiE#yYna;b1{>+=(~ zx0m+u*XnEUqO1M{GbFZqY>aer8gS~~%*2N7OFQFlZgru!!lcL}ePnfMy}oJ9bK9ZZ zdUJEF;F&S9?c4^Vg~jrO;_8qfzc=1%^T_Uhc+t6!6;?#fO~YvA$RTNJBVVUn%RkBB zG_KN|rGSl)9~&))Y*Iuhz5Sj>V^ESerAW>kWvqrPBsw@`3~EbM%gUMC7*psWtm^Oi zc(X-UyYtNR;O()^_Pp8zPbh1cZrz9MGfROCv*j~j^ZzkygI@+20bPb<-`J(3WVgEI z2H9sQC8*G!8W8xxSqO{A%T_WJqa?}q@`UfdTqiw$pWA2Mk)7*BB~uE7~p?Tx9QuYLZ05Rmnn{|g=lxA;*8ZzzT3c-hfS+#paV+a?+U>60idLG ztHAR-7JINc@C31pr}gNuWj+tPMzb*6N$`;KD$cDLGoJK#{)3$$>TsF$cqMs^OTJ)d5AMNQ z#3R}=o_8TTL&8Op*U}PhvuSR(9?z*)L_HCh6T9-YPIc)gqK|kthZZpjKl7OY0DpS{ zU`3vUk!lMcl!;A@zM<6ux$LS4N?wjKk`eei8rgBXT1T*=v;yfyL_Gz&A2&P;z6Kky4TC3q67{q8^>l<;h|$+Gj0krfBeZ? z37O7|19X!{Ez_ghK38um!wYsEcJ_7VAywmn-2pyj9>fK`RuO{{!%1cJ>KnAFWerz8 zK&Wl47b-ln75iGJuQM&T0@VhOec-&N`|`BG6+4dA*gDFN;$0=7CWH3pBo;sS&g&u-C`PE{#ZJw?sPSXiENlu`cR$Wdj-A|T~Q>|*=EEr%|%%z*LM(# z1k>S#5TVsb%Gn?K>y%%ob-?&YmiS;or5R`pvK#|g{DgUBRd$db9$Zj7W&;1X9nI*5J`*xl+G5Y3?JFdNtShImsrc;AON-@hr6>JB_#mL@u zo3rt3kl;?6L8;lX@7!pGbLYAvTx~GtjOu>a9;du9R9@4lA9h;b+tRwM;%w-vPaMsd z@D!YzeOq&n|7JQaQjEG4I&GYF>ibPJoNTV3vN%B5UnrgDqn_9H>*cr7lX@sQyR?p! z8Re1rWikeCI17sn>R~h@(Ce79V22h{kx4hp2JZ|Tu1@{DPM_3r<4?7G?bj>IN^G$E z=)jV&9Jmh7@%xt+|~SvDDcj5A*q#j}u{SQlSCJNA~o3E3f1)xvv1F zb-n^s+1Qd-iNk4q99&un%t$)GE~;hcc7ClI8;HG~m*sNG|D~(BI{aD>ajcXtW-6bJ zop0ADF{+aK3em-0 zdahxbQ__Ib^%uq8{9FX13#I9@P($Hu;jj^WCE5?MZ`Y~hA~?3mk62SSI;z9S9$H=3 zSSywG7{j|61c1Wl&vG@d*#tn+Wlpx+YmTZzS>vB>Pn7XxUF|GOAEbX%_6e*Gw47Nd zeGKGOAC$z{rrBDs##z>w^q>~*>|QU&MyAnW1*Or1nW3DgZhiG37JXKG9 zFb`E7`wcquh94;KX5v5T8h6n4Ii+S_e$CMM6p0{f+4dcsKNq~nC7jplz`uy)P}Q8l zv;Gc~oBj0K(6*D>}V5@ZMBhI;o-;$(r#bY}zG)Rxgmc^^uj<2w9ky`fdcy3j4xFdeF5g zv5bO0D`%0CQ|#GG0{0mhwxTNB+VHvT$F5awuuc)>h)_Cil*P+#&v{C^ME{R&3??vO z%)9QJaunedvo(Cgi254Ilby7FMwBqj<{``)$-?EHeyV&GLL*F%%6?4of8#Q7B#QUo z2`Vf~{pL_Ffir5u(?+m-%Gd0{aVms_0DEm~8+TY#naQuCzjp=uG z9{}PIpmy3r%mN5`eWyh=Dj{{eb>jzb8JV3twh2->_s~{h+x_ZhCk~sUqEqy+Lz0$K zGBGHOA|mNEWVa~Ku?Hss-*c&RpXTw=gGhrLYkJCI*Ew>#XqC9D`Ef;`8ho;+9B=F} zs9+=h)H`x|L;Kb05+;*)v9>mjwHhvG1{YbFz38%`7$vwGv%5D>X}@Dr;GTGuYxRMt z_15{s@6}LeB8jd@&&O(iJI>jBJo>J5bB3O5azJ>54+wByE>&ZN5uJig=2+fHuhe zRxdw!g5)v6wODUwV|k-U?tk@0C3dEy`FM{1>&iCx@G_5sS8+^rlooYqd}OrfiPbq{ zw4^wfQ{6EI{?xqUBKzf*qz_#q$+Zkl(igaX2{>}Z;G3WyY@~G2Bhw%u4Xk|pd|X66lu3!0 zfPXx9b8-ebM)hB&2Op-oHM7>IH%To`35@~FgvA;1-7bA`m;%%); z_qJZDq@W04nV(QY{~D=vpjZxH>4WoHo{3>m8|aWKu-zL})B&%PnZ!H_G=>a`sliM^ zF!aGt3R0n-`2u8$Fw@V}?JvaB+F^t+}R(l^4kH&+0o98t41SQ;iCNXAT zzhDGE%TwKTA2(O+Wz`!n^OIRR^@z)9NPB$kes{-ra6D+k9OAYj&Zg+;Ok&R@9kef3&Pf#2W68yHZHo;CLIRiSdC3(DX*!%Y?C*fAGw$HJn19w)?&c=*EzRTm#{nAb(x7| z!OK_v38^i3jgYhnc!y6cvZawb**5L=h_E#}f;Ic4D?g|S=OYO#gsbo0IAf)hoWZrp z{U252DH^)V6zcj*H42&WNmC#xmB&2(A|3&wB_ONd;lq4K1|i462Mpd4HPC^!dY-!T z8x)w~wg=g@NM|mmj9w>S*<4evBIER%ijemnq{$zp%=Y7FfNPfRIYmMJB7x@T!6+k6 zAk*B!j)8RHzVThNIu{aqH+B@kKAJ_Zhj0DzA44pW z)Y)}k4Y0F`d8Ce|Rj<1|y53R@6Gm0{G`nx$;oxQQ;1ty9(9N!EI73>yOJI5?>6UvVmId!Dq=ybd*eNvANKj*g<`72B)CL_x=ql9p|y~TVZeT@}Q5<9A!(( zRK!Rj)KA9PQqrp&r6ywiCUSHs&c8Ao45vqnL=E&m2=uU`q<)Ch7#yL zGvcVBo?g18qiSv?U2M|Rsm`vMAB>%svkEs}z7|5iO#S$hd~0@Qs)s&kj6;g*M9%-G zh-JNBA^R-QTP8P=iJ&_&Uk+mdra%`IosyXRB@7mFiJ^WMjAds#v@9%9Xc^kKr66mM z%G1-W0;}irgV40Yz<^@tf*kX~LQg&4zx~j^CyO%s4>%GM_a!-@UPeGUdLhoUvNR=& z-C8We@XU$&v!+mV5 zxG1L`qyPHTy(;dgeyt|PEgKiKMZULdstJSRi#7vnSHnL#7xQRHQc!^%Blqyi%w2Uo z^6lLE3IwoHY!`=qKbfTOB9l6EO1_+PT_#=$>IXYrJlQmB|4>H+kNi%UWA=TI4qgOV zG~>dt{444@1&fshgtIC{3jAtNv|R1oZYuXnmYp{hNfL3T=3JSO%rW&|)?U7!TGAnCF>X3o+$wxD-efEru)tsnczq z5sLS<2{=FQ40kZBHG}3puHG5I((30-{niZRieZsr}M|Wn+Rcju+{4WJmMM zy$u_^x?~U6v6j3sOBtKj-oxKp@Klq7Iio?n$ePpE+|9fDzYatySYEI~5olrSE0xnh zs|1d5n_#jN>xx1<)1#+X?`-%?7rd_7Q-O$ehw&s2`_9N$-`sBq2ruo}nJosNe`)%{ zLMR>0Ny!7cm+pECObwlD`Ujqb*tw6%dd+TA-}Lvka~LdhL=3$#$(N#h!0Yb4;~Hzd z&s_X##FaHf`K^@r9F9!atzwuCjCeM4k&%W?~-IvJ0^G6k{%r@|E$H{>Uw7H%U z#~w(Z&-!V{K1d4E-kZtJ%%5U%FI+MiM3^H>8MNeG&Aj~G^k#iDy93Qa7Kb`L+WqN^WB;Eec}IF^wu;Ji@1_U}3#c5S{KIMiWc4JIV5Qj-53=w&as%5S1wXn!8^82f=(Blxy0$s3h zBq6B_yhA?|$O50OiI5Wyi~B*Dja5Sywsh&ibW~RdJ2j)iTpQbjAloYvQ6n(FVc2kI zZKMufM>Gu-#)Fev?d3S3QSitxtAk17CxRy9;I%NiS`f`2G+`oJXM$(grRMcYG`kFi z0F4{Y(>v&3buH#Wuh0B?Qqm&$q#ZxKx_hD45`w(VdLGFPoTR3okZfMpOUf-uYa5mr zF&;2%J0<@1tDKrwI6Mf=b+mc+tL5a}l2?1#nd(Y`?pXKjq+Np`my>-BBCPYZT5zwT zkF0yGjM$}lu|Y;$mx8j{B>=OBU8@!TDZg2+Yvlq*V*y`;m>_PY+l3?U-+p!?R#oVA z_>b!;JwbAj{kMznz_sOKKm4Z%2#Z4teNIc@HjI8$oIIN9GAL!R&#`+uEk7f@WU^P| z#n_i`W)cO6U*}e~5DJmAx{uwK&9a~bMv3G1!ODQ@Kz7oz?&!>{25%E(P!?JjGRF^w z9eDlZ3fkT#df8h94O2)KP!2cVtNDEzYMKc?x{fKV9js`dn;t|itz&4kga}qy^c<*O zb~UNAxE+;gekK)8Njx#PdqgOEC=9djL12ZbK3 zi|Ej8`j1&NKgHka__Q2nm5pSV0JDBB=e|;kwyi#waNVRM_wkhrZ*;zcSyMSn@c+0< z@CnBR6R1%9u}07*|CN$uo{S{chr{LZ_*0LgHV(M|bVxOdmKkO|dbJ+Z7V;dKvA`fd>iGmgm1Wm++WbGA2SB<0^NzI5>Fm{V?bh3jkf2;3fnh8J@uTT6d z$ZjHB*4{F3SZvc%s#2QM&lZJ|eze@&VQ3(4_5FkyJ0|qOrNKp=$K#u-Hf7Y!lTc*3 z&J2I^Zo>F^6_Ts{H&f}Djm2Q=3$=W-JQbDHRtxKBGJW>OnDhS}+UuY8I{gQ4Oe$)V zbfM}`e2_i~tQd6E2X=QpOSkJQvA`ZG@pNU6r9G+;c==K5$6W``{$W&GAx=w|8GjL-u=xi6a!UldOA_PdR5 zU>wG~H6!rGi48rBsv*ZcQ+mk72>IgQREdQs%Z(sGzXKLMxlv_zJy*! z=tW@al|^c+eeAq4*jepq&VEyXKk3od@Xe$dvv_o!383&SX8Pc%YNvcP?VxV?cJbme z`|Mm3@olrdZ%6Yr^>AA5=vbb-V7DpL-qKQix4+VuG!Ri5@-CZ2=u-IJd-lzut!q!t ziS5hYz|J%(Jc>@qwCXl;IYIPDPZ313{XP?MF)-rVRN6#&N_{{GN@&csd0g=php7P6 z{AbbNkDZ!`tg&;(mMEU|vY@<^YsoeP5D*c8)%8C9F4;Sol@Q#KNVVjS>FmvRie50%OTeb$!gtPLR+PUcPVCU$bC4%} zGLWV0ej#<0pO&IGG3Mnwtf}7_8_YgenZx%XB)8=Lq5j}hTEGfzv^0I7h{TIuDT`* zH-BS>)~&VPF6;QF00@5-y9hq-P;5NZVK)4ajNI^*?{PfIZ5!{aIJ5%V))}SDe{TA+ zo?gb>n{$Kn1$OoD5@v0SimhCDVB_E%`q(uw5(Y&@etCOep!lx1h3Kl`eP0W#;l!|zpT~=ZeD3xb(B_t`!(HX|UhyJUX zAKt@TxRGm0y*09Z-b=fp-g%_algy(ntl*Y)S@!&ZN}`$W*kg}Mg{?*T9@ee!0kb3` zOjd5kG)E?g_$x;gPWp$V+Wf>8Svvqk3d&iCe-W5+tBC#h=sURG*!P781$eG8;`TiC z7Vt&qW-jWr4ll=?fCcG(TQNBQm`)!pcWXb)q#VDvOTG@kFm$kMqcw4SZALx2u3##SCBCl5DaO6EdIo$gGDu@* zfi0e~c1PI+8=|ILr(3>w;hgr9zr1U4uPma?a2)C(I zvy;giO<=#<(qU?o(cqY?^QOqreF+P!xMn&_&0N9NH0sbf9C&ZR=2~_KL*B`$J5IXT z!}@B9hvV~QzlDRYlRW-kK2_4D<`sZzb@Nb^GaJLB>pK3qp6y~|X(>I_HS!;46A&66 zJS+mMD4{aX_$PdmD6a21=U=|3r+>$ds{f*zK+1vRjR!8?2jXVGXI0nQzIT3zz8)fh zwvTNsa#l{?0;84|WKpe#{QUg9>W;I;%edEpb_u!1wwR8r(5`f(F5kFWBWbPwYAU{l z&)c}wIrb_~9)bqLUc1n)@~rf1MC0za6`e;AC;rJQA9}S~|FldJ8Dn|-h=iO7#=Hvz zlh;OqH!F@64WuI@Esh0c-M zTn+Eu+mZ2fp*QU7TAXn9{{7|^qSZ@zMrSxzBIKdBe%AX=)eCvbM2L*B!*z?(LcD^( z=u!%IP?9|mC4LKj+5j!?vN~Mv7;h_Y?~1ys#R+QO-c!)e%CRWz;fDwnr8Y`%P0VKu zA8Hk8mw=kEDj+n^jmT8A#Xxt~)$QU?uF=7XtbJ3`=~ecIv&A*{!!COcjreO_6bmxF ztc-*dX3kp)}R) z=Z3$OmmEIJe}>m3zvJoX2=i0E$Tz0DzYH__*R2}Wzq;pJmcjL`@&;io1$%ixSZY1@ z3NUeP=K;@3L}xDCGn1=h-@qV#y<}_-+qB>Bf#$&k|bmYvE@4s{MnA|g5qXq;~-UQg@m7@VurCJcJ84v zsiMQ#b5<$letwI`GtgZ@n<*)U9Y4P4(IQIgiOC~_j)rTq;Sy1l$jD>V&35MI=P8oP zQw*L-)7YSPeM;*ZU-ZeQbV0OPngI;N!YaP09Qq5aj5oW?qz2+wiA&}9x|W2N>mWIj zacP`|31eo`nZe6lU<)fEWMdG2H{DFocVUUuH4`l%lJdIMJow4Oa;7zLGvh#yxh(pApOtfAkiRLF599%^Y=XUx1hCO?99Q0K9l^b0fza02+ z<5xwIw5o@5mm{FzD%F2kmjBVKq_+$qw2Vsc&7tZKS4PQy7Xgp|ZPpN%DwLCQx5!Ts zF}|?_h&Q`iC{!qkfUyu-Z5)7aHt%4ImA5+5za@o5z!Y`(g`s;Ap!7_1ucy(qoz3>c zq!e%Oyy2nORxg}aJV_8&sdbCOdA(R=A=n7v<@|ExTEvW7s+NrM^8d1Dc-hA-=SxL$-Pw}iHclrNF#B|u(&hRcZVRuHb?pO;N+oU$ zC<0j~BFk`V`)o?PZtpW3D*EQprD>TulVy0J0H*;#DcG+5pIhznhwwE$6E!x{G1m)y z@h$2U3Vv#}F84?tR9?#xcFWN9giYpdhGtCX+4sRm9~Hh?0tP?5bHw*5#FtGP1<RRSLEA~OhF2>eQ=F6Wld<36YHm#o*X+A3y^xbvn(`Rfg<| z&p7~zTT?Mhy;-!6#DP)QxLr!|gRcq1Z1_cEApPX&w7JUG9B7+mjNubNqql}pHpc6s zd}XfGIUbe!E2m7mv7B=)GZL^~`w7Hw#&XE6plO{Hx^7b)4wOS8#*&WdXEN&>65++_ zMGs?29Pb8iQHICuOs_VSs13$cBhFHCBKVH2lD z`iGzJKL{lKK*!iT?62OalRTV`ybN)+Hl2$c^z7ag*ac)AQ1PscG_{q-#{F9% zrGBxYA+Ic4sv-RBOAfR(Q%`a|n{uT(9i`u#WLq&Av5vIefM}@yvW$$gRG>r7e`MQ% z;BZbM7sxol-d!dsr|dnpY?LVS51@fS!!OvSguI*It4Cgn!PpYghTFtAf{pk=iJfxA zW`1x+3B7REl~ozJqs-g37r2~rPQBH>=!NsqX&Ai`no}kxf&8oBQD`&zp$CM+`09l2lhjP?tB%ifH%EbZ~Q-5Sgh<@)(O`&%4g>aL3H-&~dTPx-fB7`Xfd z-6v>1`;CV$T+lB%7FM0O{qojqes*}lOCKAW(!79<$08#NMw`5pOqG_ga8@?3GqX^X zJQ!qp$jA7~76oj8J$L;)RMoeM)%0dD^E4YuS#a=fA8|e28b6fY^~4dkI0Y$C(c#M} zGiod-TH9ZNsvmgC_UR^<6%EQP7S=v)&iH?`S2oMDSMY>-Rw)9&0r1{Bw=a{+9%>Iw z!AG>ujdnrIrSDFgq``5CLXE8|&aS9vW&OF)XwSMvt{qWRK3_=Ol@{q(__Vo540F%h ze$%`=+0Tw|H#WFNiTx*9mttZYl;2ctJ{S0wp#o$b@h`L}Op!hO=@MT5v_W;IQ2TrT zadyD>cO(K|&scv?iG@Wf21X{KtA}tsXX(O#?@?mtH;L4n6>V$kB&+4?7LPan&W{?( zu{+S>IV=(^npY-=8rFUE#a^xPvV&_;P(s@Lt|*lQ1J@Vxmc(cm6{x@ul>-D1d4Lb|c>%}2oZ(1)*I^cG@ezB+OJ zYUFJlekoI#l3qZu;t)Icbm`xC+x#l~=S3rZ_e_TY;Igr2tuSw)h4p9YU~~36g(oQ? z;660kLp5;htFSctf4liTCYIQK(p420^P@|`;6K%B^#svFAHN@Z1AJHg_KjSTf;~C& z{`oN3Xm3TZjEn^nk;#=d$_Zs=bJ9+$oWI#Vn4lY4J+n0wwi!~F%byP4tl1Ud+Lr(D z!=lzZ$IAzNG9zo)Rd3`DP1Ch~sUAjbuh)H8xU#{Lna7%X*xAH>S$(rUUPPGN`nj}Ml%!vfhpZFx<Q8#?z+8A+L=cS5sw0DmA|aiZ;=Ady6%sT#@?lx-qi>^uY@!UrXaz+p#FrILJ*xiUyEN#Gc+0#;KT@vHeDXwiE@y34AvcEvq ze)Tf#Wai=GP<5I%gV3vf7uJ5|D=AJMUuFJnFvyaIQ$EBdanc^pndQ5ZesjO6uia?} zxoJnLx(;>P>8+MLNz&jEwJ)`f^U?2-H8arb{Tz)C-~s;I?Iv=o=hwRj=UJpeiWl!I z+O)F9HR}Eup-z)^$T`C#|Fp&>Nr2`9)9a2~cB8n%fhxj7<=>7htFM3nck;s<$>(NU zthzCp)~J#unZ2j*Xrmuq_h~EG`yuLVtg`D@V!946!oFlGmE(ckv?OED5GU2yg<}HV zCx|4M7N>g6PXisMVIs#{Zo5=h+vvQ`ycIw_l*TN}o&U$E;e+rH^UUwhI_$XUu?F07EOT6$J?nU4aLJT?&?URd%0BqYk zZw(fjzlz`u1VS~LJT`*K`SZ0eWcMuvsLWcoj=0Ulz5Y@WfWoW!n_QlfHFLa*i{}zD z^gE}S6!YK8b8xJb^{ECCopF6AnN0|^acq=!qdTu)MHW|23PZ<%^;g`u3X@%3Rv6y& zKZyHk#SH9zd*BjRBq)Mf)B3QDZIloQk)}Ch0li7c z0?%ziPj;arc4>*^8PynG7q8oO-LaVE_sQR7fz3}h36#G*?_0aqGN#K>^!owOOykM# znrA4p@g4*`Q?B~SUUNq39IP%$eY*Hur**Sh>((1?4zFpEHu&-|`%~UQZ|JQ@IMq1} zVr@)jSA901c(BYSf^F`BYgdHzsi@`*l`7jNwl8!$2b32=lr*b3lk!(T{EG?Y6?n3*0@_+} zp#dd+y*ixo#xV~kyN=0FVRwOmbqM6zD~peh<;>N_>qT*gDAywv(xV#>?Y8xF3y$EA zObwRlnwZJG*$Eb;xT8ae#Sxek2OX+b%B_!#F7Brnk8|k)9#2GoK(5%q%H!XoYEpIw z{F-FMDU_me>DtL&V03P02TN}xi7?!Wrn*>9g&8Ik~8vc zCy4~4D5siq{U3qW1CYiDJiCpXB8oe*!S%1z`oZhRabWSY3V1h?MZlm?R>0S=cEB|4NBXD#=K#hiLMu zf{F*irLR3Ldi!m+(9UA|<-BDy;Y-EcCLbYg?h)#X&w(9oS@P@Iz1gfWVZ?A zG1Onf@Lb@YZ5J|G%8@4u5N(OpirF+-j}}sGj=uh^%V-emZ$k_A!)Si zeDbRthf=snBZ$MHr)#jQJ>LZ}YYWG(O=0D`=zRE%2)Ox+4rHyN6pnuI0dUdd)0wLM zttyQzxSkX1C>3yQWHzyJ9knh!*vrLo;ayt5H-%T=(ty~0?xTvZ8}ztTL~%6~nQb91 zVr>Tvpa?RA(N^u5UNB0vTqT0PwSb z74^?DYui4eqMUD--YsQHs!L(CPNvD}5&q3>ckCNCiN~Ofe-4^E&DS&iNafo-IaGvR zgfl^9P5^nIDg3Qfhi1Fc8z1=ijxg$J%6RK}*n5xDQAS$s*S)`)uKJ;_;Vc+*cbvLfqZVT>8MxaW1k+F40ISJP)fUE}o%v4|#UkO?E~-wG;_Y3&?;^(<}C_RxJ{9 zs~wxVkIWJTg*Db(XIgm90XZO^{q$auJ&$o1x)O|Xx{{>(mYy;%!=o~>SOy6DBK?3t zLW;s4LJitrDs?!sp>faf6-#U!yIMAxTN$nmqI!s`JUWXHGqx6d`b}=uvpOfDO{j4? zAE?g)V1RJF5jdbhXI>Qo1p-k&->!dEOIuFV>yfA+=g?yl3S+g&0Qf_Gxrn?Af$P$% zZwUmv3{hw@bhnXzPKRFBn43+$%)%>jL4RsOXvh@H0GGn$091exBTW03)creE|x$ZkPK?+ zg`QQSLkU`EpY7hD#)v=Pd{snNL6FNUeb(_cBMZ_RGwCjA@6@+#30EBKPHYIixrdia zmEoLlqhCyqy6EEU_5Kg2IL@D#JkA0NFfcTU-T-a<>QVfu!M>) zpNo~i(wr(>dR~PgkLK%cE?)q3cM4ATY1?bpmk6rLllNWA3(+p0Ve=`~U=XGFr)~({mkmF@?XX(m#%GR z5?k?p*ZovPMH5SFd&t?vx3BMWt)<~9_Jwx)PqVB7pY0yl%u(Fz?eo+dvPe0T7Bw>{ zy#>;EUuaAfrQ8Q&yCW@!5`KR<;s#VmPgRSf$>QB(mn9js_vg+(gU5G5yHr1!aonjfxW>o~jzUq(NVRQm;N>^}9I#+GHzcoUl9){Lnk6&pJN&+h(9(u@GPt z)RpND&pzM|aD5K+jsxwH57}}iTC5MRZR}e?GK|Kn62s$>*X*bOqrNng34iCQ-y#Cs z-x6pJhnc&`I~XJ=4@#ty_G~I2_2ilD_%SmLIJ#)k8}%k{mTx$XUgOznhTBPTOy^;a zO+Y?N=Hr)r)o^ZeE z=+(QkWtOu9J=|AxdE92jI_DHEvVcEnXj!l|djx1}B7{9A%q)>SNoO;8;$TzK2;X0p z@%{i(a9TO1SLw9gAc{D*s2%44zV6$N+M!7_rQt0t+g0eC&`+yLM#iOBzqi-3PPzx@qR5Uz4yl1ByhcV}$Am#2`*h6m0F&ZB9&lN;#xVFl%->8%UgD8s^H>Bhjeri5j|d^|vfBdKoRn#(cjjbio|Te5nn zbqREn%f)}TPL<~rt#fx&&xIB0?qr>;lA!YJH0gCb>-ia;<3AQ2zcsYYzgf@sWjZC< zJ|IToaY0rV!=ecQGA1dH+VQVj`&^5x;$;F~_dV|qjw;7ct~(}g9(-MQdRC^-@rRm= zS^L=djCJZ=#rAQLK!LT(F=SgpIbZN_(1VD+j(&3^P-C3MX5eN8+pb)lO1Znlv)Xg+ zb0T60-M6OI@zn2G*jUX)1;3r#uS~jT+OqmmjgH?oEaDvrh; z0M}|K7MR-55D4R36^rhg1Jis|X>p0IKZF21ea`7kRCYh+=Fu(F4LBwR9lz)1d67Gr zx^^iYSaX$&1 zbO=v9g%AGhG>8QSZ#{-%Dp_!ch`+TYh{?F2ytcrwn3CD|Z3;hF^^EjhgKUv52d_`5 z0vB9voU~K|#y88d%&>6Sx*NQBby-Ivq*&FpHy){GW?=Vry31~*mU<7CRp65GFc>feZ6Jtai|6=O zXbx^+n}B8UJF7zPZxc?p2S%|I{ixFVyzW`4ZS&yZIgG>j-Ih1L@ZIC>`4z71T)P(0?<1Svjbz9cBFLQAV> zUZ9o=m2vbLY(?cP$>;2hilf|t0}Apj_dVPokq)Mx+*lgX^c!c`to$7vh)O~RMQ{ai z0ehPJ@%^604^py{o8$K_piI9J^3z&CI`vmR0wPllYInbM-7F#atSY`a>TI{M0nyJ* z(~2~SFy;6V0nC1)FwAR#ei1fQJ9AjecnCq5ap`zuL`EKuvSqvl56G}`nAwLN0vKW+ z!7S6Vd-z=Chzo4(PV)Mdn+b}2`rK`{Qe$u7r=F^cO^JSf$lt{|&r01Sqv_CdopUnx zhD4i>j{^jXp)j_`oe6maW2^?l}+Tu=*3sU1<$q_eg-$h1V%w0#dV z?hrynn!b=e#hw@yc26As3T#%Bw}L~4{-pxnBb@a;t>m4qjA|W3)o7EfSSAbikYFgxLxoO;SKi0)8x#y7>LD71y5j3j)`Y)=W*^wYr7-U{c?Gz zpfps*c%E5ho|yq%lqVt>8+0aUq_W{6IzO#d{isL6%%ZR@Fd+8~1aTY_`_N$9p+7DW`a<8*ra z^gN+y2u}wK+C_TI4zc=Lr3!~Ue%(~T1}#@jQ%zf|Rg-o($(S(A(o8h!Rr5Lbvghke z6V>~#iQMqw(NPn(AMEZIrKeCfveU1tY=!2%Y(atmW>qL|FofWj{B#}1$&oCsEwb%y z>#r_UC_U;eU#Kkzy2BEaZ@p|xN+lRC@-8<4|?@Kbvo9YfOhRD@T zn(MOiNS2NyJAZ!lcaOQYZM>o0?#&FV&`-JZxuYE2n$_{7RbetQGn=g50^*Q)!j%o(d#Ic;L`2>@IXh$sJP*G{rOrQuCrkgx0m~?4AlQ~kvs6f9XhSzD- ztyOzsRjzM8hm_O5*p^0DX9h;)PIu3oGQ{Eln+&xpnTxRQ_9jS8YCj_P&T;(yO^}5x z%-Tj7nvElLvo^}kZF&^((!wlc!ezf>rQ0>7ucZgz{nqj0J1c4D@*y2Oakzh4I!?8S z0PJ-L;Y_R^=ec*hGSUY{bwm1(+zkiQ({Pw2K6>@uK&cX8b3(aAv$}Es>?%i8SF%j- zSqYd}l|5Gacb9k)ytB4DlLuX8278jkw${MMXzUDn?Gw;31_ibUQbA5mZN3<7VK2HT z-SbFmf_Xn6PLC$Y~d6kv`xGvFz%#ZJ@P}f!g9=gCDFw|>AoQ4;} z+Mx<#EAhc;l)Qx{W8T*3k1o1>gqQLRj`5W>?a1g}$=8x#*P4w(OB?#l3v z1-s*w4G5UzA0N~HUizFcL*Bk%Rf+t@7Gk#9B>S#KmV=$|(dqSh!qlb%`|+6hW@OP1 ziSPBTjc!U*N$oqlYr^Lii=?xwMM~nbUPe)WUbthF5W>9~rC> z{T8cpP8_Kte`}Rn#nYyleC(OS$FNYDwG}fXY|Js2L9y(?GW+=PfZ8?j?Ne%}qzf(F zUO{-}1N_>@`a>_(B>Wn+f&Ei9!a=|~0#ExMutNF4l!%UCu5@+0l0Jv$=jU*l__`-F zd|!SFW`k}Hx;E>Um+nR)jbJ3?uEciS2IIK4-pu_h00KiG@W!(mR=do$z_u}`WaQ0h zLOML|k}#ld-5+0wZWVk}*|}J5b%IoVn#Uu=r#-F(O*W(ktg8R|0)V#aF@CViZ+&>u zT9MuOU0c-gA%e=NL|3QOc^ue~klmWoU;ITIX(%xISigG65oH#?|FoxBLqfA-OI#e# z9dAE|?D68Jlm)c+&3~wInor5JgZ6S?yi=`H^(NH5osRlJ_U3(V)}p-Gz618dc}&K< z_O@JjW0x?2C~wTvVePiD^#Y(^?H$mMYNk__#4&rHq<o%k<+GmZM7@+$LZ{T#~8jl^=9UT;A6k>JQU4>b2FHUcnCRl)&bQrze%ohx=%r! z6j?XPFk}5!G?J6j(oo@{}&<+OG<|)*+G_Vowg(wdq#6 zBroNEcI3Px zltqHLau$|VQ=F4NZ8|2Ox}JU0?s(eS43?~Qpmn$he4QR(s&LzKHwD@H2c%zB@Kak2Zea&Q>0Yum$b?Iu(rH`q?weWrcWzW_?KaD_z3V;#( zEZ*ay6XGAIX+m6#zOwnuJbsH_VDbNy&eXSdKeknW_|Nv}@XZyki+fMThY6%+HQF>) z!tq9uhqQGnD}RWnGejlDwX&d(N*f`ZGL)GSZ9C928TDjew}_)F1qeYNo#GV8>epL4 z5zSqd6mck@Xp5w#%b@fH0(2a|1|xuXsi7b0Y+7-GPHLs)Me%3?Jjp2SZXeABz{0fg zr#gn|rU_-O13h&GDMlSxEE5GlbG4rY9KB}~+o31l4J_2`OOgA7x!Yp9k)LCJ$oI5! z18dfDVZ0kvY3%EmvIu+x82Z_SZQUyCOrufVSOn5YM!VL3iPma1!y%Qo zCEG~BP~r)gL~~l_%V(z&)Zj+I1=LD^(K&FH;0!45_t|_~&JvOL!DC+fBi;LJ|2757 zurAQe!s8kpZ>w#;xoJ#4T);@3P?>&p3uQkl& z`fHXRC#zk-qdC6QsnnoY2mLSCnF6mufP>_t&v9|9!b7k0q-p6yt6kILKvYVqu&J## zFz}$o`di;s%K)xN8^OC{EKts-hOK*p$F}vQR^LlY+ZgEJYpZOb3_k?W%^GzK-T`;1 z&}7AcMh5kl@X9ygb|?r3hv1!Dg#vfYAX+RFYsNmS+B2byf=+AWl-M0W74QoIc`3nM zhVF^UlD%q;#W-`RD$t3{SgNfFcOguuCtRd2$@bhD52^Rx;@43a+*Ihz#t9+kDlDD~ zrVOh(nau7X0hPNNd5Qm+@vL|!ZR%5LuS;81>#b?+>>y1o&xj-HNKe%1=sjnHB1a_ZbPg!Q6BKlSMqkM zG;NvY!kUpQG8g^9+m}3Bbm2|`wwZ;_jNX%a6}T4$R&V(xL!}^|gJ=;hLzhzr+8bBROI~Df z3TCJW)Xzrzoe4c()DIcSRyYqyC*(2qI84k?>J7W!)k1|<9qbIC zGeLVPx2l!$wGAxvBi)y@9;2GkgKR(^U@~p#J)K{XcY#A>A90C)4lbL~(Oz3&2ntE7r9;57&{_{UwyVryeS|`c zGS6W~SNe>(O^mPfN~wv;PNLCfr}yttC|?r_;%-bSA=jyHqlTXhWXtnJz@-CfK@QV0 z&Zct-lM+Tn&~jeFUcQ&#!htn?*Qk)o#c^<(>-TlG>+&C*)_O;qcD_j-+<$d`SOPlL z%qno(<7TflLSs)mLNpjtVYCy8p2b=7Cia{LCL_znR0UYHvo3J! zaea*f(!Y>5u3EO}8S?blqS-#TZr8l8=Usg@u3U$o5-m_V0C^6>0yIlAH>|&pM685u zhA#8%xp;G0J2Tny%DX{;^=ePOGNj(&G_tAhf)IHoXrT-IEz@Ea}U8Ly3{hKutYf|>G!zbC4;rKIWX6=2+q{6?w=)+_bQ z=x(8>&oK$~hOWp;=|;zGX)r)!O%Q=1b)**0+nt;a$>25~0c&wge?1yM-QjV+TWhp% zWhf=V7-AN+TMDk2A8H}wDW|gcX#pWJt_@sQ;kU$Dl~f-G^!|{-eu}5t2^y?CaDlo@Hi?XQi@3Xm=&3VNf z#dw7Trqk!JyK$sg86rTskVf)Y60vJo&$C+a6MvEz2KQ_UEoydKUUb}MI%cWf8XbHQQI` zFhd7FM=%e$LbbW^(X%f<%$S;um&(xcR7akBWz24~GvNx#bnXr2d5#;7{l_z`QE*u% zU^BPA3W16}f50(Ox^J`)PZMJj>o6&~H^WyhP>A`Umv=kEO3|kByw_ntTdu8v4FL?V z2V8~SNq_lPalfzHY-oH8^h0wA`XFol9+uem4y>u{c&Ae9q5Peoah7N+>H-j_NLg73ikge&#$&=4#}I@3M$ zFGz*9_eO-O&NeIGmywcX>+XP=@uhO}ikG2J3J_c8uM~7*WUDTG^WHP$8UEkEo^h}{QXIk`_OK8QObBD7~>f~IB&3{Kvb6m(ewhbr?l{OEH zf@uOUZ!o&G!VzlHhP)P1<708b)FrNWrBgfdjdH0A=6W*?lnW|aYSECYx`8W;X|Hqc zb3-IwT0H|nhKWHd*YHKyQh9pU>FWNz!dkJF8ip|4!MjMNW5C_#Z0B&VLytv`cmv>6yA_E%biMjaQ%A7s+{bUf-PD+xET6$H1bIB@rJc0s-m`dwt)X>-l+P+poHdZv~STp+zw*Mv-h zAfqB2P&H+hzT8L({yNZ6P?Pn>P`UhmN@GwI;KPM@>eN@f*xnipr=3(7LD^Rp>U;Fn zD49A-;(XWgpl1Yhp{4qmzJYd$h0+2bIHN9-&m5iBelBJdKLdQ0AC@Rz1f#k=yH8qx zQ}l~f?FttJs6%oaDAs8jk#cZ5ZT%RcdE8PQsjuq8?mm@F&Tf* zZo7`4s$F=vRkB)H&In&Y=hrf{3IjFx#xf{YPW55{`~!ayJFse1sSisV9!0uBwCRS@ zOB)z!ac%w29CZ%Fy+u!g)HZM;m4JdXrf5uTGbf8<<%nY(q6Hv02 zxq)?Mg{BORVjdh8%8Gu|2F|N}Sj^>jLb|qP_D$>Ru3IBl7!`h3$2=ERzO8LgNJU!& zCVbgktkxON(B_hFYjLa})B=zjG(S@8BA}$*+S_jxuj*O;DKK?#jmu%!>IEF8s|WAe|p0U8MWQ)r?JnTV7oF5^~kIi7?Jpj+-<9@OTpWfG9KlWbDipSOeXsxpG3sZ+j6i(||N#b8I{ z7TTm?1pYIUOL@t_;N9HYndLYD?n@tDh!)k${&+G^6~?hr8&}di;xojrB>u6qy}b=4 z`G$$}HV;IS)7z+^4gLXA3Nu!>=ip;M99a@Lu&>Sgx8`ZZJwJXp{CRW%qxIH4>5Xhei^3};a9hISKb7KXBK#+SZ@YwyiJLWc?UzzT%OZ+ zMH1^CQ3C;hl1y{M1_^>Zm@tYzK06|(wgIcAatRg3ET*iev|VvH?f5cs-6{4ye0a@ ziUQVd*AHen_VC8mIv`LY%)Vu+QFeGd#@pnpND)O|^~%|YoKsA|<~GnJ4XB`7%ZY`| z=K8tY6$z?kJX9A7o-NQW026feq5w8mP*M4d@554)T*mymXEVD)Z}2W)9~T6?j%ww1 zWggZQp;-}V-GuOmOKd#z`kuUKO#j#N+s`jcs3a2M7pf+fssv0S(;B?E(tTP?M4k`> z*3l02{g7)yjcOuJAia+pKP3OpRYS^_vf7f- zn@b=(z2kCVR(iF$ozrA^d}>-zo(#91q=A#}s!HzyOy`nV(iGIEf6uwnk_$LcA%2~q zMHD$w8DEeinC+*2S=52+-Jk!k!tlZ&K3+k%!2DOE>52M0;>a;W;9$-qfXFqbj*H7N zLWUK3QS$4Xs%noBhpz2hrJVDnRgTBI{voPLQRc?nkxhT~%2aa&V5n z)evJ#?b-wXMA^jN#CvOVRmCx}kjd-w=P|3l(5&`C-jH^Xg_q~u$ap~|{6*Aa9`Tfdnnf>Jkj3Qfglq!3H7F_eTs46l zHW!|cs&RC91xS`PczrXYWAX;Q`u~r0G1OJQe&_^GA{MqM}?WQ-eqX0}&$61il@|IUby;F^EG`hFAA7dPjw@HmaKc!aa$2BUL#iB(l z3yKz@b*Wu#XEYHgT!Ov!(*pGR)jghI!itYwQ8=Tm-R!$m|AyOR!_a>^JqcSvCMJ|( z6}f+s2h}d(6CLU~uKf^-~+uX7oN58?+d!_s;6ux8j zB!$ckoIZdPZwea~GKO-Ybqh3EfaMI(vPlv7J@@mw?mzog^6@d4=rtQXgdi3}OVBhP zLX8=iKZ)Juv2(hQ}Hty*>wP=?HC7E|j6no+SDB0;;Q=@F*5QU@3pOb15m8BAz?LtJ0m{Q-VRxxU^&lS0w0B#RJt0XkFm z=cTo^H9)@yFA=*>yK$1r9+WTkOiTE-Z#;QxAB$yn$#hfKPuH&;v;N!UJlyuHzoB)l z(Slz>O!YhcjKE)7zVM>yhfIE`yT9t~-epu~x9nKT%_N4!EWLv7aF!_gHU zor3nDIE$o^rzK;RX|&tn?#>0-$Jq*VoMdAdCOPncw+5hF*JT>+=R*XG%am>hV`2^$fY%anCmC95Z);FV z^8Onq^cc9ITk$o5Ic!aB15{X&nHaR6!iWHg3HY;_qxOM)G;Q6m%;?f#@M#)`WM^?> zVjHDNZ#8AVoC8upF&xTkj*GkUQ>sQ(p%|=!rJ5;T1x{a$j{S>Dage4_`?o^CBCUOD zwz(KO?quz;d~;qz^r%p-c25*>5}S8+YOB}In~nIlmf4Rfd_WO$8&U&yMf1LLY>X_$ zq4yUDY*%KR!^^XBVTfr^lifgAg4cSMPacQ)_{v{RmwbnMC7vHG$Q_p_dql#$iPt-h zpNpQ&juOW3pQ=+90#JU=TeqCqFh!TrXg~Eu;bcTb$>bnjOqBZlf4V%WZzId{Pki4> zeFjCrf)6{Jqn&<4&hM0jGg4~c8_hAoDC55_N$U|FZ})E$>SpAZH87|T4`8K5dcXd+ z%%YH>-nS3Fmp@=PHRHi3E~KaYf@LjTG<%0QUCD+XP@#kzi;_Ffv~cEI7Ubs->E`s3 z-0jw-(S@}E;ZLgfT8bICyH=3Vm|Ecxrtpiqymwu)ejDB9`Idtav7g`J+W&@M8_n0e zWnY6F5NqV{>Rj)Rw&WVU%J$6tq#?U~1Zl?zJ8Uli-rX7Ded|&&+v7B0^}A$u&IR+u z-WA;;9<$B+?mo6N*jqg_+~jSoDdoj~k|%!u$9$yXh_vap2iKEO7^A zHFgbt^77O4{qqO>k#YOxyp2^UYXstfShVt``#(X(LhtxqpB%ZDkY}TWEJBykI2c+2 z@_vgaX@6C|25|^)0h3v~*o|y&9*R4d{VX$jb-X;0*J*mSwFcT;WFdDTRly{rZ`U0wiYi`1>X5YY5iId$E;=(SU+JP zJBD#VkaQw^?tzpU-69!e74_NbGvwbdUbH1=S+dg8qGK|-{V2c^zGgt5C5(UFaFg8S zdd>B84U>@{v+XffC0H}Y>h*Qxq#pvKHhJ2Vjpz>`ns^Ss7~IUg{eUggN0u(S^XIz% zQ)wmRv|4jLmmrtMMlIuJzIjiwae8lLxJI59TAB9(1iiOrgIk4WR;H)H-1rHe2wK6$X<~d$1NwjcWQh3m3yq$ya(G zo0H|D+p@HOo%oRUu`Q(T-Pa{N5wZSMJ2RN52VLMUz8&bTUuV!`bR6Y8*^%EeJKkP z0P|lk%W`yn(DL-(pzON~AQ}GEXML_BIfA>cvg4HIX%smP3d8NroDXEjsphY`yBWEL zcPpGcykRigLSf8Q#PP!QA%|&O-uwH76P#1VFK*o`~9X;2u^ttc9wpp}x@aT(mL2axr?e#dfTZ}Q$MwkQrm*2>fUO9b^zTk8y0tn@w`Hpt!(1hT53{ID&-U-plJS)v9uJU(o&%kjyEQGYy@Bjv z<6tHm4Po`YO=g}5e+DbB1CKJJ9f;ChPU=_pi!f}_p+VXD>nHpHE3oOMiv^MQBraFbHv|#ejZtR`dFoS;jl1HyJ>O?~2}iDUS^Wl1v@T zN6W{rT;>y|3&tgJfCICIcB2JD!jF7u==L52Pb)F8k$+X8=@dXdbU4|J;!yG)3YFu) zdhb?%@oL3gH}bRcyZ7R+R7O0g{^{U9HMNRq8Eub#T7Pe6LIy&* zw!X-1rmj`kP^ZI$X68F?1RieRfZ}|LN-!rAoQ{sX(=yFG|EX`KtF(XLxmQX{$Y&>8 zOTD*wwnu3-KWoQc_^y+&$vWYC@T*BH%KRm)^`lysRrQo*nrZjh`@6g*9y^Y-8sqP% zZyLAhT?)TwAc_gp^PF}5xP^KkCL2BT8;HBty?-AqJu07n&+9)a$QTrkY<3_1fjT<~ zAnuj>nEq-r{r8K*%Pm=!`z%aimbEUgGk+vpA*Xo8B>w@{dqY&6hfk{c1DuVqor2vV zUCl~D*%ZlM9q%Nypu0eRv)UnC~EME{Ke~3 z#B5V+g~6>$l@Gvhi=6>@MuBlaVbnMOSjDUB|82U?FHlE8n|{Msq;}#!Hr=(eP0>Aq zPu?x+m-lXWF2A){`rk0XprYtvs(xP*?@@-2FV3{9_M2PyB_@<#I;T_ru1MMRFFh|` z?X`IRkLIz`Hh5xChjZp@`oiQLp`3e)Z3-+8vDqiROiHt&lw3NE zJk&4HkKdN)Fng;a8u5)X*6V7M%lc!uv_rqzPI;)fu)6H8@o+zb;8qG&#C_c!KS>6>bdiF&*hbL6~o?qWw^%K zE!|ZXO)z&#&5D1nIx8GZqj{Vg1wC-c#^Djj6P!KO{JxaknF*itqH)d}uxcRbOR>pw zZ6>?{1G{R6Nz8L3U52 z%dTpcF_BNTUgTklHxanuT1vd z>Gl+laR4YQbg9mz>$-KG71YJa*1!Mf8;@o?Mq)7{40^I=F|;f`p{VPvS}B9Gz47Uk z;ef}tW{DOH{)k~1hvo&}6IymF*%iA|!lo*JNwl(SgF|Cp@2!b2QZnd*ho-Wzz^|BZ zd!5a{I`2i&ZNlv=XB}UMX&>Y(U(F;hs(INP-0Zct)-u;VqrM2p%^dWhS`oSt>qI3p zX-Hx78Hs7BGCZP8P`aP}KK5sL%PoD<+^EIhcQYeu7Cwq>6|iB0n-*W2l>c|EtlNLi zg9&T9Zi0_8CItT(=JO+0T~>dmtI@w?@_B6h_i}e^vykH*+O$uO*S}_IT$trA{OYwD zcW0o@>*vV0bEcoqWPIkuq0aOjt3leC>n`Pi`*zlXM*S+YzO7FsJ!U9ZJsrN^-z-+l zR=dUciJ0;Rl#Az-oU#e}XKLPRp+++Zos6R1QWh21=Tja{2#Ng%E=R)I^H&Td-81dE zn|MCosedA7{p*%Hl6(r;`-8pd9=D_hO6SjOaI)KRj5`Cu+#IfE%vQcYnGgey=y~Zb z8cALKbER|rlFSDk2b&$*(9;`~p`d5gGg35)w-~i4`WjQ;i|}zN0%qj#2f6K0ZFBiz3`z)uiwf7#4ix{aO)aeAbkQ z-0;NX8FJlALUKT6%$1d{rBiC)<`4L~_^yb2OS*JL<$|kg*&ja6iC8&9jn_XVEuWeR z1fTxN&Lf>U*pj~_?^WRXQuX6MUCH*S^XTcs-$fJGm*hVj)0IbWN8eYz-uP?|@#bW) zZ(~Ad|7M4l_2H7%SY3O;J0Dsem(=ep4wQf5*cO&)*oCa7cmrStkIiN3i%X?~Hj*!_ ziZTURxFz|rQv7(BYo#4fu2}w`q5Qsoq~NSYFeK{7t9dl!Gdi=X|39Oi?8Xx@&X?KQ zpL<^VjnAK4{@AxJe5g0})%xRcZHz$H%I2l9m9v}OyZLtSIts#k(p09!7K8pY9M^RS z`ZDrXZ%!t=O?9Pfd??b&WHq5{qLO$Q*3V_vwzf+nJ!4Ac74&1to$*p7{!9hU$)2Qq zGl%Oxu@W-Ueb=$|8oBO`7Ybr?6?K?Z)HLBkqLFKYT=(&i(fonPzEgc@|Ak(#zp1 z;8y2_}w+GdLehvHDIP@LlK?heJh zxJz*eZbe(%-HN+AMG6#$;8NUO1LRWP@2=!;R?e9-vu8gud*)CDSCI55s<~>w=UTup z46=z6aKSkK>eG26;i4?M+IzDAZS@n_Oq%m3BmeLp!tIJG|3xb+reFZZ*Z@ZXMJYcQ z4%CuVOB*j5qi!SLZHEZ!{$p%0W<2tMK%L+^)ZC9oV8I+b3)ZBPaXL|3o>M)`qniAB zy2$&Ihw0MGtOu7S(RcDR>-!SgeE#wn^(dZqOzasAiGG1M^3uUIdW1sy@4(+uHb54jt;srTNPE%9@; zYGn61dBSe20(`y+JsiPajH;?R;^=Ga*4W%pE{-hH|J}~=G=|P{HEkcIRU0xnJ-buBcP!T8z;%U^h9f2x%~gGBa7JrGD>!zot}+LI2jnHX~8v z&HhUTH{Df8hpYGDFUErM-&~y8n9S${%8NOCb|%YjE3-e(_zBG&=QlZHw_K6!i<7Bt z*(`G5nYoVta>%AOd}|6#v{f0sUGK0vaFI0zT*0IEi|81ifm(l9_1ZIFJ zjt}`P9v@RNJCb`3^HSj7DBkhmH$N%ravyt=Z4u1-$7W%c$=vCUJcR%7BPay+9`QHt z54KteS*Ac(v}yGwX-9i+~fJF6QkXahyYX zBJ-bOPXx^QdgGep+y961|E__>9i_FnqOgU%myjT66ibPE4vH`=L&I_7?hGs8V8O?& zf}<_-w%jnv5fR$LUR=(t;51y)d49KmD{Cqlv9&cFs?zqvE%Z~8wc_%AwjXu#^kSQ{zv8C&U(Y0ux=%;yA z+%wiQDbaL)6Y4)8XqH1q=-+&od0-dG764Mk{*KQj1gc>$J zd}r4m$BH0lD4*sUQ3%cS;|jh%)rl;frXC<^QyXO~cTf&oo>9!^@;7@nPown*Y8WFc zX8dm3W2DIE_k^L3OY1&LjQs*nu3;dVvUm}msHps7B337w_vNWcv(!Ju+wQI}v5VciwYM07h})E77L5WA<)=*1 zGh*-~`*2L+1P7w$YK3qKvPlzozpwxE>fRP^tx*w6rKpa>tY+ge|XSgPcd|jhzTz#L+I+P<#*l`yni#?n1vOiH#PH=%XPf7Rba)pT5@_(m3xMpWu zAzyl&l#f%uq((PgGzkyQ$}NuI62dE!zM-S+b;u>Q@UzaSa#v=2h4g8y-5ePx=)d$d z^YQG8t;3aLL5ONAvtVk;;%Cpn{wMY^5;fT5l( zeT{a{z~?fULs;3SERP8wBuBvicDqNowtsIX?k-$;@{T9S@bNyJShb6{NB%6y&hW;- zoSTFY!a~&Y zZqnMfXx(3wGd4PopS%wDguxg5q@X{JK;Cevz$EykYPR)fmYV&tt2GHNHgmUQSW^{J z*3N5m8kWDMJ>kkqHducsP5ON`+!ZvxK2KsDF7UR%314p6L#F&dZR?rcB|OqNwEE*x zgf>K-2i${|$=e7&%7T9%dTr78p7hq!cRSucDXsfO-GCc5qb^=QH^8pTfxBUU!tU5{ zXqpho^GUh1C@;+ zXb(hNL|gbEkBU9AJ7G^=3fOy@AFPBme^?TAt0c`pY0e=>Lxf4wSlkzt6PrFIC4$pd z>`N7tsxAoxk7DaYKxl?J9-Sq!(Q1a^Q{0$3LF(mO53bv%tUkMV71WF2U4a$K#>xlp zBj}&sM$=SQ4gfNyv8Se)-Eo_Fe_#u!gV#TKOW-OVtVNu>mJ$v#_oF!yS-~J;R&h_jH8z z?$ni+bINNMF9xHo6tr`j?GH=LyEX?qF_gUBk~*NL);SU0t#g;gxj z9}nTseD-GAJ2Tb!gsdsjxR`@Q3fKFwG^)iq@gx!_qZINOKbA0!YxjpnX5atIhybtX*%A7qZ0rBQSX^D2nZNbkjOgO3qEooT=0-SkZi_2` z8IqL_aFyFTd+3G?oZUO2txl>8er4qPo{r?@(o1{~@)taR^-D)<8eys;t}mqjKzIY~ zyIWlT?KF`oxT$W!-qbPJfr={6=}4atrGe%{{p(IWARE{7_g$MJozxpIE^*|~gUjcohT zj3}1xgAlttQ4ej=M@i2UXW4F9SqTMpqd`#)rzP zXLVQiPWM`%UlEm@g|ph$b_IR#tSBol=lqC#kcW(l^{DwDfi+Zr{uB&sVh=F?m=8L+ zkr!F#YoCHkWW{bFLRD6Ox#?~f)$N%XM4L`xL2fJ&Yz#yOmdK_Tn%8T}zM~NZ zGDV2YFG03zkF-3>I77Do*KHcI(eyz0#vJ^*>jykLtZzW&cT--Lk)kSZ!0RI2X`rZu zzLtp9s=>Fh{&eHWsvE*V>hB25e0v&7slNL$72Nednsofk#}H>ZxJ=vYmQ?aHNyAU{#1GnOAAQKijU*ji1g-E2W(Fltjr69sjTnyA6}63p zYiTCjkElcSi(gKQ&igy=QwY*JL_Ej8BBwFd?D7c&o`vcM4n;jY`=1$})r?vp@=+kw ziz#L+`q*!$%=(*Op%G^$k9Tb!t^4&51IT3Rj;HL1K&3~(_I!dcil<$@k~5S90H8W& zRf6>G#F&}0h9~aSff&}F$K(BL4*4e5u(;_s3oEUsw|8E7^;`>gCECxq9%Ss(g;AZO zn$^v@V45Y1pLv|A#=^X9rl2cx+2`V=?$<^kVr0yU1Bh$I=~!d;doUArBh;+-zq70u zzHBSlk$MYHQBG-|Mr4)=*Hv_PIPbhsUF>Rzrr-08w6_st<@D#YsoLEh@xX5aLf9_I zCi2G|YvH{&7x+NjLlclkKNpAt*K0{$W;)9>=4jY0HRqZ>#bFUVatqoB*-N6e*OT`F zk=g0)PI&sKQ2o)mA{>DdKWmW{=nLLppT~e{4a`*Maergoh}lRaS+RS25-W|3=)|Z8 zM3$_v56s;!*?9q!qGYD;V;^hff$i&0x9NY8$T633-omN4O&7J-S)E~aw#!XVFu;SG zEBHf;{rY#AbUOI9Q(Pg(0^+tldMEM0I{Dsj6Ev!0HU|rtMCr*O)&}6X z0*FEhh2PaGGTv@1sKh1v_GEv173~mLBkG@SLTfvPXX)w>#A@LD7 zaHU7Z^B%gp8ethbqws`z>v&6``fLYKAB9Ke^DvT1?R*5}s?z&@;i)4iZsygKy%#Pq zsr(LZ$UZsjDH80Teg~P{gCv9EKCfC?OGnXA(^cwP;&>?8UrBoqce=+(-X`P(&3rs8 zXZ7^-zK_ck@lk4-&o71wrpv+mj8VgS*y7?ZSyoFmS0~Z%FHseGVZ0z|ObXtX53Cvf z^SeyqTCk6CGSQE_L;o#+x!USD)F7D(J%*QHM)iUqBDx@1sDmnPKSOH%=-4`30$t%J zJtf|_?8?0XE0K~41!|35!K-5f@7yM{&SAczma`aE@`oY&4{*Ii6x>@&m{A+y|2@nJ zPX7)*@40oE+z5u#xrKBExT!vJd6B5ek{^{XMgeESjI>P6 z$K!859uHoC=1(F@3MuA&xzgwLN0Xy--U!}8^bK3QR z5&E9Ml2(|G+QLa@IPZxUH~ea=RDLzjm#ScUTh(4&@A;p@2hl83?G3dZ zV)yLWiOu&TxMld<)i0*4L(GX|bIEb&$%e zDmTY(HMCV)Dj!ct`+C%9e(k_7oM1LY^gxsr+AF`Ym!!w~6rKJ8mYYMUY%UgS+**^H zs~rX&X1U7aKLZV-?A4$YV5WSqCaY(oW9lyztXDOmWid)@lX6{#{xrb>Y0hjzKVvR;PUWf_ZPGGM3=}Fy zD23tLU_0OOxqm?@Q4z8Q2)}^*Y^mucZ-TgDY0WPAYGgzvhvCruI(F+0O{(0wcmvq3 zpQR3**ZrSfcSTC8byTS`uw;lNxPF?+MV(NIH=;rI$oOC2T5!j)kZeJ}X+%n-?spACE!EK7xf7x2p0R#%%LBxNjNIMFbJl|j0wszC7RVgAkw_mkl{(~^l zcgLI^6X44x`fI|z0KO-D|4Vo|X<_27fwW<5m?dljf9Z(xVD#&$jFdCQ&V{JC@vj`fmx<%A(}B)}hjN)@$*V?u`#z)^y`vR6Q@?Dr<)d`4yy4-v$r{V zC(imgQy2_Bzq+g07y1#GD^(}M=&0$Pr{~*r({}ORUe;q5<^KEtFh$H|_J%D5e%Rtp z$Q8H^nMG31Bo}K%u{u4XPKeIC)To`RhO93is8SWuNlL^j0-J*(^3mOkDr5x1`Tv-47V?jr>ghA@VwQ!wwgj@9qCR263S4Q&ggHWzS6OTU|w_rbB; zI2|iNHjf#~FsoO2vOdQT;@BP78jMdYPMOf~q993&M{j-}1XGzdiEl{&FzkQ9`MGF5 zgQwO{&n7PO!{ipAJsD> ztnJ>b5q@Y(4TH^vt&Wj}QcphEdeWrV1#iNTN9b>%|GF!1y~`Si(mq;85E3S8jYc&k z)(;xZ*W!AobA(ThkkOQ>UcdDJ-}~Z$D?Xb!^3B%@x}ehG96Cv2S4cUdE~*CG^4l| z(^*B{$THp`|6_YN|ZLb{3wUa${Xayx` z=TPCs&12pX?%+Qk_H80O=3neCFz`Jj?4f3f?wa+#wmippd5Na}qk5Gk(%1hL*tyUo zO_uS1(hM`FxDn2<|H-6Q^6mU@DJ`(7EKda=U99RN6vKYXH?)fRBY_(+XlrPdE?CuW zY}|K~js}?ybeW&e+P!}Jan>okVx-}BHAR5|SNS{&@kA^0bW#Tk|W5fwEZov z;3mRPd<8KmdzJmsA=lktx|t{F#%%MCKZfA$(*>TqW1qt_vB-p1W;q%)vQ-E$BhJB6HvR`Aji2gEWHAn ztU9lmIH~Yo9BrWfW`#qT7nc0)gDa&IN*IZvBENz>SYiLV0sR=d)of+8*c%5-f$)7ZOSZa)V>_qH?2<1WfwNiL<9DjWmit-7fetljUWL-+{;#(kE4sG! z4E2p1Ip`_{*26MU$N+jSh2PLlTqNc(6WM|!!kIM`vgQBGdq$HpjQoY&8(-pmJas(J zJux$^;>gO%%4Cyq8y=9^-ltW>d2?BrrSxyS3RWG~_-(FXH@LpZp7m^)(C_LGuF;d~ zQv9ZiZ7}QgTfv)$vk%%gvaLy6(+ca;h_fS9qMA=_8-Jg{c(KZdU>c^dsdQG=u-=QE zxNP8+EObetnA~e0Ek3Cb-C>BgIt6gv8TPOWa8Z+_j4@v(cIf*U z0;0W?#jD@}E*T_}1niglXnc&J|Xu>Ws zsS`e@dTT&#*gG9+6eY5`<__*qJZ?=_yCsw$&-b3a1slcqd?-vNzeV9f5%ouNtfe!YRM!>^fV zI80SaQ8|&tDYg`dKzu`WUbq_S`$aAs3PwgxO?eq<*eU>tKnj^N11!3pIi6)`l(kfX z@OpS>NX2=~MQc)$fCZwXgeM5 z){IT@)9aAd40A7Asl}(@Q*;;u40a=QE4W2C$Ird?BAmVA8k+x0BQy&XG-kFf4*ULu zEb)jl)}uMkDgJSJ98Xu>Pn_#OpMh|UxkY-B^$4y|GO8l ze<=};Mw}-=S+N&S^xfysO!zh*j+&3EW08SrTU-|4hpOIpa1)_jo@YiL;vJ83>0H5g z(oD=;(E^|;0%9MRIH12>e{aTT!pH0?qJ4yF%&82JV>9RuXr4Ymz84N^fl`E}vy{jx z#*`RxS*9^EoO`E{-V{5-(y;*~-Ro{I&=nq)6W9#quG-i&hx%plJ@ePN#@ki(s@r|p z95^{mS>x8OOT~ylh8!9_quKLYEZlMq^3#awUr=5T9N>rgLs0G?GaMhTgo7cwWH01s zj3dM?Kf!R!ey6XBO0@;V+c}c<^DZI}e!UiZYVCtswSD{m%N2M+`L$25zr2c2|2wm0 z=PZy4!JoX>EOL-crqT#qi6#jclB?*i$x`${BDIY;I*FGtafLCg;FvlaRSF89M_Na%h{N!}kRZ!0 zu!%^~fT9c&qUiwBYAeu0?O)@!SN2u)n=oCK@<-8@ZYdE=pdma(1-+Fk;IEEAT;uFrc#!f^(zhi<>6CZxZn10*sz_(J1;6h@XB(+L@0-JJ9IQ^OnlH6S1r-asP<8;irEVnwllwDF*dE>WFiDo>)nq0bJ1rf((C8yhviec!2ZQum|vs*_jh5Pni zb6HrUfYGv3;-S=B1^C#9=nn{R*Jm8M{sIL72}80WXC>=b-4Nwh_7g>Sin1MgyP@AB zxsSKi6>iUyp#@4Ny-=D%vOA>>YR~1+CcMAjjI3trJmoH{MjLf{dNa^(tXBi6SAcX= z=oKu{`ZaELDJyj>GUQfSjZF#n3nNp8pU|?`Q;EC_&XRZHe8&>~3HzJuvD4uePa40% zl_KscvGyb5ggdabjT45cO7SE@1BhTb%nW!%%%b0u7KNV?N?kPR)YN8UXBG?Seh?7=jHvj8k2)5Y>@FCOd}{-mF18rZ zFyWZVv(1l#(G;i|PrK@&n;E`~ks-Z7mS;TODa~hk^Kyi9(yVh*@#gPGdJ$U=x#z3W z_Y>>-7cV8kR5yYL@NNo8W4?aDU@Nekcb$^$E+UfZ{qtG(UU?J%cZl1Y=#9oWwY_#$ z=d|g~CrU(H`4vaZNY4L8P|jzEZY?f+hSwhyqH2$9^tZq}zXen|-tu(9UKs2Ma`{f6 z6g3BSRjZbn@6`BuLxn`|@YtVYB>NGfhRU(kotz^43{%Sgyj~rzCI4#6%G#e~wOszPruCg#2|Fno+3Ue-jxfIdd@2^D&O8eov zXimViF0N!6cELWI?mk5VJL@rS43+2)Zp(v_TZBil~stg`|GG*Srd0Zv* zznDn-5chq`jjIgVt1MB{WJ+t%tgb}j)^arH#OGKZU4)nT(x(^28|h${M!a?>gb{d8Tn~lMwX3*S)Apyn0T+#8nn+$ zJhjWs)$q}ocNe`~1D#boW(B5BY#L6cpJ;t6((kOaP9GS&h9@gyXa1;MInYvWWUPw| z(_+hFq%V{yw+2aQCFWHpbqdf8h#GL~YOP*vmXoE-iA`OE=NABxeXn3AeZM80)%b}n z&!d_W;U~)t-@lbkIA0sBg+Gz3|nUCScJ`-G0yo{fzU&v3r9%ao=+G;fwoojy|TOV_>*>gr6#JGzb+?R5jSjq7#GqH=Tr4<0$HHWn`aF*wUy>Goy{V5ph=)EsT{Vld>fURJW zC+Jf5A4z=og!oF2F9}*z)#&drkJc>Qu2K(5-!Y3j4oL5~wO(X7d90w`rT<>=n7kn| zC-8aFadW|Skalw56Z3sqQADUg)Yf9(l8Cn6dX-JSu11PHTxK^@O6?*rFoH$>Jf!H2 zkYL&64>P0F^-=t(bs^KEPXE2zX>3?Bbb6A5(_^TrwZkW(HV;? z%eB()@f+g?!r~c@jLmMaRza<~ApDu|l)YYFUBsaZaF> zF`R2%8>$joO`1TvPT&Zx5Rjgz6Au5M)C;cnt8I=gPzMh0$|ii8w#_6rO|ZI?PBv|w ze~Y-h&8zRj#%zP*AgG`&C3y z0ILYg+i#X$-bYF(L?cRPzVoENs8ja|=44`bT^?zPEBLzFSfOfc;1N4TcV2hnv5ZCg z zonhWB%AsKmXY5opmr1~d8@I!?#P*`f9r(1Nw>jiS?<@oLXM$sNj``NWQ>^Xr;r+Mb z?hCY2;Q7TRK4)F;8 zH=x)XwR8d$X35fQ^z>sFx#~M)POBn<6`t{)*l6A7-}ecZtz`Nvw?FgGKb;P@_bV5PmxyOx8WKWRo*H{OkE}AZ1Uq7-1o3Jd&7GDd}7JkC1PV)sC z-8X2f00_d$X6KJ|@G}xwVMj`fXm{o#?EGua_;_7`ay%T?tMawXlSzS@dG^?J3<(vf z;4Z%eK)ZFxRD}D8gjmQ2c*u0PvGWu`PBDS>}~`~nBbja z1hiE-35EwY&XiIkFt=kb^P${U)+M0{#7=6{1=M2ol*ENf(kqn6IjEiVQVs$G7bifb zdYVY1t*X;1H4d)Zf;$FxnK@e#Vh4d-u>CxijE@k~cVCBAoRexNJ6KRd9j4f?$60PK zlFg@^9HQP^rrn&`S+vP@0U^y@>!o3v;y^c)@N#%WeSKtJ6uRpA9U!h*zN>+<8X-6m z+zYEXn^I>e6^ch~Pc4{m-du|%%Se(_Gfe;10o{oCNW_akoeP*bNF4Z^K8tuenEt6m zU+-xfZXI~{L)e!_PZr49Vo=j-B^(S?LONHB{znpuwAOZbTRNE9T{2miA zf2M8nj*jwcci!GGnkp-?Z`w@F+>Nu?>Bk8#x(l!$H?cA_35sn!GNPkS8WGYF)oaS= z3}Kxc8yYrzYVaZF7Gp;Q-pR2Im=I*GvaB9X#8z3k44azE<;Cd9r0i&ZuAcC9f3cVCQT*8-w&!;&D$4~wqt_OT zpiMTE)x%azY8M=^*|~yyzaYF;-#;KK#lONx+hwK3rabQtbvLM}eP?rMaGRYOh@Txh z5;u}x>uUisrw!ITVYiir)c_X-Ha9z%tL}q`l-Ur^qF~ChzdAcTXs_jy#>uI^&V-pR zJ8SS(KACrz+L=*MrOWWB!bV9I3})W%w3YXqGfLdi)qD3Le>l}h+=>#HXxsz*dc6%w zMJ{d{e^1ldujr!r?YZ!9mxFuR2FpoD$^%Q+ueXHlOMwaL+Z-|9>vCnadOu7!R)Emr z6WJ21`+e8!dFBlFA3nIDr}Q596p(;F2!o%{NFO??7N3gK>6}GgR<$vnIJFxnR>pII7(PRw_w9g=0j#V^*MG6cz2t|Lo<@Z|;qW32@`bK?^3K{z%z>;M-VnhJI&|fy% zO9)PRU&O3G5#tB7JgK6-^eL?XW0^$w@R5|y3Va1%NL*q|J(#s}sh_ds;K6}65#p0h z$$*SpJyQPqxtQ*?s2)$lhjYujnpj2jDh~sRt_R0bjY#_13&x@=xUC=LJ>y+WXs_2X zBtN~-4c9HGB%-2zgW4ZK6kj!K!h~(`c5`^LPUm_cl(2=pS{^% zF`5XlOLEU+nXty11niD(b6h)42t7O=jx!fF86e(W6=5w=G+tDx)akZ}9dULzF5(c% zc|Mrff3I6Wbl1}~_}}dH#;kUklSX*OkGrO6?dN3l)(N5C1wuLu^w6uz1Vf4>Z5yAk zUtge3Q$T_G@`Sm_q@wZ&1N@mZGJiXGb*#$kw0a{#u>}8i=JWb~cf$fy2>UHh>hFF} zaI?3ub0C+VuI96fu0NJI@TJtWtm5%6$+ZhNSK})D(8Ejr5}Qhree==0Z%vGAX{FBB zq3%<@m)Hl7a-3p89xsE;s&GH2^y00{RUF)2+7^5je^2)NpW_9<@m=7V|KFkIR5#6` z1*ardc7a*~WZ8Lc4X&4FdxupBVI6%+!%?XEWFH5=PWXFc=Q#F_%}<|KTa+Z@Um(Yw z2MJUlw~aggyS1^qvNj?pq*Dq>o2(xawicB9Vq2+BA*N4%Am#lpY~U%y77h=i!(O zo&9dL$jun3+m5eduBmAinpoPz(6!{J7FU~xBpz<44$>Y#hjqrL8ndAlzUx88auu)~ zde9$lj(ygfFeRS6_zGJ@ODEx8()Ti^B3PH7ybi9|+e*7?*A(_7K%G;F;DptdU?R&s z+nd?FvN;h97LCW#=q=PtO#QJ=oq(s40(M~4Mh%v%2C7mPiWrITKsxK=>epa9FigDOfa3ek<^QT z{1YF;cbnyEVRipHuwZj99-H05*k>wiw>*ciqQZuIk0hHr?TCV@L*sI~I2uL+?qo_K z@!poB%EpnlKfmFSpa&+Qa)j6;B|k}USO(47ZJ+_q)tbptElsN45e>hxkfbG|dj;^k zj!%k+?1&#Ap<&)zPNeT1>G1L(*iT4bEzp^G=;CTm>Kt{0NTRyV=uG>N>UFCY{?_$% zE>X{R>4@8H^9&JWnjQcs3V(7LKP`}u3nYNEI}hzxHR$tR+*$Hro7}0BgK3^gDg-^R z7{B`xv!9eu=RdJ&hB!KGBdPI?{GAx12A`SvKD$B5^{U#{dt8^vnKelA$ys}ZeWHJL zY^!8a5Ov=;9F~h2Bm4|#ysJtoPHaX(Z5RhPa*>rVO-q?6jjMR-;b`e$?o+9xSSiwO7zkBS~>SsoDZz~M@sGSq5CPul$ERd%wT;u!eo+KLs zSOdJ*&{Cs+ia9ts5x+h*{y6>sM>Xx0Am5dy#+2NcadS5!2(H2`JxTQu6kB_oF6E#u z{qL3i4g*cOgftTiRG^8%WXJo;yO6}uL>LOG?;tH-ypbz56&^&3z7FnP1QK3x(nGJB z{l$^ZF|$40iJkaB7)USzQ@NH0hLTgd|Bu}D{rmfSr4>kL^vRNk^|H#VDi3d=$WYw zHs78S0~Y+}*oB8vs~)MpY|4Q{`Hqf!8VCPGo3xOA?AD)heQ%50h-8rCb;HIkT>7dC zE#rvVP+HH>vm*k;e3`sqIfG*qBMgSCqCji|u;DCmVg8jW+O3Qam-KSJLfv2x1y$W3 zp>Ms%YSZW0M+S0zCKS54T*a(zi&yKUY?SDi_8Z z{b6JbF|UbPjS)kL)~|ci3{6HM>Ye)reP(x3S2gB&x!gn>Z*vNE{USjoLsa%gI1{S}+&W?bwr7W2U=>40 z`pwuL>@SIEv=C1V$U1vUIAJYodT~{?+M#zjh@R-F;plj|Hm33OE5k4hNn9~$QXC8E z?9A2z!>$_B2E{6QV9Hz^KXjh^3bT9($(K?HBsgOKOK(0ZN&f#@fS6eG<5j$5u=AD6 zn~7R6a8RR=Vt)A18V6dTC07obrW`&ds50p$c%~_QWTjJH)5gd~FVVl#e!;)LPY_EN zF!7r#D7ux4Z4+YJ(~v*ehqtOVpL9X39nVVIadV#E9fT>G4G9-mqLU1Hhf2u&?>RW1 zk{ZH13bDQy60aDX%mm6XO|S~;DGbW-OL15KsJCCn+M-Y=U`7Cj9(g@L@Fw%OE40jf zLD*RpbaS?!-HagEk?~)|HH*%Wuew-JIommgOR1QACP}AB@gsA-uA(@KlQRL5H${3Y zEcp~0=&<5dU0SynDSlD@GTV0}CM;0bYrGYx+L;h^+L^YT9V7^=l@raIU`b40?bczM z=nPLG1M}tDkETxf-4Iz}O6*l~{AU|Sm(MIJAr&xeMWbj}hN&K~aiqfe-@cDVTYqjC z!{l7DE<_DAE4P`n2>meFZ~?`OYVmdWeFQoA_Ks)N?;=x>hOyA~exJT^xZ5a0d?5R_ zoi^65#_1aY1^S znp9@ke;YpmpwiCT%Dv%rc%L%9ZlY~2YIlouXL+fC3EMC=*t#&jc(B<0A2u-er8gyb zI!~TUuY(gk{LtCq6@TEf--G>CU$+Jc?>Ry)=&qiqtsl(`(~RlzZN2g=I8j=%LR828 z(CllENP>sg8u4i9S@;9=L*DiMa9PX{bTgV5{0>&1{U)#FjvP_Aq*O&)Jj9z|z~laE zsLXaLzR@1vVqXfoo144$<+@_jkpzllm_%MI7}2BjmIiE3 zYXiis_v&ISUjqCBek%pXBQ2w$Zpm@x_S|BJ6sGUd(qxOI7(2`yuhmi|;0`}dET zvS`O>>MsX0>_XlE1Q>Fu3dCNl$Z6ai<`?&kaN$Qn$d&&+*80;@3T=Iy*DNA05gp@a zx*ZhdVz?|*m^LnX6hcMuXB~a?^J3!0*>BPhpR}RWKZl6=h*_yHO2GXHh?p(YZT@K% zC5q|UmE!f}F80oj7z+CxDmigAS+=+%CIt}P%~kO`6dpxmYO~!)bIN4!65#fh{WjD+ zGdM}zC^n5hMLFy0Q2O-_{9aa2v!Q*#g_on%UYz*^GkEzZ#(I{5$!=T|#MiL!qLhz# z?SDU(F}p=&E??AS_X1ZMCkLg}N_TRkQ^s2@`~}0rD8JBOXVq~t3=z>PjnWD~Wn~>< zVkzkom3+s2M8Cb|m*ZM$YQ2|^Q$4{7nMKq}hxXehg(+*i_RLFG@nG`egD3P!KnN3> z@sMNUw>~w9<~r3pP)PN7 z3BT+DAWuWe59QsEi1@DI5ABWZ_Nc$0=-^0CxaMvtHB!3tKkUk3a|7VMvz^axg$*f_ zvZg7N*&FXW&MlOQFrC{tc0GYFCm5#D&@Eu0f1W5OPvL0kX-uL%D)mld4t4zFAUqxz z{*mkK5^zMJy~Qz&G$+nVXdW!~p#mZxLN8idF&FLjL=c_-wbSBpT)BBh27*{flcF0& zmb$DSt)$e+jn5=*TdTbp1OJ_?8}hXvv1_ORA~mPw>bTAtL-bx_#8i{D8<(RsMw-sa zsD=%%+ujW;^tW$8lY4!3h6JBeEEAi9*M(SH5s&^OUF3xG(X>c^vDbhQcEb+ceJV`G zf|xI3_n%d9em;()zC1c6o;#$yNyFYs&e93`4l7A)r(T@%YdAE=SNLMi;B-oE}!-+i^*J+S_=A>9b0fcPs_hIO^=f)UvVJW_^+G3tyq6n%EID2~cI zuwZPFI)Bu-W@P)l{wV6r0Kzg3r5(H>>8##!KzU7b$rwrUoYdUyLnnfPHu{LiCnKeM zp?uZ3wKyPvcevwbkVzBpnVme0b8ly}`1`o*M>_zk)U6@f)*~sXa$!-m)Y43yo^!^- z?qtNZV{aGNmp!OXUxeAvob;s6Z2H|t?cbL4 z<5;M@5flm@FbD{ohkRiS`?d63#T0RZ&H2%`cww)F@-U5V+CBXVO^wQ{FH+oRX=Jkj z7~gK}yk&}Vs`dO-%FT14`iLA0&Ci-=iYnA;T(1PQh;n8Iedj@zG0Z;y$d=dS<&a>4 z1?vZFuLt_esTunAK^$9U9XOJ#W^>l@DHa@Rv`#uj`ln;{`zSJjops2vBa1aToaquy z*Cm(lz4Kg2QowGsMBbpxJOohgJtHO`TOh!drjYu_LgrJUacZAd7Zhal3>Y)SZ?pk< zK7;hUK%0HL0k14wa0-Dtnow+pSUX1CsSpr8nQz=QqnQSr>00?DIbs~-yGYg4$%u&M zLD6pxd)z(my~VGJ-z|B|x67*=_08d`7xl&B3oZ78O}*N_OwWDheLNTs~bc~KmN+;;ng5Aideb=^Ue z0olSiNEjSf)tr+8kgnlf=Gn6ohkva=%|WNu;`u{|t!M`CitRl-*`>-Sn-;j3NY$m0f4nFuG(t0{{7 z9)OOpwAhDahnW(i;q%|S zid{u|dSvc}ypXML_&@#O_L{ZG7Q%mdsy3QpAiiE@BwK}|zJ|T_`ug+>=1U%5cU=2; zK@=h?JYrC%JAGBQb7kcA!l3Pc9I~8}EGVPI9g%lknOSaXY`no_9}u>-g4D_=!Hq*m zcvI39PWeuZT&GmngQ-h;BvqAAiR=#4TS3ojPmIEyGkSri@*19@Le-D|N7FU1*VT2~ zsIhI^wr#bsZ8WxRn@xkpPSe;nPK*X8c5>p}^!=Xu3-*52nsdyt#uPdr%G@1zDWP8i zp(IP2-OUq2532|lhO3h%B=GbaL_NF~>7$>i)tZn|S_J5>OOTHFKE)5@V?6Q7rl%Uv zR`SKGKo=agt(sYBg@a0?rj85I*Olg{xB3;NQ}J?t#tim9Lu*qo=f_OzqV$$Omy=BXN94T9oCP$oIk7X4cu@-4nElmvLS$NgH2>|0$wju=9?ji3GU?X#h z@|aUI{bXyhrn8MkXFlG5x1=3!R??nMQ%kv3PuChK>kCxkB}N*dHWS&|N@|YrH=wr^ z4l8FjCmV)?X+nvzpsBdu6Vxn~&mgdHJ2M@`J@zqiaw0wD zjWlsZ+Q5^U-MzISR1hiys7(2FZp0G>Z10^RosxW)MgS6sj38eI5;>`w5>;vW8g~qv z>*j~ugS1X7-;1G|{&+OcmWf_en#s>rb;$g(<5!)b2A8iDQb9AwHS&b5o4OhvV`gg< zIpxcW2PRhMgN{O7u?@9Ev*YmSEL00R=g{fm3{GlyHUSVGrd&ZtkdueZG<=`eHSLf_ z(pkBB{5Jsi993$W(x<9RUqkoL{LM7X%yHyet)&SGdRh^9>^&9}KlaSrz5 zY9PVrqqHtWh-x3tiF4*L-zfK{uKOtp6!?%w^6t93goqq*?TaPbz>lkdBG7Tw(L?lj zZ}g5HPiXuG{FzVm08AEnI|X@nqmWU#lyf$;L!Js06<;kR7TgZCb*qk~>)&4>tT1J? zB4a2*U{m|u-2&6*3hi}K<)ShuJgE4rmirof<+QL$C}B-QqH4MmtD^r4jX-CZE2I*2 z3O08)sA_WVI_7&yFuE@}(zI_mNuFY4i{40IivBns`AUeBDML2ya=9Sf9y7E(Xb{CL zHU74HIi!zARWi#;FF{){g~Iw)DZxA}3P5T(YuXZldG9ApHLbx$Ws6z1SNpnG-$iY} z3eiEx?})51tjoWMEnhx1ro6L$k;ewrmo(7c{Hi5zNm zw+vL{2>W)=G|gD~Jup)Ay5)G?@Lo+h2_DwSWYa7F5&cofQaXCAX3_%RGj%2Cs?bo$ z!MVDYe2np8Z7~e7A%x<~KI0xt7X3nGp}e?T_FiD;pd$=`|U! z24gJ*5%eT$jOr@*)iLCp!UGMa`%`ges|npme>7u|h2Q(~^Lx-ynVz)G-#)YY7iPDD zo6K!5&*bfaRC*_QvkngCnGcq=iaJy{z)d+FNXv3w><({Y|KoSl{H@9N0w-0C^Zb}z zTpYsBZr|qMOO#{*5E||jFCa#DPh3)9NXy=7>9}nld$>@T(3W54gq{ld!D%9Dv^8B6 z)S4U=(d>MxYq3qan|J@aedgucu?(V+b@g_4KxvgtYU>goF4igzi>T`#w~k8P8oF64 zvp^OW_cTB)Z>uAuKK}BnmAVsGccXYIeJEsIrD712N$Q67>ehz720BY)=e~V)Ew%$LDdP3 z;)*3=3A(XjHu^k4j=dRxx);fLZTlo+Q8Aen1u$5>*{}uuW?ar^W8KNza_jJ(Aq=zr z$Jx|QGM-`;#^di*uqZiMxub}4l0|PY4GL;8B#}&IN>)Un0o72lNm8k6^e_NbPn?x3 zEUKA**7NMY_@1yw=;Rx;2N|pcaq0gUw<10#8i%wRQOcoq@K?wJ2mGfL*0^@)8Uz`v zxXHZq%@sUc{kn4Y)S=?KZZNIq7YtcCbT~JOphJgl&|Bfv+0fsvFK}G3ngMOah#P=*<@E;!Zuz zT6}&HnPrLRG{*VWzK9K1tVs#|6&)KsGsg8rMG(7?wzS3VqTF*xPARfX(+BLk;pCv& zOO!>IMO$C+2-C#jE&qxR6HZjFov69~dO1l7EauNQ z(|%zLWUfDAx9exce0UgPzuU|ECXtZ1a4&>VUSZA6gj*C%YHC)JH?iSnxrNoQb`bL=KlRFUn9=4FLV{1t;R>| zcd{>4Sm&)|fuH7cb1;C{6Z!r$t@IbK7bUJNJjH}o7#lG3LvrSCh5u9K-q>E4>^a&= z-XtqxT4NU*`Y#Lpk`*)yX)07i7j8?K(u^YfQ{h6*evsxlh}P`EQR8njt^>lkBk7<| z;C;8kn``!$n!TLO!{=`hx&A-5ONn-3_Ct=jTknN~29_d0oFmmM8J)ox>P z{VC1}t?g1AV6&4=R<*{b?J5N4sU*%yj%DBPq-LH-e<1aoriQl8*>Pa_kGOrGHu2?E zlm)Locrx5QKx}Nmgwm7LK`<+F+Hsd>rPR`YnnONNxWW{MjYgUEs@+-OczTX=;v{b9A3lE z&SD)vIuUN8vvU&OGd>p+gojKXYKX}f^^jJipWj}rmQyNYbg1PeVXL-;OT@d?)n~@t zglo_mT?$~EQYYl-$fa$+Bu9K=J1LNLzD|ccn9u*OXHLnN(V-fTKS7({{bQ$<2 z%;G~^3{B^hQ<*Sh-IdOuZgc~b~fA1AwrHj^jZMV9-& z&jbB_Lq1|RFm^SO-O<&NxLZw#gf2N2qLnC#M@vuN(|AfHb)#U`$g2ay)}QV_tUlQhMUB`b=bM+x5|wL_}b_Pbx-gK0GjP%l}TigToGPCXoaBiZ*M z-?SrsXysnr3?RnA`h)Bek#8em)QB?4MP9~*Oa6si8hcazqai$5{p^a_IkfQ~?CO)0 z^F5x0)OoghP^p09AInd?TxyNju~9MJ2?^{!b{)mRaWaBMyCCG}D=A2fDQNB>%CW%Cc#g84$l$m9r|Z%`S2q6;cI{f| zoLPn3H>NYst!V-P&JhtlpDUJsdxYqKL$EEs#5tJDkauB6FmoU>a-88%)v>LwPN%wuIO$ZyuzV4!WR(*qj>x&ZNF1~iI36rQGg*TcrhVV1yxak>=PZ6V=$mfhoL8)o=klty`IApliW>@>8 z(-L+A$O|Z}Vp*gZ8p%Jx*LO|*+izcg9n(?gig`EU;Tl^Vi?|rR^zs=69)V`YJ8P(N zi#e}$tymswJEfW)xFZi12L4uS$ICZgcTd@(_bp_?nCnetCId2>G;_CxK}#5CGP_aG z&DT5}X}ZzlA&YNR@2@U|nJoUVuebREOeoK&8zB_ZGZdqS0Tj$zR1@5Vp*~!`F=4gqV$+q;?5izW7`k$<++XiZWy?1 z8MdV@{gpH((x)t5yVKDuw+Njzm2SLwXsH_qfRSZB}|GQ9y{9eLoRQ2`t6KKTR~ zP@tnyi47{`+P3Eeo4)FgZ(V8Am&ey3;)4~ot?n~=v*p&d{(ou5ypHJ4#J#TQH;SK9 ze9KZQTj%^nQnnt(j26{%O`7iu(`lf)Ii`Pq9)uL6x_r_S+ zsTc$14@rR4p9FluKHPM>5r)R~Ei#vR2yI5xnyw6wvf#zRhQX#jaiPw$=hcVj)QPU; zitHvf{IVqRpLi4NpN!+mHoIx+=w$ux3L~^X3>l_JDV6jWChdyk%T$}66JMB;QdC>( z|JpOk5t#~hS+0lrFw1jObmK!0eH8Z zrWu4^aT}Y><3D>n$vypq0lKP7=7(Xc-nW4kWZ*j8KuSlG?S6<(+Vz6 zyAgQqgiNU7xW1`t>TJ6A{d`+sKD)#*rV1*f=J+-WWv7XNk!h+Q&(H16hTOULXBX5S zWf=0ub~BoYsgVYjR=DisnQj40CD}Ma&Ziw*a2_NrQCR)fysTJ%9JToaD7Rjpox~xy z)7H<$9Obk|L!^Ws@uMjhJQ4{3Sq`K zd{MHIPJ)e6w69)@DhF)p@Ap7OPR9lfU(z`Z6cZizQIwAS0n=4 z45C9IhP@O7879<*l?PpWjm*FjXBslk(%m7FsCBN!^hf~y6TU8i?6=qhC!Dl}MH zpNhA6rox#4p%6f$-z`{Sz-vfdAYT&QPp@x*D~}yxwy%ZNJX+YL} zqY%0S;LC1-WI#r|AMF2bjSz^vOI73)I%zGo zI^Os1lO@-!R!WjOxrLeV45RKzhTdOFfLbDVuu0HUQFDW%&r)z&9I73iDJ%+k$IBW3 zo{<5}&9&5=H5~)8F7F&2Vh|H9__H7inD>vfY`~;Rwhpo?Mooo@lSRONk&*z%YKDzn z8KH+xd4i6qQzd z6<;U1Zk@k6m-O8S%z~6t5dLh+r#>vsKV_y> zFV2;7T8WvdPe5e%s-6!l zi(Vu&vGOIT(F8v_^wt6Ijc4HbT^Bmr3I}&GZS-G2QX$W5@q3ip%`fcUF-C@gyo28a zdoe(YuwT%{;t2MDNG#}GccR(y>KxG84oKx8p&`D!Lws`0NwV7kR$#0^&VU&n;q(=0 zn{q3Bs%>i9)?RM`J+W<>qkWxwatJ-wr`urJWF{JYwVHVX?{5f9GaYn+4*-&;HlFT5 zboxbkuZ>8kg}EnzlWZ14#KGSRQJZo|fr$%$M_LH$*>}4@g{fHL@)gJcPa5~pEeK^@7*iX-oGL{yr*r*lXT^)ZNhN% z#Cc{xy1t!TKi0?KHw7^u6io*k(NfW%#4#fnE*1!8TV9MXO9FmdDeLeC=I3I_Lvqq3 ziBCPZ*WzLu2&gR0X#P9j)eH~F6fav znl5sPo(G1j_3bxyr!!n_5qZ~kd`;}W9=jSN;y54k`v475rt7A{%LOZIf}?0Ffh)#5 zUFnXrU@*V8iK|i`{J+}Cwk89#B^O`Jq&U#NeMuFy%=8RgzAgcsT#n4DFB2|<%KbdG zp_6+yVE$tM1-DLI!+R(g!j+wRJa-!(QK{-;rqEMui^tdHrclsy?A_ghNBD`LpyJ+VDI3x^wwXSPS~!#YlIUz zYiD>LewMlGOg#Z}1K%4CU6i^c#0TE1tGGkH0(Oo`iV(tOPrP#QDrd`NF(5<5n8kSz zp?+OhemeCe*Uv{s`W1)Vqn9{(Mf|L?wYuNY+|p7k3iYSyUVl zwo!h7O?)DwAMgzq)ud|v?0-I9dXf8dqSYgtYG{fy>EdoH*2`Ia9LY{^-twFr$Dd?^ z_|5x~Bmhd}^}76ZxURqFDc)%3`X@!hh_bC1aDsi_loXrRq-gbf2j`UzYjI*KM=-{EWijP8+1K5iMcDD zZ&rj`F;}sUno?RGV!flf0;%E}*8iv~v#`OM=>ViMF6Y5mEwOQ!_D$V3MaSAGZoiM> zftO);H4|iCK&@%QUdLc{MEWg56HKJhbu45YEC0k}sN24aZi79d9m*oPo2W!&i#vUQ zpMk=QhaKUYt1Lb%KoGG5@nD+aVwbM<&l~UAzg)Mq@2OXGO@Eyw7zKk%v(Nfldj4OG zwKF$8dc4u9vR^;r%&Pvf?XrT6CQb8Azz|8E5(6&^5nAMt?A55}gDmhmwEWI5**p73 zqXJ?yr8r{j&Vy3d)zDmVQmTOxPj>(roVDwjOY!x4%#(fg0*- z%fp)72VGen-rYD)TSi@^1X)sw{VP56W)E(cqUt|T({gmGdR8b z1xNyvTk)&yEet#n?AA_~YHT(*Wuq`u+YMU`=!m{qHQIa=BZnw95f%jpjH!mtYTMW2o%~#gsipvi_8I1I1 zXQX7|%2G!^`!rCT2B!6RgI$9|nIVI5*O%~Hmk#vfaetOduZQ~jJ6X;FFj%E!=}`Np z&ieEG>Hj;zszkpuOqhVDbpdN=q8l{t9k=+n(vmq?S~QH6+yRvB;Ez3#x41%~bqi)E zvj^N_u_@6sYx%`U)?v{o%(BPk^FJGq|0{M8bpC|?+^~CsMxiR}DG23mQb&9aTk5p|hoNZfp-JW*67wky zfrlw6(uC>oymWQPXdH)5fDOS)KKGd-`Hxki9zFj@x-ufVz6-a0H=5e=*}Sy6)ZzJ&FW=SiLT7iyW-Jrx|V!+W$H1T`aM1yDS#^%mMlr zT?#Yiziben0&k6C=U)QsJ`?;^W=RgH;`TFi3a8c*KTXy+jPR_a?oR8got?@Vq zBabL9oH_qBaAep+7(s}!wIxoaL6mGka!=*`CJ04@e+Trw$#R2WBp)SA0o}(~;<3Oy zdOqz$00|SyN`QX(;WsI3Fv`@b%6h-ja1h1F$VIA?CY8Cp{q897_e%!`87Ov{pH$O9 zmV2uh>xk**cndl3IdtZ@rqu@?^zOIJB@q1C-*sKi@;I$U)2yU`r#$l)iz_UmA0yxj zMTKYWBBK87>Ps>`hUeyWV{O!g$h+U&HR#E|VsGy`@|46A^wQzGo56XrY}$Mak70FT}kDTJ@+;t0Q$sH}adCn6Asd55)M1Erq%8rI_NJ zF?a$PkIOt;!W5YSue>V4ufkRg0Q_>0A%w; zR%zY{&^abYX^A4j#+OBFP~ZCb*xUa1=u}RT&C`?G=HL$+;7SiP@RydfUaueL=Q1J& zD5Re_xIt6_esqSAEybgkkT1=OAo5&Zz*(MgUR8w4rE=%_)b1Dn2uB+cGmB(d)m3ve zt!Yq8;{1~wEyYd0B58P!LPhH_fHOfHfIo;Zl@_!tffFVOV&pSj_TXlmI6FG~Q; zl@~mn`JyIxrA7lYDIVOgF_Q0r()-28jQ2ByI)V}e!$7FcX)N0e{ET4`DqW@32?3d? zt>@ENM`3-NR^V3)HcyjNZvNyTnkW9xKb;mz?em9q%uHIbS|(?%+xt3#-d2K`12Mt4 z!%DB!m(Y`^F!LC}-YHyG`Ykd}c%O;BoIwW2#lBe8ooI{HI17OaLJT9oyUW>}N69_i4mbOcULy~vvYvafPx&2B zRo+C0U5gP;rx~2y`WOtayTw>=iOYti&4LMCS&7iT&K3HP1AV_j6PGL}T)NIc2_xWQ zJXj9P6vjO_k4cv~XWZ>kW*gfd$!Y{vynU9&B7SBR z`Gi=_t=Av=YTT)c;G5q5D2GBTkB>#Hm3Xt76kuF-*O{;95hd^N$NY;Fyg1b19F2-I z?7W3kK^yM?C(!#$)i-P0?Fr)}NOEb6g&a|48dzYRQ(2tdC2moM5qz|afb?v?{j3@L zw*8ZT|CftP_Yn}G?lcuJvL@go*1y}K^HSBAR(ieK2V=Yfmxb`bW6b_n?!_*7BW+*D zNlTT^9DBz0(gN^N@RX+dSUZ-!jZBQk4VcB#G2cRrkp;Uu^UjyKFvu}F*9zEZ$l zr{hD#c&O(}9TRt3Bq%S=X@*<53xN?c#tV`c+{1G@25Qx+-H4s&)O%ruEkXP~LmtfV zMRnme#o(S!@31q6jjFYxq77>R+AWad(|MDgnv#sI=@)>?E+5{%u0Gt}-XSGL2Fo#= zT}V?qh_)DYO+(nXPw0^Tpf?ZePE$ipxt*zU(0XsDf4XT6ct7abRyt)tF$ny#3MhQj z?>W97tJxyq3H;!BdEIz_T@5AfX)@F!xfT*INeq0ONG@lAFNGaS%lD8El$Fu@LY1%# zqcMres475x>>~NCg2^5GMl$ZRE|d0ZhNOQ1Sa?J7F#veF$xUW=!g2A@C!OsU=@B$muOpXXSgk&t{FNKz@D7U&${%#*x)5* z9t|<7%_bkD*D`^x;PCt8#%9NVhdjMiJ>r69i=bIRf0T$65-QE4O_$qVCj=l@i%7l==45qD0Qe!0PNCTd}Clvr%*EyerlmG?foWlEh)R#TN*g zl%%!&2S?LV`xqlS>dn{((Kc>g_PoLQc7R<(d9}M0b)gHvPK?Mi?Jc<=nnVI|uqBhv zu?X#NiLz)W^U&14ESbtw<28w)Tyya_F!qza&q7$TA_|N1#E34yD(OMaOq*Q(k8~Oy zNw-iTr$nnihS#BJ#K=6!2;&Goy#7XiLquF&1Z> zY|Enx_xXPCe@09tnni$KIVP|2Ny~=}r|x%p8_tAfFNtn}|886po&Xg?3GF{9Kyr4A z@}Zxrh2=n75~NjC1-0MD%L;HyM5TqE0g>v35;qM`;eK5!e?1y06b(z#%_&=*Rd8o4 zKK}>G39e}KI_wYG9|=A0m0=#mmcG+Kp@mi*=}N=Xkf_+1NyRd7EQ^mK_-8t)@kW$V zgH?Yg4ZJ_l7It=pEJg5q18(FMYb$oZcYb6$o4raXJWr7e(kxO-PNp+uySjoP=F{mI zFT`mkqhzzyogB2Q$2llI+^qA{H|?TG+~?0K=%AB$5KK?b?KZcvDO$0EuHl9I((X~P z65tsOJ6K(hZGm!SHJ{Y9R^5a$j`{f zwSX+*Hg-VXMJw`Tyg5V%tC4N*&RpDiJ#28`1`nBc??RpjirnUmPx{Zn`Iz3P-v^W# zE<`#xQh)n+U>Pv59Qj`Pkl4hgJc5hE{uB8|r=SRfow zu_v+<^a5pmx2Hx$aI=!KbPhv@sLMU(sGQRBQHT(MH<>W6PevbkS}+}`+qk~VTSTUD z6`D`EgK#aAkn2o7ZUn(#%JJLY|%7Emzceud2 zE|itiVr@yeisJ}G#~+}|?VD@bNQG2|iu7LL7ed&t^=`7l+~TrPoPO`C!cBbES*nVx zH;)RAt^e~W^u~IW&*chCB~0fzjgX0I^~+GVi=qp02{xB=o)r5K?T;(lLJV*vw>)U6 zaqw3wFY{0;BApEKue|x{FC)-wIp&HqXxDU|2fnI_l4F_G`}V?~JXCJ*VX#UVfV>{= ziwZ*?E7gG_N+>2D9*4Eas99_7^%OM$Xvg~8b%10P zoGv@1N~d$D5AgSW7Lmi%cQeKd!aA(cb9$7nq#Su2sTh&BGrXAsi8*XWQu3_j80pwn zTB|J?2!sd-qX2?{1Fdc9VSi({XGk(C`2|*nuUl{JS1*B&%qoS!KjqF{C*+}xCe7ef zLzp?tNy6w&Rb7Um9u8Sg5#+VMD35BZ3s6dullZ?9#wV2)?OXKG>1dK?88@H0Ov-dxGK1@Y>bzaUbjYGgwH9tvT-hDZ93S*O__;O1K>0gZ4F zGX3Qp&IBsaD3;EYk&be#xCfmrsS*e9D%dT-addxr53&RR?Oq?PL};K(WP86Q*QdZK z-XD2(#e8Qio{?UM4g8oH&P_DB5$Ji!iU=01t?>oxatP_m*+{wlnAyq|8B5lO$=UY3 zK+FEz8yGCSg0m`^^Mf`fQZ_loOO_+W641=SZE0rrf^(tL(Q4V$TGEm~N5~#8jYAiW zp6j1{hOmjmc>%2&UO5R10zJSaPL9(PesL?VY^;3a2*rXQ9gTAXIR}DV_0zU=rvraW zVUNNx#~`jQCjRhE5Qp^cm)%7u)EF+N@gh$k_Zk#T!o8hKS2BDV!rJm8V4t;+GW+hh z#^~yvbUvO%Qk&;l;WKRSAG6sa{E~L13Nhg1sL1F3Kx{^ew!l@p(W&GyO8E`@oI<9) zn?7B>RQ*>T?t>fHsj$UnX^$cD!!5~QWE|m<(Tj?HbNG^UXo?aP#?ZUp`f>=DO*_3Y z=ur-#`;s|jPgQ=IDUbCrhkFHuq3^Fkbt2YMn+i7L=0P%oKl}P<5eCd&eh*lDm%(=TZkk0jcFH$6szSqvM_39SopF*f@4G~D$-n{PSnVe$k$zul0%D*|16wr zB{W=m=h1z6;XO&g^}yfLA272mqhDrO-$)*(g^3igBIIZs+1a#xt3dJ?h)(h5rml+| zi>h5Q6$pE+xf>O4=UY(tXZ!8I`qHWA)VJ=5?+9qub2F8kxGfg=jy4e}^Y(hC_r6*F z>b!UZO5XlRNi39ut5^zuVQcU3tU&qW4Ct$%b0}n#0E!n9!O7bLob`&WCYS7l>UzE> z8~MR{7vJ65_vF0^Z>5X+UMOmNQN}GTwe&UEX3?^3l&L8F8WoH5%0k)w+`DPk2&gze zi>(rq%Mi;W`B;Ef$x}BBIEFLrR$36>21y;_LP7dYf^MVFC=lxKT9~@(>XVmw1E0uO z#|{2ig{k*hf7cN{dRq$K;6Sb?zv`+BNalEEGq;d8%?aO9aeW_PdcPUbv%jB}amM+U zlReBi!`=PT*$mlMN7VdgCG#4z{pN2;HMxm7Vr@FuMFYtLDDQM02IdAK;vET%KXcwJ zf00H0GkXdk0fE*jER3vlwsb2GXb(WEw>2`1+l+5dElzK*6*GyD%OonlUmNajZpBVX zU^?H4+XSzE1A3183Pk?Q%%HT+oT97*xGkQ3{5%chg7l)B?f4dLo|H02xP&;xZlWmv zF#+a)kXn1lOw1gz_Z!ZDeOQf6=NqT;ksoiU=TgRpFb<|}F|NumMX3#LUeA3t->gqHlAO5LZVZFpX=Q=@Q`vULFp{VjA^wGse>`1zGPH;|GF~YtJonJYczbkS`qc zl>v!%6Y)QPaG6&&Z&M%D@QA6TmKsrkUJm{>+Z2-kU?o4LyBcR8ao(l)?bnVAQ;t7M z43x22LEE+7__KP>tKhUXCJ<7&!E*?#k!8cxBR-kg7~yLfu({`b=02OQOap`BzkFH` z?4HV|(eye#=1^>_6{khs8=_a4kSyf?=^Y}FiiX3C^u<>wbD0Y^(QVz-7!T|5Su0!< zaNNc{o2B5Km(cG1^=YDK)tDkUJc1Zf?Z=Vd=OAs0WYcIjBqP|1jQmDtfXbYzB0(EK zz^`E{R{~bvs$ZJsXQhbdf3D64Rtf3FCpr8j+?rZaTsgoEd1t7jDbCiI6a7#3NTsn) zMHKvn@;1%z-t#E)rDXc#_MEm2dDOBnAC3REhe{YKPd&;oa4fx- zI~YlZJ>)DWY6%6pymSeN9p(Sm0?eQhs5KoFN18O8|Mz_t^sI^E#@q7#B&5!o;xtta zs^K76($y?F;BU8IdsU zNf-YDJEzHln0)MI=W-Gq%6c8G;rzJFy){8}iD5OI_=0|0gHIjHiPbo5TiT3@8_}Ps z%ERri)i#pZI@|`xn-(CvkaCKzUHR4H2y|iyk!xE9BokpVfsI4ci;)?aVq-Y%>tc@l z54H6>`n!y<*mUzUxbyxl`;5S3I$opHZJGOHP^Y3(TELqkkx5eq9SrT<|E<_eF3z=b zaV8s8PW|dZV!J)f{w-aQm%$)mnePZnpB>23TjgYU&=#=AcycPrJZ)>lyt?(XqGS~k zhuMGf5ZjBfhUpHCy>yBQOKdbw*K1dY*OU6MO{!(}PvSq!F6FU)S7>3HpW3pQ_V=y8 zoGZd6rFa| zTa@H8(pW500n<)UNma(7XUb_?zp(%q{3Rha_?@9@a6Tzs=oGJ{V~t7^IUhC5KFO8t zjWDE0R}g-rIKIFr=m?TT6niBrzAHG5~o$WTPg4jrS9h90Wxx%+;H}~gf=VIvii0ZIXVVzm>*Jx7(M_a8t88YN=N3!$T7q!OZQH4@I4tfE zFD+j8Tf9}YBI*gW*P)bJEK)NCY{Sn$O{@O$P;WSHd83O8R>$H? zh0B_87q%+ZibfF$(*rf)l)uLDXcW^Jg9P#?%!{S6SS30Te(5QR3(#^Yv|b;zEPQ+^ zGyrl0UNM%hW5vO}HH*yeyl%ScO?oqBGH(wfrXWs_%FIfvm-TIYXW>7aD2#M1|3X#_ zzgo<3FwO+*N*w1OyH(t7c6IdsUchks%LUcusH0#^^Y6E`_SbBfTxCFMpHe3wNbleO z${*~jei~rK@rnYzt<`7CHMZg8dS4ZyKL+I>Sk@-y>7GXyy=7pW^=fq{m#9&Q~i zB9&#;Eok94W*jPogV|3(UV>9yZF)<%ReY#6-CNKO9k@My5#DjAMIi<0Jx8cMmz&1F z4~6ZNnsbQK+H_{MQtBNjiySWhJXqpkxmor#I6kjBP;6CwZqEtYz%uW|O?t22Qt41o zF4!!bn59{|yg(CRA@P%ZQ~3g0W*ow={|CY;@R7~vZ5~gTZL*bk6P!^%MiS&3iXc=# zM717u3ltf~x+6PbnK5Lyvmi93R4T8auQGdF%T#vt$V*NWg~*7@(|ml5v#57qU3X}p7qOD zJJG^d7*{KA4$$YG?i#*UjS#Qb?o;30-CDwa{V$|sQ~?pf1hI0=>7gsb4h3%nB6B+K zS306x#^wL;l}66r1=EwM%U{Sej*aB@;RW!xTIYDRYcZR)Fg0vVe?%xU&~oQ+o09lGSr*$g^t8S(w%n>=oL~$TX!0}kWW^num z$^=%srqSlVkdZzjTPc2a82O_4dT-|A&LJ%9uG$nf8A9y&Vs9lj^ePL z5K=A^;7GR$%$FiQCGJ1%b}}Nq=tenvhBOjU|IrAZ`NQ4=P8_kq(tMk%BEbtX>wY$Q z_cEnz#2$`)aKE;tW~FE&rk}R|1fElg)D!M{h|4H_2cvFwdtjFAZ`}F~==f)v(rvMM zHzJu9$AALwkvlWW2ZVchbdtIXfr_+kwKd%c(Pyqu^H{GO{;)K?uhA*dw^n2u_}PlY zi88jN#8`TrYpG}1uAse=s?weKxfiUOTmA&27>{E$uhC0^9E6)#5`dWT#`c=FZNz)A z*K`I7oM+%%1a{PJNBV^3N0uUE_3Mjj#9*-UDG{hfa!y%uP*2dM)O|njIh-;I7|eCL zrws0HGQZ8iJBphWgK!NRXP^r>4E?%LMRFZ_nOEv=6*j%Ca+#uospA|q z?f0`4)!75!b>3p=*Y@7Mu(niYbIo7m_3_y8y{FIAE~k=L=- zp<1;u^uG5=E=JOK6F^(V>0Xh@fS!Rl-@NM*4h#fbz6Cr9pRz1K;?ycX-DegKzH+Ow z*IoB&0ujufuTDQ|v%6+|yzO5WgzH4EmKpB-gtl%R(nW-qPCqKnFJUf3cu!70ixk@K z--%_XzzkhnLWEyXxqR=Csnn#VRKGYBpH}9cIztC6SEUEZ9CJI~4;@{c zzkCf}YM38ZSJs@gm0Fe8;gDe2$qRzg`IWpOQ#wfh8}6;zLaZh~&^p9HfL@IK&u1T% zMD)A8^vNlt&Oe;AIM}jEL+X>qPZLg;*C!9T4@MpKvi_{n+~N%kZJROLeT7MiP~29e zWVu^eY7g}&PvWs zvN?Q{gm7f&bPouf;3-l;%|#nyH?%lbX?`dC?LyR8_rn9BPwWdw5ts1nzZdQ|`o!Fm zf2S7FeQEQ9$gg=rg1quKUZwmxK6g$3NpXA^ES<$9RL$y3WWPaa7c^BZ9MvsAXzw1p z;!IovuMRdh7q9c|VCldON?aM^cwr~5&iAl*o^xX7)qqyaCL%&~yx$ET{e$zT=d8W-?Pj1_4@7F}Z|3W5MFRmze*vkIb zP+q?-E?m(4Q-xd@{H>-Q%w=5A&Y{$KGpy7~%}U&|(aBMYr-AV|;I}t|oQ@%yzLfyZXN5`8I^n7^1gB4Eyddm^5W$6i z7X%0?Hqqm)LoN*+@&wuDr(a;<2Vo;mt_k}z)~TUxQx!!3tVWj>z+6h zrUa=5q@{SYO4@x!HOuze|ClZzgZ!~&pZOpq-1!kB&!suD(bmqAk>6x^V$rR-A2+!6 z*GefVRjmz$UsgnUodR=DRvU>g_nn*miD2x!QWS>r!ZTZDWEjhbCmO?@eLxT^1BySV zvT^vTwgO-?31qMK<>AEXVi;UVIwq5Cx+KM(u{@8-JCZ z8Fw4Cy+e9I^F;Mmpdy_WbBs0b12h=gh)db&jmtR|&G*2cyna?Xf_7C>(!Aroc?3E@ zJZ0$xPQ?pK@d~`Be~!XO{5w;)y{nE@j=}96N+jR_GT?eSY~3@)Z*1rHB=T1}yf!uT zzsBYvbYhU)yg^E&h-yQo5+B?4SpxJSmB`sa2Z&R@%58FBpgpD<6$%4RIDylWS%zj* z;tcifFl*^If69WV;H)7H9KA2Q5)3sXCU>Zk2rPRbrST9XZVB+-LND?#s7l{XZ)ip+*3~9SowCva(lNXC|7f}f z|2m(i8{F7v8{27P+qP}ncGB2rlE#f~+jbf!H@5Bjrr+P^oxk9Dc6VmZIWs#;D`j5j zWR%^hOTO&nCWh1ygP!a?G^0+l#^oob3d=ah62Yfl*y@B%9kseK3nq)e2rsS7EY~!y zb6_(#qDwG?Q>gaOcM8QilL?V)_s8zJ-*#zF-*(=pPr)_|>OjPvkBl&CNb7I7 zj4*n;;cf9arYZm7bH4Pm+UMC*O0sj%e^6yKG=NH!fGss4VvddS{ANd%A>Z9|2OZknl8A5$$Qzw zZf25m_R+QKpO@7QOk~pEf~gSQWpaA|j+xZXMGgA%RibqdUJBK0N)%~IBZMe0U|ybS zXU1yO>dP%Lw`&R8FGH5llrI|Cy;67|6#{F081k9K08<`g=Qq4lV_>U5C`DxuhhO8E58h`-+EGwr3si=2qowWK4WkeK`7Ba z?&UdM!I1`gi{Xw3e4G$wb%Rz!_57%Byn#Nk^Tv5_yuSWnE6siVL@9gtrZt2@eXE>Z z5P8`3E6p8`y$(@r?%0<%LcO9A+1SJtv0KeuY-V^%iX@)D!%476KaLw|*Cn@<4h9C} zb5X8|Q(&r?gV%5!&R@84yo-7(#4)oqtUb0lJx-Y(f8`zS&!X7B6^hZR{}+!)KI4%w z3E8iZFS_y2srhFW?Pz2~Gy^4fxxnWw3BRJX>2u1(ZucooX!k(r;A7Cr z30D7z=UZ{sWf~LC-~esQ>UkV!>QD?AurO^e>FMonNN1QJ3P*Kt*^e^6dWMh5G&27K z8ih_I;jCq+;hqX`MBnN-84<;#5d^eoNZnAO=ox$Rze0=+6&b3Le7?W(9>-iOkh)kvb=iT0q)~KJ}4Hh~yctd|Z>#VO~7mfKxY{V0h zcqji9lW`>R^&NGdE#_!M3#ORPM(y11*);BEN{bLWB+aoL6+T>(hW~^Kf_6XNQ$?cv z5;XUqfc-U|*1DnY_g&_WhZkG9%3cpBH8nM?xZ!AZbyU{Xp>V4KEHoR~2{u4KZ+>$cHMT4j5`X2T0{VsATE(2CbfR*Fy!hJ^=o2^END?+hF7UMFvDdK*nNhmdQIdmHXKYjE|`+gjhXFi|?3f9`k!xNv7sYTH7 zu{W?Y^_`r-is}99+vX+zU>Sv4#;-{`sYNvQTg{K{Z|uiR1E@zcOyX88$>~cloY3XY zPl12(ha}7+P^ty#%K@4&8M$|FtK;&w?ED*hLq%xfNTZ?m4^+=Paa5ZZew`8m$iiEQSv~MbQOl+0RVsT(y7e17biNnWgUt6`Y%8IvtCT~8YP=|vu?iASaeV-Pe}v*8hb3)$=LL_94t)uE(HqG<|(7L#i7`moGbOzcMH(IrhRmN%S0O0qs z>Z$imE`wMX5Xm`c zhP|Khgt{KzczO!!pGpj)+&o;fCLj0IPUC5?7aJ24osMHJISW7ox80jYS3f(2A=zByQpj;R7oIxMw$ z_TF=tPJe!J_^zKCV#&Kx9;y&-2RHWw2*A!XvHotoDVbQ5St%C|4@r~iGcl{VS&*%B z(qTj$##bBGnkvp)x^Tiv<2Ef=FrRSCF8ptkzgKP^x`XIACL`mOi!76?PEi#_Ot|Z( z#tqHs!tLAb&A_$zXfb$v1X79Ez+FV9X__gFH@jP=%I$Rz-%KTX8p0Z{u_Wc{PEgy5 z7pbDq6BBhD<#m7|ppPUt{?~o-uIm)luKSy$a9MEDaKqMQJ2(87Gd>4m*nGEvbJvzP z#aT+8GelT_1u#8F8F`I}t8@E?=vCS8lw=J=V=qP5<;{z&_2Bun@lIk3VHKC@yWL_+ ze9&;e?_FD5o+;#g^v2IxQIDQt=(BgnQgGkierN#K`@+uNH2q!-BWqYKn*O5~NrLwT zKiU@$MQ4Vg#;5x4Tsf5#)k2D>$;*|?s$zo4v?t$qlZO?g9_+&%?LIX~zLgJ+x&Y(Q z&GP30CnklTW({wz##5gN6=z)e-~Jfe5RSY9Zs%etM0$C5^&sk8QhkmgA`wBC2>a@; zR6KaZO?x#%jTy?D?G9P~Z=Pnp2y93)y<~}w7dB>Mj7ofYCnAq&zYROELy*X&qX6Ke zI?o)}K>v=>*Gs#b_sh)PLG$UvdHg?jnC!2@lDamspDjU4lAZ@~e|wMW+@Tv=7~!wZ zG(DaRDQiD)G-H-4n9j8~x%`81*&{D9>JmGIg+z1@Hlhm6LN20~0iR%&w8i1rxa;Pb zQV`NCmTB;Y)$c0n+tXH`=!79HwkC-|KeG>qb!+7Ke}^KI=s6#`H<1<{0X-WV@K*`K z>}Y>iTFrQc2P23H2i#7kef4lRcQF`OW6FY~3uwL=TzkiZo}741=e++Men~G7F0=-% zQdXN^cZrYwckWSoK-zw_kXcG(Jq+jXFi*(vMw*;$m-|V)VC_bKS+?veamIYCfo&mm zog~4-u&4c&=_-9n6|L_?rqUv6M^)gzX!M=eFqP*3f+iqkb*xU>sJ%S8hKiG%MPMGl zgG-GtDt1>l_Oh6!zS%NTy~%nZlRI9xP!E|1Nh2f;_S1wdGcW3bxVySbYp-@6-gEu( zokzk6^{`5!J~N9Ry@1t z{~?*~UCMP$xd{COqha}B${+US)!JkYZ|u-i>^Bi`PyXXd^J6zhf2fHmOZ7bzG4w$Dta_j?E^2sTbJ0hY{fZ_IRFNq;eAf30 z);=3a=T5C`CgZC<>ygS$X`5bO+AOE1{o%gxzXT9|Ooh|?Uf|iH4lMN+uQy+oiT^%; z<7;zrz8V}OV1qPgO3&3A3-Jy; zwtNaOM%D{8?isc)ldbCQX8&Z>*aTjK=2)j3V|Vw5V(x)lfa=Ra?aF5f0^#i}2B8Ba z=;@n79IcJIoax5>E*-T}VU%WbdcXFlfU>Y*d>I^TaSI&hpb63}m0)sg@q+o7*O&2p zzEyvEXwKeup5_Q&CjBnQ8W1==q$ahq&7! zdD4s}88w#nskRsA01cciQnu%QOuQS3)vLZgI#zsfhX~s40QLUhb3XX4$ZQ6e0u7GC z_Ix8<+~VMx#10JRR#rgSYv<`{v);U~a=oHzd396s^lhU3=jxnjpUkvYFi>VnG(q`` zQ#PEN_;TJZAAEp83sycwViEq9D0LaRG=VB_OEs!L=tR{G<)=ZqElyK^S6&w;`%kU` zDv=+ZmD17|P=jSO-6>&yp_iNNKj*5YI|0uc8#pK*wOzx`dea5sK-|FR530LhO4X*N zmpUX%+n4`pCrKt>u3$hp=VdTIPQn3XtasEmgdb-V?=x&{o@A{?7^wGx!>u`0_U(R) zxA)mbVe6IP6FyG|p>3!nC2U(YEADSQzE4N1rk@2bG~nCVeA+q%6S(Ba)|GRJ%s33; zMjp%yfZ%~j(l%CFo}YCpJCvRWCdX4qV>VkZF9U}_&2zsI!NxV7$>J84OLiZl3XL4s z#FeiNV#6L_gq8VJ@20`Fzd&`B5A;m1W~Ym<629amX%lYBs%+LA`Y=|u^)c*DsGG97 zar{q+A5Nn3_RxNC8agv=s9L(bd`UDdDO^I4k_ye3aZg8eJ%Vy_>3h0()qm>>WwZil zBFHo`%>x8s%!h@&o=;x#SYCA(@V)_@m}z~3F500V;8B+^S66`dJKUrr%;{zTPbOO4 zMt;`a?O*9rIjoO796yQJ7-Eyp^w|e|1ba6xXtm0Pb4(uAa|!#v$lN=%!E~}Hr$p?4 zQZ1asCo|se-ufI1@598%r@B|fJ$t>=j~}$$XZAjiURC65cb-b0_d}ZPy8L&pQ!xBK z&e(`V6_x0E8OyDaWWn;z;+`J7t~9InN&UPwvale<_NK(xpGu*yy(VA%m{a#X#;4yC?)2SnlAw$fBbH*cfJFC7nEm{;L`h!N3ZwYm_V1V8U=F>6PK3NpW?ot35`RkW#(pnajvqUQ1ZiGC&ztD)uDf9m!7}N&7k!IiFi3eTNfGn27nnyxM<+OxOmJcxtnLOe!o; z(Th&Mqb@KCz0YW$U0Bj$bL-9`%Kw#8e{bC~90F;`g^PBW4}pk|eAU`9|33~#<9-a` z3L6_`p#k7_|GaJ-o;ZKaU=;-`o8M5Wbbgg$T+0Y;VT6_4+GaruQ(qlM6)hHNp=%B! zI8~a>8Mll0c%DPT5E&V2tn}KGlBqOmwL+}?KaT5U3+;Z)247xI+I9Dn(t>J`n~Z9{ z3r3O%Uo)oSZ=g#-T{%rBw4Ka0=>2x>Z4juxyPCzQtPMq2-&i|1iM!tj-b%ZcZ(_gN z0mGOqFwfTG-t~Z2&L!ADNql6HGQMiK@{bq&y8m&D2>xO6nIiTqp`R&7QnYUNa9r3p+m?ECheQV{qOI7I4t1PutsEA$9 zDWS}?$W1@nlgR5BZ8`i6v+jSOBFVglsP;ZPxCk_9S8_P))1*a@2>Syy`SlqgX|cOy zyCx5F?4R=q`Ff=gIAzlhke7C@CV9GSe6RCzGarQqbN+Vjc!qgt*~F?&qq#MzeYtS?+v?ZrobO z6I~rSZrjgph6SrZ!DmD1D`}??m9P8h;1I z>0?`-Gp^YAVa8*vB~jrZf-Y9_J*x?VcEB03{XZ}n#;L0QX=a5^nV)2TSVjVqx{~D8 z1XK4S&4e0xHk=rqQLW$0lrx7OsqnJ8#5mg;+F@US`n0^FeR<)!vT#aK9uaI1f}M9vwc*bdD*O z0J`2q@wivV-Qzu@0tcUFiK$=hyYvTPmY-A1+u;j4l4Q*HoOXANu?V|K{CadjR3qrF zIQdg3UEfnU-pr`A@a6to>8xsP+UzP;v1RhH>mITDwelCUam^GQM44fU-`;C`@piEI zE<mvZCH1@0oqOg1DSy$*5L8zlXudZuHM{XD?B2e2$^ZAgDOwOO%iBt0 z?P-`?I zNN1^n(ee_=YTk2XPhhVMv!uuQ-RWQ)iYC_AQroeZXC2==u}124UQ32 zsOyZ#)AnWT(0B*KR!?I(I9dX?<{OZlMvP) zDkKN>=&!fGzB>+ksfKOArNnOOj^Z5_R zBLGkNI@87vv88C_uNl<-W6F4Bj@4A9oygOs-5-j`Z@Uhi64djg5TjYSXh>3H??JiE zGq1Y=P!(aS3WrSP=3=q}qa^|-O|Q%+q!X{jt{?76Nb3MJV!+^p=G_j*6qESL3BxRN zV4I~D#r%(T>D>OV&KhqWJ2UdNr7;QC*$6Hkeu9}iklen(;-e(W%ku4ODv9kDswj3$ z^l`zHp1Zy`{lfg_snk zV5BQrbGqQkNKQ@G(67NQN_yVLTLYe3;moC7CeZNv=TztFUH#$eSm0=<-sN23H#kM9 zD|R8EsR%$(5F#8fcV%z!>`QSp3DFu9AQ5-Zu4%pP|LU%P0D9^Ml91=S3*Q?!1FWL5 zcx@7t-JD4MXz(FJVxcbrT#Ii@n#uTN`iuTyj23;swAiQy=k z{(+nH%im)kN4si09cSKhly4D0&jZE(c45v*&aNO?@Vsd3{Xp)wez?!O620pbcIZgVq3>+36~cssGynZ4mtW@8FtS!76f zTOfB}ED^56CRqsvnQ@!6Waa;0;Mv|Km!B#~^WfAP`l?p6=BywyLMbGDF7*;?y%pCM za{>P}GTvBt$4ZvVTLsCYIl%y%xtlPX&s>R)7Z)YJtyMyiB>0z;rlCWcbxcqd-YRp& zEm3fhq7&20M6At6f7xBNAp`0elN8>Xqi+C)w#Ex!k#qnx3=1+}&GHZ8Q1mXVky&hp zim>y)C=uJ}(~?o8&Jz7OR@+o29DJBze6V+oB^*%>DF+IYmgMt26rS+32qN4}-oH1U z_Uv!_^4`Cf=UgLxqj@zrE%Sm@&DuWPrp*RnkpT3u@8&rH{`rhPQ`u&=qcFB~vV5#SNm}d?Jt8qsH6iZAv*LJj+=M6{fQ=rMhDW@4eDaXOR2AEH1tE~;)?850V6F0wp&wR8j{th@b26dEp3l|2g&%ThVV0**!)Wcurz!u@=j z`Se$^6xA>1tl@tk3D5rdsw#uk0&dgaUg~2AyH3ID z6WHkfzlu%Ap!gYf-%xip7^s~a&GO*zc;N_MTuc=y)9f!Db8-whvGGRxAAcVox*s3c;VUSP znK4(}6vNvFDI?2Q_1eELT8ixwT*esGbF!&eS$F(fO&-6`ZA(jckTid!O59W)h1+;Jw%Z*%#ZPe{S5@fA)KZLYX| zl#%Y>Wr(cy?x;l5skZz>8)J^i2$%V^j0gR+5&Ws;H_&BOMS^ti}H!svEr*)Ii(uG*|EAvG4SEJoR^CBNbMRJS=?ohv`$Vy?^ZC zl!?s?<1AE#c>&sEbd#KT!E|}Y%Zup?^~|r4uH+lH^efL9!M~8m;s={_G2hW^GFN^J z{rdHNU-O4)k85*yyRh9OVjuLO+E$$Z6$uXARg}B*P#MkMMlV^r6jRii!q?UC9%uP} z^C?jkrWFWYYn_+nr(VJ@JKMCCw1>sf?`lxfDKfmk4WYV;eVm{fzG_I3NdLkB)V&5h@kLaZTnniqlpX^KzLXoVL({meRvf}B$y%uA418XX z4JojTgI~7~-llfF>+CxThmMDv#Zb6ROF|txn&$%M> zD`g&z(Ls!|L)wRiCqIhS)Q*d^En0hT&JPO{P^|c`x1B>LENS1pg?R?WfKJy3$@*Q+ zF;U;DYiq$zGxp(AiTjs&67u}LEXm2bI=h2Xcrx-*Upo2x_}J%&L`Y^tNVP%%^qpX! zi_iN?q;|QMN!Q=;$A6Lu51U^@;r4dQUA7UbKmV)$5cq;hKFN)*SJ(|1`MBwkXA5na zeflgI68N|L(+&|-waOi3X*p)m$16VOkUC`1j;hWvG)=d$?DOo|+yU55e7`&UDY$XN zqi?QnDN(=Qo)wB2Zp(=EGQ0B=<^~i#Hq2L!qfY;(zP#=wk9z8H{!drmrRbeN)&Xm) zf=ph(Ww)zx-iMY6P~@igDd^c3O3)1^JBt1V20c2OI^Jtp%Hfy`fVV{bM;Q^Ta?`{a zUe?AziItI_(JTUM>z@j&@6pT99%>{!vn!+t%&V{&1GksIo?qf4vxW=!-t@N189+&=H|2A_oi+7t2FJK37-LSW+751!7gVy?MOr`+|9 zD__fW@*LIOI%!7tZdxy#npG4yTOwGW@G1klh0pU0csqJE*R-}HT?~wEWyZfv_4my# zCkzIJJBk2PlY!@RWAMn5M20#oEmOzzm0Kn+ep1fiU(GR0%Y)rqa1cnF+CrN*+lEdr zWjqc^$Rf`%<67YB^?LVLSA3G+ReY%)J7$7Cy`#FiD_tGEQbh83H@^DZBV1L3u1s<4 zP)&lj)@)PjZ1mg3z0`c8|MAqDh<|M*mALR`g)Z_FLEojw+etW6ReY&~6czrk2vEp= zl>&P;a#Px$7vKvzjztq{i3`qu;3&f*iyHbu9ov-qS(#F*I}f=NCLy=L;bt5&bp_+c zaX!3Mc)=#Dv$)&`=ou;frkwj<4@)LFwg4<~F8Z<*ev;(01;CwRM!Q*AH66s8QuK#p zBBK++mn|{1N4=OMWL=8kY*EcI8jI}`NlQ~sC2uXL-W<@j7Q%3sh>xdEM^sd(6xB>d z=={+K-R%KW7qnJ!RzpvQO!Z`dlT0Imzjby#S8o$0chuv3nD^TGYyNzUXa*lmvsRJ*&ApEt

$K|;@r@yRJQKQGRTw8o$=WEx+ z^Rv=tq3$;~kukJB2;I)jbo( zMS-(VyqB2-$*^YS+2Cod)2`XG1J_S3zCQoLB6@f|&`R~*_lS|^y;N;A@tJl9aNP-s z`m1zxBz`nencC2NAqe?Dc=l^=hTe6xq`0^Oy zuJekGb4q12uedwE(46iMKIPzwNa?@sanPOJJe^lhz%EGjD*0JvUURYS540O5lV~kR zh*VD1HoHD5XxLMq>u1)FCI4Fze*bq;zw125eJ+oa1cGc&2tE+!)5-Ys)ykom!IETB z1`BwmvW*<1|7}vNBsAQQp!uP2jW+RQTR4&t2>Wi73|(nF(RE6gw+bq`fsR4TW6nYS zw|mdc53s6@AdaA^&mL#MZ;JTJJMM>{k7@o0^yv%SZhOQC%O;LdP1~HSzWl)$>CsBj z=lUR~wQaZERtD&+8@yZ86ZRE42VDc8zeyHa2XTsJeB>}u1a+pfWQD8 zE%jwpEoIXcb@QEer-a-aS~TDB388@NaH#2DJqh22Re`*aLbg!${C4qxT5V%K@)MiX ziJe<;bBACky0<1%>O)G|)w20&es`DFNi=AyLAIqeg0yKDamfKG$B9(zDW=Ph{tVcz z@nJ8OZCPI=7i~uz9lEG;9ERP>uKBKg9l)gX^|)k%Bh6tDw;Ut|?&8%MDiKl(zq5yHO@{n>Gb`wKJp`up*4En)dNT`oMt>*R2%QV$j zFEOXtSvp)~6y`F;=YcQr=he|3&XYR&Z~ zT!2&zO^v24?i$Wg0TymVJS=p^@rd-p+*@d-8EG4LlZrK{q8MR72^k*U93^-MS$!aXtYaK zLSi{e0>8&w=P8P6GrEmU$ihO3jr^mpsu5TmZ2&i$rYhc?YPgVaEtUxBWb3w4B-hK! z)?ADXPPj@V(Q50xNNuuH^zcXCo7(W(h$yKtmM!OQvy>kL0i>B=PoZi{Iies`DjXx* zoJ!7GvX@$KkStV)ZE9u`1>E{kd)otmP*ZWINKW+v?w`F5U&1)3?c6cNZMp||gKls; zUVBYH?iZp(9LU}^5`|ne7L=s=S$K`Z{;VI`y8gfQjL$t_+Q^|gjt>(60tp~`$Ey>P+CmY zTwH$?r#}6)57pTWuXmwM!2TMN(dw3Aez#h5_~$DXCRT5LTrW!ZD=Kfhh?qZ3*B7(w zwMt%hYR$CxPeV;W@A1Y*XCSoXUXz;bGzZAPV?u}~7;^Yvu~rPNVbQV82PN02H&?^< zMx#QwvfAcvX1h+_m?8gyTbydoEbWtFu0g+;XmyxE^X9VCXP^jKMoKIJUC@<8C0p$J z&ZynVyMkY?+U1rS6{F@pyA5@RB=MDaQ)5)s7rr% zT-Gfq3v|Y>0NX<`^7WNQ;bFI~)etqe%o%1>#qU^ZFit<^py_##=y@6k5(giZ(01%G#XJBJc=}XhN$Pd5CVNAp0GG)Hg-be}pqHwETuega^ks$Zo^IFVkz-Ou zMh(G}O@5MuFjHfj-QIsNLV1g8*1wdunW3+;US44}q&YTee=L3iSDpu&|E-ttYuh(R zKh){0ku*84!^T!kzd1H{!{!fT4D?TPBUFO$AQWIxYV^?Gt?RU%$-piX`h_40Qzobd zd_RM39&))-SYkwT)P^!RP*Cl#KcKYzvn46fatrbC;Az@R^n~?Y=DGg8tNKrWHL}j) z*d{8suer+&8C*wu3=FvkaAxu6FKNa~XQ5B)>k85;F!|vn#j2C9jHr3$`n-)oaBy7A z!|#8OsjnMUhK*^ooMz?1Y--(ye-~6JNh!$;Kio0Zc z{pa>@>;W=L!E2kLgRO*S3^-m~2%LX#j)QGbp!kbv2tRL41ll$Cq>|^^P9(d1HSGJi zwl}I9MPypSnQgXDs9)F3yJaWdMkYjJ<<2({1EirH6z>P z!+C|s=ydU;u;%16i)2SOOpt1;>G(;DLXI_zDtw@_gQ)6UrmPfOKoPmfoYjh{RM$Igv-@sv9_fCgNy6n7*?_itaw_#u*6_SyP1LJ@&^l&044ks?GDwP4wnD<8E#59vaS+2x2tm`nn;A^ zzI?#Njzw&BuCaMzIek1Ahs2D>jsNXDE<1M|Y5?VUN>=Z%N|7Jv_PT4m3i%77?%%e0 zqnAQ@@DHH=9BHXaq6&0eX=S)|yDFRD@FE?C>a8H|Py5+k@q>v6=qrW0>{5jYPfV9aZu# z$Q{$HE6)p>HLjd=@>V&n&siRCf7{#Zj!T&3E)YotQp5ENuI;=%A_P>*uqhNB8Zw~8J2p3e zEcb3SZJ78rm`lZ^0!a$tn*NJ>`+#_wS)|AeR5S$)*(l0o?p;FG50jOw)K+H5Qyvu2 zBzV}8c@8P>walcYAaUV>615BW5Q5cqy~?h~-JuGMAMp5e2)+Om!*y^xLu$B828@Yj z*=sOH1q4`O8J@@Qlh=p-9^r@CG|$J=>Kx68!U(kx6KlE~1NguDfRgNmdXHX5A%V}o z(aULOM)UA`u7j^)!nW4v*T?5F8JDgX{%XR0F$=|D*E>xYk+bl3V}3*{wZ}j8-Ck(r z`$osMWEucVQCj0kFCgLoZ%*HHqqX&vIhA>zfxDy3#5uBhf^&vHKL}!R zGYN1&DqkDJ$$awpDJ(DdGt)E!dq6!XaJI0?`{_{McdVV#&~_n{dlIfFVMnj&R^P5r z7*5XgfVOUQ#5z`cp5veR<==^Zg`)=V$jMdk zbjZ5~n4qZoO0pyl^tcGwlb(z+g{7l)#2n|MNguaiL&}^?I$50j|Dc#13E zup7EZi9*mRXD%&6@qE#2Gd1?AJcg*^rIU{Zb}4@eh)m!XZHNu-DuTfAMo@O%!~KF@ zG$oD$o*5tSK>1_MQj_dY8XWkb2pkooOGOOgIL@ms9JH&dq2LuUs*r)5o!v<%q*R1N zgc9jO$+Ow7qq9T%a=@R!gc{0}7B)Lz3I@CtQ69v)3+ z^L(kzpJ+uY(CZxqE9a5ReUJ@f@Z-r2wk^%rJ(T2r`$no@FzEOR-jbq)bavC%YXL0e zZf>p;!>|i{bt%3N&!(|_s#~TGlcnCRiAVOxewp*ys1-fq;Br1~ebatTy>TDt1U|Xa zU&S1YDiMcW<|q{TB#Hti6%?DJ0k&u&ihCh#f0$tieZA6Cb9lIDnSP&!S7*>!H94Nm zp-OQIn2JB!^M52yPxo3OrLfqD_2wU-xiXtG>P=TZnol5#ItbWZQ381(i>OpJeUnlarB=okUh*%kB#CZAL)0ZYbh#^aVe-05F$w5RYy8e6&mB<`sag4I(=_k)c5n;$o9J|O3_XvZX7YFUooJn<-F!9wJv_@d zt8zYBsen0iE<_(TOz``#R2*fNcJcTNInDd>`x0iDZ3eAX8kVlq&H?nZBqN!t?kq6<3in*`jwE>f) z2@A8tInIIS#aK&(&ATRPik46Eq%%?;3E&7biz85FbwGvNzWG@@!ttx6F{ADG{@g0x zf~EVw;0-Jn?>j$_hf!pC-(A>PViNtlh1|a~b5=V3D|KrxX;tb@xaH@^$K0wgKj&$)~$|KA`7|UbL!AZbfu#dz*Trr(8`q zsRWUM3Tul@scClO2`c~V-(jCC!*!%;Uw}A)hJhSUtJ=gpbjKU|+YT#LIs73WC({Ky z0VQsR+|$h2VBG^%Y#n?im+hUkc(HQ4V!~mpp=xk|S6FUx3=cJYAr7>TIILI5{9)T$ zoIg)*`ncm}b#H-3FNODCn|gis@^_3us5n3zWY?qae!9JzV_jbf`uFxYKYi>NF`K)v zv}tN4MT?3oprsngM=94;rSSHCb%97f0}=^z@CIW;4!1uq832| z64TCpGgwBGWO1T<408lub-y&C7Zu~e>ZMHcR5l~5wi)lnfN@IzH>qvVg4#B!g4HT1 zIHSf|9!8>IRw-r>p^}u%?x`CJ`G+@4Noq~K-WQloA+-Cn>RI1E0g zTyLGO;l_M|83Q68-tYAR{@X8gj&`SCe~A&rVxkwZ?jE5x@$%9qH)&pDkb;pM#DV2Mup4XMTeeR&+VD0D4vfe zlIl+1&__NROA~1OJ!lT`^^2-)tOZ%M3`7I`!zqu+xNxUm| zOr3^|`|Z~@@Jw53=Q!TK_2lkuU6HIifA{m>>m|iY;~(+aE-4JT07D>e#NI@t3_>qT z?8(4JQR4ajd8D@O1f;+&xq}nTkh2X1AAKORSYnGME2QY)Vf!^;Ga#^EheyE$MQgOz zla_FnvSP);hbJxZlj26sZ^PyIgQ6akQAgI|_ z8?rxHv;&61^o-V!f#VGFCo??kqq&1jMw+x%3#c~wLrD?MTf}76ii%cHYQVxQ6i^oW z7tB68gq{D?`As8-vU_&7)E;lwpx9)JZkx)ye1~aPYZJI#+Y{?#h>jh7r#qa4| zRL_U&YU=gd^Z6QM`VlUx6%DEpgg6|L>-=`A=6Rjh({s4s`(4%2Y<81^$#h2HK+ zg15&`t?A|qYRAAzv*2CXYstAval)6&?r)zm8iBK277zWJ;on`O#NOQK82r_|WKKDs zv**$2R88DKOPLS31&x+JZ?kJmI|7W3X)&+_UC%M(URgM$ka=7q8TOTPj|{ogn^c@jg!7 zZuuhMX^o|yV*Ryjvek!%ThDnP{~l@=l2pf}^~^?}%cs@xzy8Oa+ns1KyFLvW ze(&?itw`E!0@bPF!Cd6<=x+DK-&FAU1a$3x#I>xfsawL6Vn*sX!nbS9T?jA&l?-%& zr;l;j7Oa-dsiF_TW+pw2^p!TaMs4$$GiH(1#_GByl)856CvHh(m!A@YT@-B*lBwO> z=kmru{?Om5%!O4EwtPFNiZkc?<>9$y9FA#ltg(s;Gzb-TJrUDZe7v>%p1fcb67P$Su^Lc#K-})TC+oSV_3t<%cI|e8m z{$1zV)6)qey_fk@Tm(In`rK|4;Oh>>{W^AuuD})~TdVkdmN9TtKV#X$D}hyCGh6v3 zNU(E&|KkR|yg!WtHwJwugYiX{-ZrOvrE@=^u&4JT{`HbmAR9`k#H|FV z7zDREFfEh3dg#$Svg!~By5{fRTe@7hJB{dq7o@(yV@7cuyan>eY>Opt=4-~De!n>$ zMS>P#VK0bFRFy<@f@7_g9la+P z5hh`cX3h(^3aE)xz~mIIDlx{LstgIybs75N0}C+VU|EP6ejL1H_k6Wd#{EB%J+qP}j36t@j=YL)A{&2pXFZ;J|thH_+<>2Ri ztW1jT5n=Rf*%vOSf7JO9L!_8-h+O4xr_7E*R}j-HqOaaJKI1ihR;|BnX8F2DT1o1{ zmK`OpIXf0sgW-4Z`giaAY;w%@CEi{FLO&3?5T7H^7bg8M+)NEX#2+w_zGF2{%(<8_ zKc>TOeUh6oK!{R{RO@dPCCdZ&DkQ*aV5?N;0D?;!9~nqA50nkH+?OsCx%$`)%(&Mp z@BY0n1EU7xEmE1cT+#$N_bJe zih7w!o49gWA%B%GG8%L|L-_Vj-{%h^4i}G`A#Y5!)l_}Nu3U!p2piW(mS-Pa%tXK6 z$DjU=`vbz^6o~Gx8VS5#?h$ z1B!mG(hyDn1W`ABlk$a|j~YGJKJLhu@(fgOX&(*?lPK|*OmaryhH86B8s7{)MIo}k zIR4twbu{00xF~KNM@t>webncbW&WL?;h{stKol@<{w1GnYG(?e_csG*jHBO@p+*Bh z6RC>BAM!<6DA*qcm8{5J9r}(;h(%cq%TfNZ2A?j}G6|Nk(gh87(ZK^xNEpVxqkXEK zq}svNV<6{@Mc5B3(1a2?@{E%2g#uN*SE5^c@B23e*k<`2_(p7CTJ%} z3+nY8bXH`V84X?Noe_v(E9r>)N4$em?K@b#{a_Q-qocq=q)O2|s=p_*v4^T}4YgvY zvLVIb%5#hYe39&(gH$Y$3iteWgbKP>eXM7>*4C##eca#1(Oy^%+C@vTJhzJ_z~aJL z8G9fIb6qTYt)xa3XB}lB>~#t>b&nt#J{RZl>b+YX6}s+lj-NMQ#(hR`cAg;8%0s~0 z;^Vp88R-c|Bggiwz(--9p!$!>f%ifMb2-;g$b7~uu92NU2s1h7?-rOBa3z0S=z6i_ z?9xS>mx1!tzoXha2~b_lgfAYT9du#F*znuU3ZNat`YOU!c@`!!=W9%VZy&_|k%O{D z%UhR$-|39npv_WHXm$t>)zU^O;T&+_clEmj|2c=w)`<A|@oCC={xW>MC z&wmFHoEsdvUxZ;9ZYR+@h<+5@8r+*L?{=1j*j~MB;fyyktHo5K#!?i89*l!{B;<}^ z=2Hvhe=>^W<8`+OWTdk&?c zQes@yJkU;3rJ{l6U4Lr(z*J#)MNrK00}ad^JR#`0d&Yn_rfRAzl6s`%67C;Y1CJNo z1SwFfPB7?1<%|r6wD=&Ojwa+23g(D==ttU5kG&FgoDr07&4{lkP24Oqi58)z-h)T#gWuM>x^`2c(z( z1Jcoy;9-}TZP<`)Ke$ZEF9nVDOqwc(f9*?Eu$t(&h)rI#=>Vk@ckGW*CLQ{&;-MXE zlPK3+>{jL59%NKH45uiam%jUh-(aw=-^dSWYCHhQaq6@o+p8Y4XTp`*g|Dvt*rXWR z!I>huUidJHekQ}2^_hN2iJ_scMqch>7rKVGY^1uiOLw=&@Klix7oFR+CYhmFg z*m};!5K^s>f;?!7l-ZosZS2F)BoC!!R!}1y?u=!+cRmbgWX`-Tn`s#9w!p0z|pH-ROH*Qm>7+kB5{)dk0 zSd(QSYNFSud0Oz1LFM-kyx@fVx4ZJ<=fwHj%XaBUw-Xu#x{>(AKu; zn{K*D!ZTHB>CCCvGCZ9SxSKBfs`xow@m^sz_-q1*nMHF+2Ym*yMYU;1UVjry(AbTE zabK&in94@b}X2lPbF|l-Tm$S`GT1+!6Y8w@@@Y^l==zeBK zwBc_i_h45vEnnIp>>g8GOZn)0ke6E-QR*Pth0F z1z~fg3EgPM@6|81q;osMY*W`KJbU+yE1GxQ-zx_Kp6|#1Wb$+G4B$8%L0NojY>YJy zDJ4_MY|f{LfsVEMO6MLdM|dP9%@Vk-qn5+|5C$WqBx)0^g-dNBt~%ggO08df#TvEU zIDg85Tak!}1tt6j+3|Ew=BZdj!fZV$8-h&iC~((2>*u^#ET_rA zSxM)yTXvr_Gs7;{$;3^)K5(zuK8X68$ADEVAJD$O^xWnRGOdVnxm;konqd0X6LbW^ zm7E;TuDadB9KNZbz!>41K_3(oF@@etCk+sOo+97=SR$uZ00*>Yx+xw1UxomPAJa6Q z!&qv6C&UP9#^!i!by(x|>JQObVO^Bqht;&?x)0j6W0&vJmSWEUY zl8Aj@X*(X2C%qqkUJ0U13)J>-gZsc@%1;2Y5=_R1(#f=@cuKZQ5-{~F|5{9K9xn>@ z3OcJk6F(C!y4#=sxp{tGJiKR6qSsbWj4}!@HRf8GtR~$wD3U@e(LhY%58GgYsFsu7 zHut%=l^%ly+l$uuikuEFy*sB%eV94(`pG!s_Wu*49y4lYNBhYy-_^6$&xMI!RV3`j zRX7QhHr9zyGS2#ukti@vT#^*3O1{ARlCTij0OP5i*7n889l-+R%AfSkfl<0`57y&s zy-Kr4ymj*GYG$hSn1LP}gASRT+$UEv8sIz*xO;e1o7fMx>+{l!S72Mbk8hLVtM#Qd zG$zIaaT6-(>eF_y*IZn}##m`(^4)#Ev-e#uOxLSQDEIJLsJTbyLEoWz^72oxBQk&v z5{i;kQosCTeTFeHsH1S9v0U0&l<~kWFJ)pp{UpLtGl53Ef4->#ntd5FT)R?*BAenM zuAFzQVS)8~dCwPkMb6z}+wzi#1yd`j<)zktsNBpxZlCl@`;5 z<@$F|ZvGT2S#=K=lhfQw>=#D+ckcc3eh?n$?a5KL&OZb z{C&H6DRQM(w9+>9mJX{0*SIn7%rqMyIj$D=kbBSY|JMQ}K56Whz|~Flk{|z$oxYpc zm*RD{>+#CY+#8^6se$?d3q?FJY)-1QYzrm$ts}Guq`tVjp{C>Z^GEYk)dSUQPkKu# zXOn0HBGo8hlrUMnLX9%0-r;ru{}Xh_N!)=7^sd+`9Y%FD@QoOYWuZmCR=z2D*6PWr zeLAMA()nKz3(VxX1o&Oc( zxQ2vpNkSuXK4I&5+_3uoTMBz(<5=6|glG9-Upj@Mmehzm-JacdTs#HZO!?-1bdwKf zJz*NiEzVdcCC{qndx>kezL+d`dJ^{|Q2-^PRyymwZH482 zXHgkjvj}F0q2aLz${~NshwQfmrYTT~4rbzsLzbW=4^+sb;wmJ?!27?fOOPErlf$mZ zTdtqTg$^oEqNf#g58f3GOkrMvPHy;dFuU?!IzNy+ogeoDX(ikP~rSiDC zC%|o4Wi)U^qKQ#-OW=jfU_^p;d7qx-Muk7twZZR>*rR56AC+Vu)IpsHnTA9b)zdeT z>153-I~2k$Bl$mh2+WUzbp6{NO4#dcNFKa6L!)FboTln=v6kXaSTUL$^V6-I5hX#8 zfYY?^7rw!z9!k;MDWHE?gCy|O$ND6%aotCXjyK@DySI6B4T^3W)-`(*@2IqA>RsI4>SfJOLZ-Jh%TGjmcW+e%0 zf+(rENB_;z>rl>D5-jsb=-AT_BcJx4`QK&%=!0I0z+RkUSv_A@c<&#keM2I=&b%P~ zk55%3fd!c0oJ1&@2##4%q%7iw#>?;;E)N1Y+`?X|>_zXpi2fw59$M(`DWBmO=g%8X z1>nEbj64&rh*PW8vTFe`BIp!E`|d~Vem13NX;VFQ$U54XBEh*HV$cmAw(m^_w(t83 zf;LVvO3w`pPY2v^?6jkWV@%e~ZbRLMGVuu;(Rq#D%S0MspwA zf!ndK=kMs}1eE0j;>nr@wqLlGphWwgL|32E_uM&hjnYkYql+T>lAG z3S?l&R_eS&_hyhtT3Uwjpgk^U-4`56RXoqBX2@f-Zq_>ax1(wJNm{2U6-LZouex9F z3J!@3Z4WL3SFQzlbS2NB_9wAYaiZ)Mn4k@B`nlQ4vtO}%&T$aQ#JIV*@g`SAvXMox91nn!*l&l1?Cz8&wmQb_1k)W zomuOe?Z8tX7I|kv>f{DjqlM*f(90J;s6*E%^}HWw>^q!8qn)rYDdebW`x;t9!@t01 zc7h=N?wYGXkHS9D^X3LD&?%hxWJq%RJ)0A^JYh))wLLo3!zKFhh;O@Y?BJQZUbH;1 zbHfp=`ZVe^Jbriz-Y^sqQOXIE!0aU_EW#glTHx8z*IE}zCQS+&Em}Rb@)L#{Uv+$b z^r3uhYsoCT0?kH19y5t4%?M0sa5?IvMRFU)T11%j4_S=LYgYidia__+U$6m+9C`#L{IaC%fpCK$$0G8e~Y=4k&1)Rwg(@A;1 zJ{xE9c>&qf!Ve}zk=b80i}_xDMEO%lqvLrG7`N_Ca*u9%$|_u5`4C{HR?#lrIyQ)X zGp<51^nosB;^<2IhyHTU=Kvxl300uepD##X8Ijo}91DtN|OL$w* z+;?*NCE#;^61C;TOkhWaV!s;>dUl(z-0HMw)vcLll6H0AAJ;bTyQ@e07e6S$lAN0k zkfkXcB+&+s#Nl}N+mQxF&N%Y^tu76c8ptJr`$jW~3z?l66x+?=`*5a7C=4|%)HNWE zN_eo7FgGdX?w6)xOct}~`rF9&KadCi8;thcASZg31LA(rrM^r1ltTxKPz~hveC=n$ z7kWK)4MLrqC@AMohF|Y5HTq`hW#<8K1q&H=i;=J5Bw9!fW_Vb5JU9<-Dan?Z@4t(Q zz|vezGeD^*a6GuH67+8G_7tWc1?n9}A>K*m9+hJ?f@T{Sgt)P??D**Yio;|FGT-K( z>ICmN6ds1DcwOJi$nbv@14EvdMrNb6N_i#f zmt!ATR+@9RZ#7;|PXqX2-=h59|H%jmebErPBx2NcnHW@*Wm_&yJMXD2BrFK?;~36Rfh`$DzpR_B*3u=Ixz^Ot!>ef$I=GcJ z=tQM2-~S2IV3Aohsp})v(g)^`rY$j7X_*_lr=nABeqE!vT`atW zovzMOvD@}3{MD0kze(Hg_}o3#Aruj8JPzT)!$Rx`JGvp(cm<706}x{p9x{J`e2Lrr zqQO(A@yEZAhQ;?9K&O3WBjrA!^M0gsKMa9AHT`1Jpl~#{YCU=MwD;z(s2~>2b+2ao zXwuQ0R!ka*An*=N$fuN;VgJx>ce>TB{XpDMAI%;(0bX>VU(*3qrMW{-l3gF_Xk)VO zoLwU&fRi<=l12~LRnXL9eVw&?-wHTGQ~%*d#3z__`MmXg+imCqXuEGFj!NR&(fg>U z^_^R^lX^K}hY55s?{EbuDHB<0a2a#Ilc(7P2gbsTbEjUj2{-F1&XWs(#foliRn6hE z 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%0ASP$`u=RcNW9sNV3%cx? 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 z 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 diff --git a/textures/ui/common/lobby/backgrounds/uef-summit.png b/textures/ui/common/lobby/backgrounds/uef-summit.png deleted file mode 100644 index 1b4d0a67afbb4008303d81cb2983a74cf051a79f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 169560 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 From dc25fec5ae4c7ec3ac4bd9131f80404a17835359 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Thu, 25 Jun 2026 22:40:34 +0200 Subject: [PATCH 62/98] Make custom backgrounds take precedence over the built in backgrounds --- init_faf.lua | 2 +- init_fafbeta.lua | 2 +- init_fafdevelop.lua | 2 +- lua/ui/lobby/customlobby/CLAUDE.md | 2 +- .../customlobby/CustomLobbyBackgrounds.lua | 27 +++++++++++++++---- 5 files changed, 26 insertions(+), 9 deletions(-) diff --git a/init_faf.lua b/init_faf.lua index 5a2267303d8..d82067fe191 100644 --- a/init_faf.lua +++ b/init_faf.lua @@ -659,4 +659,4 @@ MountDirectory(fa_path .. "/maps", '/maps') MountDirectory(fa_path .. "/fonts", '/fonts') -- support for custom backgrounds in the lobby -MountDirectory(InitFileDir .. '/../gamedata/custom-lobby-backgrounds', '/textures/ui/common/lobby/backgrounds') +MountDirectory(InitFileDir .. '/../gamedata/custom-lobby-backgrounds', '/textures/ui/common/lobby/custom-backgrounds') diff --git a/init_fafbeta.lua b/init_fafbeta.lua index 74f9bd125af..d2d6195fa94 100644 --- a/init_fafbeta.lua +++ b/init_fafbeta.lua @@ -643,7 +643,7 @@ MountDirectory(fa_path .. "/maps", '/maps') MountDirectory(fa_path .. "/fonts", '/fonts') -- support for custom backgrounds in the lobby -MountDirectory(InitFileDir .. '/../gamedata/custom-lobby-backgrounds', '/textures/ui/common/lobby/backgrounds') +MountDirectory(InitFileDir .. '/../gamedata/custom-lobby-backgrounds', '/textures/ui/common/lobby/custom-backgrounds') -- Allows developers to embed code to debug a replay table.insert(path, 1, { dir = InitFileDir .. '\\..\\Debug', mountpoint = '/' }) diff --git a/init_fafdevelop.lua b/init_fafdevelop.lua index cf2c4ce41d5..37ba1b0d053 100644 --- a/init_fafdevelop.lua +++ b/init_fafdevelop.lua @@ -643,7 +643,7 @@ MountDirectory(fa_path .. "/maps", '/maps') MountDirectory(fa_path .. "/fonts", '/fonts') -- support for custom backgrounds in the lobby -MountDirectory(InitFileDir .. '/../gamedata/custom-lobby-backgrounds', '/textures/ui/common/lobby/backgrounds') +MountDirectory(InitFileDir .. '/../gamedata/custom-lobby-backgrounds', '/textures/ui/common/lobby/custom-backgrounds') -- Allows developers to embed code to debug a replay table.insert(path, 1, { dir = InitFileDir .. '\\..\\Debug', mountpoint = '/' }) diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index e497fc40ff6..5dbd8b83a44 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -62,7 +62,7 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | [presetselect/](presetselect/CLAUDE.md) | the **setup-presets dialog** (host-only `Popup`): a list of named full-setup snapshots (map / options / mods / restrictions) with **Load / Save / Rename / Delete**, opened by the action-bar **Presets** button. Owns no synced state — Save/Load route through the controller's host-authoritative `RequestSaveSetupPreset` / `RequestLoadSetupPreset` intents; the persistence is [`CustomLobbyPresets`](CustomLobbyPresets.lua). Players are captured in the snapshot but **not reseated on load yet** (deferred — see presetselect/CLAUDE.md). | | [optionselect/](optionselect/CLAUDE.md) | the **options dialog**: three columns (lobby / scenario / mod options) over the selected scenario + mods, with search + hide-defaults filters; non-default options are marked. Derives the option *schema* per-peer (reference data) via [`/lua/ui/optionutil.lua`](/lua/ui/optionutil.lua); edits a working copy of the *values* and on OK routes the reconciled set through `RequestSetGameOptions` (synced via `GameOptions`). Host-only. See [optionselect/CLAUDE.md](optionselect/CLAUDE.md). | | [CustomLobbyInterface.lua](CustomLobbyInterface.lua) | composition root, laid out in **areas** (flip the module `Debug` flag to tint them), sized for the **1024×768** floor. A **one-column** layout (the two-column variant was reverted by community request): a **title bar** (title · background picker) over a **left column** split vertically — the **slots** on top (a single column of rows, up to 16, height tracking the slot count) and the **Chat / Observers** tabs below — beside a fixed-width **right column** (the config component: a pinned map preview + facts line over Options / Mods / Restrictions tabs). A full-width **action bar** at the bottom holds the global actions: **Leave** + status on the left, the host-only **Presets** button (opens the setup-presets dialog — see [presetselect/](presetselect/CLAUDE.md)) + the host-only **Launch** on the right. Reads IsHost (action-bar buttons) + SlotCount (to size the slot area via `CustomLobbySlotsInterface.HeightForSlots`); Leave (Esc handler) + **Become observer** (`RequestMoveToObserver`, in the Observers tab). The slot rows + their drag coordination now live in [`CustomLobbySlotsInterface`](CustomLobbySlotsInterface.lua), which fills the slot area. `OpenDebug()` / hot-reload. | -| [CustomLobbyBackgrounds.lua](CustomLobbyBackgrounds.lua) | **background discovery + selection** — pure data/prefs (no models, no network). Scans `textures/ui/common/lobby/backgrounds` for `*.png` (`Discover()` → `{ Path, Name }`), and holds the per-peer chosen path in a reactive `Selected` LazyVar persisted to prefs (`GetSelectedLazy`/`GetSelected`/`Select`). A purely cosmetic, never-synced local choice (like a skin); defaults to the first image found when nothing is stored. | +| [CustomLobbyBackgrounds.lua](CustomLobbyBackgrounds.lua) | **background discovery + selection** — pure data/prefs (no models, no network). `Discover()` scans for `*.png` → `{ Path, Name }`: the **custom override** folder `textures/ui/common/lobby/custom-backgrounds` (mounted from `gamedata/custom-lobby-backgrounds` by `init_fafdevelop.lua`) takes precedence — if it holds any image, only those are offered; otherwise the stock `textures/ui/common/lobby/backgrounds` set is used. Holds the per-peer chosen path in a reactive `Selected` LazyVar persisted to prefs (`GetSelectedLazy`/`GetSelected`/`Select`). A purely cosmetic, never-synced local choice (like a skin); defaults to the first image found when nothing is stored. | | [CustomLobbyBackground.lua](CustomLobbyBackground.lua) | the **full-window background surface** — a single `Bitmap` that paints the selected image so it **covers** the whole parent while keeping aspect ratio (scale to the larger axis ratio, centre, crop the overflow at the screen edge), with a solid backdrop fallback when none is chosen / the file can't be read. Subscribes to [`CustomLobbyBackgrounds`](CustomLobbyBackgrounds.lua) itself; `Width`/`Height` are bound to the parent rect so a resize re-covers for free. | | [CustomLobbyTeamScore.lua](CustomLobbyTeamScore.lua) | the **accumulated team rating** side indicator — `Side A N · M Side B`. Hosted in the strip atop the two-column slot layout (it doubles as the columns' Left/Right header). Shown only for the binary auto-team formations; **hidden** for `none`/`manual` or until a positional map's start positions load. Reads the mode + side split from [`CustomLobbyRules`](CustomLobbyRules.lua) (`AutoTeamMode` / `SideLabels` / `BuildSideResolver`) and the ratings from each slot's `PL`. Reference data; never writes. | | [CustomLobbyTabs.lua](CustomLobbyTabs.lua) | a **generic tabbed panel** (strip + content; one panel alive, created on select / destroyed on switch). Tabs **divide the strip evenly** across its width. Construct with a `{ Label, Create, Badge?, Action?, Icon?, Compact? }` list + optional `OnSelect`. A tab's optional `Badge` LazyVar drives a grey **count pill** to the right of the label; its optional `Action` (`{ Create, Visible? }`) is a small button the owner builds **inside the tab, left of the label** (e.g. a config gear), whose `Visible` LazyVar hides it (collapsing it from the layout) when it doesn't apply. The action, label and pill are centred together as one cluster; any absent/hidden/empty piece contributes 0 width so the rest re-centres. A tab can instead be **`Compact`** (a fixed narrow width, excluded from the even division — the flexible tabs share what's left) and/or show an **`Icon`** centred instead of its label (an icon-only utility tab); the default active tab is the first non-compact one. The container just mirrors the LazyVars, the owner decides what they mean. Used for the bottom-left (Logs / Chat / Observers) and the config interface's Options / Mods / Restrictions. | diff --git a/lua/ui/lobby/customlobby/CustomLobbyBackgrounds.lua b/lua/ui/lobby/customlobby/CustomLobbyBackgrounds.lua index 10b3bdf0bdd..65cfd3549d6 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyBackgrounds.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyBackgrounds.lua @@ -32,9 +32,14 @@ local Prefs = import("/lua/user/prefs.lua") local LazyVarCreate = import("/lua/lazyvar.lua").Create ---- The directory scanned for background images (repo-root absolute). +--- The directory of the stock background images shipped with the game. BackgroundsDir = '/textures/ui/common/lobby/backgrounds' +--- An optional override directory (mounted from gamedata/custom-lobby-backgrounds — see +--- init_fafdevelop.lua). When it holds any image, it *replaces* the stock set entirely; otherwise +--- the stock set is used. +CustomBackgroundsDir = '/textures/ui/common/lobby/custom-backgrounds' + --- The prefs key the chosen background path is stored under (this machine only). local PrefsKey = "customlobby_background" @@ -57,18 +62,30 @@ local function DisplayName(path) return name end ---- All background images on disk as `{ Path, Name }`, sorted by path. Empty when the folder holds ---- none. Re-scanned on each call (cheap — a handful of files). +--- All `*.png` in `dir` as `{ Path, Name }`, sorted by path. +---@param dir FileName ---@return { Path: FileName, Name: string }[] -function Discover() +local function ScanDir(dir) local backgrounds = {} - for _, path in DiskFindFiles(BackgroundsDir, '*.png') do + for _, path in DiskFindFiles(dir, '*.png') do table.insert(backgrounds, { Path = path, Name = DisplayName(path) }) end table.sort(backgrounds, function(a, b) return a.Path < b.Path end) return backgrounds end +--- All background images on disk as `{ Path, Name }`, sorted by path. The custom override folder +--- takes precedence: if it holds any image, only those are offered; otherwise the stock set is used. +--- Empty only when neither folder has any. Re-scanned on each call (cheap — a handful of files). +---@return { Path: FileName, Name: string }[] +function Discover() + local custom = ScanDir(CustomBackgroundsDir) + if next(custom) then + return custom + end + return ScanDir(BackgroundsDir) +end + --- The selected-path LazyVar, created + seeded on first call. Subscribe with `Derive` to react to --- background changes. Seeding rule: an explicit stored value (a path, or `false` for "None") is --- honoured; with nothing stored yet we default to the first discovered image so a fresh lobby From 237bab454d628281f14f8e6bd72674d940a29c0c Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Thu, 25 Jun 2026 23:02:44 +0200 Subject: [PATCH 63/98] Refinement of the lobby presets dialog --- lua/ui/CLAUDE.md | 19 ++++--- lua/ui/lobby/USER_STORIES.md | 6 +-- lua/ui/lobby/customlobby/CLAUDE.md | 21 ++++---- .../customlobby/CustomLobbyController.lua | 53 ++++--------------- .../customlobby/CustomLobbyInterface.lua | 2 +- .../lobby/customlobby/CustomLobbyPresets.lua | 11 ++-- .../lobby/customlobby/presetselect/CLAUDE.md | 18 +++---- .../presetselect/CustomLobbyPresetSelect.lua | 47 ++++++---------- 8 files changed, 67 insertions(+), 110 deletions(-) diff --git a/lua/ui/CLAUDE.md b/lua/ui/CLAUDE.md index 568bc11ca82..8505b57360a 100644 --- a/lua/ui/CLAUDE.md +++ b/lua/ui/CLAUDE.md @@ -253,13 +253,18 @@ If you find yourself reaching for `ScaleNumber` a lot, that's usually a sign you ### Standard asset sizes — look them up, don't guess A `Button` (and most textured controls) sizes to its art by default, so the texture's width *is* the -control width unless you override it. Don't eyeball these. Every texture's native (unscaled) pixel -size is dumped in [`/textures/texture-dimensions.csv`](/textures/texture-dimensions.csv) -(`RelativePath, Width, Height, Format`) — grep it for the resolved path. `SkinnableFile` maps -`/BUTTON/` → `ui/common/widgets/`; `CreateButtonStd`/`CreateButtonWithDropshadow` append -`_btn_{up,…}.dds`. Common standard buttons (unscaled W × H): `/BUTTON/large/` **392 × 92**, -`/BUTTON/medium/` **276 × 72**, `/BUTTON/small/` **152 × 40**, `/scx_menu/small-btn/small` -**200 × 72**, gear/close menu-btns **24 × 24**. Remember these are pre-scale — multiply by the UI scale. +control width unless you override it. Don't eyeball these. Most textures' native (unscaled) sizes are +dumped in [`/textures/texture-dimensions.csv`](/textures/texture-dimensions.csv) +(`RelativePath, Width, Height, Format`) — grep it for the resolved path. **The `/BUTTON/` tree is not +in that CSV**; read those from the DDS header instead (`od -An -t u4 -j 12 -N 8 ` → `height width`). +`SkinnableFile` resolves `/BUTTON/`, `/scx_menu/`, `/dialogs/` to the active **faction** skin (`common` +is only the fallback, and loads differently); `CreateButtonStd`/`CreateButtonWithDropshadow` append +`_btn_{up,…}.dds`. Verified standard buttons (unscaled W × H): `/BUTTON/large/` **296 × 80**, +`/BUTTON/medium/` **132 × 44** (these two are the lobby's Launch/Leave buttons — **there is no +`/BUTTON/small/`**, referencing it makes the button invisible), `/scx_menu/small-btn/small` **200 × 72** +(the map/mod dialogs' button), `/dialogs/close_btn/close` and gear menu-btns **24 × 24**. Don't confuse +`/BUTTON//` with the separate `widgets/_btn` family in the CSV (392/276/152 wide). Pre-scale — +multiply by the UI scale. ### Scrollbars — reserve the gutter on the content, not the bar diff --git a/lua/ui/lobby/USER_STORIES.md b/lua/ui/lobby/USER_STORIES.md index 2c947e966e5..7868d627daf 100644 --- a/lua/ui/lobby/USER_STORIES.md +++ b/lua/ui/lobby/USER_STORIES.md @@ -142,10 +142,10 @@ Roles: **Host**, **Player** (human in a slot), **Observer**, **Joining client** ## O. Presets & rehost -- 🟡 As a **host**, I want to save the current setup (map, options, mods, restrictions, players) as a named preset, so that I can reuse it. *(Save/load of map, options, mods and restrictions works via the action-bar **Presets** dialog; players are captured in the snapshot but **not reseated on load yet** — that needs AI-add + per-player slot intents.)* +- 🟡 As a **host**, I want to save the current setup (map, options, mods, restrictions, players) as a named preset, so that I can reuse it. *(Save/load of map, options, mods and restrictions works via the action-bar **Presets** dialog; presets are **setup-only by design** — players/observers are not stored.)* - ✅ As a **host**, I want to load/delete/rename presets, so that I can manage reusable configurations. *(The Presets dialog loads/deletes/renames; loading applies the setup host-authoritatively and reconciles options to the current map+mods.)* -- 🟡 As a **system**, I want the last game auto-saved as a preset at launch, so that rehost can restore it. *(Launch auto-saves the reserved `lastGame` snapshot; the rehost **restore** (in-lobby button + `/rehost` arg) is deferred with player reseating.)* -- ⬜ As a **host**, I want rehost to reseat returning players to their prior slots (displacing AIs/others as needed), so that a replay setup reconstitutes quickly. *(Blocked on AI-add + per-player slot intents; the `lastGame` snapshot stores player login names for the future match.)* +- 🟡 As a **system**, I want the last game auto-saved as a preset at launch, so that rehost can restore it. *(Launch auto-saves the reserved `lastGame` setup; the rehost **restore** (in-lobby button + `/rehost` arg) is deferred. Note the auto-save carries no roster — reseating needs a separate player capture.)* +- ⬜ As a **host**, I want rehost to reseat returning players to their prior slots (displacing AIs/others as needed), so that a replay setup reconstitutes quickly. *(Blocked on AI-add + per-player slot intents; also needs its own roster capture — setup presets deliberately don't store players.)* ## P. Lobby preferences (per user) diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index 5dbd8b83a44..710e7d7feeb 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -43,23 +43,24 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | [CustomLobbyLaunchModel.lua](CustomLobbyLaunchModel.lua) | shared + launched state — the launch payload (see above). | | [CustomLobbySessionModel.lua](CustomLobbySessionModel.lua) | shared, lobby-room-only state (slot count, closed slots). | | [CustomLobbyLocalModel.lua](CustomLobbyLocalModel.lua) | per-peer state, never synced: identity + CPU benchmarks. | +| [CustomLobbyScenarioModel.lua](CustomLobbyScenarioModel.lua) | a **derived** model (not one of the three): resolves the launch model's compact `ScenarioFile` into a rich, fully-loaded `Scenario` bundle — info (for the map texture) + extracted save markers (`Spawns` / `MassPoints` / `HydroPoints` / `Wrecks`, never the raw save) + `MaxDimension` / `ArmyCount` / `Name` / `Size` / `Version`. **It does the fishing once so consumers just read the field they need** (the map preview, the config facts line, `CustomLobbyRules`) instead of each re-resolving the file. An internal observer **dedups by file** — a launch-info rebroadcast that re-sets the same `ScenarioFile` is a no-op (the `Scenario` var only re-fires on a real map change, so the preview doesn't reload). The save extraction itself lives in the catalog's `LoadMarkers`. The first instance of a general pattern (compact synced field → derived reactive bundle); mods by UUID are the obvious next. | | [CustomLobbyPerformancePopover.lua](CustomLobbyPerformancePopover.lua) | hover popover over the CPU column; hand-built bitmap bar chart of a peer's `PerformanceTrackingV2` history, with a yellow recommended-unit-cap line. | | [CustomLobbyInstance.lua](CustomLobbyInstance.lua) | thin `moho.lobby_methods` shell; validates/dispatches traffic, forwards callbacks to the controller. Also feeds [`CustomLobbyLog`](CustomLobbyLog.lua) from its three network choke points (BroadcastData / SendData / DataReceived). | | [CustomLobbyLog.lua](CustomLobbyLog.lua) | the **network traffic log** — a reactive, per-peer, never-synced ring buffer (`Entries` LazyVar, capped) of every message this peer broadcasts / sends / receives, fed by the instance and rendered by the **Logs** tab. Each peer logs only its own traffic, so host and client views differ naturally. Not one of the three models (no game state); a diagnostic feed. | -| [CustomLobbyController.lua](CustomLobbyController.lua) | host-authority logic (free functions): seating, `Process*` handlers, intents (`RequestSetReady`, `RequestTakeSlot`, `RequestSwapSlots`, `RequestEject`, `RequestMoveToObserver`, `RequestSetScenario`, `RequestSetGameMods/Options`, `RequestResetGameOptions`, `RequestSetSlotsPinned`, `RequestAutoBalance` (stub), `RequestReopenClosedSlots`, `RequestLaunch`, `RequestSaveSetupPreset`/`RequestLoadSetupPreset` — all keyed by slot/bool/file/name so a chat command can call them too; permission is gated separately), sharing the stored CPU benchmark. **Launch:** `RequestLaunch` (host-only, readiness-validated) → auto-saves the `lastGame` preset (`BuildSetupSnapshot`) → `BuildGameConfiguration` (seed option defaults + scenario, resolve random factions, assign army numbers + push to server, stamp ratings/clan tags, resolve sim mods via `Mods.GetGameMods`) → broadcast `LaunchGame` + `instance:LaunchGame`; clients run `ProcessLaunchGame`. **Presets:** `BuildSetupSnapshot` (read the launch state → serializable snapshot) + `ApplySetup` (host-only: write scenario/mods/restrictions/teams, reconcile options to the current schema, one broadcast); players are captured but not reseated (see [presetselect/](presetselect/CLAUDE.md)). | -| [CustomLobbyRules.lua](CustomLobbyRules.lua) | game-rule derivations from lobby state (not view, not networking): `RecommendedUnitCap()` (per-player cap by map size, memoised scenario lookup). | -| [CustomLobbyPresets.lua](CustomLobbyPresets.lua) | **named full-setup presets** — pure prefs CRUD (`GetPresets`/`GetPreset`/`SavePreset`/`DeletePreset`/`RenamePreset`) over one key (`customlobby_setup_presets`), an ordered `{ Name, Setup }` array mirroring the mod presets in [`/lua/ui/modutilities.lua`](/lua/ui/modutilities.lua). Holds no models, no network. A reserved `LastGamePresetName` entry is auto-saved at launch (the rehost source). Capturing/applying a setup lives in the controller (`BuildSetupSnapshot`/`ApplySetup`); see [presetselect/](presetselect/CLAUDE.md). | +| [CustomLobbyController.lua](CustomLobbyController.lua) | host-authority logic (free functions): seating, `Process*` handlers, intents (`RequestSetReady`, `RequestTakeSlot`, `RequestSwapSlots`, `RequestEject`, `RequestMoveToObserver`, `RequestSetScenario`, `RequestSetGameMods/Options`, `RequestResetGameOptions`, `RequestSetSlotsPinned`, `RequestAutoBalance` (stub), `RequestReopenClosedSlots`, `RequestLaunch`, `RequestSaveSetupPreset`/`RequestLoadSetupPreset` — all keyed by slot/bool/file/name so a chat command can call them too; permission is gated separately), sharing the stored CPU benchmark. **Launch:** `RequestLaunch` (host-only, readiness-validated) → auto-saves the `lastGame` preset (`BuildSetupSnapshot`) → `BuildGameConfiguration` (seed option defaults + scenario, resolve random factions, assign army numbers + push to server, stamp ratings/clan tags, resolve sim mods via `Mods.GetGameMods`) → broadcast `LaunchGame` + `instance:LaunchGame`; clients run `ProcessLaunchGame`. **Presets:** `BuildSetupSnapshot` (read the launch state → serializable **setup-only** snapshot: scenario / options / mods / restrictions) + `ApplySetup` (host-only: write scenario/mods/restrictions, reconcile options to the current schema, one broadcast); players, observers and teams/spawn-mex are not stored (see [presetselect/](presetselect/CLAUDE.md)). | +| [CustomLobbyRules.lua](CustomLobbyRules.lua) | game-rule derivations from lobby state (not view, not networking): `RecommendedUnitCap()` (per-player cap by map size) and `BuildSideResolver()` (positional auto-team split). Both read the resolved map size / start spots from [`CustomLobbyScenarioModel`](CustomLobbyScenarioModel.lua) rather than loading the scenario themselves. | +| [CustomLobbyPresets.lua](CustomLobbyPresets.lua) | **named setup presets** (setup-only: scenario / options / mods / restrictions — no players/observers) — pure prefs CRUD (`GetPresets`/`GetPreset`/`SavePreset`/`DeletePreset`/`RenamePreset`) over one key (`customlobby_setup_presets`), an ordered `{ Name, Setup }` array mirroring the mod presets in [`/lua/ui/modutilities.lua`](/lua/ui/modutilities.lua). Holds no models, no network. A reserved `LastGamePresetName` entry is auto-saved at launch (the rehost source). Capturing/applying a setup lives in the controller (`BuildSetupSnapshot`/`ApplySetup`); see [presetselect/](presetselect/CLAUDE.md). | | [CustomLobbySession.lua](CustomLobbySession.lua) | the lobby session's **main trash bag**. The lobby lives in the persistent front-end Lua state, which is *not* reset when the game launches in its own state — so anything left reachable (a running thread, a cache, a singleton) leaks for the whole match. This module owns one session-lifetime `TrashBag` (`GetTrash()`); every lobby-scoped `Destroyable` registers in it, and one `Teardown()` (clean-slate in `CreateLobby`, on leave, on `OnGameLaunched`) frees the lot. Works despite the bag being weak-valued because each singleton is pinned by its own module `Instance` local. **Rollout in progress** — the map catalog is the first resource converted; models / interface / instance follow. | | [CustomLobbyMessages.lua](CustomLobbyMessages.lua) | message registry: `AddPlayer`, `SetPlayers` (launch model: players + observers), `SentLaunchInfo` (launch model: scenario / options / mods / teams / spawn mex), `SetSessionState` (session model: slot count / closed slots), `SetReady`, `TakeSlot`, `DisconnectPeer`, `LaunchGame` (host's final game config → `instance:LaunchGame`), `ReportCpuBenchmark`, `SetCpuBenchmarks`. | | [CustomLobbyContextMenu.lua](CustomLobbyContextMenu.lua) | generic framed floating menu; `Show(entries, x, y)` renders any `{label, action, enabled}` list, dismisses on item click / click-outside / Esc. Knows nothing about the lobby. | | [CustomLobbyMenus.lua](CustomLobbyMenus.lua) | declarative menu **definitions**: entry lists with `when(ctx)`/`action(ctx)` filtered by lobby state (`BuildSlotMenu`). Adding/state-gating an item is a one-liner here. | | [slots/](slots/) | the **slot subsystem** (its own folder, a sub-folder per layout). [`CustomLobbySlotsInterface`](slots/CustomLobbySlotsInterface.lua) is the entry the composition root mounts: a "Players" header over the **active layout body**, picked by the AutoTeams mode — one-column for the non-team modes, the two-column team layout for the binary modes (left/right, top/bottom, even/odd). It observes `GameOptions` and swaps the body when the kind flips (create-on-mode / destroy-on-switch); a change *within* the binary modes is handled by the two-column body's own re-layout. It is the rows' **drag coordinator** (`UICustomLobbySlotCoordinator`: hit-test which row a point is in, drop-highlight, drag ghost → `RequestSwapSlots`) for *every* layout — it alone needs to hit-test across rows — so the layout bodies stay pure build/place/reveal and never duplicate the drag logic; it also exposes `PreferredHeight()` (computed from the mode + model, swap-order-independent) so the root sizes the slot area. The header band carries a **host-only tool strip** (right-aligned: pin seating · auto-balance · reopen closed slots) of small icon buttons (local `SlotTool`, mirroring the config column's `PreviewTool`); the pin button is lit from the synced `SlotsPinned` and the strip is hidden for clients. A **"Locked" notice** (lock glyph + label) sits right of the "Players" label and is shown to **everyone** while `SlotsPinned` is on, so a client can see seating is host-controlled before clicking an open slot to no effect. [`CustomLobbySlotBase`](slots/CustomLobbySlotBase.lua) holds **all slot behaviour** (the slot + CPU subscriptions, the CPU-cap math, click/right-click/drag-to-swap + intents, the background/highlight/click overlays) and the default `RenderPlayer`/`RenderCpu` that paint the standard named controls from normalised views; a presentation subclasses it and implements just `CreateContents` / `LayoutContents`. [`onecolumn/`](slots/onecolumn/): [`CustomLobbyOneColumnSlots`](slots/onecolumn/CustomLobbyOneColumnSlots.lua) (stacks thin rows, reveals 1..count) + [`CustomLobbySlotRow`](slots/onecolumn/CustomLobbySlotRow.lua) (thin one-line presentation). [`twocolumn/`](slots/twocolumn/): [`CustomLobbyTwoColumnSlots`](slots/twocolumn/CustomLobbyTwoColumnSlots.lua) (splits slots into two team columns via [`CustomLobbyRules.BuildSideResolver`](CustomLobbyRules.lua), with a [`CustomLobbyTeamScore`](CustomLobbyTeamScore.lua) strip across the top as the Left/Right indicator, and the "two columns, unresolved" fallback — parity fill + the score self-hides until a positional map's start positions load) + [`CustomLobbySlotCard`](slots/twocolumn/CustomLobbySlotCard.lua) (fat half-width / two-line presentation; `SetMirrored` flips the right column's cards so the two teams face each other). Each body exports `HeightForCount`. | | [CustomLobbyObserversInterface.lua](CustomLobbyObserversInterface.lua) | observer strip; subscribes to the model's `Observers` list and shows the count + names (read-only). | -| [CustomLobbyScenarioPreview.lua](CustomLobbyScenarioPreview.lua) | **shared** map-preview *surface*: the scenario's map texture + overlays (start spots, resource/wreck markers, plus a **dummy translucent `WaterMask`** placeholder until a real mask exists) with aspect-correct positioning, texture-leak-safe icon sharing, per-group visibility (`SetOverlayVisible('spawns'\|'resources'\|'wrecks'\|'water', …)`), and three-phase init. Chrome-free; `CustomLobbyMapPreview` wraps it with the frame. Spawn appearance is the owner's via a `CreateSpawnIcon` factory. | -| [CustomLobbyMapPreview.lua](CustomLobbyMapPreview.lua) | the map preview **as one whole** — the chrome (glow border on top + dark backdrop, surface inset by `Padding`), the surface, and the faction spawn icon (local `MapPreviewSpawn`). Used by **both** consumers: created `Bound = true` it subscribes to the launch model and renders the committed `ScenarioFile` with per-slot faction spawns (no reload on take/swap); created unbound (the map-select dialog) it does no model wiring and the owner drives `preview.Surface` directly (numbered-dot spawns). Exposes `.Surface` for owners to drive / anchor overlays to. | +| [CustomLobbyScenarioPreview.lua](CustomLobbyScenarioPreview.lua) | **shared** map-preview *surface*: the scenario's map texture + overlays (start spots, resource/wreck markers, plus a **dummy translucent `WaterMask`** placeholder until a real mask exists) with aspect-correct positioning, texture-leak-safe icon sharing, per-group visibility (`SetOverlayVisible('spawns'\|'resources'\|'wrecks'\|'water', …)`), and three-phase init. A **pure renderer**: `SetScenario(info, markers)` takes already-extracted markers (`UICustomLobbyScenarioMarkers`) and never touches the raw save. Chrome-free; `CustomLobbyMapPreview` wraps it with the frame. Spawn appearance is the owner's via a `CreateSpawnIcon` factory. | +| [CustomLobbyMapPreview.lua](CustomLobbyMapPreview.lua) | the map preview **as one whole** — the chrome (glow border on top + dark backdrop, surface inset by `Padding`), the surface, and the faction spawn icon (local `MapPreviewSpawn`). Used by **both** consumers: created `Bound = true` it subscribes to [`CustomLobbyScenarioModel`](CustomLobbyScenarioModel.lua) and renders the resolved scenario with per-slot faction spawns (no reload on take/swap, and no reload on a same-map rebroadcast — the model dedups); created unbound (the map-select dialog) it does no model wiring and the owner drives `preview.Surface` directly (numbered-dot spawns), feeding it markers from the catalog's `LoadMarkers`. Exposes `.Surface` for owners to drive / anchor overlays to. | | [mapselect/](mapselect/CLAUDE.md) | the **map-select dialog** + its catalog and list, in their own folder (host-only `Popup`: searchable, filterable scenario list → `RequestSetScenario`). Self-contained sub-MVC; see [mapselect/CLAUDE.md](mapselect/CLAUDE.md) — including the **`MapPreview` texture-leak** writeup that shaped its design. | | [modselect/](modselect/CLAUDE.md) | the **mod-select dialog** + its catalog and list, built to the map-select shape (checkbox list + type filters + detail panel + **presets**). Returns a uid set; the opener routes it — sim mods → `RequestSetGameMods` (synced), UI mods → local prefs — or persists the lot standalone. Mod domain logic lives in [`/lua/ui/modutilities.lua`](/lua/ui/modutilities.lua) (the `maputil.lua` sibling, fronting `/lua/mods.lua`). See [modselect/CLAUDE.md](modselect/CLAUDE.md). | -| [presetselect/](presetselect/CLAUDE.md) | the **setup-presets dialog** (host-only `Popup`): a list of named full-setup snapshots (map / options / mods / restrictions) with **Load / Save / Rename / Delete**, opened by the action-bar **Presets** button. Owns no synced state — Save/Load route through the controller's host-authoritative `RequestSaveSetupPreset` / `RequestLoadSetupPreset` intents; the persistence is [`CustomLobbyPresets`](CustomLobbyPresets.lua). Players are captured in the snapshot but **not reseated on load yet** (deferred — see presetselect/CLAUDE.md). | +| [presetselect/](presetselect/CLAUDE.md) | the **setup-presets dialog** (host-only `Popup`): a list of named setup snapshots (map / options / mods / restrictions) with **Load / Save / Rename / Delete**, opened by the action-bar **Presets** button. Owns no synced state — Save/Load route through the controller's host-authoritative `RequestSaveSetupPreset` / `RequestLoadSetupPreset` intents; the persistence is [`CustomLobbyPresets`](CustomLobbyPresets.lua). Setup-only — players/observers are **not** part of presets (see presetselect/CLAUDE.md). | | [optionselect/](optionselect/CLAUDE.md) | the **options dialog**: three columns (lobby / scenario / mod options) over the selected scenario + mods, with search + hide-defaults filters; non-default options are marked. Derives the option *schema* per-peer (reference data) via [`/lua/ui/optionutil.lua`](/lua/ui/optionutil.lua); edits a working copy of the *values* and on OK routes the reconciled set through `RequestSetGameOptions` (synced via `GameOptions`). Host-only. See [optionselect/CLAUDE.md](optionselect/CLAUDE.md). | | [CustomLobbyInterface.lua](CustomLobbyInterface.lua) | composition root, laid out in **areas** (flip the module `Debug` flag to tint them), sized for the **1024×768** floor. A **one-column** layout (the two-column variant was reverted by community request): a **title bar** (title · background picker) over a **left column** split vertically — the **slots** on top (a single column of rows, up to 16, height tracking the slot count) and the **Chat / Observers** tabs below — beside a fixed-width **right column** (the config component: a pinned map preview + facts line over Options / Mods / Restrictions tabs). A full-width **action bar** at the bottom holds the global actions: **Leave** + status on the left, the host-only **Presets** button (opens the setup-presets dialog — see [presetselect/](presetselect/CLAUDE.md)) + the host-only **Launch** on the right. Reads IsHost (action-bar buttons) + SlotCount (to size the slot area via `CustomLobbySlotsInterface.HeightForSlots`); Leave (Esc handler) + **Become observer** (`RequestMoveToObserver`, in the Observers tab). The slot rows + their drag coordination now live in [`CustomLobbySlotsInterface`](CustomLobbySlotsInterface.lua), which fills the slot area. `OpenDebug()` / hot-reload. | | [CustomLobbyBackgrounds.lua](CustomLobbyBackgrounds.lua) | **background discovery + selection** — pure data/prefs (no models, no network). `Discover()` scans for `*.png` → `{ Path, Name }`: the **custom override** folder `textures/ui/common/lobby/custom-backgrounds` (mounted from `gamedata/custom-lobby-backgrounds` by `init_fafdevelop.lua`) takes precedence — if it holds any image, only those are offered; otherwise the stock `textures/ui/common/lobby/backgrounds` set is used. Holds the per-peer chosen path in a reactive `Selected` LazyVar persisted to prefs (`GetSelectedLazy`/`GetSelected`/`Select`). A purely cosmetic, never-synced local choice (like a skin); defaults to the first image found when nothing is stored. | @@ -89,10 +90,10 @@ the dialogs themselves still exist and will be rewired once it lands; `RequestSe and every other human is ready, it builds the game config, broadcasts `LaunchGame` and hands it to the engine, so all peers start together. (Lobby-UI teardown on launch and reactive enable/disable of the button are still TODO.) The host can **save / load named setup presets** (the action-bar -**Presets** button → the presets dialog): a preset captures map / options / mods / restrictions and -loading applies them host-authoritatively (options reconciled to the current map+mods); launch -auto-saves the `lastGame` preset. (Player reseating + rehost restore are deferred — see -[presetselect/](presetselect/CLAUDE.md).) Launched via +**Presets** button → the presets dialog): a preset stores map / options / mods / restrictions only +(no players/observers) and loading applies them host-authoritatively (options reconciled to the +current map+mods); launch auto-saves the `lastGame` preset. (Rehost player-reseating is a separate +later slice that needs its own roster capture — see [presetselect/](presetselect/CLAUDE.md).) Launched via `scripts/LaunchCustomLobby.ps1`, or inspect UI only with `UI_Lua import("/lua/ui/lobby/customlobby/customlobbyinterface.lua").OpenDebug()`. diff --git a/lua/ui/lobby/customlobby/CustomLobbyController.lua b/lua/ui/lobby/customlobby/CustomLobbyController.lua index 5f2e75f37ab..9eb7b0d07bf 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyController.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyController.lua @@ -877,31 +877,14 @@ end --#endregion ------------------------------------------------------------------------------- ---#region Setup presets (save / load named full-setup snapshots; § O) +--#region Setup presets (save / load named setup snapshots; § O) -- -- Persistence is in CustomLobbyPresets (pure prefs). Capturing the live launch state into a -- snapshot and applying one back touch the synced model + network, so they live here (the host --- is the only writer). Players are captured for a future rehost reseat but are NOT applied on a --- normal load — restoring players/AIs needs infra the new lobby doesn't have yet (no AI-add, no --- per-player faction/colour/team intents). See the deferred slice in CLAUDE.md. - ---- The player fields a preset captures. `OwnerID` / `Ready` / rating fields are deliberately ---- dropped (connection-specific or recomputed); `PlayerName` is kept as the stable key a future ---- rehost reseat matches returning humans on. -local PlayerPresetFields = { - 'PlayerName', 'Human', 'Faction', 'Team', 'PlayerColor', 'ArmyColor', 'StartSpot', 'AIPersonality', -} - ---- A serializable copy of a player, trimmed to the preset fields. ----@param player UICustomLobbyPlayer ----@return table -local function TrimPlayerForPreset(player) - local trimmed = {} - for _, key in PlayerPresetFields do - trimmed[key] = player[key] - end - return trimmed -end +-- is the only writer). A preset is **setup-only** — scenario / game options / sim mods / +-- restrictions. Players, observers and the (not-yet-applied) auto-teams / spawn-mex are +-- intentionally left out: a preset reconfigures a lobby, it doesn't restore a roster. The future +-- rehost reseat (§ O.4) will need its own player capture, not these presets. --- Reads the current launch state into a plain serializable setup snapshot. Used both by the --- save-preset intent and the launch auto-save. Pure read — never mutates the model. @@ -909,17 +892,6 @@ end function BuildSetupSnapshot() local launch = CustomLobbyLaunchModel.GetSingleton() - local players = {} - for slot = 1, CustomLobbyLaunchModel.MaxSlots do - local player = launch.Players[slot]() - players[slot] = player and TrimPlayerForPreset(player) or false - end - - local observers = {} - for _, observer in launch.Observers() do - table.insert(observers, TrimPlayerForPreset(observer)) - end - -- per-player Ratings / ClanTags are stamped fresh at launch; drop them from the saved options local options = table.deepcopy(launch.GameOptions()) options.Ratings = nil @@ -930,18 +902,15 @@ function BuildSetupSnapshot() GameOptions = options, GameMods = table.deepcopy(launch.GameMods()), Restrictions = table.deepcopy(launch.Restrictions()), - AutoTeams = table.deepcopy(launch.AutoTeams()), - SpawnMex = table.deepcopy(launch.SpawnMex()), - Players = players, - Observers = observers, } end --- Host-side: applies a setup snapshot to the launch model and broadcasts it. Sets the scenario ---- (re-sizing the lobby room), sim mods, restrictions and teams/spawn-mex, then reconciles the ---- game options against the now-current scenario+mods (drop stale keys, seed defaults) — the same ---- reconcile the options dialog / reset use — and broadcasts once. Players are left untouched ---- (see the region note). Mirrors the legacy `ApplyGameSettings` → single `UpdateGame`. +--- (re-sizing the lobby room), sim mods and restrictions, then reconciles the game options against +--- the now-current scenario+mods (drop stale keys, seed defaults) — the same reconcile the options +--- dialog / reset use — and broadcasts once. Players, observers and auto-teams / spawn-mex are left +--- untouched (a preset is setup-only — see the region note). Mirrors the legacy `ApplyGameSettings` +--- → single `UpdateGame`. ---@param setup UICustomLobbySetupSnapshot function ApplySetup(setup) local instance = LobbyInstance @@ -975,8 +944,6 @@ function ApplySetup(setup) CustomLobbyLaunchModel.SetGameMods(launch, setup.GameMods or {}) CustomLobbyLaunchModel.SetRestrictions(launch, setup.Restrictions or {}) - launch.AutoTeams:Set(table.deepcopy(setup.AutoTeams or {})) - launch.SpawnMex:Set(table.deepcopy(setup.SpawnMex or {})) -- reconcile the saved option values against the current scenario+mods schema local OptionUtil = import("/lua/ui/optionutil.lua") diff --git a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua index 713c3b8da74..65f1b44e05d 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua @@ -252,7 +252,7 @@ local CustomLobbyInterface = Class(Group) { CustomLobbyController.RequestLaunch() end - -- host-only: save / load named full-setup presets (map, options, mods, restrictions) + -- host-only: save / load named setup presets (map, options, mods, restrictions) self.PresetsButton = UIUtil.CreateButtonWithDropshadow(self.ActionArea, '/BUTTON/medium/', "Presets") self.PresetsButton.OnClick = function(button, modifiers) CustomLobbyPresetSelect.Open(GetFrame(0)) diff --git a/lua/ui/lobby/customlobby/CustomLobbyPresets.lua b/lua/ui/lobby/customlobby/CustomLobbyPresets.lua index ac23e0edbd8..c3913f72fda 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyPresets.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyPresets.lua @@ -20,7 +20,7 @@ --** SOFTWARE. --****************************************************************************************************** --- Named full-setup presets for the custom lobby — the customlobby-native rebuild of the legacy +-- Named setup presets for the custom lobby — the customlobby-native rebuild of the legacy -- `/lua/ui/lobby/presets.lua` (which keyed `LobbyPresets`). Pure persistence: this module only -- reads and writes the prefs; it never touches the lobby models or the network. Capturing the -- current setup into a snapshot and applying a snapshot back are host-authoritative and live in @@ -40,17 +40,14 @@ local PrefsKey = "customlobby_setup_presets" --- pinned "Last game" entry rather than a normal named preset. LastGamePresetName = "lastGame" ---- A serializable snapshot of the launch setup (written to disk). Players are captured for a future ---- rehost reseat but are not applied on a normal load (see the deferred slice in CLAUDE.md). +--- A serializable snapshot of the launch setup (written to disk). **Setup-only** — players, +--- observers and auto-teams / spawn-mex are deliberately not stored (a preset reconfigures a lobby, +--- it doesn't restore a roster). ---@class UICustomLobbySetupSnapshot ---@field ScenarioFile FileName | false ---@field GameOptions table # the host's option values, minus per-player Ratings/ClanTags ---@field GameMods table # sim-mod uid set ---@field Restrictions string[] # unit-restriction preset keys ----@field AutoTeams table # best-effort (no host setter yet) ----@field SpawnMex table# best-effort ----@field Players table # [slot] = trimmed player or false (captured, not yet applied) ----@field Observers table # trimmed observer list --- All saved presets, in creation order (the `lastGame` entry included). ---@return { Name: string, Setup: UICustomLobbySetupSnapshot }[] diff --git a/lua/ui/lobby/customlobby/presetselect/CLAUDE.md b/lua/ui/lobby/customlobby/presetselect/CLAUDE.md index 9db941c246a..c8f34fbe55e 100644 --- a/lua/ui/lobby/customlobby/presetselect/CLAUDE.md +++ b/lua/ui/lobby/customlobby/presetselect/CLAUDE.md @@ -1,6 +1,6 @@ # Preset select -The custom lobby's **setup-presets dialog** — named full-setup snapshots (map / options / mods / +The custom lobby's **setup-presets dialog** — named setup snapshots (map / options / mods / restrictions) the host can Load / Save / Rename / Delete. The customlobby-native rebuild of the legacy [`/lua/ui/lobby/presets.lua`](../../presets.lua) dialog (which keyed `LobbyPresets`), built to the same shape as the [`../mapselect/`](../mapselect/CLAUDE.md) / [`../modselect/`](../modselect/CLAUDE.md) @@ -34,12 +34,12 @@ intents. `ApplyGameSettings` → single `UpdateGame`. - **Delete / Rename** → straight to `CustomLobbyPresets` (host-local prefs; no sync). -## Players & rehost are deferred (§ O) +## Setup-only — no players (§ O) -The snapshot **captures** players (trimmed: `PlayerName` + seating/faction/colour, no `OwnerID`/rating) -and observers, but **`ApplySetup` does not reseat them** — restoring players/AIs needs infra the new -lobby doesn't have yet (no AI-add, no per-player faction/colour/team intents — roadmap slice #3). The -launch auto-saves the reserved `lastGame` preset (so the data is there), but the rehost **restore** -(an in-lobby "Rehost last game" button + `/rehost` command-line detection at `CreateLobby`, matching -the FAF client) is part of that later slice. `PlayerName` is stored as the stable key the future -reseat will match returning humans on. +A preset stores **only** the setup: scenario / game options / sim mods / restrictions. Players, +observers and the (not-yet-applied) auto-teams / spawn-mex are **not** captured — a preset +reconfigures a lobby, it doesn't restore a roster. The launch still auto-saves the reserved +`lastGame` preset, so the *configuration* of the last game can be reapplied, but it carries no +roster. The future rehost reseat (§ O.4 — reseat returning players, in-lobby "Rehost last game" +button + `/rehost` detection at `CreateLobby`) is a separate slice that will need its **own** player +capture, not these presets (and the missing AI-add / per-player slot intents — roadmap slice #3). diff --git a/lua/ui/lobby/customlobby/presetselect/CustomLobbyPresetSelect.lua b/lua/ui/lobby/customlobby/presetselect/CustomLobbyPresetSelect.lua index 4c4646d9e30..1c4a5edc2a4 100644 --- a/lua/ui/lobby/customlobby/presetselect/CustomLobbyPresetSelect.lua +++ b/lua/ui/lobby/customlobby/presetselect/CustomLobbyPresetSelect.lua @@ -20,7 +20,7 @@ --** SOFTWARE. --****************************************************************************************************** --- The setup-presets dialog: a host-only list of named full-setup snapshots (map / options / mods / +-- The setup-presets dialog: a host-only list of named setup snapshots (map / options / mods / -- restrictions) you can Load / Save / Rename / Delete — the customlobby-native rebuild of the legacy -- `/lua/ui/lobby/presets.lua` dialog. Built to the map/mod-select shape (areas layout, three-phase -- init, Popup singleton). @@ -29,10 +29,10 @@ -- CustomLobbyPresets (pure prefs); applying a preset to the synced launch state is host-authoritative -- and goes through the controller intents (`RequestLoadSetupPreset` / `RequestSaveSetupPreset`). -- --- Scope note (§ O): a preset captures the *players* too, but loading does NOT reseat them yet — --- restoring players/AIs needs infra the new lobby doesn't have (no AI-add, no per-player intents). --- The reserved `lastGame` preset (auto-saved at launch) is shown as a pinned "Last game" entry; the --- future rehost feature loads it. See ../CLAUDE.md. +-- Scope note (§ O): a preset is **setup-only** — scenario / options / mods / restrictions. Players, +-- observers and auto-teams / spawn-mex are not stored (a preset reconfigures a lobby, it doesn't +-- restore a roster). The reserved `lastGame` preset (auto-saved at launch) is shown as a pinned +-- "Last game" entry; the future rehost reseat needs its own roster capture. See ../CLAUDE.md. local UIUtil = import("/lua/ui/uiutil.lua") local LayoutHelpers = import("/lua/maui/layouthelpers.lua") @@ -51,10 +51,11 @@ local Layouter = LayoutHelpers.ReusedLayoutFor -- flip to tint each layout area so the regions are visible while iterating local Debug = false --- five action buttons (Load / Save / Rename / Delete · Close) sit in one row; each `/BUTTON/small/` --- is 152×40 unscaled (see /textures/texture-dimensions.csv), so the dialog is sized to hold --- 5×152 + the inter-button gaps + padding without overlap. -local DialogWidth = 820 +-- the five action buttons (Load / Save / Rename / Delete · Close) sit in one row, each a +-- `/BUTTON/medium/` (132×44 unscaled — the same standard button the lobby's Leave/Launch use; the +-- `/BUTTON/` tree isn't in texture-dimensions.csv, these dims are from the DDS headers). The dialog +-- is sized to hold 5×132 + the inter-button gaps + padding without overlap. +local DialogWidth = 720 local DialogHeight = 470 local Pad = 12 local ColumnGap = 20 @@ -62,7 +63,7 @@ local ListWidth = 250 local ScrollbarInset = 20 local TitleHeight = 32 local ButtonGap = 8 -local ActionHeight = 48 +local ActionHeight = 52 local LabelColor = 'ffc8ccd0' @@ -92,19 +93,6 @@ local function DisplayName(name) return name end ---- The number of seated players a snapshot captured. ----@param setup UICustomLobbySetupSnapshot ----@return number -local function CountPlayers(setup) - local n = 0 - for _, player in setup.Players or {} do - if player then - n = n + 1 - end - end - return n -end - --- The map's display name for a snapshot, or "(none)" / the raw file when it can't be read. ---@param setup UICustomLobbySetupSnapshot ---@return string @@ -130,7 +118,6 @@ local function FactsFor(setup) "Map: " .. MapNameOf(setup), "Mods: " .. table.getsize(setup.GameMods or {}), "Restrictions: " .. table.getn(setup.Restrictions or {}), - "Players: " .. CountPlayers(setup), } end @@ -204,32 +191,32 @@ local CustomLobbyPresetSelect = ClassUI(Group) { --#endregion --#region actions - self.LoadButton = UIUtil.CreateButtonStd(self.ActionArea, '/BUTTON/small/', "Load", 14, 2) + self.LoadButton = UIUtil.CreateButtonWithDropshadow(self.ActionArea, '/BUTTON/medium/', "Load") self.LoadButton.OnClick = function(button, modifiers) self:LoadSelected() end Tooltip.AddControlTooltipManual(self.LoadButton, "Load preset", "Apply the selected preset's map, options, mods and restrictions to the lobby.") - self.SaveButton = UIUtil.CreateButtonStd(self.ActionArea, '/BUTTON/small/', "Save", 14, 2) + self.SaveButton = UIUtil.CreateButtonWithDropshadow(self.ActionArea, '/BUTTON/medium/', "Save") self.SaveButton.OnClick = function(button, modifiers) self:PromptSave() end Tooltip.AddControlTooltipManual(self.SaveButton, "Save preset", "Save the current lobby setup as a named preset.") - self.RenameButton = UIUtil.CreateButtonStd(self.ActionArea, '/BUTTON/small/', "Rename", 14, 2) + self.RenameButton = UIUtil.CreateButtonWithDropshadow(self.ActionArea, '/BUTTON/medium/', "Rename") self.RenameButton.OnClick = function(button, modifiers) self:PromptRename() end Tooltip.AddControlTooltipManual(self.RenameButton, "Rename preset", "Rename the selected preset.") - self.DeleteButton = UIUtil.CreateButtonStd(self.ActionArea, '/BUTTON/small/', "Delete", 14, 2) + self.DeleteButton = UIUtil.CreateButtonWithDropshadow(self.ActionArea, '/BUTTON/medium/', "Delete") self.DeleteButton.OnClick = function(button, modifiers) self:DeleteSelected() end Tooltip.AddControlTooltipManual(self.DeleteButton, "Delete preset", "Delete the selected preset.") - self.CloseButton = UIUtil.CreateButtonStd(self.ActionArea, '/BUTTON/small/', "Close", 14, 2) + self.CloseButton = UIUtil.CreateButtonWithDropshadow(self.ActionArea, '/BUTTON/medium/', "Close") self.CloseButton.OnClick = function(button, modifiers) self.OnCloseCb() end @@ -266,7 +253,7 @@ local CustomLobbyPresetSelect = ClassUI(Group) { self.DetailList.Right:Set(function() return self.DetailArea.Right() - LayoutHelpers.ScaleNumber(ScrollbarInset) end) --#endregion - --#region actions: Load / Save / Rename / Delete on the left, Close on the right + --#region actions: Load / Save / Rename / Delete cluster on the left, Close on the right Layouter(self.LoadButton):AtLeftIn(self.ActionArea):AtVerticalCenterIn(self.ActionArea):End() Layouter(self.SaveButton):AnchorToRight(self.LoadButton, ButtonGap):AtVerticalCenterIn(self.ActionArea):End() Layouter(self.RenameButton):AnchorToRight(self.SaveButton, ButtonGap):AtVerticalCenterIn(self.ActionArea):End() From f458794f290c7da08e66a98b75f7a83158a02a9a Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Fri, 26 Jun 2026 08:03:09 +0200 Subject: [PATCH 64/98] Add a derived model for the chosen scenario to enrich it --- lua/ui/lobby/customlobby/CLAUDE.md | 6 +- .../customlobby/CustomLobbyMapPreview.lua | 41 ++-- lua/ui/lobby/customlobby/CustomLobbyRules.lua | 52 ++--- .../CustomLobbyScenarioPreview.lua | 59 ++--- .../config/CustomLobbyConfigInterface.lua | 46 ++-- lua/ui/lobby/customlobby/derived/CLAUDE.md | 42 ++++ .../CustomLobbyScenarioDerivedModel.lua | 217 ++++++++++++++++++ lua/ui/lobby/customlobby/mapselect/CLAUDE.md | 2 +- .../mapselect/CustomLobbyMapCatalog.lua | 170 ++++++++++++-- .../mapselect/CustomLobbyMapSelect.lua | 4 +- 10 files changed, 498 insertions(+), 141 deletions(-) create mode 100644 lua/ui/lobby/customlobby/derived/CLAUDE.md create mode 100644 lua/ui/lobby/customlobby/derived/CustomLobbyScenarioDerivedModel.lua diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index 710e7d7feeb..03afadb6567 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -43,12 +43,12 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | [CustomLobbyLaunchModel.lua](CustomLobbyLaunchModel.lua) | shared + launched state — the launch payload (see above). | | [CustomLobbySessionModel.lua](CustomLobbySessionModel.lua) | shared, lobby-room-only state (slot count, closed slots). | | [CustomLobbyLocalModel.lua](CustomLobbyLocalModel.lua) | per-peer state, never synced: identity + CPU benchmarks. | -| [CustomLobbyScenarioModel.lua](CustomLobbyScenarioModel.lua) | a **derived** model (not one of the three): resolves the launch model's compact `ScenarioFile` into a rich, fully-loaded `Scenario` bundle — info (for the map texture) + extracted save markers (`Spawns` / `MassPoints` / `HydroPoints` / `Wrecks`, never the raw save) + `MaxDimension` / `ArmyCount` / `Name` / `Size` / `Version`. **It does the fishing once so consumers just read the field they need** (the map preview, the config facts line, `CustomLobbyRules`) instead of each re-resolving the file. An internal observer **dedups by file** — a launch-info rebroadcast that re-sets the same `ScenarioFile` is a no-op (the `Scenario` var only re-fires on a real map change, so the preview doesn't reload). The save extraction itself lives in the catalog's `LoadMarkers`. The first instance of a general pattern (compact synced field → derived reactive bundle); mods by UUID are the obvious next. | +| [derived/](derived/CLAUDE.md) | **derived models** — read-only reactive projections of the authoritative state, the "fishing" layer between the compact synced fields and the views (never written by the controller). The first is [CustomLobbyScenarioDerivedModel](derived/CustomLobbyScenarioDerivedModel.lua): resolves the launch model's compact `ScenarioFile` into a rich `Scenario` bundle — info (for the map texture) + extracted save markers (`Spawns` / `MassPoints` / `HydroPoints` / `Wrecks`, never the raw save) + `MaxDimension` / `ArmyCount` / `Name` / `Size` / `Version`. **Does the fishing once so consumers just read the field they need** (the map preview, the config facts line, `CustomLobbyRules`); an internal observer **dedups by file** so a launch-info rebroadcast of the same map is a no-op (no preview reload). Mods / restrictions / slot-info derived models are the planned siblings — see [derived/CLAUDE.md](derived/CLAUDE.md). | | [CustomLobbyPerformancePopover.lua](CustomLobbyPerformancePopover.lua) | hover popover over the CPU column; hand-built bitmap bar chart of a peer's `PerformanceTrackingV2` history, with a yellow recommended-unit-cap line. | | [CustomLobbyInstance.lua](CustomLobbyInstance.lua) | thin `moho.lobby_methods` shell; validates/dispatches traffic, forwards callbacks to the controller. Also feeds [`CustomLobbyLog`](CustomLobbyLog.lua) from its three network choke points (BroadcastData / SendData / DataReceived). | | [CustomLobbyLog.lua](CustomLobbyLog.lua) | the **network traffic log** — a reactive, per-peer, never-synced ring buffer (`Entries` LazyVar, capped) of every message this peer broadcasts / sends / receives, fed by the instance and rendered by the **Logs** tab. Each peer logs only its own traffic, so host and client views differ naturally. Not one of the three models (no game state); a diagnostic feed. | | [CustomLobbyController.lua](CustomLobbyController.lua) | host-authority logic (free functions): seating, `Process*` handlers, intents (`RequestSetReady`, `RequestTakeSlot`, `RequestSwapSlots`, `RequestEject`, `RequestMoveToObserver`, `RequestSetScenario`, `RequestSetGameMods/Options`, `RequestResetGameOptions`, `RequestSetSlotsPinned`, `RequestAutoBalance` (stub), `RequestReopenClosedSlots`, `RequestLaunch`, `RequestSaveSetupPreset`/`RequestLoadSetupPreset` — all keyed by slot/bool/file/name so a chat command can call them too; permission is gated separately), sharing the stored CPU benchmark. **Launch:** `RequestLaunch` (host-only, readiness-validated) → auto-saves the `lastGame` preset (`BuildSetupSnapshot`) → `BuildGameConfiguration` (seed option defaults + scenario, resolve random factions, assign army numbers + push to server, stamp ratings/clan tags, resolve sim mods via `Mods.GetGameMods`) → broadcast `LaunchGame` + `instance:LaunchGame`; clients run `ProcessLaunchGame`. **Presets:** `BuildSetupSnapshot` (read the launch state → serializable **setup-only** snapshot: scenario / options / mods / restrictions) + `ApplySetup` (host-only: write scenario/mods/restrictions, reconcile options to the current schema, one broadcast); players, observers and teams/spawn-mex are not stored (see [presetselect/](presetselect/CLAUDE.md)). | -| [CustomLobbyRules.lua](CustomLobbyRules.lua) | game-rule derivations from lobby state (not view, not networking): `RecommendedUnitCap()` (per-player cap by map size) and `BuildSideResolver()` (positional auto-team split). Both read the resolved map size / start spots from [`CustomLobbyScenarioModel`](CustomLobbyScenarioModel.lua) rather than loading the scenario themselves. | +| [CustomLobbyRules.lua](CustomLobbyRules.lua) | game-rule derivations from lobby state (not view, not networking): `RecommendedUnitCap()` (per-player cap by map size) and `BuildSideResolver()` (positional auto-team split). Both read the resolved map size / start spots from [`CustomLobbyScenarioDerivedModel`](derived/CustomLobbyScenarioDerivedModel.lua) rather than loading the scenario themselves. | | [CustomLobbyPresets.lua](CustomLobbyPresets.lua) | **named setup presets** (setup-only: scenario / options / mods / restrictions — no players/observers) — pure prefs CRUD (`GetPresets`/`GetPreset`/`SavePreset`/`DeletePreset`/`RenamePreset`) over one key (`customlobby_setup_presets`), an ordered `{ Name, Setup }` array mirroring the mod presets in [`/lua/ui/modutilities.lua`](/lua/ui/modutilities.lua). Holds no models, no network. A reserved `LastGamePresetName` entry is auto-saved at launch (the rehost source). Capturing/applying a setup lives in the controller (`BuildSetupSnapshot`/`ApplySetup`); see [presetselect/](presetselect/CLAUDE.md). | | [CustomLobbySession.lua](CustomLobbySession.lua) | the lobby session's **main trash bag**. The lobby lives in the persistent front-end Lua state, which is *not* reset when the game launches in its own state — so anything left reachable (a running thread, a cache, a singleton) leaks for the whole match. This module owns one session-lifetime `TrashBag` (`GetTrash()`); every lobby-scoped `Destroyable` registers in it, and one `Teardown()` (clean-slate in `CreateLobby`, on leave, on `OnGameLaunched`) frees the lot. Works despite the bag being weak-valued because each singleton is pinned by its own module `Instance` local. **Rollout in progress** — the map catalog is the first resource converted; models / interface / instance follow. | | [CustomLobbyMessages.lua](CustomLobbyMessages.lua) | message registry: `AddPlayer`, `SetPlayers` (launch model: players + observers), `SentLaunchInfo` (launch model: scenario / options / mods / teams / spawn mex), `SetSessionState` (session model: slot count / closed slots), `SetReady`, `TakeSlot`, `DisconnectPeer`, `LaunchGame` (host's final game config → `instance:LaunchGame`), `ReportCpuBenchmark`, `SetCpuBenchmarks`. | @@ -57,7 +57,7 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | [slots/](slots/) | the **slot subsystem** (its own folder, a sub-folder per layout). [`CustomLobbySlotsInterface`](slots/CustomLobbySlotsInterface.lua) is the entry the composition root mounts: a "Players" header over the **active layout body**, picked by the AutoTeams mode — one-column for the non-team modes, the two-column team layout for the binary modes (left/right, top/bottom, even/odd). It observes `GameOptions` and swaps the body when the kind flips (create-on-mode / destroy-on-switch); a change *within* the binary modes is handled by the two-column body's own re-layout. It is the rows' **drag coordinator** (`UICustomLobbySlotCoordinator`: hit-test which row a point is in, drop-highlight, drag ghost → `RequestSwapSlots`) for *every* layout — it alone needs to hit-test across rows — so the layout bodies stay pure build/place/reveal and never duplicate the drag logic; it also exposes `PreferredHeight()` (computed from the mode + model, swap-order-independent) so the root sizes the slot area. The header band carries a **host-only tool strip** (right-aligned: pin seating · auto-balance · reopen closed slots) of small icon buttons (local `SlotTool`, mirroring the config column's `PreviewTool`); the pin button is lit from the synced `SlotsPinned` and the strip is hidden for clients. A **"Locked" notice** (lock glyph + label) sits right of the "Players" label and is shown to **everyone** while `SlotsPinned` is on, so a client can see seating is host-controlled before clicking an open slot to no effect. [`CustomLobbySlotBase`](slots/CustomLobbySlotBase.lua) holds **all slot behaviour** (the slot + CPU subscriptions, the CPU-cap math, click/right-click/drag-to-swap + intents, the background/highlight/click overlays) and the default `RenderPlayer`/`RenderCpu` that paint the standard named controls from normalised views; a presentation subclasses it and implements just `CreateContents` / `LayoutContents`. [`onecolumn/`](slots/onecolumn/): [`CustomLobbyOneColumnSlots`](slots/onecolumn/CustomLobbyOneColumnSlots.lua) (stacks thin rows, reveals 1..count) + [`CustomLobbySlotRow`](slots/onecolumn/CustomLobbySlotRow.lua) (thin one-line presentation). [`twocolumn/`](slots/twocolumn/): [`CustomLobbyTwoColumnSlots`](slots/twocolumn/CustomLobbyTwoColumnSlots.lua) (splits slots into two team columns via [`CustomLobbyRules.BuildSideResolver`](CustomLobbyRules.lua), with a [`CustomLobbyTeamScore`](CustomLobbyTeamScore.lua) strip across the top as the Left/Right indicator, and the "two columns, unresolved" fallback — parity fill + the score self-hides until a positional map's start positions load) + [`CustomLobbySlotCard`](slots/twocolumn/CustomLobbySlotCard.lua) (fat half-width / two-line presentation; `SetMirrored` flips the right column's cards so the two teams face each other). Each body exports `HeightForCount`. | | [CustomLobbyObserversInterface.lua](CustomLobbyObserversInterface.lua) | observer strip; subscribes to the model's `Observers` list and shows the count + names (read-only). | | [CustomLobbyScenarioPreview.lua](CustomLobbyScenarioPreview.lua) | **shared** map-preview *surface*: the scenario's map texture + overlays (start spots, resource/wreck markers, plus a **dummy translucent `WaterMask`** placeholder until a real mask exists) with aspect-correct positioning, texture-leak-safe icon sharing, per-group visibility (`SetOverlayVisible('spawns'\|'resources'\|'wrecks'\|'water', …)`), and three-phase init. A **pure renderer**: `SetScenario(info, markers)` takes already-extracted markers (`UICustomLobbyScenarioMarkers`) and never touches the raw save. Chrome-free; `CustomLobbyMapPreview` wraps it with the frame. Spawn appearance is the owner's via a `CreateSpawnIcon` factory. | -| [CustomLobbyMapPreview.lua](CustomLobbyMapPreview.lua) | the map preview **as one whole** — the chrome (glow border on top + dark backdrop, surface inset by `Padding`), the surface, and the faction spawn icon (local `MapPreviewSpawn`). Used by **both** consumers: created `Bound = true` it subscribes to [`CustomLobbyScenarioModel`](CustomLobbyScenarioModel.lua) and renders the resolved scenario with per-slot faction spawns (no reload on take/swap, and no reload on a same-map rebroadcast — the model dedups); created unbound (the map-select dialog) it does no model wiring and the owner drives `preview.Surface` directly (numbered-dot spawns), feeding it markers from the catalog's `LoadMarkers`. Exposes `.Surface` for owners to drive / anchor overlays to. | +| [CustomLobbyMapPreview.lua](CustomLobbyMapPreview.lua) | the map preview **as one whole** — the chrome (glow border on top + dark backdrop, surface inset by `Padding`), the surface, and the faction spawn icon (local `MapPreviewSpawn`). Used by **both** consumers: created `Bound = true` it subscribes to [`CustomLobbyScenarioDerivedModel`](derived/CustomLobbyScenarioDerivedModel.lua) and renders the resolved scenario with per-slot faction spawns (no reload on take/swap, and no reload on a same-map rebroadcast — the model dedups); created unbound (the map-select dialog) it does no model wiring and the owner drives `preview.Surface` directly (numbered-dot spawns), feeding it markers from the catalog's `LoadMarkers`. Exposes `.Surface` for owners to drive / anchor overlays to. | | [mapselect/](mapselect/CLAUDE.md) | the **map-select dialog** + its catalog and list, in their own folder (host-only `Popup`: searchable, filterable scenario list → `RequestSetScenario`). Self-contained sub-MVC; see [mapselect/CLAUDE.md](mapselect/CLAUDE.md) — including the **`MapPreview` texture-leak** writeup that shaped its design. | | [modselect/](modselect/CLAUDE.md) | the **mod-select dialog** + its catalog and list, built to the map-select shape (checkbox list + type filters + detail panel + **presets**). Returns a uid set; the opener routes it — sim mods → `RequestSetGameMods` (synced), UI mods → local prefs — or persists the lot standalone. Mod domain logic lives in [`/lua/ui/modutilities.lua`](/lua/ui/modutilities.lua) (the `maputil.lua` sibling, fronting `/lua/mods.lua`). See [modselect/CLAUDE.md](modselect/CLAUDE.md). | | [presetselect/](presetselect/CLAUDE.md) | the **setup-presets dialog** (host-only `Popup`): a list of named setup snapshots (map / options / mods / restrictions) with **Load / Save / Rename / Delete**, opened by the action-bar **Presets** button. Owns no synced state — Save/Load route through the controller's host-authoritative `RequestSaveSetupPreset` / `RequestLoadSetupPreset` intents; the persistence is [`CustomLobbyPresets`](CustomLobbyPresets.lua). Setup-only — players/observers are **not** part of presets (see presetselect/CLAUDE.md). | diff --git a/lua/ui/lobby/customlobby/CustomLobbyMapPreview.lua b/lua/ui/lobby/customlobby/CustomLobbyMapPreview.lua index 20458ab54ac..66014831fbf 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyMapPreview.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyMapPreview.lua @@ -24,9 +24,11 @@ -- shared CustomLobbyScenarioPreview surface, plus the faction spawn icon (the local -- `MapPreviewSpawn`). Both preview consumers use this one component, so they look identical: -- --- * In the lobby — created with `Bound = true`. It subscribes to the launch model and renders the --- committed `ScenarioFile` automatically (load via the catalog, hand to the surface, show/hide), --- with per-slot faction-icon spawns refreshed as players take/swap/recolour (no map reload). +-- * In the lobby — created with `Bound = true`. It subscribes to the derived scenario model +-- (CustomLobbyScenarioDerivedModel) and renders the resolved scenario automatically (hand info + markers +-- to the surface, show/hide). The model loads + dedups, so a launch-info rebroadcast of the same +-- map doesn't reload; per-slot faction-icon spawns refresh as players take/swap/recolour (no map +-- reload). -- -- * In the map-select dialog — created unbound (the default). No model wiring, numbered-dot spawns -- (the surface default); the owner drives the preview itself through `self.Surface` @@ -46,7 +48,7 @@ local LayoutHelpers = import("/lua/maui/layouthelpers.lua") local Group = import("/lua/maui/group.lua").Group local Bitmap = import("/lua/maui/bitmap.lua").Bitmap local CustomLobbyScenarioPreview = import("/lua/ui/lobby/customlobby/customlobbyscenariopreview.lua") -local CustomLobbyMapCatalog = import("/lua/ui/lobby/customlobby/mapselect/customlobbymapcatalog.lua") +local CustomLobbyScenarioDerivedModel = import("/lua/ui/lobby/customlobby/derived/customlobbyscenarioderivedmodel.lua") local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") local LazyVarDerive = import("/lua/lazyvar.lua").Derive @@ -174,16 +176,17 @@ local CustomLobbyMapPreview = ClassUI(Group) { self.Glow = UIUtil.CreateBitmap(self, GlowTexture) self.Glow:DisableHitTest() - -- bound: the launch model drives the preview. The scenario file renders the whole map; each - -- slot drives only the spawn icons (against the already-loaded scenario, so take/swap/faction - -- changes don't reload the map). Unbound: the owner drives self.Surface directly. + -- bound: the derived scenario model drives the preview. The resolved scenario renders the whole + -- map; each slot drives only the spawn icons (against the already-loaded scenario, so take/swap/ + -- faction changes don't reload the map). The model dedups by file, so a launch-info rebroadcast + -- of the same map doesn't re-fire here either. Unbound: the owner drives self.Surface directly. if self.Bound then - local model = CustomLobbyLaunchModel.GetSingleton() self.ScenarioObserver = self.Trash:Add( - LazyVarDerive(model.ScenarioFile, function(scenarioFileLazy) - self:OnScenarioFileChanged(scenarioFileLazy()) + LazyVarDerive(CustomLobbyScenarioDerivedModel.GetScenarioVar(), function(scenarioLazy) + self:OnScenarioChanged(scenarioLazy()) end)) + local model = CustomLobbyLaunchModel.GetSingleton() self.PlayerObservers = {} for slot = 1, CustomLobbyLaunchModel.MaxSlots do self.PlayerObservers[slot] = self.Trash:Add( @@ -211,24 +214,18 @@ local CustomLobbyMapPreview = ClassUI(Group) { self.Trash:Destroy() end, - --- (Bound only) Loads the scenario and hands it to the surface; hides the preview when unset. + --- (Bound only) Hands the already-resolved scenario to the surface; hides the preview when there + --- is none. The derived model did the loading + dedup, so this just renders what it's given. ---@param self UICustomLobbyMapPreview - ---@param scenarioFile FileName | false - OnScenarioFileChanged = function(self, scenarioFile) - if not scenarioFile then - self.Surface:Clear() - self:Hide() - return - end - - local scenarioInfo = CustomLobbyMapCatalog.LoadInfo(scenarioFile) - if not scenarioInfo then + ---@param scenario UICustomLobbyScenario | false + OnScenarioChanged = function(self, scenario) + if not scenario then self.Surface:Clear() self:Hide() return end - self.Surface:SetScenario(scenarioInfo, CustomLobbyMapCatalog.LoadSave(scenarioInfo)) + self.Surface:SetScenario(scenario.Info, scenario.Markers) self.Surface:SetSpawnData(self:GatherSpawnData()) self:Show() end, diff --git a/lua/ui/lobby/customlobby/CustomLobbyRules.lua b/lua/ui/lobby/customlobby/CustomLobbyRules.lua index e2d082b7692..cf989020321 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyRules.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyRules.lua @@ -25,10 +25,7 @@ -- model the source of truth; this layer only derives. local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") - --- Scenario file -> largest map dimension (ogrids). Loading a scenario is I/O, so the --- result is memoised per file (a map rarely changes, and the key changes if it does). -local MapDimensionCache = {} +local CustomLobbyScenarioDerivedModel = import("/lua/ui/lobby/customlobby/derived/customlobbyscenarioderivedmodel.lua") --- Units-per-player tier from the map's largest dimension (51.2 ogrids = 1 km), i.e. --- 250 / 375 / 500 for 5x5 / 10x10 / 20x20-or-larger. Unknown size (0) → the top tier. @@ -43,27 +40,12 @@ local function UnitsPerPlayer(maxDimension) return 500 -- 20x20 or larger / not yet known end ---- Largest dimension (ogrids) of the current scenario, or 0 when no map is set. ----@param model UICustomLobbyLaunchModel +--- Largest dimension (ogrids) of the current scenario, or 0 when no map is set. Read straight from +--- the derived scenario model, which already resolved + cached it (no disk read here). ---@return number -local function CurrentMapDimension(model) - local scenarioFile = model.ScenarioFile() - if not scenarioFile then - return 0 - end - - local cached = MapDimensionCache[scenarioFile] - if cached ~= nil then - return cached - end - - local dimension = 0 - local scenarioInfo = import("/lua/ui/maputil.lua").LoadScenario(scenarioFile) - if scenarioInfo and scenarioInfo.size then - dimension = math.max(scenarioInfo.size[1] or 0, scenarioInfo.size[2] or 0) - end - MapDimensionCache[scenarioFile] = dimension - return dimension +local function CurrentMapDimension() + local scenario = CustomLobbyScenarioDerivedModel.GetScenario() + return scenario and scenario.MaxDimension or 0 end --- The recommended total-unit ceiling for the current map and seated-player count. @@ -82,7 +64,7 @@ function RecommendedUnitCap() return nil end - return UnitsPerPlayer(CurrentMapDimension(model)) * players + return UnitsPerPlayer(CurrentMapDimension()) * players end ------------------------------------------------------------------------------- @@ -117,7 +99,8 @@ end --- * `resolved` is false when the mode is positional but positions are unavailable — callers that --- need a definite split (e.g. the team score) hide in that case, while the two-column slot --- layout still renders both columns and just withholds the side labels until it flips true. ---- Positional modes load the map start positions ONCE here, so a caller resolves all spots cheaply. +--- Positional modes read the start spots from the derived scenario model (already loaded), so a +--- caller resolves all spots cheaply. ---@return (fun(startSpot: number): number | nil) | nil resolver ---@return boolean resolved function BuildSideResolver() @@ -133,21 +116,18 @@ function BuildSideResolver() end, true end - -- positional: resolve each spot against the map centre (positions loaded once) - local MapUtil = import("/lua/ui/maputil.lua") - local CustomLobbyMapCatalog = import("/lua/ui/lobby/customlobby/mapselect/customlobbymapcatalog.lua") - local model = CustomLobbyLaunchModel.GetSingleton() - local scenarioFile = model.ScenarioFile() - local info = scenarioFile and CustomLobbyMapCatalog.LoadInfo(scenarioFile) - if type(info) ~= "table" or not info.size then + -- positional: resolve each spot against the map centre, using the derived scenario's start spots + -- (the model already loaded + extracted them — no disk read here) + local scenario = CustomLobbyScenarioDerivedModel.GetScenario() + if not (scenario and scenario.Size) then return function(spot) return nil end, false -- mode set, positions unknown → unresolved end - local positions = MapUtil.GetStartPositionsFromScenario(info, CustomLobbyMapCatalog.LoadSave(info)) - if not positions then + local positions = scenario.Markers.Spawns + if not positions or table.empty(positions) then return function(spot) return nil end, false end - local centreX, centreZ = info.size[1] / 2, info.size[2] / 2 + local centreX, centreZ = scenario.Size[1] / 2, scenario.Size[2] / 2 return function(spot) local pos = spot and positions[spot] if not pos then return nil end diff --git a/lua/ui/lobby/customlobby/CustomLobbyScenarioPreview.lua b/lua/ui/lobby/customlobby/CustomLobbyScenarioPreview.lua index aa3087838a0..16f1746b0ec 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyScenarioPreview.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyScenarioPreview.lua @@ -42,7 +42,6 @@ -- spots while numbered dots (no Update/Reset) stay shown. 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 @@ -79,7 +78,7 @@ local SpawnDotSize = 18 ---@field ShowWrecks boolean ---@field ShowWater boolean ---@field ScenarioInfo? UILobbyScenarioInfo ----@field ScenarioSave? UIScenarioSaveFile +---@field Markers? UICustomLobbyScenarioMarkers # extracted save bits (spawns + mass/hydro/wreck points) ---@field SpawnData table # per-start-spot data handed to spawn icons' :Update ---@field CreateSpawnIcon fun(surface: Control, index: number): Control ---@field Ready boolean # true once laid out by the parent; gates geometry reads @@ -107,7 +106,7 @@ local CustomLobbyScenarioPreview = ClassUI(Group) { self.ShowWater = false self.ScenarioInfo = nil - self.ScenarioSave = nil + self.Markers = nil self.SpawnData = {} self.Ready = false @@ -158,14 +157,15 @@ local CustomLobbyScenarioPreview = ClassUI(Group) { --------------------------------------------------------------------------- --#region Public API - --- Renders a scenario: map texture + resource/wreck/spawn overlays. Pass already-loaded - --- info + save (the owner / catalog does the disk read). A nil info clears the surface. + --- Renders a scenario: map texture + resource/wreck/spawn overlays. Pass the already-loaded info + --- and the extracted markers (the owner gets them from the catalog / scenario model — the surface + --- never touches the raw save). A nil info clears the surface. ---@param self UICustomLobbyScenarioPreview ---@param scenarioInfo? UILobbyScenarioInfo - ---@param scenarioSave? UIScenarioSaveFile - SetScenario = function(self, scenarioInfo, scenarioSave) + ---@param markers? UICustomLobbyScenarioMarkers + SetScenario = function(self, scenarioInfo, markers) self.ScenarioInfo = scenarioInfo - self.ScenarioSave = scenarioSave + self.Markers = markers if self.Ready then self:Render() end @@ -177,7 +177,7 @@ local CustomLobbyScenarioPreview = ClassUI(Group) { ---@param spawnData table SetSpawnData = function(self, spawnData) self.SpawnData = spawnData or {} - if self.Ready and self.ScenarioInfo and self.ScenarioSave then + if self.Ready and self.ScenarioInfo and self.Markers then self:RenderSpawns() end end, @@ -203,7 +203,7 @@ local CustomLobbyScenarioPreview = ClassUI(Group) { ---@param self UICustomLobbyScenarioPreview Clear = function(self) self.ScenarioInfo = nil - self.ScenarioSave = nil + self.Markers = nil self.MarkerTrash:Destroy() self.SpawnTrash:Destroy() self.SpawnIcons = {} @@ -235,7 +235,7 @@ local CustomLobbyScenarioPreview = ClassUI(Group) { self.Preview:SetTextureFromMap(info.map) end - if self.ScenarioSave and info.size then + if self.Markers and info.size then self:BuildResources() self:BuildWrecks() end @@ -250,11 +250,11 @@ local CustomLobbyScenarioPreview = ClassUI(Group) { self.SpawnIcons = {} local info = self.ScenarioInfo - if not (info and self.ScenarioSave and info.size) then + if not (info and self.Markers and info.size) then return end - local positions = MapUtil.GetStartPositionsFromScenario(info, self.ScenarioSave) + local positions = self.Markers.Spawns if not positions then return end @@ -279,32 +279,29 @@ local CustomLobbyScenarioPreview = ClassUI(Group) { end end, - --- Places mass + hydrocarbon icons from the save's master-chain markers. + --- Places mass + hydrocarbon icons from the extracted resource points. ---@param self UICustomLobbyScenarioPreview BuildResources = function(self) local info = self.ScenarioInfo - for _, marker in self:Markers() do - local template = (marker.type == "Mass" and self.MassTemplate) - or (marker.type == "Hydrocarbon" and self.EnergyTemplate) - or false - if template and marker.position then + local function place(points, template) + for _, point in points do local icon = self.MarkerTrash:Add(self:CreateMarkerIcon(template, ResourceIconSize)) - self:PlaceMarker(icon, info.size[1], info.size[2], marker.position[1], marker.position[3]) + self:PlaceMarker(icon, info.size[1], info.size[2], point[1], point[2]) table.insert(self.ResourceIcons, icon) end end + place(self.Markers.MassPoints, self.MassTemplate) + place(self.Markers.HydroPoints, self.EnergyTemplate) end, - --- Best-effort wreck icons: maps that expose prebuilt wreckage as save markers. + --- Best-effort wreck icons: maps that expose prebuilt wreckage (extracted as wreck points). ---@param self UICustomLobbyScenarioPreview BuildWrecks = function(self) local info = self.ScenarioInfo - for _, marker in self:Markers() do - if marker.type and marker.position and string.find(string.lower(marker.type), 'wreck') then - local icon = self.MarkerTrash:Add(self:CreateMarkerIcon(self.WreckTemplate, WreckIconSize)) - self:PlaceMarker(icon, info.size[1], info.size[2], marker.position[1], marker.position[3]) - table.insert(self.WreckIcons, icon) - end + for _, point in self.Markers.Wrecks do + local icon = self.MarkerTrash:Add(self:CreateMarkerIcon(self.WreckTemplate, WreckIconSize)) + self:PlaceMarker(icon, info.size[1], info.size[2], point[1], point[2]) + table.insert(self.WreckIcons, icon) end end, @@ -331,14 +328,6 @@ local CustomLobbyScenarioPreview = ClassUI(Group) { end end, - --- The save's master-chain markers, or an empty table. - ---@param self UICustomLobbyScenarioPreview - ---@return table - Markers = function(self) - local masterChain = self.ScenarioSave.MasterChain and self.ScenarioSave.MasterChain['_MASTERCHAIN_'] - return (masterChain and masterChain.Markers) or {} - end, - --#endregion --------------------------------------------------------------------------- diff --git a/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua b/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua index 8e9b19ae872..e42660a9c3a 100644 --- a/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua +++ b/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua @@ -59,7 +59,7 @@ local CustomLobbyModSelect = import("/lua/ui/lobby/customlobby/modselect/customl local CustomLobbyUnitSelect = import("/lua/ui/lobby/customlobby/unitselect/customlobbyunitselect.lua") local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/customlobbylocalmodel.lua") -local CustomLobbyMapCatalog = import("/lua/ui/lobby/customlobby/mapselect/customlobbymapcatalog.lua") +local CustomLobbyScenarioDerivedModel = import("/lua/ui/lobby/customlobby/derived/customlobbyscenarioderivedmodel.lua") local OptionUtil = import("/lua/ui/optionutil.lua") local ModUtilities = import("/lua/ui/modutilities.lua") @@ -216,18 +216,6 @@ local function Truncate(text, maxChars) return text end ---- Number of start spots a scenario declares, or 0. ----@param scenario UILobbyScenarioInfo ----@return number -local function ArmyCount(scenario) - local armies = scenario.Configurations - and scenario.Configurations.standard - and scenario.Configurations.standard.teams - and scenario.Configurations.standard.teams[1] - and scenario.Configurations.standard.teams[1].armies - return armies and table.getsize(armies) or 0 -end - ---@class UICustomLobbyConfigInterface : Group ---@field Trash TrashBag ---@field Preview UICustomLobbyMapPreview @@ -338,9 +326,11 @@ local CustomLobbyConfigInterface = ClassUI(Group) { }, }) + -- the derived scenario model already loaded + deduped the map; we just re-render the facts + -- when the resolved scenario actually changes (a same-map rebroadcast won't re-fire this) self.ScenarioObserver = self.Trash:Add( - LazyVarDerive(CustomLobbyLaunchModel.GetSingleton().ScenarioFile, function(scenarioFileLazy) - scenarioFileLazy() + LazyVarDerive(CustomLobbyScenarioDerivedModel.GetScenarioVar(), function(scenarioLazy) + scenarioLazy() self:RefreshFacts() end)) -- the change-map button is host-only (the gears are gated per-tab via their Visible LazyVar) @@ -390,27 +380,27 @@ local CustomLobbyConfigInterface = ClassUI(Group) { self.Tabs:Initialize() end, - --- Renders the map name + the size · players · version facts line for the current scenario. + --- Renders the map name + the size · players · version facts line — straight from the derived + --- scenario model (no disk read here; the model already fished the values out). ---@param self UICustomLobbyConfigInterface RefreshFacts = function(self) - local scenarioFile = CustomLobbyLaunchModel.GetSingleton().ScenarioFile() - local info = scenarioFile and CustomLobbyMapCatalog.LoadInfo(scenarioFile) - if type(info) == "table" then - self.Name:SetText(Truncate(LOC(info.name) or "?", NameMaxChars)) + local scenario = CustomLobbyScenarioDerivedModel.GetScenario() + if scenario then + self.Name:SetText(Truncate(scenario.Name, NameMaxChars)) local parts = {} - if info.size then - table.insert(parts, string.format("%dkm", math.floor(info.size[1] / 50))) + if scenario.Size then + table.insert(parts, string.format("%dkm", math.floor(scenario.Size[1] / 50))) end - local players = ArmyCount(info) - if players > 0 then - table.insert(parts, players .. " players") + if scenario.ArmyCount > 0 then + table.insert(parts, scenario.ArmyCount .. " players") end - if info.map_version then - table.insert(parts, "v" .. tostring(info.map_version)) + if scenario.Version then + table.insert(parts, "v" .. tostring(scenario.Version)) end self.Info:SetText(table.concat(parts, " · ")) else - self.Name:SetText(scenarioFile and "Unknown map" or "No map selected") + local hasFile = CustomLobbyLaunchModel.GetSingleton().ScenarioFile() + self.Name:SetText(hasFile and "Unknown map" or "No map selected") self.Info:SetText("") end end, diff --git a/lua/ui/lobby/customlobby/derived/CLAUDE.md b/lua/ui/lobby/customlobby/derived/CLAUDE.md new file mode 100644 index 00000000000..15f0c6b8d8e --- /dev/null +++ b/lua/ui/lobby/customlobby/derived/CLAUDE.md @@ -0,0 +1,42 @@ +# Derived models + +Read-only reactive **projections** of the authoritative lobby state. The three models +([`../CustomLobbyLaunchModel`](../CustomLobbyLaunchModel.lua) / `SessionModel` / `LocalModel`) hold +the state in its most **compact** form — a map is a single `ScenarioFile`, sim mods are a set of +UUIDs, restrictions are preset keys. Turning those into something a view can actually render (the +map's name / size / start spots / texture, a mod's title + icon + dependencies, a restriction's +display name) takes work — disk loads, catalog lookups, lookups against reference data. + +A **derived model** does that work **once** and exposes the result reactively, so every consumer +just reads the field it needs instead of each re-resolving the compact value. It is the "fishing" +layer between the compact synced fields and the views. + +## The contract + +- **Derived, never authoritative.** A derived model is a pure function of the models it reads. It is + **read-only**: it has no write helpers, and the **controller never touches it**. To change what it + holds, change the *source field* it derives from (the controller writes that). This keeps the + authoritative model the single source of truth and the derived model a cache/projection. +- **Reactive + deduped.** It subscribes to its source field(s) and republishes a resolved bundle via + a `LazyVar`. Because `LazyVar:Set` always re-fires (and the host rebroadcasts whole snapshots, + re-setting fields to unchanged values), the internal observer **dedups by key** — the same input + arriving twice is a no-op, so downstream views don't needlessly reload. Consumers subscribe to the + bundle var (`Derive`) or pull the current value (`GetScenario()` etc.). +- **Not on the wire.** Purely local reactive reference data, like the catalogs — every peer derives + its own from the synced compact fields. +- **Naming.** File **and** class carry the `DerivedModel` suffix + (`CustomLobbyDerivedModel`), and the file lives here in `derived/`, so it is unmistakable at + the import site and in the type that this is derived state — not something a controller writes. +- **Lifetime.** A module-level `ModelInstance` singleton (auto-created in `GetSingleton`) with the + standard hot-reload hooks. The internal observer is pinned on the model table so it isn't GC'd. + +## Files + +| File | Derives | From | Read by | +|------|---------|------|---------| +| [CustomLobbyScenarioDerivedModel.lua](CustomLobbyScenarioDerivedModel.lua) | the resolved `Scenario` (info for the texture + extracted save markers `Spawns`/`MassPoints`/`HydroPoints`/`Wrecks` + `MaxDimension`/`ArmyCount`/`Name`/`Size`/`Version`) | the launch model's `ScenarioFile` | the bound [`../CustomLobbyMapPreview`](../CustomLobbyMapPreview.lua), the [`../config/CustomLobbyConfigInterface`](../config/CustomLobbyConfigInterface.lua) facts line, [`../CustomLobbyRules`](../CustomLobbyRules.lua) (map size + start spots) | + +**Planned siblings** (same shape): a mods derived model (UUID set → full mod info via +[`../modselect/`](../modselect/CLAUDE.md) / `/lua/ui/modutilities.lua`), a restrictions derived model +(preset keys → display names), and a slots-info derived model (the seated players projected into the +shapes the slot layouts / team score want). diff --git a/lua/ui/lobby/customlobby/derived/CustomLobbyScenarioDerivedModel.lua b/lua/ui/lobby/customlobby/derived/CustomLobbyScenarioDerivedModel.lua new file mode 100644 index 00000000000..c586840c692 --- /dev/null +++ b/lua/ui/lobby/customlobby/derived/CustomLobbyScenarioDerivedModel.lua @@ -0,0 +1,217 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- ============================================================================================ +-- DERIVED MODEL — read-only. See /lua/ui/lobby/customlobby/derived/CLAUDE.md. +-- +-- A derived model is a pure function of the authoritative models: it resolves a compact synced field +-- into a rich, ready-to-read bundle and exposes it reactively. Views read it; the **controller never +-- writes it** (there are no write helpers) — to change what it holds, change the source field it +-- derives from. That keeps the launch model the single source of truth and this a cache/projection. +-- ============================================================================================ +-- +-- The **derived scenario** state: the launch model stores the map in its most compact form — a single +-- `ScenarioFile` path. Turning that into something a view can use (name, size, player count, start +-- spots, resource/wreck markers, the map texture) means loading the scenario off disk. This model does +-- that fishing **once** and exposes the result, so every consumer (the map preview, the facts line, the +-- rules) just reads the field it needs instead of re-resolving the file itself. +-- +-- It is reactive (a `Scenario` LazyVar) like the lobby models, but it is **derived, not authoritative** +-- — it holds no host-dictated state and never goes on the wire. It is purely a function of the launch +-- model's `ScenarioFile`. +-- +-- **De-duplication.** `LazyVar:Set` always re-fires its observers, even when the value is unchanged — +-- and the host rebroadcasts the whole launch info (re-setting `ScenarioFile` to the *same* path) on any +-- option tweak. So an internal observer dedups by file: the same scenario arriving twice is a no-op, +-- and the `Scenario` var only re-fires (→ the preview reloads, the facts re-render) on an actual change. +-- +-- Scenario is the first derived model. Mods (compact UUIDs → full mod info), restrictions and the slot +-- info are the planned siblings; they follow this same shape (see the folder's CLAUDE.md). + +local Create = import("/lua/lazyvar.lua").Create +local Derive = import("/lua/lazyvar.lua").Derive +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") +local CustomLobbyMapCatalog = import("/lua/ui/lobby/customlobby/mapselect/customlobbymapcatalog.lua") + +------------------------------------------------------------------------------- +--#region Shape + +--- The fully-resolved current scenario. Holds the lightweight `_scenario.lua` info (the surface needs +--- it for the map texture + size) and the *extracted* save markers — never the raw save itself. +---@class UICustomLobbyScenario +---@field File FileName # the file this was resolved from +---@field Info UILobbyScenarioInfo # _scenario.lua: preview, map, size, Configurations +---@field Markers UICustomLobbyScenarioMarkers # extracted save bits (spawns + mass/hydro/wreck points) +---@field MaxDimension number # largest map dimension in ogrids (0 if unknown) +---@field ArmyCount number # number of start spots the scenario declares +---@field Name string # LOC'd display name +---@field Size table | false # {x, z} in ogrids, or false +---@field Version number | false # map_version, or false + +--#endregion + +------------------------------------------------------------------------------- +--#region Derived model + +--- Reactive derived-scenario singleton. **Read-only** — no write helpers; the controller never +--- touches it. It re-derives itself from the launch model's `ScenarioFile`. +---@class UICustomLobbyScenarioDerivedModel +---@field Scenario LazyVar # the resolved scenario, or false when none / unreadable +---@field Observer LazyVar # internal: resolves ScenarioFile (deduped); pins itself + +---@type UICustomLobbyScenarioDerivedModel | nil +local ModelInstance = nil + +--- The file currently resolved into `Scenario` — the de-dup key (lowercased), or false when none. +---@type string | false +local LoadedFile = false + +--- Number of start spots a scenario declares, from its standard configuration (0 if none). +---@param info UILobbyScenarioInfo +---@return number +local function ArmyCount(info) + local armies = info.Configurations + and info.Configurations.standard + and info.Configurations.standard.teams + and info.Configurations.standard.teams[1] + and info.Configurations.standard.teams[1].armies + return armies and table.getsize(armies) or 0 +end + +--- Resolves a scenario file into the rich bundle (the one place the disk work happens), or false if +--- the file can't be read. Reuses the catalog's loaders — no new disk code here. +---@param file FileName +---@return UICustomLobbyScenario | false +local function Resolve(file) + local info = CustomLobbyMapCatalog.LoadInfo(file) + if type(info) ~= "table" then + return false + end + + local size = info.size or false + local maxDimension = 0 + if size then + maxDimension = math.max(size[1] or 0, size[2] or 0) + end + + return { + File = file, + Info = info, + Markers = CustomLobbyMapCatalog.LoadMarkers(info), + MaxDimension = maxDimension, + ArmyCount = ArmyCount(info), + Name = LOC(info.name) or "?", + Size = size, + Version = info.map_version or false, + } +end + +--- The internal observer's body: resolve `file` into the bundle, but skip the work (and the re-fire) +--- when it is the same scenario we already hold — that is the de-dup. +---@param model UICustomLobbyScenarioDerivedModel +---@param file FileName | false +local function OnScenarioFileChanged(model, file) + local key = (type(file) == "string") and string.lower(file) or false + if key == LoadedFile then + return + end + LoadedFile = key + + if not file then + model.Scenario:Set(false) + return + end + model.Scenario:Set(Resolve(file) or false) +end + +--- Allocates a fresh scenario-model singleton, replacing any existing instance, and wires its internal +--- observer to the launch model's `ScenarioFile`. The observer is stored on the model so it isn't +--- garbage-collected, and (because `Derive` fires synchronously on creation) it resolves the current +--- scenario immediately. +---@return UICustomLobbyScenarioDerivedModel +function SetupSingleton() + ---@type UICustomLobbyScenarioDerivedModel + local model = { + Scenario = Create(false), + } + ModelInstance = model + LoadedFile = false + + local launch = CustomLobbyLaunchModel.GetSingleton() + model.Observer = Derive(launch.ScenarioFile, function(scenarioFileLazy) + OnScenarioFileChanged(model, scenarioFileLazy()) + end) + + return model +end + +--- Returns the scenario-model singleton, creating (and resolving the current scenario) on first access. +---@return UICustomLobbyScenarioDerivedModel +function GetSingleton() + if not ModelInstance then + SetupSingleton() + end + return ModelInstance --[[@as UICustomLobbyScenarioDerivedModel]] +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Accessors + +--- The reactive scenario var — subscribe to it (via `Derive`) to react when the map actually changes. +---@return LazyVar +function GetScenarioVar() + return GetSingleton().Scenario +end + +--- The current resolved scenario (or false when no map / unreadable). For pull-based callers (rules). +---@return UICustomLobbyScenario | false +function GetScenario() + return GetSingleton().Scenario() +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Debugging + +--- Hot-reload hook: rebuild the singleton so its observer re-subscribes and re-resolves the current +--- scenario. The bundle is fully derived from `ScenarioFile`, so there is no state to copy across. +---@param newModule any +function __moduleinfo.OnReload(newModule) + if ModelInstance then + newModule.SetupSingleton() + end +end + +--- Hot-reload hook: re-imports this module after a couple of frames. +function __moduleinfo.OnDirty() + ForkThread( + function() + WaitFrames(2) + import(__moduleinfo.name) + end + ) +end + +--#endregion diff --git a/lua/ui/lobby/customlobby/mapselect/CLAUDE.md b/lua/ui/lobby/customlobby/mapselect/CLAUDE.md index b61b680bc8d..d5f0873de72 100644 --- a/lua/ui/lobby/customlobby/mapselect/CLAUDE.md +++ b/lua/ui/lobby/customlobby/mapselect/CLAUDE.md @@ -12,7 +12,7 @@ button in [`../CustomLobbyInterface.lua`](../CustomLobbyInterface.lua). | File | Role | |------|------| | [CustomLobbyMapSelect.lua](CustomLobbyMapSelect.lua) | the dialog (transient `Popup`, host-only). Laid out in **areas** (Group containers: title / left {filter, selection, stats} / preview / actions — flip the module `Debug` flag to tint them). Searchable list + size & player filters with comparison operators (`=`/`>=`/`<=`, persisted to Prefs); candidate preview (the shared `../CustomLobbyScenarioPreview` surface with numbered-dot spawns) with name overlay, an "Open page" link (allowlisted `url` → `OpenURL`), and toggle-able overlays (spawns / resources / wrecks); scrollable description; file-health check that disables Select for broken maps; Random / Select / Cancel. On Select → `CustomLobbyController.RequestSetScenario(file)`. Owns no synced state. | -| [CustomLobbyMapCatalog.lua](CustomLobbyMapCatalog.lua) | the lobby's **own** scenario data layer — and the single point of contact with `MapUtil`'s `doscript` loaders. Async enumeration of playable skirmish maps (streams into a `Scenarios` `LazyVar` across frames via `EnsureLoaded`; *not* `MapUtil.EnumerateSkirmishScenarios`), plus on-demand `LoadInfo` / `LoadSave` (the latter memoised by save path with a FIFO bound — the save `doscript` is expensive and browsing re-loads the same maps). **Reference data, never synced** — local only; the host syncs just its `ScenarioFile` choice. | +| [CustomLobbyMapCatalog.lua](CustomLobbyMapCatalog.lua) | the lobby's **own** scenario data layer — and its single source of truth for what a scenario is, with **no MapUtil dependency** (the `_scenario.lua` / `_save.lua` `doscript` loaders + start-spot derivation are re-implemented here as small pure locals). Async enumeration of playable skirmish maps (streams into a `Scenarios` `LazyVar` across frames via `EnsureLoaded`; *not* `MapUtil.EnumerateSkirmishScenarios`), plus on-demand `LoadInfo` / `LoadSave` / `LoadMarkers` (the save is memoised by path with a FIFO bound — the save `doscript` is expensive and browsing re-loads the same maps; `LoadMarkers` extracts the preview's start-spot / mass / hydro / wreck points so callers never touch the raw save). **Reference data, never synced** — local only; the host syncs just its `ScenarioFile` choice. | | [CustomLobbyMapList.lua](CustomLobbyMapList.lua) | the dialog's scrollable, **virtualised** list: a fixed pool of **text-only** rows (name + `size · players`) reused while scrolling, standard scrollbar contract, mouse-driven (no keyboard focus — a custom Group list can't take it). `OnSelect` / `OnConfirm`. | This is the first sub-dialog split out of the legacy `dialogs/mapselect.lua` god-dialog: it does diff --git a/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapCatalog.lua b/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapCatalog.lua index 1c1fa0706a9..e611a7ef1c6 100644 --- a/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapCatalog.lua +++ b/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapCatalog.lua @@ -22,11 +22,11 @@ -- The map catalog: the custom lobby's list of playable skirmish maps. -- --- This is the custom lobby's OWN enumeration — it does not call MapUtil's --- `EnumerateSkirmishScenarios` (which eagerly loads each map's options + strings and blocks --- while it reads the whole `/maps` tree). We're slowly moving off MapUtil: the only thing we --- still borrow is `LoadScenarioInfoFile` — the `doscript` loader for one `_scenario.lua` — and --- even that is a candidate to inline later. +-- This is the custom lobby's OWN scenario layer — it does **not** depend on MapUtil at all. It +-- neither calls `EnumerateSkirmishScenarios` (which eagerly loads each map's options + strings and +-- blocks while it reads the whole `/maps` tree) nor borrows MapUtil's file loaders: the `doscript` +-- loaders for `_scenario.lua` / `_save.lua` and the start-spot derivation are re-implemented below +-- (they are tiny), so the catalog is the lobby's single source of truth for what a scenario is. -- -- Two differences from the legacy enumerator: -- * **lighter** — we load only the scenario *info* (name / map / preview / size / @@ -51,11 +51,14 @@ local Create = import("/lua/lazyvar.lua").Create local CustomLobbySession = import("/lua/ui/lobby/customlobby/customlobbysession.lua") --- The catalog is the lobby's single point of contact with MapUtil's `doscript` file loaders --- (info + save) — so no other custom-lobby module imports MapUtil. Everything above that --- (enumeration, filtering, caching) is ours. -local MapUtil = import("/lua/ui/maputil.lua") -local LoadScenarioInfoFile = MapUtil.LoadScenarioInfoFile +--- The *interesting* bits extracted from a scenario's save — what the preview overlays and the +--- rules read, so nothing downstream has to know the raw save's shape. Points are 2-element `{x, z}` +--- map coordinates (ogrids). Produced by `LoadMarkers`. +---@class UICustomLobbyScenarioMarkers +---@field Spawns Vector2[] # start spots, one {x, z} per army (army order) +---@field MassPoints Vector2[] # mass deposit positions +---@field HydroPoints Vector2[] # hydrocarbon deposit positions +---@field Wrecks Vector2[] # prebuilt-wreckage positions (maps that define any) -- maps loaded per frame-slice before yielding — keeps the open responsive on big vaults local BatchSize = 5 @@ -70,6 +73,100 @@ local SaveCacheMax = 24 ---@type UICustomLobbyMapCatalog | nil local Instance = nil +------------------------------------------------------------------------------- +--#region Scenario file loaders (pure; no instance state) +-- +-- Our own `doscript` loaders, so the catalog doesn't depend on MapUtil. A scenario's files are Lua +-- scripts that, when run, populate globals (`ScenarioInfo`, `Scenario`, …); we run them into a fresh +-- sandbox table seeded with `dataInit` rather than into `_G`. + +--- Runs a scenario-family file (`_scenario.lua` / `_save.lua`) into a fresh sandbox table, so its +--- globals land there instead of polluting `_G`. Returns the populated table, or nil if the file is +--- absent. The `doscript`s are the expensive part — callers cache the results. +---@param path FileName +---@return table | nil +local function LoadScenarioFile(path) + if not DiskGetFileInfo(path) then + return nil + end + local data = {} + doscript('/lua/dataInit.lua', data) + doscript(path, data) + return data +end + +--- Loads a scenario's info from its `_scenario.lua` (the `ScenarioInfo` table), handling the legacy +--- v1 shape where the info lived at the top level. nil if the file is missing. +---@param path FileName +---@return UIScenarioInfoFile | nil +local function LoadScenarioInfoFile(path) + local data = LoadScenarioFile(path) + if not data then + return nil + end + if data.version == 1 then -- legacy: the info table WAS the top-level data + return data --[[@as UIScenarioInfoFile]] + end + return data.ScenarioInfo +end + +--- Loads a scenario's save (the `Scenario` table) from its `_save.lua`. nil if the file is missing. +--- Expensive (`doscript`) — `LoadSave` memoises the result. +---@param path FileName +---@return UIScenarioSaveFile | nil +local function LoadScenarioSaveFile(path) + local data = LoadScenarioFile(path) + return data and data.Scenario +end + +--- The playable armies of a scenario (`{ 'ARMY_1', 'ARMY_2', … }`), or nil when malformed. Reads the +--- `standard` FFA team config — the same shape `IsPlayableSkirmish` requires for a map to be listed. +---@param scenarioInfo UIScenarioInfoFile +---@return string[] | nil +local function GetArmies(scenarioInfo) + local standard = scenarioInfo.Configurations and scenarioInfo.Configurations.standard + local teams = standard and standard.teams + if not teams then + return nil + end + for _, teamConfig in teams do + if teamConfig.name == 'FFA' then + return teamConfig.armies + end + end + return nil +end + +--- The start spots of a scenario as `{x, z}` per army (army order), read from the save's master-chain +--- markers (each army has a like-named marker). nil when armies / markers are missing; a `{0, 0}` +--- placeholder keeps the array aligned with the army list for any army that lacks a marker. +---@param scenarioInfo UIScenarioInfoFile +---@param scenarioSave UIScenarioSaveFile +---@return Vector2[] | nil +local function GetStartPositions(scenarioInfo, scenarioSave) + local armies = GetArmies(scenarioInfo) + if not armies then + return nil + end + local masterChain = scenarioSave.MasterChain and scenarioSave.MasterChain['_MASTERCHAIN_'] + local markers = masterChain and masterChain.Markers + if not markers then + return nil + end + local output = {} + for _, army in armies do + local marker = markers[army] + if marker and marker.position then + table.insert(output, { marker.position[1], marker.position[3] }) + else + table.insert(output, { 0, 0 }) + end + end + return output +end + +--#endregion + ------------------------------------------------------------------------------- --#region Enumeration helpers (pure; no instance state) @@ -251,10 +348,7 @@ local Catalog = ClassSimple { end ---@type UIScenarioSaveFile | false - local save = false - if DiskGetFileInfo(scenarioInfo.save) then - save = MapUtil.LoadScenarioSaveFile(scenarioInfo.save) or false - end + local save = LoadScenarioSaveFile(scenarioInfo.save) or false self.SaveCache[key] = save table.insert(self.SaveCacheOrder, key) @@ -266,6 +360,46 @@ local Catalog = ClassSimple { return save or nil end, + --- Extracts the *interesting* bits of a scenario's save — the things the preview overlays and the + --- rules actually read — so callers never touch the raw save shape. Loads the (memoised) save once + --- and returns the start spots plus mass / hydrocarbon / wreck point lists, each normalised to a + --- 2-element `{x, z}` (the save's resource markers carry a 3D `position`, so we take `[1]` and + --- `[3]`). Always returns a table — empty lists when the save is missing / unreadable — so callers + --- can read its fields unguarded. + ---@param self UICustomLobbyMapCatalog + ---@param scenarioInfo UILobbyScenarioInfo + ---@return UICustomLobbyScenarioMarkers + LoadMarkers = function(self, scenarioInfo) + ---@type UICustomLobbyScenarioMarkers + local markers = { Spawns = {}, MassPoints = {}, HydroPoints = {}, Wrecks = {} } + + local save = self:LoadSave(scenarioInfo) + if not save then + return markers + end + + -- start spots: one {x, z} per army, in army order + markers.Spawns = GetStartPositions(scenarioInfo, save) or {} + + -- resource + wreck deposits: walk the master-chain markers once, bucket by type + local masterChain = save.MasterChain and save.MasterChain['_MASTERCHAIN_'] + local raw = (masterChain and masterChain.Markers) or {} + for _, marker in raw do + local position = marker.position + if position then + if marker.type == "Mass" then + table.insert(markers.MassPoints, { position[1], position[3] }) + elseif marker.type == "Hydrocarbon" then + table.insert(markers.HydroPoints, { position[1], position[3] }) + elseif marker.type and string.find(string.lower(marker.type), 'wreck') then + table.insert(markers.Wrecks, { position[1], position[3] }) + end + end + end + + return markers + end, + --- Drops everything so the next `EnsureLoaded` re-reads from disk (e.g. maps changed on disk). --- Kills any in-flight load thread and resets the cached list *in place* (same LazyVar) so --- existing subscribers stay valid — this is a reset, not a teardown. @@ -377,6 +511,14 @@ function LoadSave(scenarioInfo) return GetSingleton():LoadSave(scenarioInfo) end +--- Extracts a scenario's interesting save markers (spawns + mass / hydro / wreck points) so callers +--- read structured points instead of fishing through the raw save. See the method. +---@param scenarioInfo UILobbyScenarioInfo +---@return UICustomLobbyScenarioMarkers +function LoadMarkers(scenarioInfo) + return GetSingleton():LoadMarkers(scenarioInfo) +end + --- Drops everything so the next `EnsureLoaded` re-reads from disk (e.g. maps changed on disk). function Refresh() GetSingleton():Refresh() diff --git a/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapSelect.lua b/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapSelect.lua index e34eafed022..273b5d2c36f 100644 --- a/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapSelect.lua +++ b/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapSelect.lua @@ -843,8 +843,8 @@ local CustomLobbyMapSelect = ClassUI(Group) { end -- the catalog owns scenario loading + caching (the save doscript is expensive and we - -- re-inspect the same maps as you browse) - self.Surface:SetScenario(scenario, CustomLobbyMapCatalog.LoadSave(scenario)) + -- re-inspect the same maps as you browse); it also extracts the markers the surface renders + self.Surface:SetScenario(scenario, CustomLobbyMapCatalog.LoadMarkers(scenario)) self:UpdateInfo(scenario) self.RandomButton:Enable() From 0c675dcc0f855ca88510dc29f9322bd20bb17f46 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Fri, 26 Jun 2026 08:36:38 +0200 Subject: [PATCH 65/98] Add derived state for options --- lua/ui/lobby/customlobby/CLAUDE.md | 4 +- .../customlobby/CustomLobbyController.lua | 9 +- .../config/CustomLobbyConfigInterface.lua | 4 +- .../config/CustomLobbyOptionsPanel.lua | 114 +++---- lua/ui/lobby/customlobby/derived/CLAUDE.md | 7 + .../CustomLobbyOptionsDerivedModel.lua | 323 ++++++++++++++++++ lua/ui/lobby/customlobby/mapselect/CLAUDE.md | 2 +- .../mapselect/CustomLobbyMapCatalog.lua | 62 ++++ .../lobby/customlobby/optionselect/CLAUDE.md | 5 +- .../optionselect/CustomLobbyOptionSelect.lua | 3 +- 10 files changed, 454 insertions(+), 79 deletions(-) create mode 100644 lua/ui/lobby/customlobby/derived/CustomLobbyOptionsDerivedModel.lua diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index 03afadb6567..9942d91296c 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -43,7 +43,7 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | [CustomLobbyLaunchModel.lua](CustomLobbyLaunchModel.lua) | shared + launched state — the launch payload (see above). | | [CustomLobbySessionModel.lua](CustomLobbySessionModel.lua) | shared, lobby-room-only state (slot count, closed slots). | | [CustomLobbyLocalModel.lua](CustomLobbyLocalModel.lua) | per-peer state, never synced: identity + CPU benchmarks. | -| [derived/](derived/CLAUDE.md) | **derived models** — read-only reactive projections of the authoritative state, the "fishing" layer between the compact synced fields and the views (never written by the controller). The first is [CustomLobbyScenarioDerivedModel](derived/CustomLobbyScenarioDerivedModel.lua): resolves the launch model's compact `ScenarioFile` into a rich `Scenario` bundle — info (for the map texture) + extracted save markers (`Spawns` / `MassPoints` / `HydroPoints` / `Wrecks`, never the raw save) + `MaxDimension` / `ArmyCount` / `Name` / `Size` / `Version`. **Does the fishing once so consumers just read the field they need** (the map preview, the config facts line, `CustomLobbyRules`); an internal observer **dedups by file** so a launch-info rebroadcast of the same map is a no-op (no preview reload). Mods / restrictions / slot-info derived models are the planned siblings — see [derived/CLAUDE.md](derived/CLAUDE.md). | +| [derived/](derived/CLAUDE.md) | **derived models** — read-only reactive projections of the authoritative state, the "fishing" layer between the compact synced fields and the views (never written by the controller). The first is [CustomLobbyScenarioDerivedModel](derived/CustomLobbyScenarioDerivedModel.lua): resolves the launch model's compact `ScenarioFile` into a rich `Scenario` bundle — info (for the map texture) + extracted save markers (`Spawns` / `MassPoints` / `HydroPoints` / `Wrecks`, never the raw save) + `MaxDimension` / `ArmyCount` / `Name` / `Size` / `Version`. **Does the fishing once so consumers just read the field they need** (the map preview, the config facts line, `CustomLobbyRules`); an internal observer **dedups by file** so a launch-info rebroadcast of the same map is a no-op (no preview reload). [CustomLobbyOptionsDerivedModel](derived/CustomLobbyOptionsDerivedModel.lua) is the second: it splits `GameOptions` into lobby / scenario / mods categories and enriches each option (label, help, chosen value, is-default), caching the disk-loaded schema. Mods / restrictions / slot-info derived models are the planned siblings — see [derived/CLAUDE.md](derived/CLAUDE.md). | | [CustomLobbyPerformancePopover.lua](CustomLobbyPerformancePopover.lua) | hover popover over the CPU column; hand-built bitmap bar chart of a peer's `PerformanceTrackingV2` history, with a yellow recommended-unit-cap line. | | [CustomLobbyInstance.lua](CustomLobbyInstance.lua) | thin `moho.lobby_methods` shell; validates/dispatches traffic, forwards callbacks to the controller. Also feeds [`CustomLobbyLog`](CustomLobbyLog.lua) from its three network choke points (BroadcastData / SendData / DataReceived). | | [CustomLobbyLog.lua](CustomLobbyLog.lua) | the **network traffic log** — a reactive, per-peer, never-synced ring buffer (`Entries` LazyVar, capped) of every message this peer broadcasts / sends / receives, fed by the instance and rendered by the **Logs** tab. Each peer logs only its own traffic, so host and client views differ naturally. Not one of the three models (no game state); a diagnostic feed. | @@ -68,7 +68,7 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | [CustomLobbyTeamScore.lua](CustomLobbyTeamScore.lua) | the **accumulated team rating** side indicator — `Side A N · M Side B`. Hosted in the strip atop the two-column slot layout (it doubles as the columns' Left/Right header). Shown only for the binary auto-team formations; **hidden** for `none`/`manual` or until a positional map's start positions load. Reads the mode + side split from [`CustomLobbyRules`](CustomLobbyRules.lua) (`AutoTeamMode` / `SideLabels` / `BuildSideResolver`) and the ratings from each slot's `PL`. Reference data; never writes. | | [CustomLobbyTabs.lua](CustomLobbyTabs.lua) | a **generic tabbed panel** (strip + content; one panel alive, created on select / destroyed on switch). Tabs **divide the strip evenly** across its width. Construct with a `{ Label, Create, Badge?, Action?, Icon?, Compact? }` list + optional `OnSelect`. A tab's optional `Badge` LazyVar drives a grey **count pill** to the right of the label; its optional `Action` (`{ Create, Visible? }`) is a small button the owner builds **inside the tab, left of the label** (e.g. a config gear), whose `Visible` LazyVar hides it (collapsing it from the layout) when it doesn't apply. The action, label and pill are centred together as one cluster; any absent/hidden/empty piece contributes 0 width so the rest re-centres. A tab can instead be **`Compact`** (a fixed narrow width, excluded from the even division — the flexible tabs share what's left) and/or show an **`Icon`** centred instead of its label (an icon-only utility tab); the default active tab is the first non-compact one. The container just mirrors the LazyVars, the owner decides what they mean. Used for the bottom-left (Logs / Chat / Observers) and the config interface's Options / Mods / Restrictions. | | [social/](social/) | the lobby's **bottom-left** column (the `CustomLobbyTabs` content): [`CustomLobbyChatPanel`](social/CustomLobbyChatPanel.lua) (the **Chat** tab — placeholder until the chat slice lands) and [`CustomLobbyObserversPanel`](social/CustomLobbyObserversPanel.lua) (the **Observers** tab — the shared observer list + a host-authoritative **Become observer** button → `RequestMoveToObserver`). Each is a tab content component (`Create(parent)`, created on select / destroyed on switch). The Chat / Observers tabs mirror the config column's shape — a per-tab **config gear** (`CustomLobbyInterface`'s local `GearAction`; both no-ops with a "coming soon" tooltip for now) + a right-side **count pill**: Observers shows the live observer count, Chat a dummy until the chat slice lands. A third **compact, icon-only [`CustomLobbyLogsPanel`](social/CustomLobbyLogsPanel.lua)** sits left of Chat (the **Logs** tab — a live **tail view** of this peer's network traffic from [`CustomLobbyLog`](CustomLobbyLog.lua): the most recent entries that fit, in columns `time · kind · ⚠ · name`, with a malformed/unauthorised message tinted + a ⚠ icon whose tooltip is the failure reason). | -| [config/](config/) | the lobby's **right** column. [`CustomLobbyConfigInterface.lua`](config/CustomLobbyConfigInterface.lua) is the column **composition**: a bound square `CustomLobbyMapPreview` **pinned** at the top with a vertical **preview tool strip** to its right (local `PreviewTool` icon buttons — toggles for army/start icons, mass+hydro deposits and the dummy water mask, driving `preview.Surface:SetOverlayVisible`, + a host-only **change-map** config icon at the bottom → the map-select dialog), a name + size/players/version facts line under it, and a [`CustomLobbyTabs`](CustomLobbyTabs.lua) (**Options / Mods / Restrictions**) filling the rest. Each tab carries its own **config gear** (`CustomLobbyTabs`' per-tab `Action`, built by the interface's `GearAction` helper) — a skinned button **inside the tab, left of the label** — that opens that tab's editor: Options → `CustomLobbyOptionSelect`, Mods → `CustomLobbyModSelect`, Restrictions → `CustomLobbyUnitSelect` (see [unitselect/](unitselect/)). The Options + Restrictions gears are **host-only**: their `Visible` LazyVar is the `IsHost` field, so they're **hidden** for clients and the label re-centres; the Mods gear shows for everyone (UI mods are local; the sim portion is host-gated inside the dialog). The interface also owns the tabs' **count badges** as computed LazyVars over the launch model — Options shows the non-default-option count (`OptionUtil.CountNonDefault`), Mods shows `sim / ui` (synced sim mods / this peer's UI-mod prefs), Restrictions shows the active restriction count (preset keys in the launch model's `Restrictions`). All three tab panels are now **read-only**: their per-domain action buttons (open editor / reset / manage mods) are removed — the grid/content fills the whole panel — and the action-bar **Settings** button (now joined by this gear) is the edit entry point. [`CustomLobbyOptionsPanel`](config/CustomLobbyOptionsPanel.lua) (options grouped **Lobby / Scenario / Mods** + hide-defaults toggle; map/mod options gold-flagged with an origin tooltip — schema via [`/lua/ui/optionutil.lua`](/lua/ui/optionutil.lua)), [`CustomLobbyModsPanel`](config/CustomLobbyModsPanel.lua) (enabled mods in **Game / UI** sections), [`CustomLobbyUnitsPanel`](config/CustomLobbyUnitsPanel.lua) (the **Restrictions** read-only list — the active restrictions' names, from the launch model's `Restrictions`). Each self-subscribes to the model and exposes `Initialize()` + `Create(parent)`. **Parked** (built, but unwired): [`CustomLobbyMapPanel`](config/CustomLobbyMapPanel.lua) (the full Map tab — preview + label/value details + Change-map), now superseded by the pinned preview. Churning a preview is safe here because the lobby shows only **one** current map and the engine caches map textures by name (the texture-leak rule only bites the *map-select dialog* — see [mapselect/CLAUDE.md](mapselect/CLAUDE.md)). | +| [config/](config/) | the lobby's **right** column. [`CustomLobbyConfigInterface.lua`](config/CustomLobbyConfigInterface.lua) is the column **composition**: a bound square `CustomLobbyMapPreview` **pinned** at the top with a vertical **preview tool strip** to its right (local `PreviewTool` icon buttons — toggles for army/start icons, mass+hydro deposits and the dummy water mask, driving `preview.Surface:SetOverlayVisible`, + a host-only **change-map** config icon at the bottom → the map-select dialog), a name + size/players/version facts line under it, and a [`CustomLobbyTabs`](CustomLobbyTabs.lua) (**Options / Mods / Restrictions**) filling the rest. Each tab carries its own **config gear** (`CustomLobbyTabs`' per-tab `Action`, built by the interface's `GearAction` helper) — a skinned button **inside the tab, left of the label** — that opens that tab's editor: Options → `CustomLobbyOptionSelect`, Mods → `CustomLobbyModSelect`, Restrictions → `CustomLobbyUnitSelect` (see [unitselect/](unitselect/)). The Options + Restrictions gears are **host-only**: their `Visible` LazyVar is the `IsHost` field, so they're **hidden** for clients and the label re-centres; the Mods gear shows for everyone (UI mods are local; the sim portion is host-gated inside the dialog). The interface also owns the tabs' **count badges** as computed LazyVars over the launch model — Options shows the non-default-option count (the options derived model's `NonDefaultCount`), Mods shows `sim / ui` (synced sim mods / this peer's UI-mod prefs), Restrictions shows the active restriction count (preset keys in the launch model's `Restrictions`). All three tab panels are now **read-only**: their per-domain action buttons (open editor / reset / manage mods) are removed — the grid/content fills the whole panel — and the action-bar **Settings** button (now joined by this gear) is the edit entry point. [`CustomLobbyOptionsPanel`](config/CustomLobbyOptionsPanel.lua) (options grouped **Lobby / Scenario / Mods** + hide-defaults toggle; map/mod options gold-flagged with an origin tooltip, option help as a label tooltip — all read from the [options derived model](derived/CustomLobbyOptionsDerivedModel.lua), the panel does no schema gathering), [`CustomLobbyModsPanel`](config/CustomLobbyModsPanel.lua) (enabled mods in **Game / UI** sections), [`CustomLobbyUnitsPanel`](config/CustomLobbyUnitsPanel.lua) (the **Restrictions** read-only list — the active restrictions' names, from the launch model's `Restrictions`). Each self-subscribes to the model and exposes `Initialize()` + `Create(parent)`. **Parked** (built, but unwired): [`CustomLobbyMapPanel`](config/CustomLobbyMapPanel.lua) (the full Map tab — preview + label/value details + Change-map), now superseded by the pinned preview. Churning a preview is safe here because the lobby shows only **one** current map and the engine caches map textures by name (the texture-leak rule only bites the *map-select dialog* — see [mapselect/CLAUDE.md](mapselect/CLAUDE.md)). | | [/lua/ui/lobby/lobby.lua](../lobby.lua) | engine entry wrapper (`CreateLobby`/`HostGame`/`JoinGame`) → CustomLobby. Old lobby preserved at `lobby-old.lua`. | Working today: host + clients see each other (host-authoritative player sync), the diff --git a/lua/ui/lobby/customlobby/CustomLobbyController.lua b/lua/ui/lobby/customlobby/CustomLobbyController.lua index 9eb7b0d07bf..4c5a5f15493 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyController.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyController.lua @@ -722,13 +722,14 @@ function RequestResetGameOptions() end local OptionUtil = import("/lua/ui/optionutil.lua") + local CustomLobbyMapCatalog = import("/lua/ui/lobby/customlobby/mapselect/customlobbymapcatalog.lua") local launch = CustomLobbyLaunchModel.GetSingleton() local options = {} for _, option in OptionUtil.GetLobbyOptions() do table.insert(options, option) end - for _, option in OptionUtil.GetScenarioOptions(launch.ScenarioFile()) do + for _, option in CustomLobbyMapCatalog.LoadOptions(launch.ScenarioFile()) do table.insert(options, option) end for _, option in OptionUtil.GetModOptions(launch.GameMods()) do @@ -779,6 +780,7 @@ end ---@return UILobbyLaunchConfiguration local function BuildGameConfiguration(instance) local OptionUtil = import("/lua/ui/optionutil.lua") + local CustomLobbyMapCatalog = import("/lua/ui/lobby/customlobby/mapselect/customlobbymapcatalog.lua") local Factions = import("/lua/factions.lua").Factions local factionCount = table.getn(Factions) local launch = CustomLobbyLaunchModel.GetSingleton() @@ -786,7 +788,7 @@ local function BuildGameConfiguration(instance) -- the full option set: the host's chosen values, with defaults seeded for anything unset local schema = {} for _, option in OptionUtil.GetLobbyOptions() do table.insert(schema, option) end - for _, option in OptionUtil.GetScenarioOptions(launch.ScenarioFile()) do table.insert(schema, option) end + for _, option in CustomLobbyMapCatalog.LoadOptions(launch.ScenarioFile()) do table.insert(schema, option) end for _, option in OptionUtil.GetModOptions(launch.GameMods()) do table.insert(schema, option) end local gameOptions = OptionUtil.SeedDefaults(schema, launch.GameOptions()) gameOptions.ScenarioFile = launch.ScenarioFile() @@ -947,9 +949,10 @@ function ApplySetup(setup) -- reconcile the saved option values against the current scenario+mods schema local OptionUtil = import("/lua/ui/optionutil.lua") + local CustomLobbyMapCatalog = import("/lua/ui/lobby/customlobby/mapselect/customlobbymapcatalog.lua") local schema = {} for _, option in OptionUtil.GetLobbyOptions() do table.insert(schema, option) end - for _, option in OptionUtil.GetScenarioOptions(scenario) do table.insert(schema, option) end + for _, option in CustomLobbyMapCatalog.LoadOptions(scenario) do table.insert(schema, option) end for _, option in OptionUtil.GetModOptions(launch.GameMods()) do table.insert(schema, option) end CustomLobbyLaunchModel.SetGameOptions(launch, OptionUtil.SeedDefaults(schema, setup.GameOptions or {})) diff --git a/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua b/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua index e42660a9c3a..dde1b8e5a98 100644 --- a/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua +++ b/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua @@ -60,7 +60,7 @@ local CustomLobbyUnitSelect = import("/lua/ui/lobby/customlobby/unitselect/custo local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/customlobbylocalmodel.lua") local CustomLobbyScenarioDerivedModel = import("/lua/ui/lobby/customlobby/derived/customlobbyscenarioderivedmodel.lua") -local OptionUtil = import("/lua/ui/optionutil.lua") +local CustomLobbyOptionsDerivedModel = import("/lua/ui/lobby/customlobby/derived/customlobbyoptionsderivedmodel.lua") local ModUtilities = import("/lua/ui/modutilities.lua") local LazyVarCreate = import("/lua/lazyvar.lua").Create @@ -279,7 +279,7 @@ local CustomLobbyConfigInterface = ClassUI(Group) { -- the badges always render, even at 0 (an empty string would collapse the pill) self.OptionsBadge = self.Trash:Add(LazyVarCreate()) self.OptionsBadge:Set(function() - return tostring(OptionUtil.CountNonDefault(launch.ScenarioFile(), launch.GameMods(), launch.GameOptions())) + return tostring(CustomLobbyOptionsDerivedModel.GetOptionsVar()().NonDefaultCount) end) -- "sim / ui" — sim mods are the synced GameMods; UI mods are this peer's prefs (not a diff --git a/lua/ui/lobby/customlobby/config/CustomLobbyOptionsPanel.lua b/lua/ui/lobby/customlobby/config/CustomLobbyOptionsPanel.lua index 9bc68bc31cc..653f8fc9895 100644 --- a/lua/ui/lobby/customlobby/config/CustomLobbyOptionsPanel.lua +++ b/lua/ui/lobby/customlobby/config/CustomLobbyOptionsPanel.lua @@ -27,8 +27,9 @@ -- rework resumes. -- -- Options that come from the map or a mod are flagged with a gold marker + tinted label; the --- marker's tooltip names the precise origin (`Map: …` / `Mod: …`). The schema is derived per-peer --- from the synced scenario + mods via `optionutil`; the values are the synced `GameOptions`. +-- marker's tooltip names the precise origin (`Map: …` / `Mod: …`), and an option's help shows as a +-- tooltip on its label. All of this is read straight from the **options derived model** (categorized, +-- enriched, schema-cached) — the panel does no schema gathering or value interpretation itself. -- -- It is a tab panel: created when its tab is selected and destroyed on switch, so it's the -- live/visible panel for its whole lifetime — model observers just rebuild it. `Initialize` (called @@ -43,9 +44,7 @@ local Group = import("/lua/maui/group.lua").Group local Bitmap = import("/lua/maui/bitmap.lua").Bitmap local Grid = import("/lua/maui/grid.lua").Grid -local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") -local CustomLobbyMapCatalog = import("/lua/ui/lobby/customlobby/mapselect/customlobbymapcatalog.lua") -local OptionUtil = import("/lua/ui/optionutil.lua") +local CustomLobbyOptionsDerivedModel = import("/lua/ui/lobby/customlobby/derived/customlobbyoptionsderivedmodel.lua") local LazyVarDerive = import("/lua/lazyvar.lua").Derive local Layouter = LayoutHelpers.ReusedLayoutFor @@ -53,7 +52,8 @@ local Layouter = LayoutHelpers.ReusedLayoutFor local RowHeight = 22 local ScrollGap = 32 -- standard lobby scrollbar gutter (see ModSelect) local GridContentWidth = 360 - 6 - ScrollGap -local LabelMaxChars = 26 +local LabelMaxChars = 22 +local ValueMaxChars = 22 local SpecialColor = 'ffd0a24c' -- marker + label tint for a map/mod option local NormalColor = 'ffc8ccd0' @@ -79,9 +79,7 @@ end ---@field OptionsGrid Grid ---@field Scrollbar Scrollbar | false ---@field Empty Text ----@field ScenarioObserver LazyVar ---@field OptionsObserver LazyVar ----@field ModsObserver LazyVar local CustomLobbyOptionsPanel = ClassUI(Group) { ---@param self UICustomLobbyOptionsPanel @@ -110,14 +108,10 @@ local CustomLobbyOptionsPanel = ClassUI(Group) { self.Empty:Hide() -- the panel is created/destroyed with its tab, so it's always the live/visible panel while - -- it exists — observers just rebuild (Refresh is Ready-gated); no show/hide juggling - local launch = CustomLobbyLaunchModel.GetSingleton() - self.ScenarioObserver = self.Trash:Add( - LazyVarDerive(launch.ScenarioFile, function(lazy) lazy(); self:Refresh() end)) + -- it exists — the observer just rebuilds (Refresh is Ready-gated); no show/hide juggling. One + -- subscription: the derived model already joins scenario + mods + values into the bundle. self.OptionsObserver = self.Trash:Add( - LazyVarDerive(launch.GameOptions, function(lazy) lazy(); self:Refresh() end)) - self.ModsObserver = self.Trash:Add( - LazyVarDerive(launch.GameMods, function(lazy) lazy(); self:Refresh() end)) + LazyVarDerive(CustomLobbyOptionsDerivedModel.GetOptionsVar(), function(lazy) lazy(); self:Refresh() end)) end, ---@param self UICustomLobbyOptionsPanel @@ -140,55 +134,27 @@ local CustomLobbyOptionsPanel = ClassUI(Group) { self:Refresh() end, - --- Rebuilds the read-only options grid, grouped into Lobby / Scenario / Mods sections with the - --- hide-defaults filter applied. + --- Rebuilds the read-only options grid from the derived model's categories, with the hide-defaults + --- filter applied. The bundle is already split + enriched, so the panel only filters + lays out. ---@param self UICustomLobbyOptionsPanel Refresh = function(self) if not self.Ready then return end - local launch = CustomLobbyLaunchModel.GetSingleton() - local scenarioFile = launch.ScenarioFile() - local gameMods = launch.GameMods() - local values = launch.GameOptions() + local options = CustomLobbyOptionsDerivedModel.GetOptions() - -- visible (hide-defaults-filtered) entries for one source - local function collect(options, origin) - local entries = {} - for _, option in options do - if not (self.HideDefaults and OptionUtil.IsDefault(option, values)) then - table.insert(entries, { - Option = option, - Origin = origin, - ValueKey = OptionUtil.GetCurrentValueKey(option, values), - }) + local rows = {} + for _, category in options.Categories do + local visible = {} + for _, option in category.Options do + if not (self.HideDefaults and option.IsDefault) then + table.insert(visible, option) end end - return entries - end - - local info = scenarioFile and CustomLobbyMapCatalog.LoadInfo(scenarioFile) - local mapName = (info and LOC(info.name)) or "the map" - - local modEntries = {} - for _, group in OptionUtil.GetModOptionsByMod(gameMods) do - for _, entry in collect(group.options, "Mod: " .. group.name) do - table.insert(modEntries, entry) - end - end - - local sections = { - { Title = "Lobby", Entries = collect(OptionUtil.GetLobbyOptions(), false) }, - { Title = "Scenario", Entries = collect(OptionUtil.GetScenarioOptions(scenarioFile), "Map: " .. mapName) }, - { Title = "Mods", Entries = modEntries }, - } - - local rows = {} - for _, section in sections do - if table.getn(section.Entries) > 0 then - table.insert(rows, { Header = section.Title }) - for _, entry in section.Entries do - table.insert(rows, { Entry = entry }) + if table.getn(visible) > 0 then + table.insert(rows, { Header = category.Title }) + for _, option in visible do + table.insert(rows, { Option = option }) end end end @@ -199,7 +165,7 @@ local CustomLobbyOptionsPanel = ClassUI(Group) { self.OptionsGrid:AppendCols(1, true) self.OptionsGrid:AppendRows(table.getn(rows), true) for index, row in rows do - local control = row.Header and self:CreateSectionHeader(row.Header) or self:CreateOptionRow(row.Entry) + local control = row.Header and self:CreateSectionHeader(row.Header) or self:CreateOptionRow(row.Option) self.OptionsGrid:SetItem(control, 1, index, true) end self.OptionsGrid:EndBatch() @@ -230,32 +196,44 @@ local CustomLobbyOptionsPanel = ClassUI(Group) { return row end, - --- Builds one read-only option row: origin marker (special only) + label + current value. + --- Builds one read-only option row from an enriched option: origin marker (special only) + label + --- (help as a tooltip) + current value. Everything is pre-resolved on the bundle. ---@param self UICustomLobbyOptionsPanel - ---@param entry { Option: ScenarioOption, Origin: string|false, ValueKey: any } + ---@param option UICustomLobbyOption ---@return Group - CreateOptionRow = function(self, entry) - local option = entry.Option + CreateOptionRow = function(self, option) local row = Group(self.OptionsGrid) LayoutHelpers.SetDimensions(row, GridContentWidth, RowHeight) local labelLeft = 4 - if entry.Origin then + if option.Origin then local marker = Bitmap(row) marker:SetSolidColor(SpecialColor) Layouter(marker):AtLeftIn(row, 4):AtVerticalCenterIn(row):Width(8):Height(8):End() - Tooltip.AddControlTooltipManual(marker, "Source", entry.Origin) + local prefix = (option.Origin.Kind == 'scenario') and "Map: " or "Mod: " + Tooltip.AddControlTooltipManual(marker, "Source", prefix .. option.Origin.Name) labelLeft = 18 end - local label = UIUtil.CreateText(row, Truncate(LOC(option.label) or option.key, LabelMaxChars), 13, UIUtil.bodyFont) - label:SetColor(entry.Origin and SpecialColor or NormalColor) - label:DisableHitTest() + local label = UIUtil.CreateText(row, Truncate(option.Label, LabelMaxChars), 13, UIUtil.bodyFont) + label:SetColor(option.Origin and SpecialColor or NormalColor) + -- the option's help reads as a tooltip on its label (hit-test stays on for that); no help → inert + if option.Help then + Tooltip.AddControlTooltipManual(label, option.Label, option.Help) + else + label:DisableHitTest() + end Layouter(label):AtLeftIn(row, labelLeft):AtVerticalCenterIn(row):End() - local value = UIUtil.CreateText(row, OptionUtil.ValueDisplay(option, entry.ValueKey), 13, UIUtil.bodyFont) + local value = UIUtil.CreateText(row, Truncate(option.ValueText, ValueMaxChars), 13, UIUtil.bodyFont) value:SetColor(ValueColor) - value:DisableHitTest() + -- the chosen value's help reads as a tooltip (titled with the full value text, in case it was + -- truncated); no help → inert + if option.ValueHelp then + Tooltip.AddControlTooltipManual(value, option.ValueText, option.ValueHelp) + else + value:DisableHitTest() + end Layouter(value):AtRightIn(row, 4):AtVerticalCenterIn(row):End() return row diff --git a/lua/ui/lobby/customlobby/derived/CLAUDE.md b/lua/ui/lobby/customlobby/derived/CLAUDE.md index 15f0c6b8d8e..fec6e52604d 100644 --- a/lua/ui/lobby/customlobby/derived/CLAUDE.md +++ b/lua/ui/lobby/customlobby/derived/CLAUDE.md @@ -35,6 +35,13 @@ layer between the compact synced fields and the views. | File | Derives | From | Read by | |------|---------|------|---------| | [CustomLobbyScenarioDerivedModel.lua](CustomLobbyScenarioDerivedModel.lua) | the resolved `Scenario` (info for the texture + extracted save markers `Spawns`/`MassPoints`/`HydroPoints`/`Wrecks` + `MaxDimension`/`ArmyCount`/`Name`/`Size`/`Version`) | the launch model's `ScenarioFile` | the bound [`../CustomLobbyMapPreview`](../CustomLobbyMapPreview.lua), the [`../config/CustomLobbyConfigInterface`](../config/CustomLobbyConfigInterface.lua) facts line, [`../CustomLobbyRules`](../CustomLobbyRules.lua) (map size + start spots) | +| [CustomLobbyOptionsDerivedModel.lua](CustomLobbyOptionsDerivedModel.lua) | the `Options` view: options split into **Categories** lobby / scenario / mods, each option **enriched** (label, help, chosen value-key + display, `IsDefault`, origin), plus `NonDefaultCount` | the launch model's `GameOptions` + `GameMods` and the scenario derived model (map file + name); the scenario `_options.lua` schema via the catalog's `LoadOptions`, the lobby/mod schema + value interpretation via [`/lua/ui/optionutil.lua`](/lua/ui/optionutil.lua) | the [`../config/CustomLobbyOptionsPanel`](../config/CustomLobbyOptionsPanel.lua) and the Options tab badge in [`../config/CustomLobbyConfigInterface`](../config/CustomLobbyConfigInterface.lua) | + +The options model also shows a second trait worth copying: it **caches the expensive part**. Gathering +the option schema is disk work (the map's `_options.lua` `doscript`, mod option files) and only changes +when the scenario / mod set changes, so it is rebuilt only when those inputs change (keyed) — a value +edit just re-enriches the cached schema. (Composing derived models is fine too: it reads the scenario +*from the scenario derived model*, not by re-resolving the file.) **Planned siblings** (same shape): a mods derived model (UUID set → full mod info via [`../modselect/`](../modselect/CLAUDE.md) / `/lua/ui/modutilities.lua`), a restrictions derived model diff --git a/lua/ui/lobby/customlobby/derived/CustomLobbyOptionsDerivedModel.lua b/lua/ui/lobby/customlobby/derived/CustomLobbyOptionsDerivedModel.lua new file mode 100644 index 00000000000..8f1c6b10791 --- /dev/null +++ b/lua/ui/lobby/customlobby/derived/CustomLobbyOptionsDerivedModel.lua @@ -0,0 +1,323 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- ============================================================================================ +-- DERIVED MODEL — read-only. See /lua/ui/lobby/customlobby/derived/CLAUDE.md. +-- +-- A derived model is a pure function of the authoritative models: it resolves compact synced fields +-- into a rich, ready-to-read bundle and exposes it reactively. Views read it; the **controller never +-- writes it** (there are no write helpers) — to change what it holds, change the source fields it +-- derives from. That keeps the launch model the single source of truth and this a cache/projection. +-- ============================================================================================ +-- +-- The **derived game options**: the launch model stores options in their most compact form — a flat +-- `GameOptions` value table (`key -> chosen value-key`). On its own that says nothing about what an +-- option *is* (its label, help, possible values, which category it belongs to, what its default is). +-- That meaning lives in the option *schema*, gathered from three sources: +-- * **lobby** — the static base options (lobbyOptions.lua), via `/lua/ui/optionutil.lua`; +-- * **scenario** — the selected map's `_options.lua`, via the map catalog's `LoadOptions`; +-- * **mods** — each selected sim mod's lobby options, via `/lua/ui/optionutil.lua`. +-- +-- This model joins the two: it splits the options into those three categories and **enriches** each +-- one with its localized text + help, its currently-chosen value (display + key) and whether that is +-- the default — so consumers (the Options panel, the Options tab badge) just read the field they need +-- instead of re-gathering the schema and re-interpreting values themselves. +-- +-- **Schema caching.** Gathering the schema is disk work (the map's `_options.lua` is a `doscript`, +-- mod options are file reads), and it only changes when the scenario or the mod set changes — not when +-- a value changes. So the schema is cached and rebuilt only when its inputs change (keyed by scenario +-- file + mod set); a value edit just re-enriches the cached schema, which is cheap. (The old Options +-- panel re-read those files on *every* refresh — i.e. every option tweak.) +-- +-- It reads the scenario from the scenario derived model (already deduped), and the mod set + values +-- from the launch model. Reference data, never on the wire — every peer derives its own. + +local Create = import("/lua/lazyvar.lua").Create +local Derive = import("/lua/lazyvar.lua").Derive +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") +local CustomLobbyScenarioDerivedModel = import("/lua/ui/lobby/customlobby/derived/customlobbyscenarioderivedmodel.lua") +local CustomLobbyMapCatalog = import("/lua/ui/lobby/customlobby/mapselect/customlobbymapcatalog.lua") +local OptionUtil = import("/lua/ui/optionutil.lua") + +------------------------------------------------------------------------------- +--#region Shape + +--- Where an option comes from — `false` for the lobby base options, else the map / mod it belongs to. +---@class UICustomLobbyOptionOrigin +---@field Kind 'scenario' | 'mod' +---@field Name string # the map name / the mod name + +--- One enriched option: the schema entry joined with its current value. +---@class UICustomLobbyOption +---@field Key string +---@field Label string # LOC'd display text +---@field Help string | false # LOC'd help/description, or false when none +---@field ValueKey any # the chosen value-key in effect (stored, else default) +---@field ValueText string # the chosen value's display text (its `text`, not its key) +---@field ValueHelp string | false # LOC'd help for the chosen value, or false when none +---@field IsDefault boolean # whether the value in effect is the option's default +---@field Origin UICustomLobbyOptionOrigin | false +---@field Option ScenarioOption # the raw schema entry (escape hatch: value list, etc.) + +--- A category of options (lobby / scenario / mods). +---@class UICustomLobbyOptionCategory +---@field Key 'lobby' | 'scenario' | 'mods' +---@field Title string +---@field Options UICustomLobbyOption[] + +--- The fully-derived options view: the three categories + the count changed from default. +---@class UICustomLobbyOptions +---@field Categories UICustomLobbyOptionCategory[] # always three, in order: lobby, scenario, mods +---@field NonDefaultCount number # options changed from their default (backs the badge) + +--#endregion + +------------------------------------------------------------------------------- +--#region Derived model + +--- Reactive derived-options singleton. **Read-only** — no write helpers; the controller never touches +--- it. It re-derives from the launch model's `GameOptions` / `GameMods` and the scenario derived model. +---@class UICustomLobbyOptionsDerivedModel +---@field Options LazyVar +---@field ScenarioObserver LazyVar +---@field ModsObserver LazyVar +---@field OptionsObserver LazyVar + +---@type UICustomLobbyOptionsDerivedModel | nil +local ModelInstance = nil + +-- The cached option schema and the key it was gathered for, so a value change re-enriches without +-- re-reading the map / mod option files. `SchemaKey` = scenario file + sorted mod uids. +---@type string | false +local SchemaKey = false +---@type { Lobby: ScenarioOption[], Scenario: ScenarioOption[], ScenarioName: string|false, ModGroups: table[] } | false +local Schema = false + +--- An empty (no-options) bundle — the initial value, before the first derivation runs. +---@return UICustomLobbyOptions +local function EmptyOptions() + return { + Categories = { + { Key = 'lobby', Title = "Lobby", Options = {} }, + { Key = 'scenario', Title = "Scenario", Options = {} }, + { Key = 'mods', Title = "Mods", Options = {} }, + }, + NonDefaultCount = 0, + } +end + +--- A stable key for the schema inputs: scenario file + the sorted selected mod uids. +---@param scenarioFile FileName | false +---@param gameMods table +---@return string +local function SchemaKeyFor(scenarioFile, gameMods) + local uids = {} + for uid in (gameMods or {}) do + table.insert(uids, uid) + end + table.sort(uids) + return tostring(scenarioFile) .. "|" .. table.concat(uids, ",") +end + +--- (Re)gathers the option schema if the scenario / mod set changed since last time; otherwise keeps +--- the cache. This is the only disk-touching step, so it is deduped by `SchemaKey`. +---@param scenario UICustomLobbyScenario | false +---@param gameMods table +local function EnsureSchema(scenario, gameMods) + local scenarioFile = scenario and scenario.File or false + local key = SchemaKeyFor(scenarioFile, gameMods) + if Schema and key == SchemaKey then + return + end + SchemaKey = key + Schema = { + Lobby = OptionUtil.GetLobbyOptions(), + Scenario = CustomLobbyMapCatalog.LoadOptions(scenarioFile), + ScenarioName = scenario and scenario.Name or false, + ModGroups = OptionUtil.GetModOptionsByMod(gameMods or {}), + } +end + +--- Joins one schema option with the current values into an enriched option. +---@param option ScenarioOption +---@param values table +---@param origin UICustomLobbyOptionOrigin | false +---@return UICustomLobbyOption +local function EnrichOption(option, values, origin) + local valueKey = OptionUtil.GetCurrentValueKey(option, values) + -- the value *entry* in effect (a `{ key, text, help }` table, or a bare value) — resolve it from + -- the key so we show its `text` (not the raw key) and can surface its `help` + local valueEntry = option.values and option.values[OptionUtil.FindValueIndex(option, valueKey)] + return { + Key = option.key, + Label = LOC(option.label) or option.key, + Help = (option.help and LOC(option.help)) or false, + ValueKey = valueKey, + ValueText = OptionUtil.ValueDisplay(option, valueEntry), + ValueHelp = (type(valueEntry) == 'table' and valueEntry.help and LOC(valueEntry.help)) or false, + IsDefault = OptionUtil.IsDefault(option, values), + Origin = origin, + Option = option, + } +end + +--- Builds the full enriched, categorized options bundle for the current schema + values. +---@param scenario UICustomLobbyScenario | false +---@param gameMods table +---@param values table +---@return UICustomLobbyOptions +local function BuildOptions(scenario, gameMods, values) + EnsureSchema(scenario, gameMods) + values = values or {} + + local lobby = {} + for _, option in Schema.Lobby do + table.insert(lobby, EnrichOption(option, values, false)) + end + + local scenarioOrigin = { Kind = 'scenario', Name = Schema.ScenarioName or "the map" } + local scenarioOpts = {} + for _, option in Schema.Scenario do + table.insert(scenarioOpts, EnrichOption(option, values, scenarioOrigin)) + end + + local mods = {} + for _, group in Schema.ModGroups do + local origin = { Kind = 'mod', Name = group.name } + for _, option in group.options do + table.insert(mods, EnrichOption(option, values, origin)) + end + end + + -- count changed-from-default (mirrors OptionUtil.CountNonDefault: lobby + scenario as-is, mods + -- deduped by key so a key two mods both declare is counted once) + local count = 0 + for _, e in lobby do if not e.IsDefault then count = count + 1 end end + for _, e in scenarioOpts do if not e.IsDefault then count = count + 1 end end + local seenMod = {} + for _, e in mods do + if not seenMod[e.Key] then + seenMod[e.Key] = true + if not e.IsDefault then count = count + 1 end + end + end + + return { + Categories = { + { Key = 'lobby', Title = "Lobby", Options = lobby }, + { Key = 'scenario', Title = "Scenario", Options = scenarioOpts }, + { Key = 'mods', Title = "Mods", Options = mods }, + }, + NonDefaultCount = count, + } +end + +--- Re-derives the options bundle from the current sources and publishes it. +---@param model UICustomLobbyOptionsDerivedModel +local function Recompute(model) + local scenario = CustomLobbyScenarioDerivedModel.GetScenario() + local launch = CustomLobbyLaunchModel.GetSingleton() + model.Options:Set(BuildOptions(scenario, launch.GameMods(), launch.GameOptions())) +end + +--- Allocates a fresh options-model singleton and wires its observers. The scenario is read through +--- the scenario derived model (so a same-map rebroadcast doesn't re-gather the scenario options); +--- the mod set + values are the launch model's. Observers are pinned on the model so they aren't GC'd. +---@return UICustomLobbyOptionsDerivedModel +function SetupSingleton() + SchemaKey = false + Schema = false + + ---@type UICustomLobbyOptionsDerivedModel + local model = { + Options = Create(EmptyOptions()), + } + ModelInstance = model + + local launch = CustomLobbyLaunchModel.GetSingleton() + model.ScenarioObserver = Derive(CustomLobbyScenarioDerivedModel.GetScenarioVar(), function(lazy) + lazy() + Recompute(model) + end) + model.ModsObserver = Derive(launch.GameMods, function(lazy) + lazy() + Recompute(model) + end) + model.OptionsObserver = Derive(launch.GameOptions, function(lazy) + lazy() + Recompute(model) + end) + + return model +end + +--- Returns the options-model singleton, creating (and deriving the current options) on first access. +---@return UICustomLobbyOptionsDerivedModel +function GetSingleton() + if not ModelInstance then + SetupSingleton() + end + return ModelInstance --[[@as UICustomLobbyOptionsDerivedModel]] +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Accessors + +--- The reactive options var — subscribe to it (via `Derive`) to react when options/values change. +---@return LazyVar +function GetOptionsVar() + return GetSingleton().Options +end + +--- The current enriched, categorized options bundle. +---@return UICustomLobbyOptions +function GetOptions() + return GetSingleton().Options() +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Debugging + +--- Hot-reload hook: rebuild the singleton so its observers re-subscribe and re-derive. The bundle is +--- fully derived from the launch model + scenario model, so there is no state to copy across. +---@param newModule any +function __moduleinfo.OnReload(newModule) + if ModelInstance then + newModule.SetupSingleton() + end +end + +--- Hot-reload hook: re-imports this module after a couple of frames. +function __moduleinfo.OnDirty() + ForkThread( + function() + WaitFrames(2) + import(__moduleinfo.name) + end + ) +end + +--#endregion diff --git a/lua/ui/lobby/customlobby/mapselect/CLAUDE.md b/lua/ui/lobby/customlobby/mapselect/CLAUDE.md index d5f0873de72..0e786b50495 100644 --- a/lua/ui/lobby/customlobby/mapselect/CLAUDE.md +++ b/lua/ui/lobby/customlobby/mapselect/CLAUDE.md @@ -12,7 +12,7 @@ button in [`../CustomLobbyInterface.lua`](../CustomLobbyInterface.lua). | File | Role | |------|------| | [CustomLobbyMapSelect.lua](CustomLobbyMapSelect.lua) | the dialog (transient `Popup`, host-only). Laid out in **areas** (Group containers: title / left {filter, selection, stats} / preview / actions — flip the module `Debug` flag to tint them). Searchable list + size & player filters with comparison operators (`=`/`>=`/`<=`, persisted to Prefs); candidate preview (the shared `../CustomLobbyScenarioPreview` surface with numbered-dot spawns) with name overlay, an "Open page" link (allowlisted `url` → `OpenURL`), and toggle-able overlays (spawns / resources / wrecks); scrollable description; file-health check that disables Select for broken maps; Random / Select / Cancel. On Select → `CustomLobbyController.RequestSetScenario(file)`. Owns no synced state. | -| [CustomLobbyMapCatalog.lua](CustomLobbyMapCatalog.lua) | the lobby's **own** scenario data layer — and its single source of truth for what a scenario is, with **no MapUtil dependency** (the `_scenario.lua` / `_save.lua` `doscript` loaders + start-spot derivation are re-implemented here as small pure locals). Async enumeration of playable skirmish maps (streams into a `Scenarios` `LazyVar` across frames via `EnsureLoaded`; *not* `MapUtil.EnumerateSkirmishScenarios`), plus on-demand `LoadInfo` / `LoadSave` / `LoadMarkers` (the save is memoised by path with a FIFO bound — the save `doscript` is expensive and browsing re-loads the same maps; `LoadMarkers` extracts the preview's start-spot / mass / hydro / wreck points so callers never touch the raw save). **Reference data, never synced** — local only; the host syncs just its `ScenarioFile` choice. | +| [CustomLobbyMapCatalog.lua](CustomLobbyMapCatalog.lua) | the lobby's **own** scenario data layer — and its single source of truth for what a scenario is, with **no MapUtil dependency** (the `_scenario.lua` / `_save.lua` `doscript` loaders + start-spot derivation are re-implemented here as small pure locals). Async enumeration of playable skirmish maps (streams into a `Scenarios` `LazyVar` across frames via `EnsureLoaded`; *not* `MapUtil.EnumerateSkirmishScenarios`), plus on-demand `LoadInfo` / `LoadSave` / `LoadMarkers` / `LoadOptions` (the save is memoised by path with a FIFO bound — the save `doscript` is expensive and browsing re-loads the same maps; `LoadMarkers` extracts the preview's start-spot / mass / hydro / wreck points so callers never touch the raw save; `LoadOptions` reads + validates the map's `_options.lua` schema, so the catalog is the one reader of every scenario file — `_scenario.lua` / `_save.lua` / `_options.lua`). **Reference data, never synced** — local only; the host syncs just its `ScenarioFile` choice. | | [CustomLobbyMapList.lua](CustomLobbyMapList.lua) | the dialog's scrollable, **virtualised** list: a fixed pool of **text-only** rows (name + `size · players`) reused while scrolling, standard scrollbar contract, mouse-driven (no keyboard focus — a custom Group list can't take it). `OnSelect` / `OnConfirm`. | This is the first sub-dialog split out of the legacy `dialogs/mapselect.lua` god-dialog: it does diff --git a/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapCatalog.lua b/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapCatalog.lua index e611a7ef1c6..32ad5071372 100644 --- a/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapCatalog.lua +++ b/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapCatalog.lua @@ -165,6 +165,39 @@ local function GetStartPositions(scenarioInfo, scenarioSave) return output end +--- The path to a scenario's `_options.lua`, derived from its `_scenario.lua` path — the reference +--- isn't stored in the info file. Mirrors MapUtil: strip the trailing "scenario.lua" and append +--- "options.lua" (`/maps/x/x_scenario.lua` -> `/maps/x/x_options.lua`). +---@param scenarioFile FileName +---@return FileName +local function OptionsPathFor(scenarioFile) + return string.sub(scenarioFile, 1, string.len(scenarioFile) - string.len("scenario.lua")) .. "options.lua" +end + +--- Repairs malformed `default` indices in an options list *in place* (a `default` is a 1-based index +--- into `values`, not a value). The common mistake is storing the wanted value-key as the default; we +--- recover by finding that key's index. Untrusted disk data, so callers pcall this. +---@param options ScenarioOption[] +local function ValidateOptions(options) + for _, option in options do + local values = option.values + if type(values) == "table" then + local default = option.default + if type(default) ~= "number" or default <= 0 or default > table.getn(values) then + local replacement = 1 + for index, value in values do + local valueKey = (type(value) == "table") and value.key or value + if valueKey == default then + replacement = index + break + end + end + option.default = replacement + end + end + end +end + --#endregion ------------------------------------------------------------------------------- @@ -400,6 +433,28 @@ local Catalog = ClassSimple { return markers end, + --- Loads (and validates) a scenario's own lobby options from its `_options.lua` — the option + --- *schema* the map contributes (separate from the synced option values). Most maps declare none → + --- an empty list. The load + validate are pcall'd (untrusted disk `doscript` / malformed options). + --- Not cached here — the only caller (the options derived model) caches the gathered schema itself. + ---@param self UICustomLobbyMapCatalog + ---@param scenarioFile FileName | false + ---@return ScenarioOption[] + LoadOptions = function(self, scenarioFile) + if not scenarioFile then + return {} + end + local ok, options = pcall(function() + local data = LoadScenarioFile(OptionsPathFor(scenarioFile)) + return data and data.options + end) + if not ok or type(options) ~= "table" then + return {} + end + pcall(ValidateOptions, options) + return options + end, + --- Drops everything so the next `EnsureLoaded` re-reads from disk (e.g. maps changed on disk). --- Kills any in-flight load thread and resets the cached list *in place* (same LazyVar) so --- existing subscribers stay valid — this is a reset, not a teardown. @@ -519,6 +574,13 @@ function LoadMarkers(scenarioInfo) return GetSingleton():LoadMarkers(scenarioInfo) end +--- Loads (and validates) a scenario's own lobby options from its `_options.lua`. See the method. +---@param scenarioFile FileName | false +---@return ScenarioOption[] +function LoadOptions(scenarioFile) + return GetSingleton():LoadOptions(scenarioFile) +end + --- Drops everything so the next `EnsureLoaded` re-reads from disk (e.g. maps changed on disk). function Refresh() GetSingleton():Refresh() diff --git a/lua/ui/lobby/customlobby/optionselect/CLAUDE.md b/lua/ui/lobby/customlobby/optionselect/CLAUDE.md index eee7851bf74..ffa31b0c333 100644 --- a/lua/ui/lobby/customlobby/optionselect/CLAUDE.md +++ b/lua/ui/lobby/customlobby/optionselect/CLAUDE.md @@ -26,8 +26,9 @@ The option **schema** is derived per-peer from the synced `ScenarioFile` + `Game *reference data*, not a model (like the map/mod catalogs). The three sources: - **lobby** — the static base options (team ∪ global ∪ AI) from [`../../lobbyOptions.lua`](../../lobbyOptions.lua); -- **scenario** — the selected map's `_options.lua`, via `MapUtil.LoadScenarioOptionsFile` - (the `_options.lua` name mirrors `_scenario.lua` / `mod_info.lua`); +- **scenario** — the selected map's `_options.lua`, via the map catalog's `LoadOptions` + ([`../mapselect/CustomLobbyMapCatalog.lua`](../mapselect/CustomLobbyMapCatalog.lua) — the lobby's + one reader of scenario files; the `_options.lua` name mirrors `_scenario.lua` / `mod_info.lua`); - **mods** — each selected sim mod's `/lua/AI/LobbyOptions/lobbyoptions.lua` (`AIOpts`). Only the option **values** sync: the host edits them here and they ride in the launch model's diff --git a/lua/ui/lobby/customlobby/optionselect/CustomLobbyOptionSelect.lua b/lua/ui/lobby/customlobby/optionselect/CustomLobbyOptionSelect.lua index 534bbcda565..5e5d10800ce 100644 --- a/lua/ui/lobby/customlobby/optionselect/CustomLobbyOptionSelect.lua +++ b/lua/ui/lobby/customlobby/optionselect/CustomLobbyOptionSelect.lua @@ -48,6 +48,7 @@ local Edit = import("/lua/maui/edit.lua").Edit local Popup = import("/lua/ui/controls/popups/popup.lua").Popup local OptionUtil = import("/lua/ui/optionutil.lua") +local CustomLobbyMapCatalog = import("/lua/ui/lobby/customlobby/mapselect/customlobbymapcatalog.lua") local CustomLobbyOptionColumn = import("/lua/ui/lobby/customlobby/optionselect/customlobbyoptioncolumn.lua") local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") @@ -141,7 +142,7 @@ local CustomLobbyOptionSelect = ClassUI(Group) { -- the schema, derived per-column from the selected scenario + mods (reference data) self.LobbyOptions = OptionUtil.GetLobbyOptions() - self.ScenarioOptions = OptionUtil.GetScenarioOptions(options.scenarioFile) + self.ScenarioOptions = CustomLobbyMapCatalog.LoadOptions(options.scenarioFile) self.ModOptions = OptionUtil.GetModOptions(options.gameMods) local saved = import("/lua/user/prefs.lua").GetFromCurrentProfile(PrefsKey) or {} From 3cbb75b257e71e73371b401947c031dbae960e88 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Fri, 26 Jun 2026 10:50:24 +0200 Subject: [PATCH 66/98] Uitbreiden van mods en restricties derived state --- lua/ui/lobby/customlobby/CLAUDE.md | 6 +- lua/ui/lobby/customlobby/TODO.md | 59 +++++ .../config/CustomLobbyConfigInterface.lua | 19 +- .../config/CustomLobbyModsPanel.lua | 83 +++---- .../config/CustomLobbyUnitsPanel.lua | 59 +++-- lua/ui/lobby/customlobby/derived/CLAUDE.md | 6 +- .../derived/CustomLobbyModsDerivedModel.lua | 226 ++++++++++++++++++ .../CustomLobbyRestrictionsDerivedModel.lua | 226 ++++++++++++++++++ 8 files changed, 600 insertions(+), 84 deletions(-) create mode 100644 lua/ui/lobby/customlobby/TODO.md create mode 100644 lua/ui/lobby/customlobby/derived/CustomLobbyModsDerivedModel.lua create mode 100644 lua/ui/lobby/customlobby/derived/CustomLobbyRestrictionsDerivedModel.lua diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index 9942d91296c..d362063c5f6 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -4,6 +4,8 @@ A ground-up, reactive-MVC rebuild of the custom-games lobby (the player-driven counterpart to the matchmaker [`autolobby/`](../autolobby/CLAUDE.md)). It replaces the organically-grown [`/lua/ui/lobby/lobby.lua`](../lobby.lua). +> Deferred / parked work is tracked in [TODO.md](TODO.md). + > **Read first**, in order: > - [`/lua/ui/CLAUDE.md`](/lua/ui/CLAUDE.md) — UI patterns (LazyVar/`Derive`, `__init`/`__post_init`, `TrashBag`). > - [`/lua/ui/lobby/autolobby/CLAUDE.md`](../autolobby/CLAUDE.md) — the proven Model / Instance / Controller / components split this mirrors. @@ -43,7 +45,7 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | [CustomLobbyLaunchModel.lua](CustomLobbyLaunchModel.lua) | shared + launched state — the launch payload (see above). | | [CustomLobbySessionModel.lua](CustomLobbySessionModel.lua) | shared, lobby-room-only state (slot count, closed slots). | | [CustomLobbyLocalModel.lua](CustomLobbyLocalModel.lua) | per-peer state, never synced: identity + CPU benchmarks. | -| [derived/](derived/CLAUDE.md) | **derived models** — read-only reactive projections of the authoritative state, the "fishing" layer between the compact synced fields and the views (never written by the controller). The first is [CustomLobbyScenarioDerivedModel](derived/CustomLobbyScenarioDerivedModel.lua): resolves the launch model's compact `ScenarioFile` into a rich `Scenario` bundle — info (for the map texture) + extracted save markers (`Spawns` / `MassPoints` / `HydroPoints` / `Wrecks`, never the raw save) + `MaxDimension` / `ArmyCount` / `Name` / `Size` / `Version`. **Does the fishing once so consumers just read the field they need** (the map preview, the config facts line, `CustomLobbyRules`); an internal observer **dedups by file** so a launch-info rebroadcast of the same map is a no-op (no preview reload). [CustomLobbyOptionsDerivedModel](derived/CustomLobbyOptionsDerivedModel.lua) is the second: it splits `GameOptions` into lobby / scenario / mods categories and enriches each option (label, help, chosen value, is-default), caching the disk-loaded schema. Mods / restrictions / slot-info derived models are the planned siblings — see [derived/CLAUDE.md](derived/CLAUDE.md). | +| [derived/](derived/CLAUDE.md) | **derived models** — read-only reactive projections of the authoritative state, the "fishing" layer between the compact synced fields and the views (never written by the controller). The first is [CustomLobbyScenarioDerivedModel](derived/CustomLobbyScenarioDerivedModel.lua): resolves the launch model's compact `ScenarioFile` into a rich `Scenario` bundle — info (for the map texture) + extracted save markers (`Spawns` / `MassPoints` / `HydroPoints` / `Wrecks`, never the raw save) + `MaxDimension` / `ArmyCount` / `Name` / `Size` / `Version`. **Does the fishing once so consumers just read the field they need** (the map preview, the config facts line, `CustomLobbyRules`); an internal observer **dedups by file** so a launch-info rebroadcast of the same map is a no-op (no preview reload). [CustomLobbyOptionsDerivedModel](derived/CustomLobbyOptionsDerivedModel.lua) splits `GameOptions` into lobby / scenario / mods categories and enriches each option (label, help, chosen value, is-default), caching the disk-loaded schema; [CustomLobbyRestrictionsDerivedModel](derived/CustomLobbyRestrictionsDerivedModel.lua) maps the active restriction preset keys to their name / icon / tooltip; [CustomLobbyModsDerivedModel](derived/CustomLobbyModsDerivedModel.lua) splits the enabled mods into game / ui groups enriched with name / icon / author / version. A slot-info derived model is the remaining planned sibling — see [derived/CLAUDE.md](derived/CLAUDE.md). | | [CustomLobbyPerformancePopover.lua](CustomLobbyPerformancePopover.lua) | hover popover over the CPU column; hand-built bitmap bar chart of a peer's `PerformanceTrackingV2` history, with a yellow recommended-unit-cap line. | | [CustomLobbyInstance.lua](CustomLobbyInstance.lua) | thin `moho.lobby_methods` shell; validates/dispatches traffic, forwards callbacks to the controller. Also feeds [`CustomLobbyLog`](CustomLobbyLog.lua) from its three network choke points (BroadcastData / SendData / DataReceived). | | [CustomLobbyLog.lua](CustomLobbyLog.lua) | the **network traffic log** — a reactive, per-peer, never-synced ring buffer (`Entries` LazyVar, capped) of every message this peer broadcasts / sends / receives, fed by the instance and rendered by the **Logs** tab. Each peer logs only its own traffic, so host and client views differ naturally. Not one of the three models (no game state); a diagnostic feed. | @@ -68,7 +70,7 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | [CustomLobbyTeamScore.lua](CustomLobbyTeamScore.lua) | the **accumulated team rating** side indicator — `Side A N · M Side B`. Hosted in the strip atop the two-column slot layout (it doubles as the columns' Left/Right header). Shown only for the binary auto-team formations; **hidden** for `none`/`manual` or until a positional map's start positions load. Reads the mode + side split from [`CustomLobbyRules`](CustomLobbyRules.lua) (`AutoTeamMode` / `SideLabels` / `BuildSideResolver`) and the ratings from each slot's `PL`. Reference data; never writes. | | [CustomLobbyTabs.lua](CustomLobbyTabs.lua) | a **generic tabbed panel** (strip + content; one panel alive, created on select / destroyed on switch). Tabs **divide the strip evenly** across its width. Construct with a `{ Label, Create, Badge?, Action?, Icon?, Compact? }` list + optional `OnSelect`. A tab's optional `Badge` LazyVar drives a grey **count pill** to the right of the label; its optional `Action` (`{ Create, Visible? }`) is a small button the owner builds **inside the tab, left of the label** (e.g. a config gear), whose `Visible` LazyVar hides it (collapsing it from the layout) when it doesn't apply. The action, label and pill are centred together as one cluster; any absent/hidden/empty piece contributes 0 width so the rest re-centres. A tab can instead be **`Compact`** (a fixed narrow width, excluded from the even division — the flexible tabs share what's left) and/or show an **`Icon`** centred instead of its label (an icon-only utility tab); the default active tab is the first non-compact one. The container just mirrors the LazyVars, the owner decides what they mean. Used for the bottom-left (Logs / Chat / Observers) and the config interface's Options / Mods / Restrictions. | | [social/](social/) | the lobby's **bottom-left** column (the `CustomLobbyTabs` content): [`CustomLobbyChatPanel`](social/CustomLobbyChatPanel.lua) (the **Chat** tab — placeholder until the chat slice lands) and [`CustomLobbyObserversPanel`](social/CustomLobbyObserversPanel.lua) (the **Observers** tab — the shared observer list + a host-authoritative **Become observer** button → `RequestMoveToObserver`). Each is a tab content component (`Create(parent)`, created on select / destroyed on switch). The Chat / Observers tabs mirror the config column's shape — a per-tab **config gear** (`CustomLobbyInterface`'s local `GearAction`; both no-ops with a "coming soon" tooltip for now) + a right-side **count pill**: Observers shows the live observer count, Chat a dummy until the chat slice lands. A third **compact, icon-only [`CustomLobbyLogsPanel`](social/CustomLobbyLogsPanel.lua)** sits left of Chat (the **Logs** tab — a live **tail view** of this peer's network traffic from [`CustomLobbyLog`](CustomLobbyLog.lua): the most recent entries that fit, in columns `time · kind · ⚠ · name`, with a malformed/unauthorised message tinted + a ⚠ icon whose tooltip is the failure reason). | -| [config/](config/) | the lobby's **right** column. [`CustomLobbyConfigInterface.lua`](config/CustomLobbyConfigInterface.lua) is the column **composition**: a bound square `CustomLobbyMapPreview` **pinned** at the top with a vertical **preview tool strip** to its right (local `PreviewTool` icon buttons — toggles for army/start icons, mass+hydro deposits and the dummy water mask, driving `preview.Surface:SetOverlayVisible`, + a host-only **change-map** config icon at the bottom → the map-select dialog), a name + size/players/version facts line under it, and a [`CustomLobbyTabs`](CustomLobbyTabs.lua) (**Options / Mods / Restrictions**) filling the rest. Each tab carries its own **config gear** (`CustomLobbyTabs`' per-tab `Action`, built by the interface's `GearAction` helper) — a skinned button **inside the tab, left of the label** — that opens that tab's editor: Options → `CustomLobbyOptionSelect`, Mods → `CustomLobbyModSelect`, Restrictions → `CustomLobbyUnitSelect` (see [unitselect/](unitselect/)). The Options + Restrictions gears are **host-only**: their `Visible` LazyVar is the `IsHost` field, so they're **hidden** for clients and the label re-centres; the Mods gear shows for everyone (UI mods are local; the sim portion is host-gated inside the dialog). The interface also owns the tabs' **count badges** as computed LazyVars over the launch model — Options shows the non-default-option count (the options derived model's `NonDefaultCount`), Mods shows `sim / ui` (synced sim mods / this peer's UI-mod prefs), Restrictions shows the active restriction count (preset keys in the launch model's `Restrictions`). All three tab panels are now **read-only**: their per-domain action buttons (open editor / reset / manage mods) are removed — the grid/content fills the whole panel — and the action-bar **Settings** button (now joined by this gear) is the edit entry point. [`CustomLobbyOptionsPanel`](config/CustomLobbyOptionsPanel.lua) (options grouped **Lobby / Scenario / Mods** + hide-defaults toggle; map/mod options gold-flagged with an origin tooltip, option help as a label tooltip — all read from the [options derived model](derived/CustomLobbyOptionsDerivedModel.lua), the panel does no schema gathering), [`CustomLobbyModsPanel`](config/CustomLobbyModsPanel.lua) (enabled mods in **Game / UI** sections), [`CustomLobbyUnitsPanel`](config/CustomLobbyUnitsPanel.lua) (the **Restrictions** read-only list — the active restrictions' names, from the launch model's `Restrictions`). Each self-subscribes to the model and exposes `Initialize()` + `Create(parent)`. **Parked** (built, but unwired): [`CustomLobbyMapPanel`](config/CustomLobbyMapPanel.lua) (the full Map tab — preview + label/value details + Change-map), now superseded by the pinned preview. Churning a preview is safe here because the lobby shows only **one** current map and the engine caches map textures by name (the texture-leak rule only bites the *map-select dialog* — see [mapselect/CLAUDE.md](mapselect/CLAUDE.md)). | +| [config/](config/) | the lobby's **right** column. [`CustomLobbyConfigInterface.lua`](config/CustomLobbyConfigInterface.lua) is the column **composition**: a bound square `CustomLobbyMapPreview` **pinned** at the top with a vertical **preview tool strip** to its right (local `PreviewTool` icon buttons — toggles for army/start icons, mass+hydro deposits and the dummy water mask, driving `preview.Surface:SetOverlayVisible`, + a host-only **change-map** config icon at the bottom → the map-select dialog), a name + size/players/version facts line under it, and a [`CustomLobbyTabs`](CustomLobbyTabs.lua) (**Options / Mods / Restrictions**) filling the rest. Each tab carries its own **config gear** (`CustomLobbyTabs`' per-tab `Action`, built by the interface's `GearAction` helper) — a skinned button **inside the tab, left of the label** — that opens that tab's editor: Options → `CustomLobbyOptionSelect`, Mods → `CustomLobbyModSelect`, Restrictions → `CustomLobbyUnitSelect` (see [unitselect/](unitselect/)). The Options + Restrictions gears are **host-only**: their `Visible` LazyVar is the `IsHost` field, so they're **hidden** for clients and the label re-centres; the Mods gear shows for everyone (UI mods are local; the sim portion is host-gated inside the dialog). The interface also owns the tabs' **count badges** as computed LazyVars over the launch model — Options shows the non-default-option count (the options derived model's `NonDefaultCount`), Mods shows `sim / ui` (synced sim mods / this peer's UI-mod prefs), Restrictions shows the active restriction count (preset keys in the launch model's `Restrictions`). All three tab panels are now **read-only**: their per-domain action buttons (open editor / reset / manage mods) are removed — the grid/content fills the whole panel — and the action-bar **Settings** button (now joined by this gear) is the edit entry point. [`CustomLobbyOptionsPanel`](config/CustomLobbyOptionsPanel.lua) (options grouped **Lobby / Scenario / Mods** + hide-defaults toggle; map/mod options gold-flagged with an origin tooltip, option help as a label tooltip — all read from the [options derived model](derived/CustomLobbyOptionsDerivedModel.lua), the panel does no schema gathering), [`CustomLobbyModsPanel`](config/CustomLobbyModsPanel.lua) (enabled mods in **Game / UI** sections, each an **icon + name** row with author/version on hover, from the [mods derived model](derived/CustomLobbyModsDerivedModel.lua)), [`CustomLobbyUnitsPanel`](config/CustomLobbyUnitsPanel.lua) (the **Restrictions** read-only list — each active restriction's **preset icon + name** in taller rows, from the [restrictions derived model](derived/CustomLobbyRestrictionsDerivedModel.lua); the icon's tooltip is the preset description). Each self-subscribes to the model and exposes `Initialize()` + `Create(parent)`. **Parked** (built, but unwired): [`CustomLobbyMapPanel`](config/CustomLobbyMapPanel.lua) (the full Map tab — preview + label/value details + Change-map), now superseded by the pinned preview. Churning a preview is safe here because the lobby shows only **one** current map and the engine caches map textures by name (the texture-leak rule only bites the *map-select dialog* — see [mapselect/CLAUDE.md](mapselect/CLAUDE.md)). | | [/lua/ui/lobby/lobby.lua](../lobby.lua) | engine entry wrapper (`CreateLobby`/`HostGame`/`JoinGame`) → CustomLobby. Old lobby preserved at `lobby-old.lua`. | Working today: host + clients see each other (host-authoritative player sync), the diff --git a/lua/ui/lobby/customlobby/TODO.md b/lua/ui/lobby/customlobby/TODO.md new file mode 100644 index 00000000000..8b81604d854 --- /dev/null +++ b/lua/ui/lobby/customlobby/TODO.md @@ -0,0 +1,59 @@ +# Custom lobby — TODO / parked work + +Things deliberately deferred, with enough context to pick them up without re-deriving the problem. + +## Enrich specific-unit restrictions (name + icon) in the Restrictions panel + +**Status:** parked — needs a decision on how to manage blueprint-loading complexity in the front-end. + +### What works today + +The launch model's `Restrictions` is a `string[]` of keys. A key is one of two kinds: + +- a **preset** key (e.g. `"T3"`, `"AIR"`) — fully resolved by + [derived/CustomLobbyRestrictionsDerivedModel.lua](derived/CustomLobbyRestrictionsDerivedModel.lua) + against [`/lua/ui/lobby/unitsrestrictions.lua`](/lua/ui/lobby/unitsrestrictions.lua) → name / icon / tooltip. +- a **specific unit id** (e.g. `"uel0201"`) — **not** enriched yet; it currently shows as its raw id + (no icon, no name) via the fallback branch in `EnrichKey`. + +The goal: a unit-id restriction should show the unit's **icon + name** (+ tooltip), like the preset rows, +so it's easy to read. The panel already renders `{ Name, Icon, Tooltip }` generically — so this is purely a +model-side resolution problem; **no panel change is needed** once the model can produce those fields. + +### The complexity (why it's parked) + +There is no cheap, synchronous "unit id → name + icon" lookup available in the lobby (front-end) Lua state: + +- **`__blueprints` is sim-side only.** It is created when a *game* loads (see + [`/lua/RuleInit.lua`](/lua/RuleInit.lua) `__blueprints = …`) and is **not populated in the front-end** — + confirmed by the note in [`/lua/ui/lobby/UnitsAnalyzer.lua`](/lua/ui/lobby/UnitsAnalyzer.lua) (~line 496: + "we cannot use global `__blueprints` … because it is created on SIM side"). An earlier attempt that read + `__blueprints[unitId]` was reverted for this reason — it always hit nil and fell through. +- **The icon, on its own, *is* resolvable synchronously.** The build icon lives on disk at + `/textures/ui/common/icons/units/_icon.dds`, and `DiskGetFileInfo` works in the front-end (that's how + `UnitsAnalyzer.GetImagePath` resolves it). So **icon-only** enrichment needs no blueprint load. +- **The name needs a loaded blueprint.** In the front-end the only source is + [`/lua/ui/lobby/UnitsAnalyzer.lua`](/lua/ui/lobby/UnitsAnalyzer.lua), which loads blueprints by + **reading the `.bp` files itself** (`GetBlueprints` → `LoadBlueprints('*_unit.bp', …)` → `CacheUnit`, which + sets `bp.Name = GetUnitName(bp)` etc.). It is **async**, **mod-aware**, and **loads the entire unit set**. + It's wrapped reactively by [mapselect-style] [`unitselect/CustomLobbyUnitCatalog.lua`](unitselect/CustomLobbyUnitCatalog.lua) + (`EnsureLoaded(activeMods)` + `GetFactionsVar()`), which the unit-select dialog uses. + +### Options considered (pick one when we resume) + +1. **Icon + raw id (lightweight).** Resolve the icon via the disk path (sync), label = unit id. Zero load + cost, works for host + clients immediately. Loses the human-readable name. +2. **Full name via UnitsAnalyzer.** Have the model consume `CustomLobbyUnitCatalog` (trigger + `EnsureLoaded` only when unit-id restrictions are present, re-derive when `GetFactionsVar()` fires). + Proper names + tooltips, but loads *all* blueprints (async, heavy) in any lobby with unit restrictions, + on host **and** clients — a lot of machinery for a few labels. +3. **Lightweight single-`.bp` read.** Mirror what `UnitsAnalyzer`/`CacheUnit` do but for *one* unit: + synchronously `doscript` the unit's `_unit.bp` and read `General.UnitName` / `Description`. Avoids loading + the whole set, but re-implements a slice of the blueprint loader and needs care (mods, file path from id, + `doscript` safety/caching). + +### Open question + +How to manage this blueprint-loading complexity for a **read-only** panel without paying the full +load-everything cost — likely option 3 (a small cached "resolve one unit's name from its `.bp`") or option 1 +as an interim. Decide, then implement entirely in the derived model's `EnrichKey` (the panel is already generic). diff --git a/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua b/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua index dde1b8e5a98..10fc761cd60 100644 --- a/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua +++ b/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua @@ -61,7 +61,8 @@ local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaun local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/customlobbylocalmodel.lua") local CustomLobbyScenarioDerivedModel = import("/lua/ui/lobby/customlobby/derived/customlobbyscenarioderivedmodel.lua") local CustomLobbyOptionsDerivedModel = import("/lua/ui/lobby/customlobby/derived/customlobbyoptionsderivedmodel.lua") -local ModUtilities = import("/lua/ui/modutilities.lua") +local CustomLobbyRestrictionsDerivedModel = import("/lua/ui/lobby/customlobby/derived/customlobbyrestrictionsderivedmodel.lua") +local CustomLobbyModsDerivedModel = import("/lua/ui/lobby/customlobby/derived/customlobbymodsderivedmodel.lua") local LazyVarCreate = import("/lua/lazyvar.lua").Create local LazyVarDerive = import("/lua/lazyvar.lua").Derive @@ -271,10 +272,9 @@ local CustomLobbyConfigInterface = ClassUI(Group) { Tooltip.AddControlTooltipManual(self.ConfigButton.Bg, "Change map", "Pick a different scenario (host only).") --#endregion - -- count badges for the tab strip: computed LazyVars over the launch model (the tabs + -- count badges for the tab strip: computed LazyVars over the derived models (the tabs -- container observes them and renders the grey pills). Built before the tabs so each -- button can subscribe at creation. - local launch = CustomLobbyLaunchModel.GetSingleton() -- the badges always render, even at 0 (an empty string would collapse the pill) self.OptionsBadge = self.Trash:Add(LazyVarCreate()) @@ -282,20 +282,17 @@ local CustomLobbyConfigInterface = ClassUI(Group) { return tostring(CustomLobbyOptionsDerivedModel.GetOptionsVar()().NonDefaultCount) end) - -- "sim / ui" — sim mods are the synced GameMods; UI mods are this peer's prefs (not a - -- reactive field, so the count refreshes whenever the sim mods change, which is good enough - -- until the mod-select dialog is rewired). + -- "sim / ui" — game (sim) mods and this peer's UI mods, from the mods derived model self.ModsBadge = self.Trash:Add(LazyVarCreate()) self.ModsBadge:Set(function() - local sim = table.getsize(launch.GameMods()) - local ui = table.getsize(ModUtilities.GetSelectedUIMods()) - return sim .. " / " .. ui + local mods = CustomLobbyModsDerivedModel.GetModsVar()() + return mods.GameCount .. " / " .. mods.UiCount end) - -- the count of active unit restrictions (preset keys in the launch model's Restrictions) + -- the count of active unit restrictions (from the restrictions derived model) self.RestrictionsBadge = self.Trash:Add(LazyVarCreate()) self.RestrictionsBadge:Set(function() - return tostring(table.getn(launch.Restrictions())) + return tostring(CustomLobbyRestrictionsDerivedModel.GetRestrictionsVar()().Count) end) -- the read-only config tabs below the preview (created-on-select / destroyed-on-switch), diff --git a/lua/ui/lobby/customlobby/config/CustomLobbyModsPanel.lua b/lua/ui/lobby/customlobby/config/CustomLobbyModsPanel.lua index 7beb2181895..33f155b6d13 100644 --- a/lua/ui/lobby/customlobby/config/CustomLobbyModsPanel.lua +++ b/lua/ui/lobby/customlobby/config/CustomLobbyModsPanel.lua @@ -21,32 +21,35 @@ --****************************************************************************************************** -- The Mods tab panel of the config interface: the enabled mods, grouped into Game mods (the --- shared sim mods from the launch model) and UI mods (this peer's local choice from prefs). The --- grid fills the whole panel — the "Manage mods" button is gone for now (the per-domain edit --- buttons are removed during the layout rework and will be reconsidered when it resumes). +-- shared sim mods) and UI mods (this peer's local choice), each row showing the mod's **icon + name** +-- with author/version on hover. The grid fills the whole panel — the "Manage mods" button is gone +-- for now (the per-domain edit buttons are removed during the layout rework). -- --- It is a tab panel: created when its tab is selected and destroyed on switch, so it's the --- live/visible panel for its whole lifetime. UI mods are prefs, not a reactive model field, so --- they're read on the first render (`Initialize`); the synced sim mods additionally refresh it live. +-- It reads the **mods derived model** (the enabled mods, already split into groups and enriched with +-- name / icon / author / version), so the panel does no uid resolution or formatting itself. It is a +-- tab panel: created when its tab is selected and destroyed on switch, so it's the live/visible panel +-- for its whole lifetime; the (synced) sim mods refresh it live (UI-mod prefs aren't reactive — same +-- caveat as before, the model re-reads them whenever the sim mods change). local UIUtil = import("/lua/ui/uiutil.lua") local LayoutHelpers = import("/lua/maui/layouthelpers.lua") +local Tooltip = import("/lua/ui/game/tooltip.lua") local Group = import("/lua/maui/group.lua").Group local Bitmap = import("/lua/maui/bitmap.lua").Bitmap local Grid = import("/lua/maui/grid.lua").Grid -local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") -local ModUtilities = import("/lua/ui/modutilities.lua") -local Mods = import("/lua/mods.lua") +local CustomLobbyModsDerivedModel = import("/lua/ui/lobby/customlobby/derived/customlobbymodsderivedmodel.lua") local LazyVarDerive = import("/lua/lazyvar.lua").Derive local Layouter = LayoutHelpers.ReusedLayoutFor -local RowHeight = 22 +-- taller rows than a plain text list so each mod's icon reads clearly (like the Restrictions panel) +local RowHeight = 34 +local IconSize = 26 local ScrollGap = 32 -- standard lobby scrollbar gutter (see ModSelect) local GridContentWidth = 360 - 6 - ScrollGap -local LabelMaxChars = 30 +local LabelMaxChars = 28 local NormalColor = 'ffc8ccd0' --- Truncates `text` to `maxChars`, appending "…" when it had to cut. @@ -85,10 +88,10 @@ local CustomLobbyModsPanel = ClassUI(Group) { self.Empty:DisableHitTest() self.Empty:Hide() - -- only the shared sim mods are a model field; UI mods (prefs) are picked up on the first - -- render (Initialize), since the panel is created fresh each time its tab is selected + -- the derived model already split + enriched the enabled mods; one subscription rebuilds the + -- panel when they change (Refresh is Ready-gated for the immediate fire on creation) self.ModsObserver = self.Trash:Add( - LazyVarDerive(CustomLobbyLaunchModel.GetSingleton().GameMods, function(lazy) + LazyVarDerive(CustomLobbyModsDerivedModel.GetModsVar(), function(lazy) lazy() self:Refresh() end)) @@ -113,38 +116,20 @@ local CustomLobbyModsPanel = ClassUI(Group) { self:Refresh() end, - --- Rebuilds the enabled-mods grid: a Game mods section (shared sim mods) + a UI mods section - --- (this peer's local mods), each name resolved + sorted. + --- Rebuilds the enabled-mods grid from the derived model's groups: a Game mods section + a UI mods + --- section, each mod shown as icon + name. The bundle is already split + enriched + sorted. ---@param self UICustomLobbyModsPanel Refresh = function(self) if not self.Ready then return end - local allMods = Mods.AllMods() - - local function names(uidSet) - local list = {} - for uid in uidSet do - local mod = allMods[uid] - if mod then - table.insert(list, mod.name or uid) - end - end - table.sort(list) - return list - end - - local sections = { - { Title = "Game mods", Names = names(CustomLobbyLaunchModel.GetSingleton().GameMods()) }, - { Title = "UI mods", Names = names(ModUtilities.GetSelectedUIMods()) }, - } local rows = {} - for _, section in sections do - if table.getn(section.Names) > 0 then - table.insert(rows, { Header = section.Title }) - for _, name in section.Names do - table.insert(rows, { Name = name }) + for _, group in CustomLobbyModsDerivedModel.GetMods().Groups do + if table.getn(group.Mods) > 0 then + table.insert(rows, { Header = group.Title }) + for _, mod in group.Mods do + table.insert(rows, { Mod = mod }) end end end @@ -155,7 +140,7 @@ local CustomLobbyModsPanel = ClassUI(Group) { self.ModsGrid:AppendCols(1, true) self.ModsGrid:AppendRows(table.getn(rows), true) for index, row in rows do - local control = row.Header and self:CreateSectionHeader(row.Header) or self:CreateModRow(row.Name) + local control = row.Header and self:CreateSectionHeader(row.Header) or self:CreateModRow(row.Mod) self.ModsGrid:SetItem(control, 1, index, true) end self.ModsGrid:EndBatch() @@ -186,18 +171,26 @@ local CustomLobbyModsPanel = ClassUI(Group) { return row end, - --- Builds one enabled-mod row (name only — the section header conveys sim vs. UI). + --- Builds one enabled-mod row: the mod's icon + name, with author/version on hover (the section + --- header conveys sim vs. UI). ---@param self UICustomLobbyModsPanel - ---@param name string + ---@param mod UICustomLobbyMod ---@return Group - CreateModRow = function(self, name) + CreateModRow = function(self, mod) local row = Group(self.ModsGrid) LayoutHelpers.SetDimensions(row, GridContentWidth, RowHeight) - local label = UIUtil.CreateText(row, Truncate(name, LabelMaxChars), 13, UIUtil.bodyFont) + local icon = Bitmap(row) + icon:SetTexture(mod.Icon) + Layouter(icon):AtLeftIn(row, 4):AtVerticalCenterIn(row):Width(IconSize):Height(IconSize):End() + -- author · version on hover (titled with the mod's full name, in case it was truncated) + local facts = mod.Version ~= "" and (mod.Author .. " · " .. mod.Version) or mod.Author + Tooltip.AddControlTooltipManual(icon, mod.Name, facts) + + local label = UIUtil.CreateText(row, Truncate(mod.Name, LabelMaxChars), 13, UIUtil.bodyFont) label:SetColor(NormalColor) label:DisableHitTest() - Layouter(label):AtLeftIn(row, 4):AtVerticalCenterIn(row):End() + Layouter(label):AtLeftIn(row, 4 + IconSize + 8):AtVerticalCenterIn(row):End() return row end, diff --git a/lua/ui/lobby/customlobby/config/CustomLobbyUnitsPanel.lua b/lua/ui/lobby/customlobby/config/CustomLobbyUnitsPanel.lua index 4954e6701ef..c2a8e7fa225 100644 --- a/lua/ui/lobby/customlobby/config/CustomLobbyUnitsPanel.lua +++ b/lua/ui/lobby/customlobby/config/CustomLobbyUnitsPanel.lua @@ -24,24 +24,26 @@ -- restrictions. A config-interface tab panel — the host creates it when the Units tab is selected -- and destroys it on switch, and calls `Initialize` after sizing it (same interface as the others). -- --- It subscribes to the launch model's `Restrictions` (the preset-key list, host-dictated + synced) --- and lists each restriction's name. Editing happens in the host-only `CustomLobbyUnitSelect` dialog --- behind this tab's config gear (see CustomLobbyConfigInterface). Step 1 lists names only; the --- preset icons land with the dialog's icon grid. +-- It reads the **restrictions derived model** (the active restrictions, each already enriched with its +-- preset name / icon / tooltip) and lists each one with its preset icon + name. Editing happens in the +-- host-only `CustomLobbyUnitSelect` dialog behind this tab's config gear (see CustomLobbyConfigInterface). local UIUtil = import("/lua/ui/uiutil.lua") local LayoutHelpers = import("/lua/maui/layouthelpers.lua") +local Tooltip = import("/lua/ui/game/tooltip.lua") local Group = import("/lua/maui/group.lua").Group +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap local Grid = import("/lua/maui/grid.lua").Grid -local UnitsRestrictions = import("/lua/ui/lobby/unitsrestrictions.lua") -local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") +local CustomLobbyRestrictionsDerivedModel = import("/lua/ui/lobby/customlobby/derived/customlobbyrestrictionsderivedmodel.lua") local LazyVarDerive = import("/lua/lazyvar.lua").Derive local Layouter = LayoutHelpers.ReusedLayoutFor -local RowHeight = 24 +-- taller rows than a plain text list so each restriction's preset icon reads clearly +local RowHeight = 34 +local IconSize = 26 local ScrollbarGap = 32 local LabelColor = 'ffc8ccd0' local DimColor = 'ff8a909a' @@ -76,7 +78,7 @@ local CustomLobbyUnitsPanel = ClassUI(Group) { -- gated behind Ready so the immediate fire on creation (hot-reload, model already populated) -- doesn't rebuild rows before the parent has sized us — see ../CLAUDE.md layout gotchas self.RestrictionsObserver = self.Trash:Add( - LazyVarDerive(CustomLobbyLaunchModel.GetSingleton().Restrictions, function(restrictionsLazy) + LazyVarDerive(CustomLobbyRestrictionsDerivedModel.GetRestrictionsVar(), function(restrictionsLazy) restrictionsLazy() if self.Ready then self:Refresh() @@ -103,16 +105,15 @@ local CustomLobbyUnitsPanel = ClassUI(Group) { self:Refresh() end, - --- Rebuilds the list of active restriction names from the launch model. + --- Rebuilds the list of active restrictions (icon + name) from the derived model. ---@param self UICustomLobbyUnitsPanel Refresh = function(self) - local presets = UnitsRestrictions.GetPresetsData() - local keys = CustomLobbyLaunchModel.GetSingleton().Restrictions() - local count = table.getn(keys) + local restrictions = CustomLobbyRestrictionsDerivedModel.GetRestrictions() + local items = restrictions.Items self.Grid:DeleteAndDestroyAll(true) - if count == 0 then + if restrictions.Count == 0 then self.Empty:Show() self:UpdateScrollbar() return @@ -120,29 +121,41 @@ local CustomLobbyUnitsPanel = ClassUI(Group) { self.Empty:Hide() self.Grid:AppendCols(1, true) - self.Grid:AppendRows(count, true) - for row, key in keys do - local preset = presets[key] - local name = (preset and preset.name and LOC(preset.name)) or key - self.Grid:SetItem(self:CreateRow(name), 1, row, true) + self.Grid:AppendRows(restrictions.Count, true) + for row, item in items do + self.Grid:SetItem(self:CreateRow(item), 1, row, true) end self.Grid:EndBatch() self:UpdateScrollbar() end, - --- Builds one read-only row: the restriction's name. Private. + --- Builds one read-only row: the restriction's preset icon + name (the icon's tooltip is the + --- preset's description). Private. ---@param self UICustomLobbyUnitsPanel - ---@param name string + ---@param item UICustomLobbyRestriction ---@return Group - CreateRow = function(self, name) + CreateRow = function(self, item) local row = Group(self.Grid) LayoutHelpers.SetDimensions(row, 10, RowHeight) row.Width:Set(function() return self.Grid.Width() end) - local label = UIUtil.CreateText(row, name, 13, UIUtil.bodyFont) + local labelLeft = 4 + if item.Icon then + local icon = Bitmap(row) + icon:SetTexture(item.Icon) + Layouter(icon):AtLeftIn(row, 4):AtVerticalCenterIn(row):Width(IconSize):Height(IconSize):End() + if item.Tooltip then + Tooltip.AddControlTooltipManual(icon, item.Name, item.Tooltip) + else + icon:DisableHitTest() + end + labelLeft = 4 + IconSize + 8 + end + + local label = UIUtil.CreateText(row, item.Name, 13, UIUtil.bodyFont) label:SetColor(LabelColor) label:DisableHitTest() - Layouter(label):AtLeftIn(row, 4):AtVerticalCenterIn(row):End() + Layouter(label):AtLeftIn(row, labelLeft):AtVerticalCenterIn(row):End() return row end, diff --git a/lua/ui/lobby/customlobby/derived/CLAUDE.md b/lua/ui/lobby/customlobby/derived/CLAUDE.md index fec6e52604d..b8163cc8476 100644 --- a/lua/ui/lobby/customlobby/derived/CLAUDE.md +++ b/lua/ui/lobby/customlobby/derived/CLAUDE.md @@ -36,6 +36,8 @@ layer between the compact synced fields and the views. |------|---------|------|---------| | [CustomLobbyScenarioDerivedModel.lua](CustomLobbyScenarioDerivedModel.lua) | the resolved `Scenario` (info for the texture + extracted save markers `Spawns`/`MassPoints`/`HydroPoints`/`Wrecks` + `MaxDimension`/`ArmyCount`/`Name`/`Size`/`Version`) | the launch model's `ScenarioFile` | the bound [`../CustomLobbyMapPreview`](../CustomLobbyMapPreview.lua), the [`../config/CustomLobbyConfigInterface`](../config/CustomLobbyConfigInterface.lua) facts line, [`../CustomLobbyRules`](../CustomLobbyRules.lua) (map size + start spots) | | [CustomLobbyOptionsDerivedModel.lua](CustomLobbyOptionsDerivedModel.lua) | the `Options` view: options split into **Categories** lobby / scenario / mods, each option **enriched** (label, help, chosen value-key + display, `IsDefault`, origin), plus `NonDefaultCount` | the launch model's `GameOptions` + `GameMods` and the scenario derived model (map file + name); the scenario `_options.lua` schema via the catalog's `LoadOptions`, the lobby/mod schema + value interpretation via [`/lua/ui/optionutil.lua`](/lua/ui/optionutil.lua) | the [`../config/CustomLobbyOptionsPanel`](../config/CustomLobbyOptionsPanel.lua) and the Options tab badge in [`../config/CustomLobbyConfigInterface`](../config/CustomLobbyConfigInterface.lua) | +| [CustomLobbyRestrictionsDerivedModel.lua](CustomLobbyRestrictionsDerivedModel.lua) | the `Restrictions` view: each active **preset** key **enriched** with its `Name` / `Icon` / `Tooltip`, plus `Count`. De-duped by set (length + sorted compare). Keys can also be **specific unit ids**, but enriching those (name + icon) is **parked** — see [../TODO.md](../TODO.md) — so a unit id currently shows as its raw id. | the launch model's `Restrictions`, joined with the preset table in [`/lua/ui/lobby/unitsrestrictions.lua`](/lua/ui/lobby/unitsrestrictions.lua) | the [`../config/CustomLobbyUnitsPanel`](../config/CustomLobbyUnitsPanel.lua) (icon + name rows) and the Restrictions tab badge | +| [CustomLobbyModsDerivedModel.lua](CustomLobbyModsDerivedModel.lua) | the `Mods` view: enabled mods split into **game** (sim) + **ui** `Groups`, each mod **enriched** (`Name` / `Icon` / `Author` / `Version` / `UiOnly`), plus `GameCount` / `UiCount`. The simplest model — `Mods.AllMods()` is available synchronously, so no disk load. | the launch model's `GameMods` (synced sim mods) + `ModUtilities.GetSelectedUIMods()` (per-peer prefs, re-read on each sim-mod change), joined with `/lua/mods.lua` | the [`../config/CustomLobbyModsPanel`](../config/CustomLobbyModsPanel.lua) (icon + name rows) and the Mods tab badge (`sim / ui`) | The options model also shows a second trait worth copying: it **caches the expensive part**. Gathering the option schema is disk work (the map's `_options.lua` `doscript`, mod option files) and only changes @@ -43,7 +45,5 @@ when the scenario / mod set changes, so it is rebuilt only when those inputs cha edit just re-enriches the cached schema. (Composing derived models is fine too: it reads the scenario *from the scenario derived model*, not by re-resolving the file.) -**Planned siblings** (same shape): a mods derived model (UUID set → full mod info via -[`../modselect/`](../modselect/CLAUDE.md) / `/lua/ui/modutilities.lua`), a restrictions derived model -(preset keys → display names), and a slots-info derived model (the seated players projected into the +**Planned sibling** (same shape): a slots-info derived model (the seated players projected into the shapes the slot layouts / team score want). diff --git a/lua/ui/lobby/customlobby/derived/CustomLobbyModsDerivedModel.lua b/lua/ui/lobby/customlobby/derived/CustomLobbyModsDerivedModel.lua new file mode 100644 index 00000000000..a0d0516716d --- /dev/null +++ b/lua/ui/lobby/customlobby/derived/CustomLobbyModsDerivedModel.lua @@ -0,0 +1,226 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- ============================================================================================ +-- DERIVED MODEL — read-only. See /lua/ui/lobby/customlobby/derived/CLAUDE.md. +-- +-- A derived model is a pure function of the authoritative models: it resolves a compact synced field +-- into a rich, ready-to-read bundle and exposes it reactively. Views read it; the **controller never +-- writes it** (there are no write helpers) — to change what it holds, change the source it derives +-- from. That keeps the launch model the single source of truth and this a cache/projection. +-- ============================================================================================ +-- +-- The **derived mods**: the lobby's enabled mods come in two flavours, each stored as a uid set — +-- * **game** (sim) mods — the launch model's `GameMods`, host-dictated + synced; +-- * **UI** mods — this peer's own choice, in prefs (`ModUtilities.GetSelectedUIMods`), never synced. +-- A uid on its own says nothing about the mod, so this model joins each uid to its `ModInfo` (from +-- `/lua/mods.lua`) and **enriches** it with display name / icon / author / version — so the Mods panel +-- (and the tab badge) just read those fields instead of resolving uids and formatting them itself. +-- +-- Unlike scenario / restriction data this needs no disk load or `__blueprints` — `Mods.AllMods()` is +-- already available synchronously in the lobby — which is why it's the simplest of the derived models. +-- +-- NOTE on icons: the mod-select *dialog* keeps its list text-only because one distinct texture per row +-- over a big vault leaks (see modselect/CLAUDE.md). Here we only ever show the **enabled** mods (a +-- handful), and re-rendering reuses the engine's by-name texture cache, so the icon count is bounded — +-- the same bounded trickle the single map preview accepts. + +local Create = import("/lua/lazyvar.lua").Create +local Derive = import("/lua/lazyvar.lua").Derive +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") +local ModUtilities = import("/lua/ui/modutilities.lua") +local Mods = import("/lua/mods.lua") + +-- shown when a mod declares no icon (or its icon file is missing) — matches the mod-select catalog +local FallbackIcon = '/textures/ui/common/dialogs/mod-manager/generic-icon_bmp.dds' + +------------------------------------------------------------------------------- +--#region Shape + +--- One enriched enabled mod. +---@class UICustomLobbyMod +---@field Uid string +---@field Name string # formatted display name (version suffix stripped, capitalised) +---@field Icon string # icon texture path — always set (FallbackIcon when the mod has none) +---@field Author string # formatted author ("UNKNOWN" when none) +---@field Version string # formatted version ("vN", or "" when none) +---@field UiOnly boolean + +--- A group of enabled mods (game / ui). +---@class UICustomLobbyModGroup +---@field Key 'game' | 'ui' +---@field Title string +---@field Mods UICustomLobbyMod[] # sorted by name + +--- The fully-derived mods view. +---@class UICustomLobbyMods +---@field Groups UICustomLobbyModGroup[] # always two, in order: game, ui +---@field GameCount number +---@field UiCount number + +--#endregion + +------------------------------------------------------------------------------- +--#region Derived model + +--- Reactive derived-mods singleton. **Read-only** — no write helpers; the controller never touches it. +---@class UICustomLobbyModsDerivedModel +---@field Mods LazyVar +---@field Observer LazyVar + +---@type UICustomLobbyModsDerivedModel | nil +local ModelInstance = nil + +--- Enriches one mod uid against the all-mods table; returns a minimal entry for an unknown uid. +---@param uid string +---@param allMods table +---@return UICustomLobbyMod +local function EnrichMod(uid, allMods) + local mod = allMods[uid] + if not mod then + return { Uid = uid, Name = uid, Icon = FallbackIcon, Author = "", Version = "", UiOnly = false } + end + + local icon = mod.icon + if not icon or icon == "" or not DiskGetFileInfo(icon) then + icon = FallbackIcon + end + return { + Uid = uid, + Name = ModUtilities.FormatName(mod), + Icon = icon, + Author = ModUtilities.FormatAuthor(mod), + Version = ModUtilities.FormatVersion(mod), + UiOnly = mod.ui_only and true or false, + } +end + +--- Builds the enriched, name-sorted mod list for one uid set. +---@param uidSet table +---@param allMods table +---@return UICustomLobbyMod[] +local function BuildGroup(uidSet, allMods) + local mods = {} + for uid in uidSet do + table.insert(mods, EnrichMod(uid, allMods)) + end + table.sort(mods, function(a, b) return string.upper(a.Name) < string.upper(b.Name) end) + return mods +end + +--- Builds the full mods bundle (game + ui groups + counts) from the current uid sets. +---@param gameMods table +---@param uiMods table +---@return UICustomLobbyMods +local function BuildMods(gameMods, uiMods) + local allMods = Mods.AllMods() + local game = BuildGroup(gameMods, allMods) + local ui = BuildGroup(uiMods, allMods) + return { + Groups = { + { Key = 'game', Title = "Game mods", Mods = game }, + { Key = 'ui', Title = "UI mods", Mods = ui }, + }, + GameCount = table.getn(game), + UiCount = table.getn(ui), + } +end + +--- Re-derives the mods bundle from the current launch state + UI-mod prefs and publishes it. +--- NOTE: UI mods are prefs, not a reactive field, so they're re-read here whenever the (reactive) sim +--- mods change — the same "good enough until the mod dialog is rewired" caveat the panel/badge had. +---@param model UICustomLobbyModsDerivedModel +local function Recompute(model) + model.Mods:Set(BuildMods(CustomLobbyLaunchModel.GetSingleton().GameMods(), ModUtilities.GetSelectedUIMods())) +end + +--- Allocates a fresh mods-model singleton and wires its observer to the launch model's `GameMods`. +--- The observer is pinned on the model so it isn't GC'd, and (because `Derive` fires synchronously on +--- creation) it resolves the current mods immediately. +---@return UICustomLobbyModsDerivedModel +function SetupSingleton() + ---@type UICustomLobbyModsDerivedModel + local model = { + Mods = Create({ Groups = {}, GameCount = 0, UiCount = 0 }), + } + ModelInstance = model + + local launch = CustomLobbyLaunchModel.GetSingleton() + model.Observer = Derive(launch.GameMods, function(lazy) + lazy() + Recompute(model) + end) + + return model +end + +--- Returns the mods-model singleton, creating (and deriving the current mods) on first access. +---@return UICustomLobbyModsDerivedModel +function GetSingleton() + if not ModelInstance then + SetupSingleton() + end + return ModelInstance --[[@as UICustomLobbyModsDerivedModel]] +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Accessors + +--- The reactive mods var — subscribe to it (via `Derive`) to react when the sim mods change. +---@return LazyVar +function GetModsVar() + return GetSingleton().Mods +end + +--- The current enriched mods bundle. +---@return UICustomLobbyMods +function GetMods() + return GetSingleton().Mods() +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Debugging + +--- Hot-reload hook: rebuild the singleton so its observer re-subscribes and re-derives. The bundle is +--- fully derived from the launch model + prefs, so there is no state to copy across. +---@param newModule any +function __moduleinfo.OnReload(newModule) + if ModelInstance then + newModule.SetupSingleton() + end +end + +--- Hot-reload hook: re-imports this module after a couple of frames. +function __moduleinfo.OnDirty() + ForkThread( + function() + WaitFrames(2) + import(__moduleinfo.name) + end + ) +end + +--#endregion diff --git a/lua/ui/lobby/customlobby/derived/CustomLobbyRestrictionsDerivedModel.lua b/lua/ui/lobby/customlobby/derived/CustomLobbyRestrictionsDerivedModel.lua new file mode 100644 index 00000000000..6f815fcdf74 --- /dev/null +++ b/lua/ui/lobby/customlobby/derived/CustomLobbyRestrictionsDerivedModel.lua @@ -0,0 +1,226 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- ============================================================================================ +-- DERIVED MODEL — read-only. See /lua/ui/lobby/customlobby/derived/CLAUDE.md. +-- +-- A derived model is a pure function of the authoritative models: it resolves a compact synced field +-- into a rich, ready-to-read bundle and exposes it reactively. Views read it; the **controller never +-- writes it** (there are no write helpers) — to change what it holds, change the source field it +-- derives from. That keeps the launch model the single source of truth and this a cache/projection. +-- ============================================================================================ +-- +-- The **derived unit restrictions**: the launch model stores restrictions in their most compact form — +-- a list of preset *keys* (e.g. `"T3"`, `"AIR"`). On its own that's just an identifier; what it +-- means (its display name, icon and tooltip) lives in the restriction-preset table in +-- `/lua/ui/lobby/unitsrestrictions.lua`. This model joins the two: it maps each active key to its +-- preset and exposes the enriched item — so consumers (the Restrictions panel, the tab badge) just +-- read `Name` / `Icon` / `Tooltip` instead of looking the preset up themselves. +-- +-- NOTE: a restriction key can also be a **specific unit blueprint id** (not just a preset). Enriching +-- those with a real name + icon is **parked** — `__blueprints` isn't available in the lobby front-end, +-- so it needs the heavier `UnitsAnalyzer` path. See /lua/ui/lobby/customlobby/TODO.md. For now a +-- non-preset key falls through and shows as its raw id. + +local Create = import("/lua/lazyvar.lua").Create +local Derive = import("/lua/lazyvar.lua").Derive +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") +local UnitsRestrictions = import("/lua/ui/lobby/unitsrestrictions.lua") + +------------------------------------------------------------------------------- +--#region Shape + +--- One enriched restriction: a preset key joined with its preset definition. +---@class UICustomLobbyRestriction +---@field Key string +---@field Name string # LOC'd display name (falls back to the key) +---@field Icon string | false # preset icon texture path, or false when the preset has none +---@field Tooltip string | false # LOC'd tooltip body, or false when none + +--- The fully-derived restrictions view. +---@class UICustomLobbyRestrictions +---@field Items UICustomLobbyRestriction[] # one per active preset key, in the launch model's order +---@field Count number + +--#endregion + +------------------------------------------------------------------------------- +--#region Derived model + +--- Reactive derived-restrictions singleton. **Read-only** — no write helpers; the controller never +--- touches it. It re-derives from the launch model's `Restrictions`. +---@class UICustomLobbyRestrictionsDerivedModel +---@field Restrictions LazyVar +---@field Observer LazyVar + +---@type UICustomLobbyRestrictionsDerivedModel | nil +local ModelInstance = nil + +--- The preset keys the bundle was last built from — the de-dup baseline, or false before the first +--- build. Restrictions are a *set*, so this is compared order-independently (see `SameKeys`). +---@type string[] | false +local LoadedKeys = false + +--- Whether two preset-key lists describe the same *set* — equal length, then equal once both are +--- sorted (so a pure reorder of the same restrictions doesn't count as a change). Restrictions are +--- short lists of strings, so sorting copies each time is cheap. +---@param a string[] +---@param b string[] +---@return boolean +local function SameKeys(a, b) + if table.getn(a) ~= table.getn(b) then + return false + end + local sortedA = table.copy(a) + local sortedB = table.copy(b) + table.sort(sortedA) + table.sort(sortedB) + for i = 1, table.getn(sortedA) do + if sortedA[i] ~= sortedB[i] then + return false + end + end + return true +end + +--- Resolves one restriction key into an enriched item — a preset (the common case), else a specific +--- unit blueprint, else an unknown key shown as-is. +---@param key string +---@param presets table # UnitsRestrictions.GetPresetsData() +---@return UICustomLobbyRestriction +local function EnrichKey(key, presets) + local preset = presets[key] + if preset then + return { + Key = key, + Name = (preset.name and LOC(preset.name)) or key, + Icon = preset.Icon or false, + Tooltip = (preset.tooltip and LOC(preset.tooltip)) or false, + } + end + + -- a non-preset key is a specific unit blueprint id; enriching it with a real name + icon is + -- parked (needs blueprints, which aren't loaded in the lobby front-end — see TODO.md), so for + -- now it shows as its raw id + return { Key = key, Name = key, Icon = false, Tooltip = false } +end + +--- Builds the enriched, counted restrictions bundle from the active keys (presets and/or unit ids). +---@param keys string[] +---@return UICustomLobbyRestrictions +local function BuildRestrictions(keys) + local presets = UnitsRestrictions.GetPresetsData() + local items = {} + for _, key in keys do + table.insert(items, EnrichKey(key, presets)) + end + return { Items = items, Count = table.getn(keys) } +end + +--- Re-derives the restrictions bundle and publishes it — unless the active set is unchanged, in which +--- case it's a no-op (the de-dup): `LazyVar:Set` always re-fires, and the host re-sets `Restrictions` +--- to the same list on every launch-info rebroadcast, so without this the panel would rebuild its icon +--- rows on every unrelated option change. +---@param model UICustomLobbyRestrictionsDerivedModel +local function Recompute(model) + local keys = CustomLobbyLaunchModel.GetSingleton().Restrictions() + if LoadedKeys and SameKeys(keys, LoadedKeys) then + return + end + LoadedKeys = keys + model.Restrictions:Set(BuildRestrictions(keys)) +end + +--- Allocates a fresh restrictions-model singleton and wires its observer to the launch model's +--- `Restrictions`. The observer is pinned on the model so it isn't GC'd, and (because `Derive` fires +--- synchronously on creation) it resolves the current restrictions immediately. +---@return UICustomLobbyRestrictionsDerivedModel +function SetupSingleton() + LoadedKeys = false + + ---@type UICustomLobbyRestrictionsDerivedModel + local model = { + Restrictions = Create({ Items = {}, Count = 0 }), + } + ModelInstance = model + + local launch = CustomLobbyLaunchModel.GetSingleton() + model.Observer = Derive(launch.Restrictions, function(lazy) + lazy() + Recompute(model) + end) + + return model +end + +--- Returns the restrictions-model singleton, creating (and deriving the current restrictions) on +--- first access. +---@return UICustomLobbyRestrictionsDerivedModel +function GetSingleton() + if not ModelInstance then + SetupSingleton() + end + return ModelInstance --[[@as UICustomLobbyRestrictionsDerivedModel]] +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Accessors + +--- The reactive restrictions var — subscribe to it (via `Derive`) to react when restrictions change. +---@return LazyVar +function GetRestrictionsVar() + return GetSingleton().Restrictions +end + +--- The current enriched restrictions bundle. +---@return UICustomLobbyRestrictions +function GetRestrictions() + return GetSingleton().Restrictions() +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Debugging + +--- Hot-reload hook: rebuild the singleton so its observer re-subscribes and re-derives. The bundle is +--- fully derived from the launch model, so there is no state to copy across. +---@param newModule any +function __moduleinfo.OnReload(newModule) + if ModelInstance then + newModule.SetupSingleton() + end +end + +--- Hot-reload hook: re-imports this module after a couple of frames. +function __moduleinfo.OnDirty() + ForkThread( + function() + WaitFrames(2) + import(__moduleinfo.name) + end + ) +end + +--#endregion From 4c7378bac6a5c01869cd2e807c75ee71ff5621bd Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Fri, 26 Jun 2026 12:23:47 +0200 Subject: [PATCH 67/98] Add derived state for lobby slots --- lua/ui/lobby/customlobby/CLAUDE.md | 4 +- lua/ui/lobby/customlobby/derived/CLAUDE.md | 15 +- .../derived/CustomLobbySlotsDerivedModel.lua | 409 ++++++++++++++++++ .../customlobby/slots/CustomLobbySlotBase.lua | 189 ++------ 4 files changed, 452 insertions(+), 165 deletions(-) create mode 100644 lua/ui/lobby/customlobby/derived/CustomLobbySlotsDerivedModel.lua diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index d362063c5f6..c99bfa75385 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -45,7 +45,7 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | [CustomLobbyLaunchModel.lua](CustomLobbyLaunchModel.lua) | shared + launched state — the launch payload (see above). | | [CustomLobbySessionModel.lua](CustomLobbySessionModel.lua) | shared, lobby-room-only state (slot count, closed slots). | | [CustomLobbyLocalModel.lua](CustomLobbyLocalModel.lua) | per-peer state, never synced: identity + CPU benchmarks. | -| [derived/](derived/CLAUDE.md) | **derived models** — read-only reactive projections of the authoritative state, the "fishing" layer between the compact synced fields and the views (never written by the controller). The first is [CustomLobbyScenarioDerivedModel](derived/CustomLobbyScenarioDerivedModel.lua): resolves the launch model's compact `ScenarioFile` into a rich `Scenario` bundle — info (for the map texture) + extracted save markers (`Spawns` / `MassPoints` / `HydroPoints` / `Wrecks`, never the raw save) + `MaxDimension` / `ArmyCount` / `Name` / `Size` / `Version`. **Does the fishing once so consumers just read the field they need** (the map preview, the config facts line, `CustomLobbyRules`); an internal observer **dedups by file** so a launch-info rebroadcast of the same map is a no-op (no preview reload). [CustomLobbyOptionsDerivedModel](derived/CustomLobbyOptionsDerivedModel.lua) splits `GameOptions` into lobby / scenario / mods categories and enriches each option (label, help, chosen value, is-default), caching the disk-loaded schema; [CustomLobbyRestrictionsDerivedModel](derived/CustomLobbyRestrictionsDerivedModel.lua) maps the active restriction preset keys to their name / icon / tooltip; [CustomLobbyModsDerivedModel](derived/CustomLobbyModsDerivedModel.lua) splits the enabled mods into game / ui groups enriched with name / icon / author / version. A slot-info derived model is the remaining planned sibling — see [derived/CLAUDE.md](derived/CLAUDE.md). | +| [derived/](derived/CLAUDE.md) | **derived models** — read-only reactive projections of the authoritative state, the "fishing" layer between the compact synced fields and the views (never written by the controller). The first is [CustomLobbyScenarioDerivedModel](derived/CustomLobbyScenarioDerivedModel.lua): resolves the launch model's compact `ScenarioFile` into a rich `Scenario` bundle — info (for the map texture) + extracted save markers (`Spawns` / `MassPoints` / `HydroPoints` / `Wrecks`, never the raw save) + `MaxDimension` / `ArmyCount` / `Name` / `Size` / `Version`. **Does the fishing once so consumers just read the field they need** (the map preview, the config facts line, `CustomLobbyRules`); an internal observer **dedups by file** so a launch-info rebroadcast of the same map is a no-op (no preview reload). [CustomLobbyOptionsDerivedModel](derived/CustomLobbyOptionsDerivedModel.lua) splits `GameOptions` into lobby / scenario / mods categories and enriches each option (label, help, chosen value, is-default), caching the disk-loaded schema; [CustomLobbyRestrictionsDerivedModel](derived/CustomLobbyRestrictionsDerivedModel.lua) maps the active restriction preset keys to their name / icon / tooltip; [CustomLobbyModsDerivedModel](derived/CustomLobbyModsDerivedModel.lua) splits the enabled mods into game / ui groups enriched with name / icon / author / version; [CustomLobbySlotsDerivedModel](derived/CustomLobbySlotsDerivedModel.lua) is the `Slots` **lookup table** (one entry per slot) merging each seat's player + scenario placement + closed flag + CPU benchmark into a ready-to-paint entry (the slot/faction/CPU formatting moved out of the slot controls into here). See [derived/CLAUDE.md](derived/CLAUDE.md). | | [CustomLobbyPerformancePopover.lua](CustomLobbyPerformancePopover.lua) | hover popover over the CPU column; hand-built bitmap bar chart of a peer's `PerformanceTrackingV2` history, with a yellow recommended-unit-cap line. | | [CustomLobbyInstance.lua](CustomLobbyInstance.lua) | thin `moho.lobby_methods` shell; validates/dispatches traffic, forwards callbacks to the controller. Also feeds [`CustomLobbyLog`](CustomLobbyLog.lua) from its three network choke points (BroadcastData / SendData / DataReceived). | | [CustomLobbyLog.lua](CustomLobbyLog.lua) | the **network traffic log** — a reactive, per-peer, never-synced ring buffer (`Entries` LazyVar, capped) of every message this peer broadcasts / sends / receives, fed by the instance and rendered by the **Logs** tab. Each peer logs only its own traffic, so host and client views differ naturally. Not one of the three models (no game state); a diagnostic feed. | @@ -56,7 +56,7 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | [CustomLobbyMessages.lua](CustomLobbyMessages.lua) | message registry: `AddPlayer`, `SetPlayers` (launch model: players + observers), `SentLaunchInfo` (launch model: scenario / options / mods / teams / spawn mex), `SetSessionState` (session model: slot count / closed slots), `SetReady`, `TakeSlot`, `DisconnectPeer`, `LaunchGame` (host's final game config → `instance:LaunchGame`), `ReportCpuBenchmark`, `SetCpuBenchmarks`. | | [CustomLobbyContextMenu.lua](CustomLobbyContextMenu.lua) | generic framed floating menu; `Show(entries, x, y)` renders any `{label, action, enabled}` list, dismisses on item click / click-outside / Esc. Knows nothing about the lobby. | | [CustomLobbyMenus.lua](CustomLobbyMenus.lua) | declarative menu **definitions**: entry lists with `when(ctx)`/`action(ctx)` filtered by lobby state (`BuildSlotMenu`). Adding/state-gating an item is a one-liner here. | -| [slots/](slots/) | the **slot subsystem** (its own folder, a sub-folder per layout). [`CustomLobbySlotsInterface`](slots/CustomLobbySlotsInterface.lua) is the entry the composition root mounts: a "Players" header over the **active layout body**, picked by the AutoTeams mode — one-column for the non-team modes, the two-column team layout for the binary modes (left/right, top/bottom, even/odd). It observes `GameOptions` and swaps the body when the kind flips (create-on-mode / destroy-on-switch); a change *within* the binary modes is handled by the two-column body's own re-layout. It is the rows' **drag coordinator** (`UICustomLobbySlotCoordinator`: hit-test which row a point is in, drop-highlight, drag ghost → `RequestSwapSlots`) for *every* layout — it alone needs to hit-test across rows — so the layout bodies stay pure build/place/reveal and never duplicate the drag logic; it also exposes `PreferredHeight()` (computed from the mode + model, swap-order-independent) so the root sizes the slot area. The header band carries a **host-only tool strip** (right-aligned: pin seating · auto-balance · reopen closed slots) of small icon buttons (local `SlotTool`, mirroring the config column's `PreviewTool`); the pin button is lit from the synced `SlotsPinned` and the strip is hidden for clients. A **"Locked" notice** (lock glyph + label) sits right of the "Players" label and is shown to **everyone** while `SlotsPinned` is on, so a client can see seating is host-controlled before clicking an open slot to no effect. [`CustomLobbySlotBase`](slots/CustomLobbySlotBase.lua) holds **all slot behaviour** (the slot + CPU subscriptions, the CPU-cap math, click/right-click/drag-to-swap + intents, the background/highlight/click overlays) and the default `RenderPlayer`/`RenderCpu` that paint the standard named controls from normalised views; a presentation subclasses it and implements just `CreateContents` / `LayoutContents`. [`onecolumn/`](slots/onecolumn/): [`CustomLobbyOneColumnSlots`](slots/onecolumn/CustomLobbyOneColumnSlots.lua) (stacks thin rows, reveals 1..count) + [`CustomLobbySlotRow`](slots/onecolumn/CustomLobbySlotRow.lua) (thin one-line presentation). [`twocolumn/`](slots/twocolumn/): [`CustomLobbyTwoColumnSlots`](slots/twocolumn/CustomLobbyTwoColumnSlots.lua) (splits slots into two team columns via [`CustomLobbyRules.BuildSideResolver`](CustomLobbyRules.lua), with a [`CustomLobbyTeamScore`](CustomLobbyTeamScore.lua) strip across the top as the Left/Right indicator, and the "two columns, unresolved" fallback — parity fill + the score self-hides until a positional map's start positions load) + [`CustomLobbySlotCard`](slots/twocolumn/CustomLobbySlotCard.lua) (fat half-width / two-line presentation; `SetMirrored` flips the right column's cards so the two teams face each other). Each body exports `HeightForCount`. | +| [slots/](slots/) | the **slot subsystem** (its own folder, a sub-folder per layout). [`CustomLobbySlotsInterface`](slots/CustomLobbySlotsInterface.lua) is the entry the composition root mounts: a "Players" header over the **active layout body**, picked by the AutoTeams mode — one-column for the non-team modes, the two-column team layout for the binary modes (left/right, top/bottom, even/odd). It observes `GameOptions` and swaps the body when the kind flips (create-on-mode / destroy-on-switch); a change *within* the binary modes is handled by the two-column body's own re-layout. It is the rows' **drag coordinator** (`UICustomLobbySlotCoordinator`: hit-test which row a point is in, drop-highlight, drag ghost → `RequestSwapSlots`) for *every* layout — it alone needs to hit-test across rows — so the layout bodies stay pure build/place/reveal and never duplicate the drag logic; it also exposes `PreferredHeight()` (computed from the mode + model, swap-order-independent) so the root sizes the slot area. The header band carries a **host-only tool strip** (right-aligned: pin seating · auto-balance · reopen closed slots) of small icon buttons (local `SlotTool`, mirroring the config column's `PreviewTool`); the pin button is lit from the synced `SlotsPinned` and the strip is hidden for clients. A **"Locked" notice** (lock glyph + label) sits right of the "Players" label and is shown to **everyone** while `SlotsPinned` is on, so a client can see seating is host-controlled before clicking an open slot to no effect. [`CustomLobbySlotBase`](slots/CustomLobbySlotBase.lua) holds **all slot behaviour** (one subscription to the [slots derived model](derived/CustomLobbySlotsDerivedModel.lua) — which already merged this seat's player / placement / closed / CPU benchmark and did the faction/CPU formatting — click/right-click/drag-to-swap + intents, the background/highlight/click overlays) and the default `RenderPlayer`/`RenderCpu` that paint the standard named controls from the entry's resolved views; a presentation subclasses it and implements just `CreateContents` / `LayoutContents`. [`onecolumn/`](slots/onecolumn/): [`CustomLobbyOneColumnSlots`](slots/onecolumn/CustomLobbyOneColumnSlots.lua) (stacks thin rows, reveals 1..count) + [`CustomLobbySlotRow`](slots/onecolumn/CustomLobbySlotRow.lua) (thin one-line presentation). [`twocolumn/`](slots/twocolumn/): [`CustomLobbyTwoColumnSlots`](slots/twocolumn/CustomLobbyTwoColumnSlots.lua) (splits slots into two team columns via [`CustomLobbyRules.BuildSideResolver`](CustomLobbyRules.lua), with a [`CustomLobbyTeamScore`](CustomLobbyTeamScore.lua) strip across the top as the Left/Right indicator, and the "two columns, unresolved" fallback — parity fill + the score self-hides until a positional map's start positions load) + [`CustomLobbySlotCard`](slots/twocolumn/CustomLobbySlotCard.lua) (fat half-width / two-line presentation; `SetMirrored` flips the right column's cards so the two teams face each other). Each body exports `HeightForCount`. | | [CustomLobbyObserversInterface.lua](CustomLobbyObserversInterface.lua) | observer strip; subscribes to the model's `Observers` list and shows the count + names (read-only). | | [CustomLobbyScenarioPreview.lua](CustomLobbyScenarioPreview.lua) | **shared** map-preview *surface*: the scenario's map texture + overlays (start spots, resource/wreck markers, plus a **dummy translucent `WaterMask`** placeholder until a real mask exists) with aspect-correct positioning, texture-leak-safe icon sharing, per-group visibility (`SetOverlayVisible('spawns'\|'resources'\|'wrecks'\|'water', …)`), and three-phase init. A **pure renderer**: `SetScenario(info, markers)` takes already-extracted markers (`UICustomLobbyScenarioMarkers`) and never touches the raw save. Chrome-free; `CustomLobbyMapPreview` wraps it with the frame. Spawn appearance is the owner's via a `CreateSpawnIcon` factory. | | [CustomLobbyMapPreview.lua](CustomLobbyMapPreview.lua) | the map preview **as one whole** — the chrome (glow border on top + dark backdrop, surface inset by `Padding`), the surface, and the faction spawn icon (local `MapPreviewSpawn`). Used by **both** consumers: created `Bound = true` it subscribes to [`CustomLobbyScenarioDerivedModel`](derived/CustomLobbyScenarioDerivedModel.lua) and renders the resolved scenario with per-slot faction spawns (no reload on take/swap, and no reload on a same-map rebroadcast — the model dedups); created unbound (the map-select dialog) it does no model wiring and the owner drives `preview.Surface` directly (numbered-dot spawns), feeding it markers from the catalog's `LoadMarkers`. Exposes `.Surface` for owners to drive / anchor overlays to. | diff --git a/lua/ui/lobby/customlobby/derived/CLAUDE.md b/lua/ui/lobby/customlobby/derived/CLAUDE.md index b8163cc8476..af6f11bff8b 100644 --- a/lua/ui/lobby/customlobby/derived/CLAUDE.md +++ b/lua/ui/lobby/customlobby/derived/CLAUDE.md @@ -38,12 +38,17 @@ layer between the compact synced fields and the views. | [CustomLobbyOptionsDerivedModel.lua](CustomLobbyOptionsDerivedModel.lua) | the `Options` view: options split into **Categories** lobby / scenario / mods, each option **enriched** (label, help, chosen value-key + display, `IsDefault`, origin), plus `NonDefaultCount` | the launch model's `GameOptions` + `GameMods` and the scenario derived model (map file + name); the scenario `_options.lua` schema via the catalog's `LoadOptions`, the lobby/mod schema + value interpretation via [`/lua/ui/optionutil.lua`](/lua/ui/optionutil.lua) | the [`../config/CustomLobbyOptionsPanel`](../config/CustomLobbyOptionsPanel.lua) and the Options tab badge in [`../config/CustomLobbyConfigInterface`](../config/CustomLobbyConfigInterface.lua) | | [CustomLobbyRestrictionsDerivedModel.lua](CustomLobbyRestrictionsDerivedModel.lua) | the `Restrictions` view: each active **preset** key **enriched** with its `Name` / `Icon` / `Tooltip`, plus `Count`. De-duped by set (length + sorted compare). Keys can also be **specific unit ids**, but enriching those (name + icon) is **parked** — see [../TODO.md](../TODO.md) — so a unit id currently shows as its raw id. | the launch model's `Restrictions`, joined with the preset table in [`/lua/ui/lobby/unitsrestrictions.lua`](/lua/ui/lobby/unitsrestrictions.lua) | the [`../config/CustomLobbyUnitsPanel`](../config/CustomLobbyUnitsPanel.lua) (icon + name rows) and the Restrictions tab badge | | [CustomLobbyModsDerivedModel.lua](CustomLobbyModsDerivedModel.lua) | the `Mods` view: enabled mods split into **game** (sim) + **ui** `Groups`, each mod **enriched** (`Name` / `Icon` / `Author` / `Version` / `UiOnly`), plus `GameCount` / `UiCount`. The simplest model — `Mods.AllMods()` is available synchronously, so no disk load. | the launch model's `GameMods` (synced sim mods) + `ModUtilities.GetSelectedUIMods()` (per-peer prefs, re-read on each sim-mod change), joined with `/lua/mods.lua` | the [`../config/CustomLobbyModsPanel`](../config/CustomLobbyModsPanel.lua) (icon + name rows) and the Mods tab badge (`sim / ui`) | +| [CustomLobbySlotsDerivedModel.lua](CustomLobbySlotsDerivedModel.lua) | the `Slots` **lookup table** (one entry per slot, indexed 1..MaxSlots): each seat merges its **player** (name / colour / faction / team / ready, resolved to a ready-to-paint `PlayerView`), its scenario **placement** (`StartSpot` + map `Position`), its **closed** flag, and its **CPU benchmark** (resolved to a `CpuView` — unit count + green→red headroom step — plus the raw `Benchmark` + `UnitCap` for the hover popover). The slot/faction/CPU formatting that used to live in the slot controls now lives here. | the launch model's `Players[slot]`, the session model's `ClosedSlots`, the local model's `CpuBenchmarks` (per-peer, **not launched**), and the scenario derived model (placement + the size-driven unit cap via [`../CustomLobbyRules`](../CustomLobbyRules.lua)) | the [`../slots/CustomLobbySlotBase`](../slots/CustomLobbySlotBase.lua) (the thin row + fat card paint the resolved entry) | The options model also shows a second trait worth copying: it **caches the expensive part**. Gathering the option schema is disk work (the map's `_options.lua` `doscript`, mod option files) and only changes when the scenario / mod set changes, so it is rebuilt only when those inputs change (keyed) — a value -edit just re-enriches the cached schema. (Composing derived models is fine too: it reads the scenario -*from the scenario derived model*, not by re-resolving the file.) - -**Planned sibling** (same shape): a slots-info derived model (the seated players projected into the -shapes the slot layouts / team score want). +edit just re-enriches the cached schema. (Composing derived models is fine too: both the options model +and the slots model read the scenario *from the scenario derived model*, not by re-resolving the file.) + +The slots model shows a third variation: **one table rebuilt whole vs. a var per item.** The other +models expose a list/bundle, but the slots model is keyed by slot index because a seat's CPU bar is +styled against the unit cap, which depends on the *total* seated count — so filling one seat must +restyle every seat. A per-slot var would leave the others stale (only the changed seat's var fires); a +single table rebuilt whole keeps the board consistent. The de-dup then compares a **signature** of the +rendered fields so an unchanged rebroadcast still re-publishes nothing. diff --git a/lua/ui/lobby/customlobby/derived/CustomLobbySlotsDerivedModel.lua b/lua/ui/lobby/customlobby/derived/CustomLobbySlotsDerivedModel.lua new file mode 100644 index 00000000000..0cb8801249b --- /dev/null +++ b/lua/ui/lobby/customlobby/derived/CustomLobbySlotsDerivedModel.lua @@ -0,0 +1,409 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- ============================================================================================ +-- DERIVED MODEL — read-only. See /lua/ui/lobby/customlobby/derived/CLAUDE.md. +-- +-- A derived model is a pure function of the authoritative models: it resolves compact synced fields +-- into a rich, ready-to-read bundle and exposes it reactively. Views read it; the **controller never +-- writes it** (there are no write helpers) — to change what it holds, change the source it derives +-- from. That keeps the launch / session / local models the single source of truth and this a projection. +-- ============================================================================================ +-- +-- The **derived slots**: a single lookup table, one entry per slot, that merges *every* model that has +-- something to say about a seat into one place so a slot view reads one field instead of joining four +-- models itself. Each entry combines — +-- * the seated **player** (launch model, synced) — name / colour / faction / team / ready; +-- * the scenario **placement** (the derived scenario model) — the start spot's map position, i.e. +-- *where* on the map this seat sits, plus the per-player unit cap the map size implies; +-- * the **closed** flag (session model, synced lobby-room state, not launched); +-- * the **CPU benchmark** (local model, per-peer, never synced and *not part of the game*) — the +-- sim-performance projection the slot shows as a unit count + green→red headroom indicator. +-- The slot/faction/CPU formatting that used to live in each slot control now lives here, once, so the +-- presentations (the thin row + the fat card) are pure arrangement over a resolved view. +-- +-- **Why one table, not a var per slot.** The CPU indicator's colour depends on the recommended unit +-- cap, which depends on the *total seated count* — so a seat filling anywhere restyles every other +-- seat's CPU bar. A per-slot var would leave those stale (only the changed slot's var fires); a single +-- table rebuilt whole keeps the whole board consistent. The user-facing model is literally "the slots, +-- looked up by index". +-- +-- **De-duplication.** `LazyVar:Set` always re-fires, and the host rebroadcasts the whole launch info +-- (re-setting every `Players[slot]` to an equal value) on any option tweak. So the rebuild compares a +-- signature of the rendered fields and only re-publishes `Slots` when something visible actually +-- changed — a rebroadcast of an unchanged board is a no-op, not 16 needless re-renders per seat. + +local Create = import("/lua/lazyvar.lua").Create +local Derive = import("/lua/lazyvar.lua").Derive +local GameColors = import("/lua/gamecolors.lua").GameColors +local Color = import("/lua/shared/color.lua") +local Factions = import("/lua/factions.lua").Factions + +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") +local CustomLobbySessionModel = import("/lua/ui/lobby/customlobby/customlobbysessionmodel.lua") +local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/customlobbylocalmodel.lua") +local CustomLobbyScenarioDerivedModel = import("/lua/ui/lobby/customlobby/derived/customlobbyscenarioderivedmodel.lua") +local CustomLobbyRules = import("/lua/ui/lobby/customlobby/customlobbyrules.lua") + +local MaxSlots = CustomLobbyLaunchModel.MaxSlots + +------------------------------------------------------------------------------- +--#region Shape + +--- The seated player's resolved presentation (false on an empty seat — the view paints "- open -"). +---@class UICustomLobbySlotPlayerView +---@field colorHex string +---@field name string +---@field nameColor string +---@field faction string +---@field team string +---@field ready string +---@field readyColor string + +--- The CPU column's resolved presentation (false when there is no player / no benchmark to show). +---@class UICustomLobbySlotCpuView +---@field text string +---@field textColor string +---@field indicatorColor Color | nil +---@field showIndicator boolean + +--- One fully-resolved slot: the merge of player (launch) + placement (scenario) + closed (session) + +--- CPU benchmark (local). Carries both the ready-to-paint views and the raw refs interaction needs. +---@class UICustomLobbySlot +---@field Slot number # slot index 1..MaxSlots +---@field Player UICustomLobbyPlayer | false # the seated player (raw), false when empty — for intents/drag +---@field Closed boolean # session: seat closed (no army at launch) +---@field StartSpot number | false # the player's chosen start spot +---@field Position table | false # {x, z} of that start spot on the map, or false +---@field PlayerView UICustomLobbySlotPlayerView | false +---@field CpuView UICustomLobbySlotCpuView | false +---@field Benchmark UIPerformanceMetrics | false # raw metrics for the owner (the hover popover reads this) +---@field UnitCap number | false # recommended cap used for the indicator (popover reads this) + +--#endregion + +------------------------------------------------------------------------------- +--#region Formatting (single-sourced here; the slot presentations are pure arrangement) + +--- Short faction label for a faction index (5 = Random has no factions.lua entry). +---@param faction number +---@return string +local function FactionLabel(faction) + local data = Factions[faction] + if data then + return data.DisplayName or data.Key or tostring(faction) + end + return "Random" +end + +--- The sim-rate categories tracked in PerformanceTrackingV2, ordered for the "most-played" pick. +local PerformanceCategories = { 'Skirmish', 'SkirmishWithAI', 'Campaign' } + +--- The bucket index for a sim rate (index k holds rate k - 11, so +0 -> 11, -4 -> 7). +---@param rate number +---@return number +local function BucketForRate(rate) + return rate + 11 +end + +--- The most-played category in a benchmark, by sample count, or nil if none. +---@param metrics UIPerformanceMetrics | nil +---@return table | nil +local function PickCategory(metrics) + if not metrics then + return nil + end + local best, bestSamples = nil, -1 + for _, key in PerformanceCategories do + local c = metrics[key] + if c and (c.Samples or 0) > bestSamples then + bestSamples = c.Samples or 0 + best = c + end + end + if not best or bestSamples <= 0 then + return nil + end + return best +end + +--- Compact unit count for the slot label (e.g. 1421 -> "1.4k"). +---@param value number +---@return string +local function FormatUnits(value) + if value >= 1000 then + return string.format("%.1fk", value / 1000) + end + return tostring(math.floor(value + 0.5)) +end + +--- Indicator colour for how far the sim has to slow down to sustain the cap: green when +0 already +--- suffices (step 0), fading to red at -4 or worse (step 4). +---@param step number # sim-rate steps below +0 needed to reach the cap (0..4) +---@return Color +local function StepColor(step) + local t = math.clamp(step / 4, 0.0, 1.0) + local hue = (1.0 - t) * 0.333 -- 0.333 turns = green, 0 = red + return Color.ColorHSV(hue, 1.0, 0.85, 1.0) +end + +--- The player presentation for a seated player. +---@param player UICustomLobbyPlayer +---@return UICustomLobbySlotPlayerView +local function BuildPlayerView(player) + return { + colorHex = GameColors.PlayerColors[player.PlayerColor] or 'ffffffff', + name = player.PlayerName or "?", + nameColor = player.Human and 'ffffffff' or 'ffd9c97a', + faction = FactionLabel(player.Faction), + team = (player.Team and player.Team > 1) and ("T" .. (player.Team - 1)) or "-", + ready = player.Ready and "ready" or "", + readyColor = player.Ready and 'ff7ad97a' or 'ff888888', + } +end + +--- The CPU presentation for a seated player from its shared sim-performance benchmark: the label is the +--- max units the machine handled at full speed (+0), and the indicator is green if that already covers +--- the recommended cap, fading to red the further the sim must slow (down to -4). Returns false when +--- there is no benchmark to show. +---@param metrics UIPerformanceMetrics | nil +---@param cap number | nil +---@return UICustomLobbySlotCpuView | false +local function BuildCpuView(metrics, cap) + local category = PickCategory(metrics) + local atZero = category and category[BucketForRate(0)] + if not (atZero and atZero.UnitCount) then + return { text = "—", textColor = 'ff9aa0a8', showIndicator = false } + end + + local maxAtZero = atZero.UnitCount.Max or 0 + local view = { text = FormatUnits(maxAtZero), textColor = 'ff9aa0a8', showIndicator = false } + + if cap and cap > 0 then + -- how many sim-rate steps below +0 are needed before the machine sustains the cap (0 = fine at + -- +0); worst case (red) if even -4 falls short + local step = 0 + if maxAtZero < cap then + step = 4 + for s = 1, 4 do + local bucket = category[BucketForRate(-s)] + if bucket and bucket.UnitCount and (bucket.UnitCount.Max or 0) >= cap then + step = s + break + end + end + end + view.indicatorColor = StepColor(step) + view.showIndicator = true + end + + return view +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Derived model + +--- Reactive derived-slots singleton. **Read-only** — no write helpers; the controller never touches it. +---@class UICustomLobbySlotsDerivedModel +---@field Slots LazyVar # the lookup table, indexed 1..MaxSlots +---@field Observers LazyVar[] # internal: the source subscriptions; pinned so they aren't GC'd + +---@type UICustomLobbySlotsDerivedModel | nil +local ModelInstance = nil + +--- A signature of the currently-published table's rendered fields — the de-dup key. Re-set only when +--- the signature changes, so a rebroadcast of an unchanged board re-fires nothing. +---@type string | false +local LoadedSignature = false + +--- Builds one slot entry by joining every model for that seat. +---@param slot number +---@param player UICustomLobbyPlayer | false +---@param closed boolean +---@param benchmarks table +---@param spawns table | nil # scenario start-spot -> {x, z} +---@param cap number | nil # recommended unit cap (seated-count × per-player tier) +---@return UICustomLobbySlot +local function BuildSlot(slot, player, closed, benchmarks, spawns, cap) + if not player then + return { + Slot = slot, Player = false, Closed = closed, + StartSpot = false, Position = false, + PlayerView = false, CpuView = false, Benchmark = false, UnitCap = false, + } + end + + local startSpot = player.StartSpot or false + local benchmark = benchmarks[player.OwnerID] or false + return { + Slot = slot, + Player = player, + Closed = closed, + StartSpot = startSpot, + Position = (spawns and startSpot and spawns[startSpot]) or false, + PlayerView = BuildPlayerView(player), + CpuView = BuildCpuView(benchmark or nil, cap), + Benchmark = benchmark, + UnitCap = cap or false, + } +end + +--- A compact signature of the table's rendered fields (everything that affects what a slot paints), so +--- the rebuild can skip re-publishing when nothing visible changed. +---@param slots UICustomLobbySlot[] +---@return string +local function Signature(slots) + local parts = {} + for slot = 1, MaxSlots do + local entry = slots[slot] + local pv = entry.PlayerView + local cv = entry.CpuView + local pos = entry.Position + -- built with `..` (not table.concat) so numbers coerce to strings — Lua 5.0's table.concat + -- rejects non-string elements + parts[slot] = tostring(slot) + .. "|" .. (entry.Closed and "C" or "_") + .. "|" .. (pv and (pv.colorHex .. pv.name .. pv.nameColor .. pv.faction .. pv.team .. pv.ready .. pv.readyColor) or "-") + .. "|" .. (cv and (cv.text .. cv.textColor .. (cv.showIndicator and tostring(cv.indicatorColor) or "0")) or "-") + .. "|" .. tostring(entry.StartSpot) + .. "|" .. (pos and (tostring(pos[1]) .. ":" .. tostring(pos[2])) or "-") + end + return table.concat(parts, "/") +end + +--- Rebuilds the whole lookup table from the current launch / session / local / scenario state and +--- publishes it — but only when its signature changed (the de-dup). +---@param model UICustomLobbySlotsDerivedModel +local function Recompute(model) + local launch = CustomLobbyLaunchModel.GetSingleton() + local closedSlots = CustomLobbySessionModel.GetSingleton().ClosedSlots() + local benchmarks = CustomLobbyLocalModel.GetSingleton().CpuBenchmarks() + + local scenario = CustomLobbyScenarioDerivedModel.GetScenario() + local spawns = scenario and scenario.Markers and scenario.Markers.Spawns or nil + local cap = CustomLobbyRules.RecommendedUnitCap() + + local slots = {} + for slot = 1, MaxSlots do + slots[slot] = BuildSlot(slot, launch.Players[slot](), closedSlots[slot] and true or false, benchmarks, spawns, cap) + end + + local signature = Signature(slots) + if signature == LoadedSignature then + return + end + LoadedSignature = signature + model.Slots:Set(slots) +end + +--- Allocates a fresh slots-model singleton and subscribes its internal observers to every source that +--- feeds a seat: each slot's player, the closed slots, the CPU benchmarks and the derived scenario +--- (placement + the size-driven unit cap). The observers are pinned on the model so they aren't GC'd, +--- and (because `Derive` fires synchronously on creation) the table resolves immediately. +---@return UICustomLobbySlotsDerivedModel +function SetupSingleton() + ---@type UICustomLobbySlotsDerivedModel + local model = { + Slots = Create({}), + Observers = {}, + } + ModelInstance = model + LoadedSignature = false + + local function subscribe(source) + table.insert(model.Observers, Derive(source, function(lazy) + lazy() + Recompute(model) + end)) + end + + local launch = CustomLobbyLaunchModel.GetSingleton() + for slot = 1, MaxSlots do + subscribe(launch.Players[slot]) + end + subscribe(CustomLobbySessionModel.GetSingleton().ClosedSlots) + subscribe(CustomLobbyLocalModel.GetSingleton().CpuBenchmarks) + subscribe(CustomLobbyScenarioDerivedModel.GetScenarioVar()) + + return model +end + +--- Returns the slots-model singleton, creating (and resolving the current table) on first access. +---@return UICustomLobbySlotsDerivedModel +function GetSingleton() + if not ModelInstance then + SetupSingleton() + end + return ModelInstance --[[@as UICustomLobbySlotsDerivedModel]] +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Accessors + +--- The reactive slots var — subscribe to it (via `Derive`) to react when any seat changes. +---@return LazyVar +function GetSlotsVar() + return GetSingleton().Slots +end + +--- The current lookup table (indexed 1..MaxSlots). +---@return UICustomLobbySlot[] +function GetSlots() + return GetSingleton().Slots() +end + +--- The resolved entry for a single slot (always present, 1..MaxSlots). +---@param slot number +---@return UICustomLobbySlot +function GetSlot(slot) + return GetSingleton().Slots()[slot] +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Debugging + +--- Hot-reload hook: rebuild the singleton so its observers re-subscribe and re-derive. The table is +--- fully derived from the source models, so there is no state to copy across. +---@param newModule any +function __moduleinfo.OnReload(newModule) + if ModelInstance then + newModule.SetupSingleton() + end +end + +--- Hot-reload hook: re-imports this module after a couple of frames. +function __moduleinfo.OnDirty() + ForkThread( + function() + WaitFrames(2) + import(__moduleinfo.name) + end + ) +end + +--#endregion diff --git a/lua/ui/lobby/customlobby/slots/CustomLobbySlotBase.lua b/lua/ui/lobby/customlobby/slots/CustomLobbySlotBase.lua index c6c997e9180..4681e75e930 100644 --- a/lua/ui/lobby/customlobby/slots/CustomLobbySlotBase.lua +++ b/lua/ui/lobby/customlobby/slots/CustomLobbySlotBase.lua @@ -23,8 +23,8 @@ -- The behaviour of a single slot, shared by every presentation (the thin CustomLobbySlotRow and the -- fat CustomLobbySlotCard). It owns *everything except the visible widgets and their layout*: -- --- * the model subscription (`model.Players[slot]`) + the CPU-benchmark subscription, --- * the CPU-cap math (most-played category, +0 unit count, green→red headroom step), +-- * one subscription to the derived slots model (the lookup table that already merged this seat's +-- player / placement / closed flag / CPU benchmark into a ready-to-paint entry), -- * the click / right-click / drag-to-swap gesture handling and the controller intents, -- * the layout-agnostic overlays (background, drop highlight, the full-row click catcher). -- @@ -36,96 +36,30 @@ -- -- A presentation just has to provide the standard named controls — `ColorSwatch`, `Name`, `Faction`, -- `Team`, `Ready`, `Cpu` (Texts) and `CpuIndicator` (Bitmap) — arranged however it likes; the base's --- `RenderPlayer` / `RenderCpu` paint those from the normalised player / CPU views, so the formatting --- (faction label, `T1`, ready/CPU colours, unit string) stays here, in one place. A presentation that --- needs a different mapping (e.g. a faction *icon*) can override `RenderPlayer` / `RenderCpu`. +-- `RenderPlayer` / `RenderCpu` paint those from the entry's resolved player / CPU views. The formatting +-- (faction label, `T1`, ready/CPU colours, unit string, the green→red headroom step) lives in the +-- derived model now — see derived/CustomLobbySlotsDerivedModel.lua — so it is single-sourced and the +-- CPU bar restyles consistently across every seat when the unit cap moves. A presentation that needs a +-- different mapping (e.g. a faction *icon*) can override `RenderPlayer` / `RenderCpu`. -- --- This keeps the drag/CPU/intent logic single-sourced; the presentations are pure arrangement. +-- This keeps the drag/intent logic single-sourced; the presentations are pure arrangement. -local UIUtil = import("/lua/ui/uiutil.lua") local LayoutHelpers = import("/lua/maui/layouthelpers.lua") -local GameColors = import("/lua/gamecolors.lua").GameColors -local Color = import("/lua/shared/color.lua") local Group = import("/lua/maui/group.lua").Group local Bitmap = import("/lua/maui/bitmap.lua").Bitmap local Dragger = import("/lua/maui/dragger.lua").Dragger -local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/customlobbylocalmodel.lua") local CustomLobbyPerformancePopover = import("/lua/ui/lobby/customlobby/customlobbyperformancepopover.lua") local CustomLobbyContextMenu = import("/lua/ui/lobby/customlobby/customlobbycontextmenu.lua") local CustomLobbyMenus = import("/lua/ui/lobby/customlobby/customlobbymenus.lua") -local CustomLobbyRules = import("/lua/ui/lobby/customlobby/customlobbyrules.lua") +local CustomLobbySlotsDerivedModel = import("/lua/ui/lobby/customlobby/derived/customlobbyslotsderivedmodel.lua") local LazyVarDerive = import("/lua/lazyvar.lua").Derive local Layouter = LayoutHelpers.ReusedLayoutFor ---- Short faction label for a faction index (5 = Random has no factions.lua entry). ----@param faction number ----@return string -local function FactionLabel(faction) - local factions = import("/lua/factions.lua").Factions - local data = factions[faction] - if data then - return data.DisplayName or data.Key or tostring(faction) - end - return "Random" -end - ---- The sim-rate categories tracked in PerformanceTrackingV2, ordered for the ---- "most-played" pick (matches the popover). -local PerformanceCategories = { 'Skirmish', 'SkirmishWithAI', 'Campaign' } - ---- The bucket index for a sim rate (index k holds rate k - 11, so +0 -> 11, -4 -> 7). ----@param rate number ----@return number -local function BucketForRate(rate) - return rate + 11 -end - ---- The most-played category in a benchmark, by sample count, or nil if none. ----@param metrics UIPerformanceMetrics | nil ----@return table | nil -local function PickCategory(metrics) - if not metrics then - return nil - end - local best, bestSamples = nil, -1 - for _, key in PerformanceCategories do - local c = metrics[key] - if c and (c.Samples or 0) > bestSamples then - bestSamples = c.Samples or 0 - best = c - end - end - if not best or bestSamples <= 0 then - return nil - end - return best -end - ---- Compact unit count for the slot label (e.g. 1421 -> "1.4k"). ----@param value number ----@return string -local function FormatUnits(value) - if value >= 1000 then - return string.format("%.1fk", value / 1000) - end - return tostring(math.floor(value + 0.5)) -end - ---- Indicator colour for how far the sim has to slow down to sustain the cap: green ---- when +0 already suffices (step 0), fading to red at -4 or worse (step 4). ----@param step number # sim-rate steps below +0 needed to reach the cap (0..4) ----@return Color -local function StepColor(step) - local t = math.clamp(step / 4, 0.0, 1.0) - local hue = (1.0 - t) * 0.333 -- 0.333 turns = green, 0 = red - return Color.ColorHSV(hue, 1.0, 0.85, 1.0) -end - -- cursor travel (screen px) before a press becomes a drag instead of a click local DragThreshold = 5 @@ -163,8 +97,8 @@ local DragThreshold = 5 ---@field Background Bitmap ---@field DropHighlight Bitmap ---@field ClickArea Bitmap ----@field PlayerObserver LazyVar ----@field CpuObserver LazyVar +---@field SlotObserver LazyVar +---@field CurrentEntry UICustomLobbySlot | nil # the seat's last resolved entry (interaction reads it) ---@field CurrentPlayer UICustomLobbyPlayer | false -- the standard named controls a presentation must provide (the base's Render* paint these): ---@field ColorSwatch Bitmap @@ -216,18 +150,13 @@ local CustomLobbySlotBase = Class(Group) { -- the presentation builds its visible widgets (+ the CPU hover zone) self:CreateContents() - local model = CustomLobbyLaunchModel.GetSingleton() - self.PlayerObserver = self.Trash:Add( - LazyVarDerive(model.Players[slotIndex], function(playerLazy) - self:OnPlayerChanged(playerLazy()) - end)) - - self.CpuObserver = self.Trash:Add( - LazyVarDerive(CustomLobbyLocalModel.GetSingleton().CpuBenchmarks, function(benchmarksLazy) - -- read the lazy so the dependency edge (re)forms; the value itself - -- is read from the model inside RefreshCpu - benchmarksLazy() - self:RefreshCpu() + -- one subscription to the derived slots table: it already merged this seat's player, + -- placement, closed flag and CPU benchmark into a resolved entry. The table is rebuilt whole, so + -- this fires for every seat whenever the unit cap (seated count) moves — exactly when a CPU bar + -- needs restyling — not only when *this* seat's player changes. + self.SlotObserver = self.Trash:Add( + LazyVarDerive(CustomLobbySlotsDerivedModel.GetSlotsVar(), function(slotsLazy) + self:OnSlotChanged(slotsLazy()[slotIndex]) end)) end, @@ -302,73 +231,17 @@ local CustomLobbySlotBase = Class(Group) { --#endregion --------------------------------------------------------------------------- - --#region Rendering (computes the view, defers painting to the hooks) + --#region Rendering (paints the resolved entry; the derived model did the formatting) - --- Normalises the slot's player into a view and hands it to the presentation. + --- The seat's resolved entry changed: keep the raw refs interaction needs (the player for + --- click/drag, the whole entry for the CPU popover) and paint the pre-resolved player + CPU views. ---@param self UICustomLobbySlotBase - ---@param player UICustomLobbyPlayer | false - OnPlayerChanged = function(self, player) - self.CurrentPlayer = player - self:RefreshCpu() - - if not player then - self:RenderPlayer(nil) - return - end - - self:RenderPlayer({ - colorHex = GameColors.PlayerColors[player.PlayerColor] or 'ffffffff', - name = player.PlayerName or "?", - nameColor = player.Human and 'ffffffff' or 'ffd9c97a', - faction = FactionLabel(player.Faction), - team = (player.Team and player.Team > 1) and ("T" .. (player.Team - 1)) or "-", - ready = player.Ready and "ready" or "", - readyColor = player.Ready and 'ff7ad97a' or 'ff888888', - }) - end, - - --- Computes the CPU view from this slot player's shared sim-performance benchmark: the label is - --- the max units the machine handled at full speed (+0), and the indicator is green if that - --- already covers the recommended cap, fading to red the further the sim must slow (down to -4). - ---@param self UICustomLobbySlotBase - RefreshCpu = function(self) - local player = self.CurrentPlayer - if not player then - self:RenderCpu(nil) - return - end - - local metrics = CustomLobbyLocalModel.GetSingleton().CpuBenchmarks()[player.OwnerID] - local category = PickCategory(metrics) - local atZero = category and category[BucketForRate(0)] - if not (atZero and atZero.UnitCount) then - self:RenderCpu({ text = "—", textColor = 'ff9aa0a8', showIndicator = false }) - return - end - - local maxAtZero = atZero.UnitCount.Max or 0 - local view = { text = FormatUnits(maxAtZero), textColor = 'ff9aa0a8', showIndicator = false } - - local cap = CustomLobbyRules.RecommendedUnitCap() - if cap and cap > 0 then - -- how many sim-rate steps below +0 are needed before the machine sustains the - -- cap (0 = fine at +0); worst case (red) if even -4 falls short - local step = 0 - if maxAtZero < cap then - step = 4 - for s = 1, 4 do - local bucket = category[BucketForRate(-s)] - if bucket and bucket.UnitCount and (bucket.UnitCount.Max or 0) >= cap then - step = s - break - end - end - end - view.indicatorColor = StepColor(step) - view.showIndicator = true - end - - self:RenderCpu(view) + ---@param entry UICustomLobbySlot + OnSlotChanged = function(self, entry) + self.CurrentEntry = entry + self.CurrentPlayer = entry.Player + self:RenderPlayer(entry.PlayerView or nil) + self:RenderCpu(entry.CpuView or nil) end, --#endregion @@ -403,13 +276,13 @@ local CustomLobbySlotBase = Class(Group) { --- passes the control to anchor the popover to (its CPU label). ---@param self UICustomLobbySlotBase OnCpuHoverEnter = function(self) - local player = self.CurrentPlayer - if not player then + local entry = self.CurrentEntry + if not (entry and entry.Player) then CustomLobbyPerformancePopover.Hide() return end - local benchmark = CustomLobbyLocalModel.GetSingleton().CpuBenchmarks()[player.OwnerID] - CustomLobbyPerformancePopover.Show(self:CpuAnchor(), benchmark, CustomLobbyRules.RecommendedUnitCap()) + -- the entry already carries the owner's raw benchmark + the unit cap the indicator was sized to + CustomLobbyPerformancePopover.Show(self:CpuAnchor(), entry.Benchmark or nil, entry.UnitCap or nil) end, --- The control the performance popover anchors to (the CPU label). Overridable; defaults to the From e58c908b465d708cfc708aac33c925f2eaf5e3ed Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Fri, 26 Jun 2026 13:30:58 +0200 Subject: [PATCH 68/98] Reduce duplicated code --- lua/ui/lobby/customlobby/CLAUDE.md | 4 +- .../customlobby/CustomLobbyTeamScore.lua | 75 +++--------- lua/ui/lobby/customlobby/derived/CLAUDE.md | 2 +- .../derived/CustomLobbySlotsDerivedModel.lua | 107 ++++++++++++++++-- .../twocolumn/CustomLobbyTwoColumnSlots.lua | 32 +++--- 5 files changed, 134 insertions(+), 86 deletions(-) diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index c99bfa75385..4b88d40da3f 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -56,7 +56,7 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | [CustomLobbyMessages.lua](CustomLobbyMessages.lua) | message registry: `AddPlayer`, `SetPlayers` (launch model: players + observers), `SentLaunchInfo` (launch model: scenario / options / mods / teams / spawn mex), `SetSessionState` (session model: slot count / closed slots), `SetReady`, `TakeSlot`, `DisconnectPeer`, `LaunchGame` (host's final game config → `instance:LaunchGame`), `ReportCpuBenchmark`, `SetCpuBenchmarks`. | | [CustomLobbyContextMenu.lua](CustomLobbyContextMenu.lua) | generic framed floating menu; `Show(entries, x, y)` renders any `{label, action, enabled}` list, dismisses on item click / click-outside / Esc. Knows nothing about the lobby. | | [CustomLobbyMenus.lua](CustomLobbyMenus.lua) | declarative menu **definitions**: entry lists with `when(ctx)`/`action(ctx)` filtered by lobby state (`BuildSlotMenu`). Adding/state-gating an item is a one-liner here. | -| [slots/](slots/) | the **slot subsystem** (its own folder, a sub-folder per layout). [`CustomLobbySlotsInterface`](slots/CustomLobbySlotsInterface.lua) is the entry the composition root mounts: a "Players" header over the **active layout body**, picked by the AutoTeams mode — one-column for the non-team modes, the two-column team layout for the binary modes (left/right, top/bottom, even/odd). It observes `GameOptions` and swaps the body when the kind flips (create-on-mode / destroy-on-switch); a change *within* the binary modes is handled by the two-column body's own re-layout. It is the rows' **drag coordinator** (`UICustomLobbySlotCoordinator`: hit-test which row a point is in, drop-highlight, drag ghost → `RequestSwapSlots`) for *every* layout — it alone needs to hit-test across rows — so the layout bodies stay pure build/place/reveal and never duplicate the drag logic; it also exposes `PreferredHeight()` (computed from the mode + model, swap-order-independent) so the root sizes the slot area. The header band carries a **host-only tool strip** (right-aligned: pin seating · auto-balance · reopen closed slots) of small icon buttons (local `SlotTool`, mirroring the config column's `PreviewTool`); the pin button is lit from the synced `SlotsPinned` and the strip is hidden for clients. A **"Locked" notice** (lock glyph + label) sits right of the "Players" label and is shown to **everyone** while `SlotsPinned` is on, so a client can see seating is host-controlled before clicking an open slot to no effect. [`CustomLobbySlotBase`](slots/CustomLobbySlotBase.lua) holds **all slot behaviour** (one subscription to the [slots derived model](derived/CustomLobbySlotsDerivedModel.lua) — which already merged this seat's player / placement / closed / CPU benchmark and did the faction/CPU formatting — click/right-click/drag-to-swap + intents, the background/highlight/click overlays) and the default `RenderPlayer`/`RenderCpu` that paint the standard named controls from the entry's resolved views; a presentation subclasses it and implements just `CreateContents` / `LayoutContents`. [`onecolumn/`](slots/onecolumn/): [`CustomLobbyOneColumnSlots`](slots/onecolumn/CustomLobbyOneColumnSlots.lua) (stacks thin rows, reveals 1..count) + [`CustomLobbySlotRow`](slots/onecolumn/CustomLobbySlotRow.lua) (thin one-line presentation). [`twocolumn/`](slots/twocolumn/): [`CustomLobbyTwoColumnSlots`](slots/twocolumn/CustomLobbyTwoColumnSlots.lua) (splits slots into two team columns via [`CustomLobbyRules.BuildSideResolver`](CustomLobbyRules.lua), with a [`CustomLobbyTeamScore`](CustomLobbyTeamScore.lua) strip across the top as the Left/Right indicator, and the "two columns, unresolved" fallback — parity fill + the score self-hides until a positional map's start positions load) + [`CustomLobbySlotCard`](slots/twocolumn/CustomLobbySlotCard.lua) (fat half-width / two-line presentation; `SetMirrored` flips the right column's cards so the two teams face each other). Each body exports `HeightForCount`. | +| [slots/](slots/) | the **slot subsystem** (its own folder, a sub-folder per layout). [`CustomLobbySlotsInterface`](slots/CustomLobbySlotsInterface.lua) is the entry the composition root mounts: a "Players" header over the **active layout body**, picked by the AutoTeams mode — one-column for the non-team modes, the two-column team layout for the binary modes (left/right, top/bottom, even/odd). It observes `GameOptions` and swaps the body when the kind flips (create-on-mode / destroy-on-switch); a change *within* the binary modes is handled by the two-column body's own re-layout. It is the rows' **drag coordinator** (`UICustomLobbySlotCoordinator`: hit-test which row a point is in, drop-highlight, drag ghost → `RequestSwapSlots`) for *every* layout — it alone needs to hit-test across rows — so the layout bodies stay pure build/place/reveal and never duplicate the drag logic; it also exposes `PreferredHeight()` (computed from the mode + model, swap-order-independent) so the root sizes the slot area. The header band carries a **host-only tool strip** (right-aligned: pin seating · auto-balance · reopen closed slots) of small icon buttons (local `SlotTool`, mirroring the config column's `PreviewTool`); the pin button is lit from the synced `SlotsPinned` and the strip is hidden for clients. A **"Locked" notice** (lock glyph + label) sits right of the "Players" label and is shown to **everyone** while `SlotsPinned` is on, so a client can see seating is host-controlled before clicking an open slot to no effect. [`CustomLobbySlotBase`](slots/CustomLobbySlotBase.lua) holds **all slot behaviour** (one subscription to the [slots derived model](derived/CustomLobbySlotsDerivedModel.lua) — which already merged this seat's player / placement / closed / CPU benchmark and did the faction/CPU formatting — click/right-click/drag-to-swap + intents, the background/highlight/click overlays) and the default `RenderPlayer`/`RenderCpu` that paint the standard named controls from the entry's resolved views; a presentation subclasses it and implements just `CreateContents` / `LayoutContents`. [`onecolumn/`](slots/onecolumn/): [`CustomLobbyOneColumnSlots`](slots/onecolumn/CustomLobbyOneColumnSlots.lua) (stacks thin rows, reveals 1..count) + [`CustomLobbySlotRow`](slots/onecolumn/CustomLobbySlotRow.lua) (thin one-line presentation). [`twocolumn/`](slots/twocolumn/): [`CustomLobbyTwoColumnSlots`](slots/twocolumn/CustomLobbyTwoColumnSlots.lua) (splits slots into two team columns by each seat's `Side` from the [slots derived model](derived/CustomLobbySlotsDerivedModel.lua) — no longer re-resolving the split itself — with a [`CustomLobbyTeamScore`](CustomLobbyTeamScore.lua) strip across the top as the Left/Right indicator, and the "two columns, unresolved" fallback — parity fill + the score self-hides until a positional map's start positions load) + [`CustomLobbySlotCard`](slots/twocolumn/CustomLobbySlotCard.lua) (fat half-width / two-line presentation; `SetMirrored` flips the right column's cards so the two teams face each other). Each body exports `HeightForCount`. | | [CustomLobbyObserversInterface.lua](CustomLobbyObserversInterface.lua) | observer strip; subscribes to the model's `Observers` list and shows the count + names (read-only). | | [CustomLobbyScenarioPreview.lua](CustomLobbyScenarioPreview.lua) | **shared** map-preview *surface*: the scenario's map texture + overlays (start spots, resource/wreck markers, plus a **dummy translucent `WaterMask`** placeholder until a real mask exists) with aspect-correct positioning, texture-leak-safe icon sharing, per-group visibility (`SetOverlayVisible('spawns'\|'resources'\|'wrecks'\|'water', …)`), and three-phase init. A **pure renderer**: `SetScenario(info, markers)` takes already-extracted markers (`UICustomLobbyScenarioMarkers`) and never touches the raw save. Chrome-free; `CustomLobbyMapPreview` wraps it with the frame. Spawn appearance is the owner's via a `CreateSpawnIcon` factory. | | [CustomLobbyMapPreview.lua](CustomLobbyMapPreview.lua) | the map preview **as one whole** — the chrome (glow border on top + dark backdrop, surface inset by `Padding`), the surface, and the faction spawn icon (local `MapPreviewSpawn`). Used by **both** consumers: created `Bound = true` it subscribes to [`CustomLobbyScenarioDerivedModel`](derived/CustomLobbyScenarioDerivedModel.lua) and renders the resolved scenario with per-slot faction spawns (no reload on take/swap, and no reload on a same-map rebroadcast — the model dedups); created unbound (the map-select dialog) it does no model wiring and the owner drives `preview.Surface` directly (numbered-dot spawns), feeding it markers from the catalog's `LoadMarkers`. Exposes `.Surface` for owners to drive / anchor overlays to. | @@ -67,7 +67,7 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | [CustomLobbyInterface.lua](CustomLobbyInterface.lua) | composition root, laid out in **areas** (flip the module `Debug` flag to tint them), sized for the **1024×768** floor. A **one-column** layout (the two-column variant was reverted by community request): a **title bar** (title · background picker) over a **left column** split vertically — the **slots** on top (a single column of rows, up to 16, height tracking the slot count) and the **Chat / Observers** tabs below — beside a fixed-width **right column** (the config component: a pinned map preview + facts line over Options / Mods / Restrictions tabs). A full-width **action bar** at the bottom holds the global actions: **Leave** + status on the left, the host-only **Presets** button (opens the setup-presets dialog — see [presetselect/](presetselect/CLAUDE.md)) + the host-only **Launch** on the right. Reads IsHost (action-bar buttons) + SlotCount (to size the slot area via `CustomLobbySlotsInterface.HeightForSlots`); Leave (Esc handler) + **Become observer** (`RequestMoveToObserver`, in the Observers tab). The slot rows + their drag coordination now live in [`CustomLobbySlotsInterface`](CustomLobbySlotsInterface.lua), which fills the slot area. `OpenDebug()` / hot-reload. | | [CustomLobbyBackgrounds.lua](CustomLobbyBackgrounds.lua) | **background discovery + selection** — pure data/prefs (no models, no network). `Discover()` scans for `*.png` → `{ Path, Name }`: the **custom override** folder `textures/ui/common/lobby/custom-backgrounds` (mounted from `gamedata/custom-lobby-backgrounds` by `init_fafdevelop.lua`) takes precedence — if it holds any image, only those are offered; otherwise the stock `textures/ui/common/lobby/backgrounds` set is used. Holds the per-peer chosen path in a reactive `Selected` LazyVar persisted to prefs (`GetSelectedLazy`/`GetSelected`/`Select`). A purely cosmetic, never-synced local choice (like a skin); defaults to the first image found when nothing is stored. | | [CustomLobbyBackground.lua](CustomLobbyBackground.lua) | the **full-window background surface** — a single `Bitmap` that paints the selected image so it **covers** the whole parent while keeping aspect ratio (scale to the larger axis ratio, centre, crop the overflow at the screen edge), with a solid backdrop fallback when none is chosen / the file can't be read. Subscribes to [`CustomLobbyBackgrounds`](CustomLobbyBackgrounds.lua) itself; `Width`/`Height` are bound to the parent rect so a resize re-covers for free. | -| [CustomLobbyTeamScore.lua](CustomLobbyTeamScore.lua) | the **accumulated team rating** side indicator — `Side A N · M Side B`. Hosted in the strip atop the two-column slot layout (it doubles as the columns' Left/Right header). Shown only for the binary auto-team formations; **hidden** for `none`/`manual` or until a positional map's start positions load. Reads the mode + side split from [`CustomLobbyRules`](CustomLobbyRules.lua) (`AutoTeamMode` / `SideLabels` / `BuildSideResolver`) and the ratings from each slot's `PL`. Reference data; never writes. | +| [CustomLobbyTeamScore.lua](CustomLobbyTeamScore.lua) | the **accumulated team rating** side indicator — `Side A N · M Side B`. Hosted in the strip atop the two-column slot layout (it doubles as the columns' Left/Right header). Shown only for the binary auto-team formations; **hidden** for `none`/`manual` or until a positional map's start positions load. A **single subscription** to the [slots derived model](derived/CustomLobbySlotsDerivedModel.lua)'s `Teams` aggregate (mode / labels / resolved / per-side rating totals — all computed once there); no side/rating logic of its own. Reference data; never writes. | | [CustomLobbyTabs.lua](CustomLobbyTabs.lua) | a **generic tabbed panel** (strip + content; one panel alive, created on select / destroyed on switch). Tabs **divide the strip evenly** across its width. Construct with a `{ Label, Create, Badge?, Action?, Icon?, Compact? }` list + optional `OnSelect`. A tab's optional `Badge` LazyVar drives a grey **count pill** to the right of the label; its optional `Action` (`{ Create, Visible? }`) is a small button the owner builds **inside the tab, left of the label** (e.g. a config gear), whose `Visible` LazyVar hides it (collapsing it from the layout) when it doesn't apply. The action, label and pill are centred together as one cluster; any absent/hidden/empty piece contributes 0 width so the rest re-centres. A tab can instead be **`Compact`** (a fixed narrow width, excluded from the even division — the flexible tabs share what's left) and/or show an **`Icon`** centred instead of its label (an icon-only utility tab); the default active tab is the first non-compact one. The container just mirrors the LazyVars, the owner decides what they mean. Used for the bottom-left (Logs / Chat / Observers) and the config interface's Options / Mods / Restrictions. | | [social/](social/) | the lobby's **bottom-left** column (the `CustomLobbyTabs` content): [`CustomLobbyChatPanel`](social/CustomLobbyChatPanel.lua) (the **Chat** tab — placeholder until the chat slice lands) and [`CustomLobbyObserversPanel`](social/CustomLobbyObserversPanel.lua) (the **Observers** tab — the shared observer list + a host-authoritative **Become observer** button → `RequestMoveToObserver`). Each is a tab content component (`Create(parent)`, created on select / destroyed on switch). The Chat / Observers tabs mirror the config column's shape — a per-tab **config gear** (`CustomLobbyInterface`'s local `GearAction`; both no-ops with a "coming soon" tooltip for now) + a right-side **count pill**: Observers shows the live observer count, Chat a dummy until the chat slice lands. A third **compact, icon-only [`CustomLobbyLogsPanel`](social/CustomLobbyLogsPanel.lua)** sits left of Chat (the **Logs** tab — a live **tail view** of this peer's network traffic from [`CustomLobbyLog`](CustomLobbyLog.lua): the most recent entries that fit, in columns `time · kind · ⚠ · name`, with a malformed/unauthorised message tinted + a ⚠ icon whose tooltip is the failure reason). | | [config/](config/) | the lobby's **right** column. [`CustomLobbyConfigInterface.lua`](config/CustomLobbyConfigInterface.lua) is the column **composition**: a bound square `CustomLobbyMapPreview` **pinned** at the top with a vertical **preview tool strip** to its right (local `PreviewTool` icon buttons — toggles for army/start icons, mass+hydro deposits and the dummy water mask, driving `preview.Surface:SetOverlayVisible`, + a host-only **change-map** config icon at the bottom → the map-select dialog), a name + size/players/version facts line under it, and a [`CustomLobbyTabs`](CustomLobbyTabs.lua) (**Options / Mods / Restrictions**) filling the rest. Each tab carries its own **config gear** (`CustomLobbyTabs`' per-tab `Action`, built by the interface's `GearAction` helper) — a skinned button **inside the tab, left of the label** — that opens that tab's editor: Options → `CustomLobbyOptionSelect`, Mods → `CustomLobbyModSelect`, Restrictions → `CustomLobbyUnitSelect` (see [unitselect/](unitselect/)). The Options + Restrictions gears are **host-only**: their `Visible` LazyVar is the `IsHost` field, so they're **hidden** for clients and the label re-centres; the Mods gear shows for everyone (UI mods are local; the sim portion is host-gated inside the dialog). The interface also owns the tabs' **count badges** as computed LazyVars over the launch model — Options shows the non-default-option count (the options derived model's `NonDefaultCount`), Mods shows `sim / ui` (synced sim mods / this peer's UI-mod prefs), Restrictions shows the active restriction count (preset keys in the launch model's `Restrictions`). All three tab panels are now **read-only**: their per-domain action buttons (open editor / reset / manage mods) are removed — the grid/content fills the whole panel — and the action-bar **Settings** button (now joined by this gear) is the edit entry point. [`CustomLobbyOptionsPanel`](config/CustomLobbyOptionsPanel.lua) (options grouped **Lobby / Scenario / Mods** + hide-defaults toggle; map/mod options gold-flagged with an origin tooltip, option help as a label tooltip — all read from the [options derived model](derived/CustomLobbyOptionsDerivedModel.lua), the panel does no schema gathering), [`CustomLobbyModsPanel`](config/CustomLobbyModsPanel.lua) (enabled mods in **Game / UI** sections, each an **icon + name** row with author/version on hover, from the [mods derived model](derived/CustomLobbyModsDerivedModel.lua)), [`CustomLobbyUnitsPanel`](config/CustomLobbyUnitsPanel.lua) (the **Restrictions** read-only list — each active restriction's **preset icon + name** in taller rows, from the [restrictions derived model](derived/CustomLobbyRestrictionsDerivedModel.lua); the icon's tooltip is the preset description). Each self-subscribes to the model and exposes `Initialize()` + `Create(parent)`. **Parked** (built, but unwired): [`CustomLobbyMapPanel`](config/CustomLobbyMapPanel.lua) (the full Map tab — preview + label/value details + Change-map), now superseded by the pinned preview. Churning a preview is safe here because the lobby shows only **one** current map and the engine caches map textures by name (the texture-leak rule only bites the *map-select dialog* — see [mapselect/CLAUDE.md](mapselect/CLAUDE.md)). | diff --git a/lua/ui/lobby/customlobby/CustomLobbyTeamScore.lua b/lua/ui/lobby/customlobby/CustomLobbyTeamScore.lua index de8c3c94d66..dc7ad325d3d 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyTeamScore.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyTeamScore.lua @@ -32,15 +32,15 @@ -- mirrors how auto-teams actually resolve at launch (start position), so the score reads true to -- the map; the positional modes hide until a map (with start spots) is selected. -- --- Self-subscribes to the model: `GameOptions` (the `AutoTeams` mode), `ScenarioFile` (start --- positions) and every slot's player (ratings). Reference data only — it never writes the model. +-- It reads the resolved split from the slots derived model's **team aggregate** (`GetTeams`): mode, +-- side labels, whether the split is resolved, and the per-side rating totals — all computed once +-- there. So this widget is a single subscription with no logic of its own; it never writes the model. local UIUtil = import("/lua/ui/uiutil.lua") local LayoutHelpers = import("/lua/maui/layouthelpers.lua") local Group = import("/lua/maui/group.lua").Group -local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") -local CustomLobbyRules = import("/lua/ui/lobby/customlobby/customlobbyrules.lua") +local CustomLobbySlotsDerivedModel = import("/lua/ui/lobby/customlobby/derived/customlobbyslotsderivedmodel.lua") local LazyVarDerive = import("/lua/lazyvar.lua").Derive local Layouter = LayoutHelpers.ReusedLayoutFor @@ -53,9 +53,7 @@ local Layouter = LayoutHelpers.ReusedLayoutFor ---@field ScoreB Text ---@field LabelB Text ---@field Ready boolean ----@field GameOptionsObserver LazyVar ----@field ScenarioObserver LazyVar ----@field PlayerObservers LazyVar[] +---@field TeamsObserver LazyVar local CustomLobbyTeamScore = ClassUI(Group) { ---@param self UICustomLobbyTeamScore @@ -80,16 +78,10 @@ local CustomLobbyTeamScore = ClassUI(Group) { self.LabelB:SetColor('ff9aa0a8') self.LabelB:DisableHitTest() - local model = CustomLobbyLaunchModel.GetSingleton() - self.GameOptionsObserver = self.Trash:Add( - LazyVarDerive(model.GameOptions, function(lazy) lazy() self:Refresh() end)) - self.ScenarioObserver = self.Trash:Add( - LazyVarDerive(model.ScenarioFile, function(lazy) lazy() self:Refresh() end)) - self.PlayerObservers = {} - for slot = 1, CustomLobbyLaunchModel.MaxSlots do - self.PlayerObservers[slot] = self.Trash:Add( - LazyVarDerive(model.Players[slot], function(lazy) lazy() self:Refresh() end)) - end + -- one subscription: the slots derived model's team aggregate already resolved the side split + -- and summed the per-side ratings (and re-fires only when those move) + self.TeamsObserver = self.Trash:Add( + LazyVarDerive(CustomLobbySlotsDerivedModel.GetTeamsVar(), function(lazy) lazy() self:Refresh() end)) end, ---@param self UICustomLobbyTeamScore @@ -109,61 +101,26 @@ local CustomLobbyTeamScore = ClassUI(Group) { self:Refresh() end, - --- Recomputes the two side totals for the current auto-team mode, or hides the widget when the + --- Paints the two side totals from the slots model's team aggregate, or hides the widget when the --- mode has no 2-side split (none / manual) or the sides can't be determined yet (no map). ---@param self UICustomLobbyTeamScore Refresh = function(self) if not self.Ready then return end - local mode = CustomLobbyRules.AutoTeamMode() - local labels = CustomLobbyRules.SideLabels(mode) - if not labels then - self:Hide() - return - end - - local totals = self:Totals() - if not totals then + local teams = CustomLobbySlotsDerivedModel.GetTeams() + if not (teams.Labels and teams.Resolved) then self:Hide() return end - self.LabelA:SetText(labels[1]) - self.ScoreA:SetText(tostring(totals[1])) - self.LabelB:SetText(labels[2]) - self.ScoreB:SetText(tostring(totals[2])) + self.LabelA:SetText(teams.Labels[1]) + self.ScoreA:SetText(tostring(teams.Totals[1])) + self.LabelB:SetText(teams.Labels[2]) + self.ScoreB:SetText(tostring(teams.Totals[2])) self:Show() end, - --- The accumulated rating per side `{ a, b }`, or nil if the split can't be determined yet (a - --- positional mode with no map / start positions loaded). Each seated player is attributed by - --- the shared side resolver, keyed on its start spot. - ---@param self UICustomLobbyTeamScore - ---@return number[] | nil - Totals = function(self) - local resolver, resolved = CustomLobbyRules.BuildSideResolver() - if not (resolver and resolved) then - return nil - end - - local model = CustomLobbyLaunchModel.GetSingleton() - local a, b = 0, 0 - for slot = 1, CustomLobbyLaunchModel.MaxSlots do - local player = model.Players[slot]() - if player then - local rating = player.PL or 0 - local side = resolver(player.StartSpot) - if side == 1 then - a = a + rating - elseif side == 2 then - b = b + rating - end - end - end - return { math.floor(a), math.floor(b) } - end, - ---@param self UICustomLobbyTeamScore OnDestroy = function(self) self.Trash:Destroy() diff --git a/lua/ui/lobby/customlobby/derived/CLAUDE.md b/lua/ui/lobby/customlobby/derived/CLAUDE.md index af6f11bff8b..e8dcb02b269 100644 --- a/lua/ui/lobby/customlobby/derived/CLAUDE.md +++ b/lua/ui/lobby/customlobby/derived/CLAUDE.md @@ -38,7 +38,7 @@ layer between the compact synced fields and the views. | [CustomLobbyOptionsDerivedModel.lua](CustomLobbyOptionsDerivedModel.lua) | the `Options` view: options split into **Categories** lobby / scenario / mods, each option **enriched** (label, help, chosen value-key + display, `IsDefault`, origin), plus `NonDefaultCount` | the launch model's `GameOptions` + `GameMods` and the scenario derived model (map file + name); the scenario `_options.lua` schema via the catalog's `LoadOptions`, the lobby/mod schema + value interpretation via [`/lua/ui/optionutil.lua`](/lua/ui/optionutil.lua) | the [`../config/CustomLobbyOptionsPanel`](../config/CustomLobbyOptionsPanel.lua) and the Options tab badge in [`../config/CustomLobbyConfigInterface`](../config/CustomLobbyConfigInterface.lua) | | [CustomLobbyRestrictionsDerivedModel.lua](CustomLobbyRestrictionsDerivedModel.lua) | the `Restrictions` view: each active **preset** key **enriched** with its `Name` / `Icon` / `Tooltip`, plus `Count`. De-duped by set (length + sorted compare). Keys can also be **specific unit ids**, but enriching those (name + icon) is **parked** — see [../TODO.md](../TODO.md) — so a unit id currently shows as its raw id. | the launch model's `Restrictions`, joined with the preset table in [`/lua/ui/lobby/unitsrestrictions.lua`](/lua/ui/lobby/unitsrestrictions.lua) | the [`../config/CustomLobbyUnitsPanel`](../config/CustomLobbyUnitsPanel.lua) (icon + name rows) and the Restrictions tab badge | | [CustomLobbyModsDerivedModel.lua](CustomLobbyModsDerivedModel.lua) | the `Mods` view: enabled mods split into **game** (sim) + **ui** `Groups`, each mod **enriched** (`Name` / `Icon` / `Author` / `Version` / `UiOnly`), plus `GameCount` / `UiCount`. The simplest model — `Mods.AllMods()` is available synchronously, so no disk load. | the launch model's `GameMods` (synced sim mods) + `ModUtilities.GetSelectedUIMods()` (per-peer prefs, re-read on each sim-mod change), joined with `/lua/mods.lua` | the [`../config/CustomLobbyModsPanel`](../config/CustomLobbyModsPanel.lua) (icon + name rows) and the Mods tab badge (`sim / ui`) | -| [CustomLobbySlotsDerivedModel.lua](CustomLobbySlotsDerivedModel.lua) | the `Slots` **lookup table** (one entry per slot, indexed 1..MaxSlots): each seat merges its **player** (name / colour / faction / team / ready, resolved to a ready-to-paint `PlayerView`), its scenario **placement** (`StartSpot` + map `Position`), its **closed** flag, and its **CPU benchmark** (resolved to a `CpuView` — unit count + green→red headroom step — plus the raw `Benchmark` + `UnitCap` for the hover popover). The slot/faction/CPU formatting that used to live in the slot controls now lives here. | the launch model's `Players[slot]`, the session model's `ClosedSlots`, the local model's `CpuBenchmarks` (per-peer, **not launched**), and the scenario derived model (placement + the size-driven unit cap via [`../CustomLobbyRules`](../CustomLobbyRules.lua)) | the [`../slots/CustomLobbySlotBase`](../slots/CustomLobbySlotBase.lua) (the thin row + fat card paint the resolved entry) | +| [CustomLobbySlotsDerivedModel.lua](CustomLobbySlotsDerivedModel.lua) | **two faces** over the seating board. `Slots` — a **lookup table** (one entry per slot, 1..MaxSlots): each seat merges its **player** (resolved `PlayerView`), scenario **placement** (`StartSpot` + map `Position`), **closed** flag, **CPU benchmark** (`CpuView` + raw `Benchmark`/`UnitCap` for the popover), and its binary auto-team **`Side`** (1/2/false). `Teams` — the **aggregate** (`Mode` / `Labels` / `Resolved` / per-side rating `Totals`). The side rule stays in [`../CustomLobbyRules`](../CustomLobbyRules.lua) (`BuildSideResolver`); this model *applies* it once so the two-column layout reads `entry.Side` and the score reads `Teams`. Each face deduped by its **own** signature, so a rating change re-fires `Teams` but not the rows. | the launch model's `Players[slot]` + `GameOptions` (the AutoTeams mode), the session model's `ClosedSlots`, the local model's `CpuBenchmarks` (per-peer, **not launched**), and the scenario derived model (placement + map size, via `CustomLobbyRules`) | the [`../slots/CustomLobbySlotBase`](../slots/CustomLobbySlotBase.lua) (`Slots`), the [`../slots/twocolumn/CustomLobbyTwoColumnSlots`](../slots/twocolumn/CustomLobbyTwoColumnSlots.lua) (`entry.Side`) and [`../CustomLobbyTeamScore`](../CustomLobbyTeamScore.lua) (`Teams`) | The options model also shows a second trait worth copying: it **caches the expensive part**. Gathering the option schema is disk work (the map's `_options.lua` `doscript`, mod option files) and only changes diff --git a/lua/ui/lobby/customlobby/derived/CustomLobbySlotsDerivedModel.lua b/lua/ui/lobby/customlobby/derived/CustomLobbySlotsDerivedModel.lua index 0cb8801249b..cc2eb0ca5e0 100644 --- a/lua/ui/lobby/customlobby/derived/CustomLobbySlotsDerivedModel.lua +++ b/lua/ui/lobby/customlobby/derived/CustomLobbySlotsDerivedModel.lua @@ -37,10 +37,18 @@ -- *where* on the map this seat sits, plus the per-player unit cap the map size implies; -- * the **closed** flag (session model, synced lobby-room state, not launched); -- * the **CPU benchmark** (local model, per-peer, never synced and *not part of the game*) — the --- sim-performance projection the slot shows as a unit count + green→red headroom indicator. +-- sim-performance projection the slot shows as a unit count + green→red headroom indicator; +-- * the **binary auto-team side** (1/2/false) the seat resolves to, applied once from the rule in +-- `CustomLobbyRules` so the two-column layout reads `entry.Side` instead of re-resolving it. -- The slot/faction/CPU formatting that used to live in each slot control now lives here, once, so the -- presentations (the thin row + the fat card) are pure arrangement over a resolved view. -- +-- **Two read faces.** Per-seat `Slots` (the rows/cards) and the board's binary auto-team aggregate +-- `Teams` (the team-score strip — side labels + per-side rating totals). Each is deduped by its own +-- signature, so a player's *rating* change re-fires only `Teams` (a total moved) without re-rendering +-- the rows, and a mode/map change that moves the split re-fires both. The side rule itself stays in +-- `CustomLobbyRules` (the controller will reuse it at launch); this model only *applies* it once. +-- -- **Why one table, not a var per slot.** The CPU indicator's colour depends on the recommended unit -- cap, which depends on the *total seated count* — so a seat filling anywhere restyles every other -- seat's CPU bar. A per-slot var would leave those stale (only the changed slot's var fires); a single @@ -94,11 +102,21 @@ local MaxSlots = CustomLobbyLaunchModel.MaxSlots ---@field Closed boolean # session: seat closed (no army at launch) ---@field StartSpot number | false # the player's chosen start spot ---@field Position table | false # {x, z} of that start spot on the map, or false +---@field Side 1 | 2 | false # binary auto-team side (keyed on start spot, else slot), false when none/unresolved ---@field PlayerView UICustomLobbySlotPlayerView | false ---@field CpuView UICustomLobbySlotCpuView | false ---@field Benchmark UIPerformanceMetrics | false # raw metrics for the owner (the hover popover reads this) ---@field UnitCap number | false # recommended cap used for the indicator (popover reads this) +--- The board's binary auto-team aggregate (the team-score strip reads this). Derived once from +--- `CustomLobbyRules` (the rule's home) over the resolved per-seat `Side`. `Labels` is false unless the +--- mode forms two well-defined sides; `Resolved` is false for a positional mode whose map isn't loaded. +---@class UICustomLobbyTeams +---@field Mode string | false # the binary AutoTeams mode (tvsb/lvsr/pvsi), or false +---@field Labels table | false # the two side labels {a, b}, or false for a non-binary mode +---@field Resolved boolean # whether the split is determined (false: positional mode, no map yet) +---@field Totals table # accumulated rating per side {a, b} + --#endregion ------------------------------------------------------------------------------- @@ -225,8 +243,12 @@ end --#region Derived model --- Reactive derived-slots singleton. **Read-only** — no write helpers; the controller never touches it. +--- Two read faces over the same board: per-seat `Slots` (the row/card paint these) and the binary +--- auto-team aggregate `Teams` (the team-score strip), each deduped independently so a rating-only +--- change re-fires `Teams` but not `Slots`. ---@class UICustomLobbySlotsDerivedModel ---@field Slots LazyVar # the lookup table, indexed 1..MaxSlots +---@field Teams LazyVar # the binary auto-team aggregate (side labels + per-side rating totals) ---@field Observers LazyVar[] # internal: the source subscriptions; pinned so they aren't GC'd ---@type UICustomLobbySlotsDerivedModel | nil @@ -237,6 +259,12 @@ local ModelInstance = nil ---@type string | false local LoadedSignature = false +--- A signature of the currently-published `Teams` aggregate — its own de-dup key, so a rating change +--- re-fires `Teams` (totals moved) without re-rendering the rows, and an option tweak that doesn't move +--- the split re-fires neither face. +---@type string | false +local LoadedTeamsSignature = false + --- Builds one slot entry by joining every model for that seat. ---@param slot number ---@param player UICustomLobbyPlayer | false @@ -244,12 +272,13 @@ local LoadedSignature = false ---@param benchmarks table ---@param spawns table | nil # scenario start-spot -> {x, z} ---@param cap number | nil # recommended unit cap (seated-count × per-player tier) +---@param side 1 | 2 | false # resolved binary auto-team side for this seat ---@return UICustomLobbySlot -local function BuildSlot(slot, player, closed, benchmarks, spawns, cap) +local function BuildSlot(slot, player, closed, benchmarks, spawns, cap, side) if not player then return { Slot = slot, Player = false, Closed = closed, - StartSpot = false, Position = false, + StartSpot = false, Position = false, Side = side, PlayerView = false, CpuView = false, Benchmark = false, UnitCap = false, } end @@ -262,6 +291,7 @@ local function BuildSlot(slot, player, closed, benchmarks, spawns, cap) Closed = closed, StartSpot = startSpot, Position = (spawns and startSpot and spawns[startSpot]) or false, + Side = side, PlayerView = BuildPlayerView(player), CpuView = BuildCpuView(benchmark or nil, cap), Benchmark = benchmark, @@ -288,10 +318,22 @@ local function Signature(slots) .. "|" .. (cv and (cv.text .. cv.textColor .. (cv.showIndicator and tostring(cv.indicatorColor) or "0")) or "-") .. "|" .. tostring(entry.StartSpot) .. "|" .. (pos and (tostring(pos[1]) .. ":" .. tostring(pos[2])) or "-") + .. "|" .. tostring(entry.Side) end return table.concat(parts, "/") end +--- A compact signature of the `Teams` aggregate (mode + labels + resolved + the two totals), so the +--- rebuild only re-publishes `Teams` when the split or a side total actually moved. +---@param teams UICustomLobbyTeams +---@return string +local function TeamsSignature(teams) + return tostring(teams.Mode) + .. "|" .. (teams.Labels and (teams.Labels[1] .. "/" .. teams.Labels[2]) or "-") + .. "|" .. tostring(teams.Resolved) + .. "|" .. tostring(teams.Totals[1]) .. "/" .. tostring(teams.Totals[2]) +end + --- Rebuilds the whole lookup table from the current launch / session / local / scenario state and --- publishes it — but only when its signature changed (the de-dup). ---@param model UICustomLobbySlotsDerivedModel @@ -304,17 +346,48 @@ local function Recompute(model) local spawns = scenario and scenario.Markers and scenario.Markers.Spawns or nil local cap = CustomLobbyRules.RecommendedUnitCap() + -- the binary auto-team split, applied once here (the rule lives in CustomLobbyRules). A seat's side + -- is keyed on its start spot, falling back to the slot index when empty so empty cards still place; + -- `resolved` is false for a positional mode whose map/start spots aren't loaded yet. + local mode = CustomLobbyRules.AutoTeamMode() + local labels = CustomLobbyRules.SideLabels(mode) + local resolver, resolved = CustomLobbyRules.BuildSideResolver() + local slots = {} + local totalA, totalB = 0, 0 for slot = 1, MaxSlots do - slots[slot] = BuildSlot(slot, launch.Players[slot](), closedSlots[slot] and true or false, benchmarks, spawns, cap) + local player = launch.Players[slot]() + local spot = (player and player.StartSpot) or slot + local side = (resolved and resolver and resolver(spot)) or false + slots[slot] = BuildSlot(slot, player, closedSlots[slot] and true or false, benchmarks, spawns, cap, side) + if player then + if side == 1 then + totalA = totalA + (player.PL or 0) + elseif side == 2 then + totalB = totalB + (player.PL or 0) + end + end end + -- per-seat face (rows/cards): re-publish only when a rendered field or a seat's side changed local signature = Signature(slots) - if signature == LoadedSignature then - return + if signature ~= LoadedSignature then + LoadedSignature = signature + model.Slots:Set(slots) + end + + -- aggregate face (team-score strip): re-publish only when the split or a total moved + local teams = { + Mode = mode or false, + Labels = labels or false, + Resolved = resolved, + Totals = { math.floor(totalA), math.floor(totalB) }, + } + local teamsSignature = TeamsSignature(teams) + if teamsSignature ~= LoadedTeamsSignature then + LoadedTeamsSignature = teamsSignature + model.Teams:Set(teams) end - LoadedSignature = signature - model.Slots:Set(slots) end --- Allocates a fresh slots-model singleton and subscribes its internal observers to every source that @@ -326,10 +399,12 @@ function SetupSingleton() ---@type UICustomLobbySlotsDerivedModel local model = { Slots = Create({}), + Teams = Create({ Mode = false, Labels = false, Resolved = false, Totals = { 0, 0 } }), Observers = {}, } ModelInstance = model LoadedSignature = false + LoadedTeamsSignature = false local function subscribe(source) table.insert(model.Observers, Derive(source, function(lazy) @@ -345,6 +420,9 @@ function SetupSingleton() subscribe(CustomLobbySessionModel.GetSingleton().ClosedSlots) subscribe(CustomLobbyLocalModel.GetSingleton().CpuBenchmarks) subscribe(CustomLobbyScenarioDerivedModel.GetScenarioVar()) + -- GameOptions feeds the AutoTeams mode (the side split); the per-face dedup keeps an unrelated + -- option tweak from re-firing either var + subscribe(launch.GameOptions) return model end @@ -382,6 +460,19 @@ function GetSlot(slot) return GetSingleton().Slots()[slot] end +--- The reactive team aggregate var — subscribe to it (via `Derive`) to react when the side split or a +--- side's rating total changes (the team-score strip's single source). +---@return LazyVar +function GetTeamsVar() + return GetSingleton().Teams +end + +--- The current binary auto-team aggregate (mode / labels / resolved / per-side totals). +---@return UICustomLobbyTeams +function GetTeams() + return GetSingleton().Teams() +end + --#endregion ------------------------------------------------------------------------------- diff --git a/lua/ui/lobby/customlobby/slots/twocolumn/CustomLobbyTwoColumnSlots.lua b/lua/ui/lobby/customlobby/slots/twocolumn/CustomLobbyTwoColumnSlots.lua index 7d2f03cf2c7..a5a958055d4 100644 --- a/lua/ui/lobby/customlobby/slots/twocolumn/CustomLobbyTwoColumnSlots.lua +++ b/lua/ui/lobby/customlobby/slots/twocolumn/CustomLobbyTwoColumnSlots.lua @@ -22,8 +22,8 @@ -- The two-column slot layout: the binary AutoTeams modes (left/right, top/bottom, even/odd) split -- the slots into two team columns of half-width, double-height CustomLobbySlotCard cards. Each --- slot's side comes from CustomLobbyRules.BuildSideResolver (start position for the positional --- modes, start-spot parity for pvsi). +-- slot's side is read straight from the slots derived model (`entry.Side`), which resolves it once +-- (via CustomLobbyRules.BuildSideResolver) — this body no longer re-derives the split itself. -- -- The CustomLobbyTeamScore widget sits in a strip across the top — it *is* the side indicator -- (`Left 3150 · 3025 Right`), so it doubles as the columns' header. It self-hides for the @@ -42,7 +42,7 @@ local LayoutHelpers = import("/lua/maui/layouthelpers.lua") local Group = import("/lua/maui/group.lua").Group local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") local CustomLobbySessionModel = import("/lua/ui/lobby/customlobby/customlobbysessionmodel.lua") -local CustomLobbyRules = import("/lua/ui/lobby/customlobby/customlobbyrules.lua") +local CustomLobbySlotsDerivedModel = import("/lua/ui/lobby/customlobby/derived/customlobbyslotsderivedmodel.lua") local CustomLobbyTeamScore = import("/lua/ui/lobby/customlobby/customlobbyteamscore.lua") local CustomLobbySlotCard = import("/lua/ui/lobby/customlobby/slots/twocolumn/customlobbyslotcard.lua") @@ -54,16 +54,18 @@ local CardGap = 2 local ColumnGap = 8 -- the gutter between the two columns local ScoreHeight = 26 -- the team-score strip across the top (the side indicator) ---- Assigns the visible slots (1..count) to the two team columns, in slot order. Sides come from the ---- shared resolver; an unresolvable slot (a positional mode whose positions aren't loaded, or a slot ---- past the map's start spots) falls back to slot-index parity so both columns still populate. +--- Assigns the visible slots (1..count) to the two team columns, in slot order. Each seat's side is +--- read straight from the slots derived model (`entry.Side`, resolved once there); an unresolved seat +--- (a positional mode whose positions aren't loaded, or a slot past the map's start spots) is `false` +--- and falls back to slot-index parity so both columns still populate. ---@param count number ---@return table cols # { [1] = {slots…}, [2] = {slots…} } local function ComputeColumns(count) - local resolver, resolved = CustomLobbyRules.BuildSideResolver() + local slots = CustomLobbySlotsDerivedModel.GetSlots() local cols = { {}, {} } for slot = 1, count do - local side = resolved and resolver and resolver(slot) or nil + local entry = slots[slot] + local side = entry and entry.Side if side ~= 1 and side ~= 2 then side = (math.mod(slot, 2) == 1) and 1 or 2 end @@ -80,8 +82,7 @@ end ---@field Columns Group[] # [1] = side A container, [2] = side B ---@field Ready boolean ---@field SlotCountObserver LazyVar ----@field ScenarioObserver LazyVar ----@field GameOptionsObserver LazyVar +---@field SlotsObserver LazyVar local CustomLobbyTwoColumnSlots = Class(Group) { ---@param self UICustomLobbyTwoColumnSlots @@ -105,12 +106,11 @@ local CustomLobbyTwoColumnSlots = Class(Group) { self.Rows[slot] = CustomLobbySlotCard.Create(self, slot, coordinator) end - -- the side split depends on the mode + the map's start positions, so re-place on any of them - local launch = CustomLobbyLaunchModel.GetSingleton() - self.GameOptionsObserver = self.Trash:Add( - LazyVarDerive(launch.GameOptions, function(lazy) lazy(); self:Relayout() end)) - self.ScenarioObserver = self.Trash:Add( - LazyVarDerive(launch.ScenarioFile, function(lazy) lazy(); self:Relayout() end)) + -- each seat's side is resolved in the slots derived model, so re-place whenever that table + -- changes (a seat's side flips when the mode / map / start positions change); the reveal count + -- is session state, so watch it too + self.SlotsObserver = self.Trash:Add( + LazyVarDerive(CustomLobbySlotsDerivedModel.GetSlotsVar(), function(lazy) lazy(); self:Relayout() end)) self.SlotCountObserver = self.Trash:Add( LazyVarDerive(CustomLobbySessionModel.GetSingleton().SlotCount, function(lazy) lazy() From 871e8e7d399e462181afc8d45957ce3c6eb049bf Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sat, 27 Jun 2026 11:56:50 +0200 Subject: [PATCH 69/98] Make lobby rules pure --- lua/ui/lobby/customlobby/CLAUDE.md | 2 +- .../customlobby/CustomLobbyInterface.lua | 2 +- lua/ui/lobby/customlobby/CustomLobbyRules.lua | 69 +++----- .../derived/CustomLobbySlotsDerivedModel.lua | 16 +- .../design/session-trashbag-teardown.md | 2 +- lua/ui/lobby/customlobby/readme.md | 164 ++++++++++++++++++ .../slots/CustomLobbySlotsInterface.lua | 2 +- 7 files changed, 208 insertions(+), 49 deletions(-) create mode 100644 lua/ui/lobby/customlobby/readme.md diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index 4b88d40da3f..2a1b2f6a09b 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -50,7 +50,7 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | [CustomLobbyInstance.lua](CustomLobbyInstance.lua) | thin `moho.lobby_methods` shell; validates/dispatches traffic, forwards callbacks to the controller. Also feeds [`CustomLobbyLog`](CustomLobbyLog.lua) from its three network choke points (BroadcastData / SendData / DataReceived). | | [CustomLobbyLog.lua](CustomLobbyLog.lua) | the **network traffic log** — a reactive, per-peer, never-synced ring buffer (`Entries` LazyVar, capped) of every message this peer broadcasts / sends / receives, fed by the instance and rendered by the **Logs** tab. Each peer logs only its own traffic, so host and client views differ naturally. Not one of the three models (no game state); a diagnostic feed. | | [CustomLobbyController.lua](CustomLobbyController.lua) | host-authority logic (free functions): seating, `Process*` handlers, intents (`RequestSetReady`, `RequestTakeSlot`, `RequestSwapSlots`, `RequestEject`, `RequestMoveToObserver`, `RequestSetScenario`, `RequestSetGameMods/Options`, `RequestResetGameOptions`, `RequestSetSlotsPinned`, `RequestAutoBalance` (stub), `RequestReopenClosedSlots`, `RequestLaunch`, `RequestSaveSetupPreset`/`RequestLoadSetupPreset` — all keyed by slot/bool/file/name so a chat command can call them too; permission is gated separately), sharing the stored CPU benchmark. **Launch:** `RequestLaunch` (host-only, readiness-validated) → auto-saves the `lastGame` preset (`BuildSetupSnapshot`) → `BuildGameConfiguration` (seed option defaults + scenario, resolve random factions, assign army numbers + push to server, stamp ratings/clan tags, resolve sim mods via `Mods.GetGameMods`) → broadcast `LaunchGame` + `instance:LaunchGame`; clients run `ProcessLaunchGame`. **Presets:** `BuildSetupSnapshot` (read the launch state → serializable **setup-only** snapshot: scenario / options / mods / restrictions) + `ApplySetup` (host-only: write scenario/mods/restrictions, reconcile options to the current schema, one broadcast); players, observers and teams/spawn-mex are not stored (see [presetselect/](presetselect/CLAUDE.md)). | -| [CustomLobbyRules.lua](CustomLobbyRules.lua) | game-rule derivations from lobby state (not view, not networking): `RecommendedUnitCap()` (per-player cap by map size) and `BuildSideResolver()` (positional auto-team split). Both read the resolved map size / start spots from [`CustomLobbyScenarioDerivedModel`](derived/CustomLobbyScenarioDerivedModel.lua) rather than loading the scenario themselves. | +| [CustomLobbyRules.lua](CustomLobbyRules.lua) | the game-rule **pure kernel** (not view, not networking, **reads no models**): `RecommendedUnitCap(seatedCount, maxDimension)` (per-player cap by map size), `AutoTeamMode(gameOptions)` / `SideLabels(mode)` / `BuildSideResolver(mode, scenario)` (binary auto-team split). Every input is passed in, so the same functions serve the reactive [slots derived model](derived/CustomLobbySlotsDerivedModel.lua) (which applies them to publish `Side` / `Teams`) and, later, the host's launch-time team assignment — neither path makes this module read state. | | [CustomLobbyPresets.lua](CustomLobbyPresets.lua) | **named setup presets** (setup-only: scenario / options / mods / restrictions — no players/observers) — pure prefs CRUD (`GetPresets`/`GetPreset`/`SavePreset`/`DeletePreset`/`RenamePreset`) over one key (`customlobby_setup_presets`), an ordered `{ Name, Setup }` array mirroring the mod presets in [`/lua/ui/modutilities.lua`](/lua/ui/modutilities.lua). Holds no models, no network. A reserved `LastGamePresetName` entry is auto-saved at launch (the rehost source). Capturing/applying a setup lives in the controller (`BuildSetupSnapshot`/`ApplySetup`); see [presetselect/](presetselect/CLAUDE.md). | | [CustomLobbySession.lua](CustomLobbySession.lua) | the lobby session's **main trash bag**. The lobby lives in the persistent front-end Lua state, which is *not* reset when the game launches in its own state — so anything left reachable (a running thread, a cache, a singleton) leaks for the whole match. This module owns one session-lifetime `TrashBag` (`GetTrash()`); every lobby-scoped `Destroyable` registers in it, and one `Teardown()` (clean-slate in `CreateLobby`, on leave, on `OnGameLaunched`) frees the lot. Works despite the bag being weak-valued because each singleton is pinned by its own module `Instance` local. **Rollout in progress** — the map catalog is the first resource converted; models / interface / instance follow. | | [CustomLobbyMessages.lua](CustomLobbyMessages.lua) | message registry: `AddPlayer`, `SetPlayers` (launch model: players + observers), `SentLaunchInfo` (launch model: scenario / options / mods / teams / spawn mex), `SetSessionState` (session model: slot count / closed slots), `SetReady`, `TakeSlot`, `DisconnectPeer`, `LaunchGame` (host's final game config → `instance:LaunchGame`), `ReportCpuBenchmark`, `SetCpuBenchmarks`. | diff --git a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua index 65f1b44e05d..2d4a9787d8d 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua @@ -112,7 +112,7 @@ local function GearAction(onOpen, title, body) end -- flip to tint each layout area so the regions are visible while iterating -local Debug = false +local Debug = true -- the lobby content is designed for the 1024x768 floor; the root fills the frame (full-screen -- backdrop) but the content is centered and capped to this size, so it never stretches on a diff --git a/lua/ui/lobby/customlobby/CustomLobbyRules.lua b/lua/ui/lobby/customlobby/CustomLobbyRules.lua index cf989020321..51ac444bfb1 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyRules.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyRules.lua @@ -20,12 +20,12 @@ --** SOFTWARE. --****************************************************************************************************** --- Game-rule derivations from the lobby state — neither view nor host-authority logic. --- Views call these for display hints; the controller may call them at launch. Keep the --- model the source of truth; this layer only derives. - -local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") -local CustomLobbyScenarioDerivedModel = import("/lua/ui/lobby/customlobby/derived/customlobbyscenarioderivedmodel.lua") +-- Game-rule derivations — the pure kernel behind the lobby's display hints and (eventually) the +-- host's launch-time team assignment. These are **pure functions**: every input is passed in (the map +-- size, the seated count, the AutoTeams mode, the resolved scenario), so this module reads no models +-- and holds no state. Callers own reading the state — the slots derived model applies these reactively +-- to publish `Side` / `Teams`, and the controller can apply the same kernel at launch with its own +-- inputs (without depending on a view-facing derived model). The model stays the source of truth. --- Units-per-player tier from the map's largest dimension (51.2 ogrids = 1 km), i.e. --- 250 / 375 / 500 for 5x5 / 10x10 / 20x20-or-larger. Unknown size (0) → the top tier. @@ -40,31 +40,16 @@ local function UnitsPerPlayer(maxDimension) return 500 -- 20x20 or larger / not yet known end ---- Largest dimension (ogrids) of the current scenario, or 0 when no map is set. Read straight from ---- the derived scenario model, which already resolved + cached it (no disk read here). ----@return number -local function CurrentMapDimension() - local scenario = CustomLobbyScenarioDerivedModel.GetScenario() - return scenario and scenario.MaxDimension or 0 -end - ---- The recommended total-unit ceiling for the current map and seated-player count. ---- Returns nil when there are no players to scale by. +--- The recommended total-unit ceiling for `seatedCount` players on a map of `maxDimension` ogrids +--- (0 = unknown → top tier). Returns nil when there are no players to scale by. +---@param seatedCount number +---@param maxDimension number ---@return number | nil -function RecommendedUnitCap() - local model = CustomLobbyLaunchModel.GetSingleton() - - local players = 0 - for slot = 1, CustomLobbyLaunchModel.MaxSlots do - if model.Players[slot]() then - players = players + 1 - end - end - if players < 1 then +function RecommendedUnitCap(seatedCount, maxDimension) + if not seatedCount or seatedCount < 1 then return nil end - - return UnitsPerPlayer(CurrentMapDimension()) * players + return UnitsPerPlayer(maxDimension or 0) * seatedCount end ------------------------------------------------------------------------------- @@ -78,10 +63,11 @@ local BinaryModeLabels = { pvsi = { "Odd", "Even" }, -- by start-spot parity (needs no map) } ---- The current AutoTeams mode when it forms two sides (tvsb / lvsr / pvsi), else nil. +--- The AutoTeams mode in `gameOptions` when it forms two sides (tvsb / lvsr / pvsi), else nil. +---@param gameOptions table | nil ---@return string | nil -function AutoTeamMode() - local mode = (CustomLobbyLaunchModel.GetSingleton().GameOptions() or {}).AutoTeams +function AutoTeamMode(gameOptions) + local mode = (gameOptions or {}).AutoTeams return BinaryModeLabels[mode] and mode or nil end @@ -92,19 +78,20 @@ function SideLabels(mode) return mode and BinaryModeLabels[mode] or nil end ---- Builds a side resolver for the current binary AutoTeams mode: a function `startSpot -> 1|2|nil` ---- (nil = "unresolved", i.e. a positional mode whose map/start positions aren't loaded yet). Returns ---- `resolver, resolved`: ---- * `resolver` is nil only when there is no binary mode; +--- Builds a side resolver for a binary AutoTeams `mode` over a resolved `scenario`: a function +--- `startSpot -> 1|2|nil` (nil = "unresolved", i.e. a positional mode whose map/start positions +--- aren't loaded yet). Returns `resolver, resolved`: +--- * `resolver` is nil only when there is no binary mode (`mode` is nil); --- * `resolved` is false when the mode is positional but positions are unavailable — callers that --- need a definite split (e.g. the team score) hide in that case, while the two-column slot --- layout still renders both columns and just withholds the side labels until it flips true. ---- Positional modes read the start spots from the derived scenario model (already loaded), so a ---- caller resolves all spots cheaply. +--- The caller passes the resolved scenario bundle (e.g. from the scenario derived model, or the one +--- the controller is launching with), so this does no reading itself. +---@param mode string | nil # a binary mode (tvsb/lvsr/pvsi), or nil +---@param scenario UICustomLobbyScenario | false # the resolved scenario (for the positional modes) ---@return (fun(startSpot: number): number | nil) | nil resolver ---@return boolean resolved -function BuildSideResolver() - local mode = AutoTeamMode() +function BuildSideResolver(mode, scenario) if not mode then return nil, false end @@ -116,9 +103,7 @@ function BuildSideResolver() end, true end - -- positional: resolve each spot against the map centre, using the derived scenario's start spots - -- (the model already loaded + extracted them — no disk read here) - local scenario = CustomLobbyScenarioDerivedModel.GetScenario() + -- positional: resolve each spot against the map centre, from the passed scenario's start spots if not (scenario and scenario.Size) then return function(spot) return nil end, false -- mode set, positions unknown → unresolved end diff --git a/lua/ui/lobby/customlobby/derived/CustomLobbySlotsDerivedModel.lua b/lua/ui/lobby/customlobby/derived/CustomLobbySlotsDerivedModel.lua index cc2eb0ca5e0..7a94fdfa7c6 100644 --- a/lua/ui/lobby/customlobby/derived/CustomLobbySlotsDerivedModel.lua +++ b/lua/ui/lobby/customlobby/derived/CustomLobbySlotsDerivedModel.lua @@ -344,14 +344,24 @@ local function Recompute(model) local scenario = CustomLobbyScenarioDerivedModel.GetScenario() local spawns = scenario and scenario.Markers and scenario.Markers.Spawns or nil - local cap = CustomLobbyRules.RecommendedUnitCap() + local maxDimension = scenario and scenario.MaxDimension or 0 + + -- the recommended cap scales by seated count (same for every seat), so count once up front; we feed + -- the inputs to the pure rule (CustomLobbyRules reads no models itself) + local seatedCount = 0 + for slot = 1, MaxSlots do + if launch.Players[slot]() then + seatedCount = seatedCount + 1 + end + end + local cap = CustomLobbyRules.RecommendedUnitCap(seatedCount, maxDimension) -- the binary auto-team split, applied once here (the rule lives in CustomLobbyRules). A seat's side -- is keyed on its start spot, falling back to the slot index when empty so empty cards still place; -- `resolved` is false for a positional mode whose map/start spots aren't loaded yet. - local mode = CustomLobbyRules.AutoTeamMode() + local mode = CustomLobbyRules.AutoTeamMode(launch.GameOptions()) local labels = CustomLobbyRules.SideLabels(mode) - local resolver, resolved = CustomLobbyRules.BuildSideResolver() + local resolver, resolved = CustomLobbyRules.BuildSideResolver(mode, scenario) local slots = {} local totalA, totalB = 0, 0 diff --git a/lua/ui/lobby/customlobby/design/session-trashbag-teardown.md b/lua/ui/lobby/customlobby/design/session-trashbag-teardown.md index 1aea5b6e39d..dcdb893c1ed 100644 --- a/lua/ui/lobby/customlobby/design/session-trashbag-teardown.md +++ b/lua/ui/lobby/customlobby/design/session-trashbag-teardown.md @@ -102,6 +102,6 @@ implementation. `UICustomLobbyMapCatalog : Destroyable`: 1. ✅ Map catalog (done — reference implementation). 2. The three models (low-risk, thin `Destroy`). 3. The mod catalog (mirror the map catalog). -4. `CustomLobbyRules` map-dimension cache. +4. ~~`CustomLobbyRules` map-dimension cache.~~ — N/A: `CustomLobbyRules` is now a **pure, stateless** kernel (all inputs passed in; the cached map-dimension lookup is gone), so it holds nothing to tear down. 5. The interface + performance popover — **after** the ordering decision (#1 above). 6. Track the lobby instance in the session bag (drop the manual `Instance:Destroy()` in lobby.lua). diff --git a/lua/ui/lobby/customlobby/readme.md b/lua/ui/lobby/customlobby/readme.md new file mode 100644 index 00000000000..9e7f8c7aae7 --- /dev/null +++ b/lua/ui/lobby/customlobby/readme.md @@ -0,0 +1,164 @@ + +_Architecture spine (layers overview)_ + +```mermaid +flowchart TD + Entry["lobby.lua (entry)"] + Root["CustomLobbyInterface (root)"] + Net["Instance + Messages (networking)"] + Views["Views: config column · slots · social"] + Dialogs["Editor dialogs (map/mod/option/unit/preset)"] + Derived["Derived models (read-only projections)"] + Catalogs["Catalogs (map/mod/unit reference data)"] + Ctrl["Controller (host authority)"] + Models["Authoritative models (Launch/Session/Local)"] + Rules["Rules (pure kernel)"] + + Entry --> Root + Entry --> Net + Root --> Views + Root --> Dialogs + Root --> Ctrl + Net --> Ctrl + Views --> Derived + Dialogs --> Catalogs + Dialogs --> Ctrl + Derived --> Catalogs + Derived --> Rules + Derived --> Models + Catalogs --> Models + Ctrl --> Models + Ctrl -.->|deferred re-fish| Catalogs +``` + +_2. Config column_ + +```mermaid +flowchart TD + CFG["ConfigInterface"] + MPrev["MapPreview"] + OP["OptionsPanel"] + MP["ModsPanel"] + UP["UnitsPanel"] + MS["MapSelect"] + OS["OptionSelect"] + MdS["ModSelect"] + US["UnitSelect"] + DScen["ScenarioDerivedModel"] + DOpt["OptionsDerivedModel"] + DMods["ModsDerivedModel"] + DRes["RestrictionsDerivedModel"] + + CFG --> MPrev + CFG --> OP + CFG --> MP + CFG --> UP + CFG --> MS + CFG --> OS + CFG --> MdS + CFG --> US + CFG --> DScen + CFG --> DOpt + CFG --> DMods + CFG --> DRes + OP --> DOpt + MP --> DMods + UP --> DRes + MPrev --> DScen +``` + +_3. Slot subsystem_ + +```mermaid +flowchart TD + SI["SlotsInterface"] + OCS["OneColumnSlots"] + TCS["TwoColumnSlots"] + SRow["SlotRow"] + SCard["SlotCard"] + SB["SlotBase"] + TS["TeamScore"] + DSlots["SlotsDerivedModel"] + Rules["Rules"] + Ctrl["Controller"] + + SI --> OCS + SI --> TCS + SI --> Rules + SI --> Ctrl + OCS --> SRow + TCS --> SCard + TCS --> TS + TCS --> DSlots + SRow --> SB + SCard --> SB + SB --> DSlots + SB --> Ctrl + TS --> DSlots +``` + +_Derived layer + its sources_ + +```mermaid +flowchart TD + DScen["ScenarioDerivedModel"] + DOpt["OptionsDerivedModel"] + DMods["ModsDerivedModel"] + DRes["RestrictionsDerivedModel"] + DSlots["SlotsDerivedModel"] + LM["LaunchModel"] + SM["SessionModel"] + LoM["LocalModel"] + Rules["Rules (pure kernel)"] + MCat["MapCatalog"] + + DScen --> LM + DScen --> MCat + DOpt --> LM + DOpt --> DScen + DOpt --> MCat + DMods --> LM + DRes --> LM + DSlots --> LM + DSlots --> SM + DSlots --> LoM + DSlots --> DScen + DSlots --> Rules +``` + +_Networking, controller & intents_ + +```mermaid +flowchart TD + INST["Instance"] + MSG["Messages"] + Ctrl["Controller"] + MS["MapSelect"] + MdS["ModSelect"] + OS["OptionSelect"] + US["UnitSelect"] + PS["PresetSelect"] + Menus["Menus"] + LM["LaunchModel"] + SM["SessionModel"] + LoM["LocalModel"] + SESS["Session (trashbag)"] + PRE["Presets"] + MCat["MapCatalog"] + + INST --> MSG + INST --> Ctrl + MSG --> Ctrl + MS --> Ctrl + MdS --> Ctrl + OS --> Ctrl + US --> Ctrl + PS --> Ctrl + Menus --> Ctrl + Ctrl --> LM + Ctrl --> SM + Ctrl --> LoM + Ctrl --> SESS + Ctrl --> PRE + Ctrl -.->|deferred re-fish| MCat +``` \ No newline at end of file diff --git a/lua/ui/lobby/customlobby/slots/CustomLobbySlotsInterface.lua b/lua/ui/lobby/customlobby/slots/CustomLobbySlotsInterface.lua index a926f5915f2..28b9846dc20 100644 --- a/lua/ui/lobby/customlobby/slots/CustomLobbySlotsInterface.lua +++ b/lua/ui/lobby/customlobby/slots/CustomLobbySlotsInterface.lua @@ -296,7 +296,7 @@ local CustomLobbySlotsInterface = Class(Group) { ---@param self UICustomLobbySlotsInterface ---@return "one" | "two" KindForMode = function(self) - return CustomLobbyRules.AutoTeamMode() and "two" or "one" + return CustomLobbyRules.AutoTeamMode(CustomLobbyLaunchModel.GetSingleton().GameOptions()) and "two" or "one" end, --- (Re)creates the layout body for the current mode, destroying the previous one. Lays it out From 2cdcca3da25692dddded1c0e7d6c49aa3ef0da3d Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sat, 27 Jun 2026 12:30:24 +0200 Subject: [PATCH 70/98] Refactor models (and derivatives) to be in their own folder --- lua/ui/lobby/customlobby/CLAUDE.md | 24 +++++++++---------- .../customlobby/CustomLobbyController.lua | 6 ++--- .../customlobby/CustomLobbyInterface.lua | 6 ++--- .../customlobby/CustomLobbyMapPreview.lua | 4 ++-- lua/ui/lobby/customlobby/CustomLobbyMenus.lua | 4 ++-- .../lobby/customlobby/CustomLobbyMessages.lua | 2 +- .../CustomLobbyObserversInterface.lua | 2 +- .../customlobby/CustomLobbyTeamScore.lua | 2 +- lua/ui/lobby/customlobby/TODO.md | 2 +- .../config/CustomLobbyConfigInterface.lua | 12 +++++----- .../config/CustomLobbyMapPanel.lua | 4 ++-- .../config/CustomLobbyModsPanel.lua | 2 +- .../config/CustomLobbyOptionsPanel.lua | 2 +- .../config/CustomLobbyUnitsPanel.lua | 2 +- .../mapselect/CustomLobbyMapSelect.lua | 2 +- .../{ => models}/CustomLobbyLaunchModel.lua | 0 .../{ => models}/CustomLobbyLocalModel.lua | 0 .../{ => models}/CustomLobbySessionModel.lua | 0 .../{ => models}/derived/CLAUDE.md | 0 .../derived/CustomLobbyModsDerivedModel.lua | 4 ++-- .../CustomLobbyOptionsDerivedModel.lua | 6 ++--- .../CustomLobbyRestrictionsDerivedModel.lua | 4 ++-- .../CustomLobbyScenarioDerivedModel.lua | 4 ++-- .../derived/CustomLobbySlotsDerivedModel.lua | 10 ++++---- .../modselect/CustomLobbyModSelect.lua | 4 ++-- .../optionselect/CustomLobbyOptionSelect.lua | 2 +- .../customlobby/slots/CustomLobbySlotBase.lua | 4 ++-- .../slots/CustomLobbySlotsInterface.lua | 6 ++--- .../onecolumn/CustomLobbyOneColumnSlots.lua | 4 ++-- .../twocolumn/CustomLobbyTwoColumnSlots.lua | 6 ++--- .../social/CustomLobbyObserversPanel.lua | 4 ++-- .../unitselect/CustomLobbyUnitSelect.lua | 4 ++-- lua/ui/lobby/lobby.lua | 6 ++--- 33 files changed, 72 insertions(+), 72 deletions(-) rename lua/ui/lobby/customlobby/{ => models}/CustomLobbyLaunchModel.lua (100%) rename lua/ui/lobby/customlobby/{ => models}/CustomLobbyLocalModel.lua (100%) rename lua/ui/lobby/customlobby/{ => models}/CustomLobbySessionModel.lua (100%) rename lua/ui/lobby/customlobby/{ => models}/derived/CLAUDE.md (100%) rename lua/ui/lobby/customlobby/{ => models}/derived/CustomLobbyModsDerivedModel.lua (98%) rename lua/ui/lobby/customlobby/{ => models}/derived/CustomLobbyOptionsDerivedModel.lua (98%) rename lua/ui/lobby/customlobby/{ => models}/derived/CustomLobbyRestrictionsDerivedModel.lua (98%) rename lua/ui/lobby/customlobby/{ => models}/derived/CustomLobbyScenarioDerivedModel.lua (98%) rename lua/ui/lobby/customlobby/{ => models}/derived/CustomLobbySlotsDerivedModel.lua (98%) diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index 2a1b2f6a09b..4a1ed9b58ff 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -23,17 +23,17 @@ the automated/matchmaker path). State is split by two questions — *is it shared (host-dictated → everyone)?* and *does it get launched (becomes part of the game)?* See the `customlobby-model-choice` skill. -- **[CustomLobbyLaunchModel.lua](CustomLobbyLaunchModel.lua)** — shared **and** launched: +- **[CustomLobbyLaunchModel.lua](models/CustomLobbyLaunchModel.lua)** — shared **and** launched: the launch payload (players, observers, scenario, game options, mods, auto-teams, spawn mex, unit restrictions) + `MaxSlots` + the `UICustomLobbyPlayer` shape. `Players` is an **array of per-slot LazyVars** so one slot's change re-fires only that row. Write helpers (`SetPlayer`, `SetPlayerField`, `AddObserver`, …) keep the copy-then-`Set` discipline. -- **[CustomLobbySessionModel.lua](CustomLobbySessionModel.lua)** — shared but **not** +- **[CustomLobbySessionModel.lua](models/CustomLobbySessionModel.lua)** — shared but **not** launched: lobby-room management (slot count, closed slots, `SlotsPinned`). A closed slot is just empty at launch and slot count is map-derived presentation — neither reaches the scenario. `SlotsPinned` locks seating so the host rejects client slot-takes (only the host may move players), enforced in `ProcessTakeSlot`. -- **[CustomLobbyLocalModel.lua](CustomLobbyLocalModel.lua)** — **per-peer, never synced**: +- **[CustomLobbyLocalModel.lua](models/CustomLobbyLocalModel.lua)** — **per-peer, never synced**: identity (`LocalPeerId` / `HostID` / `IsHost`, set on the connection handshake) + connectivity (CPU benchmarks now; ping later). Broadcasting identity would corrupt the receiver's sense of itself, so it never goes on the wire. @@ -42,24 +42,24 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | File | Role | |------|------| -| [CustomLobbyLaunchModel.lua](CustomLobbyLaunchModel.lua) | shared + launched state — the launch payload (see above). | -| [CustomLobbySessionModel.lua](CustomLobbySessionModel.lua) | shared, lobby-room-only state (slot count, closed slots). | -| [CustomLobbyLocalModel.lua](CustomLobbyLocalModel.lua) | per-peer state, never synced: identity + CPU benchmarks. | -| [derived/](derived/CLAUDE.md) | **derived models** — read-only reactive projections of the authoritative state, the "fishing" layer between the compact synced fields and the views (never written by the controller). The first is [CustomLobbyScenarioDerivedModel](derived/CustomLobbyScenarioDerivedModel.lua): resolves the launch model's compact `ScenarioFile` into a rich `Scenario` bundle — info (for the map texture) + extracted save markers (`Spawns` / `MassPoints` / `HydroPoints` / `Wrecks`, never the raw save) + `MaxDimension` / `ArmyCount` / `Name` / `Size` / `Version`. **Does the fishing once so consumers just read the field they need** (the map preview, the config facts line, `CustomLobbyRules`); an internal observer **dedups by file** so a launch-info rebroadcast of the same map is a no-op (no preview reload). [CustomLobbyOptionsDerivedModel](derived/CustomLobbyOptionsDerivedModel.lua) splits `GameOptions` into lobby / scenario / mods categories and enriches each option (label, help, chosen value, is-default), caching the disk-loaded schema; [CustomLobbyRestrictionsDerivedModel](derived/CustomLobbyRestrictionsDerivedModel.lua) maps the active restriction preset keys to their name / icon / tooltip; [CustomLobbyModsDerivedModel](derived/CustomLobbyModsDerivedModel.lua) splits the enabled mods into game / ui groups enriched with name / icon / author / version; [CustomLobbySlotsDerivedModel](derived/CustomLobbySlotsDerivedModel.lua) is the `Slots` **lookup table** (one entry per slot) merging each seat's player + scenario placement + closed flag + CPU benchmark into a ready-to-paint entry (the slot/faction/CPU formatting moved out of the slot controls into here). See [derived/CLAUDE.md](derived/CLAUDE.md). | +| [CustomLobbyLaunchModel.lua](models/CustomLobbyLaunchModel.lua) | shared + launched state — the launch payload (see above). | +| [CustomLobbySessionModel.lua](models/CustomLobbySessionModel.lua) | shared, lobby-room-only state (slot count, closed slots). | +| [CustomLobbyLocalModel.lua](models/CustomLobbyLocalModel.lua) | per-peer state, never synced: identity + CPU benchmarks. | +| [models/derived/](models/derived/CLAUDE.md) | **derived models** — read-only reactive projections of the authoritative state, the "fishing" layer between the compact synced fields and the views (never written by the controller). The first is [CustomLobbyScenarioDerivedModel](models/derived/CustomLobbyScenarioDerivedModel.lua): resolves the launch model's compact `ScenarioFile` into a rich `Scenario` bundle — info (for the map texture) + extracted save markers (`Spawns` / `MassPoints` / `HydroPoints` / `Wrecks`, never the raw save) + `MaxDimension` / `ArmyCount` / `Name` / `Size` / `Version`. **Does the fishing once so consumers just read the field they need** (the map preview, the config facts line, `CustomLobbyRules`); an internal observer **dedups by file** so a launch-info rebroadcast of the same map is a no-op (no preview reload). [CustomLobbyOptionsDerivedModel](models/derived/CustomLobbyOptionsDerivedModel.lua) splits `GameOptions` into lobby / scenario / mods categories and enriches each option (label, help, chosen value, is-default), caching the disk-loaded schema; [CustomLobbyRestrictionsDerivedModel](models/derived/CustomLobbyRestrictionsDerivedModel.lua) maps the active restriction preset keys to their name / icon / tooltip; [CustomLobbyModsDerivedModel](models/derived/CustomLobbyModsDerivedModel.lua) splits the enabled mods into game / ui groups enriched with name / icon / author / version; [CustomLobbySlotsDerivedModel](models/derived/CustomLobbySlotsDerivedModel.lua) is the `Slots` **lookup table** (one entry per slot) merging each seat's player + scenario placement + closed flag + CPU benchmark into a ready-to-paint entry (the slot/faction/CPU formatting moved out of the slot controls into here). See [derived/CLAUDE.md](models/derived/CLAUDE.md). | | [CustomLobbyPerformancePopover.lua](CustomLobbyPerformancePopover.lua) | hover popover over the CPU column; hand-built bitmap bar chart of a peer's `PerformanceTrackingV2` history, with a yellow recommended-unit-cap line. | | [CustomLobbyInstance.lua](CustomLobbyInstance.lua) | thin `moho.lobby_methods` shell; validates/dispatches traffic, forwards callbacks to the controller. Also feeds [`CustomLobbyLog`](CustomLobbyLog.lua) from its three network choke points (BroadcastData / SendData / DataReceived). | | [CustomLobbyLog.lua](CustomLobbyLog.lua) | the **network traffic log** — a reactive, per-peer, never-synced ring buffer (`Entries` LazyVar, capped) of every message this peer broadcasts / sends / receives, fed by the instance and rendered by the **Logs** tab. Each peer logs only its own traffic, so host and client views differ naturally. Not one of the three models (no game state); a diagnostic feed. | | [CustomLobbyController.lua](CustomLobbyController.lua) | host-authority logic (free functions): seating, `Process*` handlers, intents (`RequestSetReady`, `RequestTakeSlot`, `RequestSwapSlots`, `RequestEject`, `RequestMoveToObserver`, `RequestSetScenario`, `RequestSetGameMods/Options`, `RequestResetGameOptions`, `RequestSetSlotsPinned`, `RequestAutoBalance` (stub), `RequestReopenClosedSlots`, `RequestLaunch`, `RequestSaveSetupPreset`/`RequestLoadSetupPreset` — all keyed by slot/bool/file/name so a chat command can call them too; permission is gated separately), sharing the stored CPU benchmark. **Launch:** `RequestLaunch` (host-only, readiness-validated) → auto-saves the `lastGame` preset (`BuildSetupSnapshot`) → `BuildGameConfiguration` (seed option defaults + scenario, resolve random factions, assign army numbers + push to server, stamp ratings/clan tags, resolve sim mods via `Mods.GetGameMods`) → broadcast `LaunchGame` + `instance:LaunchGame`; clients run `ProcessLaunchGame`. **Presets:** `BuildSetupSnapshot` (read the launch state → serializable **setup-only** snapshot: scenario / options / mods / restrictions) + `ApplySetup` (host-only: write scenario/mods/restrictions, reconcile options to the current schema, one broadcast); players, observers and teams/spawn-mex are not stored (see [presetselect/](presetselect/CLAUDE.md)). | -| [CustomLobbyRules.lua](CustomLobbyRules.lua) | the game-rule **pure kernel** (not view, not networking, **reads no models**): `RecommendedUnitCap(seatedCount, maxDimension)` (per-player cap by map size), `AutoTeamMode(gameOptions)` / `SideLabels(mode)` / `BuildSideResolver(mode, scenario)` (binary auto-team split). Every input is passed in, so the same functions serve the reactive [slots derived model](derived/CustomLobbySlotsDerivedModel.lua) (which applies them to publish `Side` / `Teams`) and, later, the host's launch-time team assignment — neither path makes this module read state. | +| [CustomLobbyRules.lua](CustomLobbyRules.lua) | the game-rule **pure kernel** (not view, not networking, **reads no models**): `RecommendedUnitCap(seatedCount, maxDimension)` (per-player cap by map size), `AutoTeamMode(gameOptions)` / `SideLabels(mode)` / `BuildSideResolver(mode, scenario)` (binary auto-team split). Every input is passed in, so the same functions serve the reactive [slots derived model](models/derived/CustomLobbySlotsDerivedModel.lua) (which applies them to publish `Side` / `Teams`) and, later, the host's launch-time team assignment — neither path makes this module read state. | | [CustomLobbyPresets.lua](CustomLobbyPresets.lua) | **named setup presets** (setup-only: scenario / options / mods / restrictions — no players/observers) — pure prefs CRUD (`GetPresets`/`GetPreset`/`SavePreset`/`DeletePreset`/`RenamePreset`) over one key (`customlobby_setup_presets`), an ordered `{ Name, Setup }` array mirroring the mod presets in [`/lua/ui/modutilities.lua`](/lua/ui/modutilities.lua). Holds no models, no network. A reserved `LastGamePresetName` entry is auto-saved at launch (the rehost source). Capturing/applying a setup lives in the controller (`BuildSetupSnapshot`/`ApplySetup`); see [presetselect/](presetselect/CLAUDE.md). | | [CustomLobbySession.lua](CustomLobbySession.lua) | the lobby session's **main trash bag**. The lobby lives in the persistent front-end Lua state, which is *not* reset when the game launches in its own state — so anything left reachable (a running thread, a cache, a singleton) leaks for the whole match. This module owns one session-lifetime `TrashBag` (`GetTrash()`); every lobby-scoped `Destroyable` registers in it, and one `Teardown()` (clean-slate in `CreateLobby`, on leave, on `OnGameLaunched`) frees the lot. Works despite the bag being weak-valued because each singleton is pinned by its own module `Instance` local. **Rollout in progress** — the map catalog is the first resource converted; models / interface / instance follow. | | [CustomLobbyMessages.lua](CustomLobbyMessages.lua) | message registry: `AddPlayer`, `SetPlayers` (launch model: players + observers), `SentLaunchInfo` (launch model: scenario / options / mods / teams / spawn mex), `SetSessionState` (session model: slot count / closed slots), `SetReady`, `TakeSlot`, `DisconnectPeer`, `LaunchGame` (host's final game config → `instance:LaunchGame`), `ReportCpuBenchmark`, `SetCpuBenchmarks`. | | [CustomLobbyContextMenu.lua](CustomLobbyContextMenu.lua) | generic framed floating menu; `Show(entries, x, y)` renders any `{label, action, enabled}` list, dismisses on item click / click-outside / Esc. Knows nothing about the lobby. | | [CustomLobbyMenus.lua](CustomLobbyMenus.lua) | declarative menu **definitions**: entry lists with `when(ctx)`/`action(ctx)` filtered by lobby state (`BuildSlotMenu`). Adding/state-gating an item is a one-liner here. | -| [slots/](slots/) | the **slot subsystem** (its own folder, a sub-folder per layout). [`CustomLobbySlotsInterface`](slots/CustomLobbySlotsInterface.lua) is the entry the composition root mounts: a "Players" header over the **active layout body**, picked by the AutoTeams mode — one-column for the non-team modes, the two-column team layout for the binary modes (left/right, top/bottom, even/odd). It observes `GameOptions` and swaps the body when the kind flips (create-on-mode / destroy-on-switch); a change *within* the binary modes is handled by the two-column body's own re-layout. It is the rows' **drag coordinator** (`UICustomLobbySlotCoordinator`: hit-test which row a point is in, drop-highlight, drag ghost → `RequestSwapSlots`) for *every* layout — it alone needs to hit-test across rows — so the layout bodies stay pure build/place/reveal and never duplicate the drag logic; it also exposes `PreferredHeight()` (computed from the mode + model, swap-order-independent) so the root sizes the slot area. The header band carries a **host-only tool strip** (right-aligned: pin seating · auto-balance · reopen closed slots) of small icon buttons (local `SlotTool`, mirroring the config column's `PreviewTool`); the pin button is lit from the synced `SlotsPinned` and the strip is hidden for clients. A **"Locked" notice** (lock glyph + label) sits right of the "Players" label and is shown to **everyone** while `SlotsPinned` is on, so a client can see seating is host-controlled before clicking an open slot to no effect. [`CustomLobbySlotBase`](slots/CustomLobbySlotBase.lua) holds **all slot behaviour** (one subscription to the [slots derived model](derived/CustomLobbySlotsDerivedModel.lua) — which already merged this seat's player / placement / closed / CPU benchmark and did the faction/CPU formatting — click/right-click/drag-to-swap + intents, the background/highlight/click overlays) and the default `RenderPlayer`/`RenderCpu` that paint the standard named controls from the entry's resolved views; a presentation subclasses it and implements just `CreateContents` / `LayoutContents`. [`onecolumn/`](slots/onecolumn/): [`CustomLobbyOneColumnSlots`](slots/onecolumn/CustomLobbyOneColumnSlots.lua) (stacks thin rows, reveals 1..count) + [`CustomLobbySlotRow`](slots/onecolumn/CustomLobbySlotRow.lua) (thin one-line presentation). [`twocolumn/`](slots/twocolumn/): [`CustomLobbyTwoColumnSlots`](slots/twocolumn/CustomLobbyTwoColumnSlots.lua) (splits slots into two team columns by each seat's `Side` from the [slots derived model](derived/CustomLobbySlotsDerivedModel.lua) — no longer re-resolving the split itself — with a [`CustomLobbyTeamScore`](CustomLobbyTeamScore.lua) strip across the top as the Left/Right indicator, and the "two columns, unresolved" fallback — parity fill + the score self-hides until a positional map's start positions load) + [`CustomLobbySlotCard`](slots/twocolumn/CustomLobbySlotCard.lua) (fat half-width / two-line presentation; `SetMirrored` flips the right column's cards so the two teams face each other). Each body exports `HeightForCount`. | +| [slots/](slots/) | the **slot subsystem** (its own folder, a sub-folder per layout). [`CustomLobbySlotsInterface`](slots/CustomLobbySlotsInterface.lua) is the entry the composition root mounts: a "Players" header over the **active layout body**, picked by the AutoTeams mode — one-column for the non-team modes, the two-column team layout for the binary modes (left/right, top/bottom, even/odd). It observes `GameOptions` and swaps the body when the kind flips (create-on-mode / destroy-on-switch); a change *within* the binary modes is handled by the two-column body's own re-layout. It is the rows' **drag coordinator** (`UICustomLobbySlotCoordinator`: hit-test which row a point is in, drop-highlight, drag ghost → `RequestSwapSlots`) for *every* layout — it alone needs to hit-test across rows — so the layout bodies stay pure build/place/reveal and never duplicate the drag logic; it also exposes `PreferredHeight()` (computed from the mode + model, swap-order-independent) so the root sizes the slot area. The header band carries a **host-only tool strip** (right-aligned: pin seating · auto-balance · reopen closed slots) of small icon buttons (local `SlotTool`, mirroring the config column's `PreviewTool`); the pin button is lit from the synced `SlotsPinned` and the strip is hidden for clients. A **"Locked" notice** (lock glyph + label) sits right of the "Players" label and is shown to **everyone** while `SlotsPinned` is on, so a client can see seating is host-controlled before clicking an open slot to no effect. [`CustomLobbySlotBase`](slots/CustomLobbySlotBase.lua) holds **all slot behaviour** (one subscription to the [slots derived model](models/derived/CustomLobbySlotsDerivedModel.lua) — which already merged this seat's player / placement / closed / CPU benchmark and did the faction/CPU formatting — click/right-click/drag-to-swap + intents, the background/highlight/click overlays) and the default `RenderPlayer`/`RenderCpu` that paint the standard named controls from the entry's resolved views; a presentation subclasses it and implements just `CreateContents` / `LayoutContents`. [`onecolumn/`](slots/onecolumn/): [`CustomLobbyOneColumnSlots`](slots/onecolumn/CustomLobbyOneColumnSlots.lua) (stacks thin rows, reveals 1..count) + [`CustomLobbySlotRow`](slots/onecolumn/CustomLobbySlotRow.lua) (thin one-line presentation). [`twocolumn/`](slots/twocolumn/): [`CustomLobbyTwoColumnSlots`](slots/twocolumn/CustomLobbyTwoColumnSlots.lua) (splits slots into two team columns by each seat's `Side` from the [slots derived model](models/derived/CustomLobbySlotsDerivedModel.lua) — no longer re-resolving the split itself — with a [`CustomLobbyTeamScore`](CustomLobbyTeamScore.lua) strip across the top as the Left/Right indicator, and the "two columns, unresolved" fallback — parity fill + the score self-hides until a positional map's start positions load) + [`CustomLobbySlotCard`](slots/twocolumn/CustomLobbySlotCard.lua) (fat half-width / two-line presentation; `SetMirrored` flips the right column's cards so the two teams face each other). Each body exports `HeightForCount`. | | [CustomLobbyObserversInterface.lua](CustomLobbyObserversInterface.lua) | observer strip; subscribes to the model's `Observers` list and shows the count + names (read-only). | | [CustomLobbyScenarioPreview.lua](CustomLobbyScenarioPreview.lua) | **shared** map-preview *surface*: the scenario's map texture + overlays (start spots, resource/wreck markers, plus a **dummy translucent `WaterMask`** placeholder until a real mask exists) with aspect-correct positioning, texture-leak-safe icon sharing, per-group visibility (`SetOverlayVisible('spawns'\|'resources'\|'wrecks'\|'water', …)`), and three-phase init. A **pure renderer**: `SetScenario(info, markers)` takes already-extracted markers (`UICustomLobbyScenarioMarkers`) and never touches the raw save. Chrome-free; `CustomLobbyMapPreview` wraps it with the frame. Spawn appearance is the owner's via a `CreateSpawnIcon` factory. | -| [CustomLobbyMapPreview.lua](CustomLobbyMapPreview.lua) | the map preview **as one whole** — the chrome (glow border on top + dark backdrop, surface inset by `Padding`), the surface, and the faction spawn icon (local `MapPreviewSpawn`). Used by **both** consumers: created `Bound = true` it subscribes to [`CustomLobbyScenarioDerivedModel`](derived/CustomLobbyScenarioDerivedModel.lua) and renders the resolved scenario with per-slot faction spawns (no reload on take/swap, and no reload on a same-map rebroadcast — the model dedups); created unbound (the map-select dialog) it does no model wiring and the owner drives `preview.Surface` directly (numbered-dot spawns), feeding it markers from the catalog's `LoadMarkers`. Exposes `.Surface` for owners to drive / anchor overlays to. | +| [CustomLobbyMapPreview.lua](CustomLobbyMapPreview.lua) | the map preview **as one whole** — the chrome (glow border on top + dark backdrop, surface inset by `Padding`), the surface, and the faction spawn icon (local `MapPreviewSpawn`). Used by **both** consumers: created `Bound = true` it subscribes to [`CustomLobbyScenarioDerivedModel`](models/derived/CustomLobbyScenarioDerivedModel.lua) and renders the resolved scenario with per-slot faction spawns (no reload on take/swap, and no reload on a same-map rebroadcast — the model dedups); created unbound (the map-select dialog) it does no model wiring and the owner drives `preview.Surface` directly (numbered-dot spawns), feeding it markers from the catalog's `LoadMarkers`. Exposes `.Surface` for owners to drive / anchor overlays to. | | [mapselect/](mapselect/CLAUDE.md) | the **map-select dialog** + its catalog and list, in their own folder (host-only `Popup`: searchable, filterable scenario list → `RequestSetScenario`). Self-contained sub-MVC; see [mapselect/CLAUDE.md](mapselect/CLAUDE.md) — including the **`MapPreview` texture-leak** writeup that shaped its design. | | [modselect/](modselect/CLAUDE.md) | the **mod-select dialog** + its catalog and list, built to the map-select shape (checkbox list + type filters + detail panel + **presets**). Returns a uid set; the opener routes it — sim mods → `RequestSetGameMods` (synced), UI mods → local prefs — or persists the lot standalone. Mod domain logic lives in [`/lua/ui/modutilities.lua`](/lua/ui/modutilities.lua) (the `maputil.lua` sibling, fronting `/lua/mods.lua`). See [modselect/CLAUDE.md](modselect/CLAUDE.md). | | [presetselect/](presetselect/CLAUDE.md) | the **setup-presets dialog** (host-only `Popup`): a list of named setup snapshots (map / options / mods / restrictions) with **Load / Save / Rename / Delete**, opened by the action-bar **Presets** button. Owns no synced state — Save/Load route through the controller's host-authoritative `RequestSaveSetupPreset` / `RequestLoadSetupPreset` intents; the persistence is [`CustomLobbyPresets`](CustomLobbyPresets.lua). Setup-only — players/observers are **not** part of presets (see presetselect/CLAUDE.md). | @@ -67,10 +67,10 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | [CustomLobbyInterface.lua](CustomLobbyInterface.lua) | composition root, laid out in **areas** (flip the module `Debug` flag to tint them), sized for the **1024×768** floor. A **one-column** layout (the two-column variant was reverted by community request): a **title bar** (title · background picker) over a **left column** split vertically — the **slots** on top (a single column of rows, up to 16, height tracking the slot count) and the **Chat / Observers** tabs below — beside a fixed-width **right column** (the config component: a pinned map preview + facts line over Options / Mods / Restrictions tabs). A full-width **action bar** at the bottom holds the global actions: **Leave** + status on the left, the host-only **Presets** button (opens the setup-presets dialog — see [presetselect/](presetselect/CLAUDE.md)) + the host-only **Launch** on the right. Reads IsHost (action-bar buttons) + SlotCount (to size the slot area via `CustomLobbySlotsInterface.HeightForSlots`); Leave (Esc handler) + **Become observer** (`RequestMoveToObserver`, in the Observers tab). The slot rows + their drag coordination now live in [`CustomLobbySlotsInterface`](CustomLobbySlotsInterface.lua), which fills the slot area. `OpenDebug()` / hot-reload. | | [CustomLobbyBackgrounds.lua](CustomLobbyBackgrounds.lua) | **background discovery + selection** — pure data/prefs (no models, no network). `Discover()` scans for `*.png` → `{ Path, Name }`: the **custom override** folder `textures/ui/common/lobby/custom-backgrounds` (mounted from `gamedata/custom-lobby-backgrounds` by `init_fafdevelop.lua`) takes precedence — if it holds any image, only those are offered; otherwise the stock `textures/ui/common/lobby/backgrounds` set is used. Holds the per-peer chosen path in a reactive `Selected` LazyVar persisted to prefs (`GetSelectedLazy`/`GetSelected`/`Select`). A purely cosmetic, never-synced local choice (like a skin); defaults to the first image found when nothing is stored. | | [CustomLobbyBackground.lua](CustomLobbyBackground.lua) | the **full-window background surface** — a single `Bitmap` that paints the selected image so it **covers** the whole parent while keeping aspect ratio (scale to the larger axis ratio, centre, crop the overflow at the screen edge), with a solid backdrop fallback when none is chosen / the file can't be read. Subscribes to [`CustomLobbyBackgrounds`](CustomLobbyBackgrounds.lua) itself; `Width`/`Height` are bound to the parent rect so a resize re-covers for free. | -| [CustomLobbyTeamScore.lua](CustomLobbyTeamScore.lua) | the **accumulated team rating** side indicator — `Side A N · M Side B`. Hosted in the strip atop the two-column slot layout (it doubles as the columns' Left/Right header). Shown only for the binary auto-team formations; **hidden** for `none`/`manual` or until a positional map's start positions load. A **single subscription** to the [slots derived model](derived/CustomLobbySlotsDerivedModel.lua)'s `Teams` aggregate (mode / labels / resolved / per-side rating totals — all computed once there); no side/rating logic of its own. Reference data; never writes. | +| [CustomLobbyTeamScore.lua](CustomLobbyTeamScore.lua) | the **accumulated team rating** side indicator — `Side A N · M Side B`. Hosted in the strip atop the two-column slot layout (it doubles as the columns' Left/Right header). Shown only for the binary auto-team formations; **hidden** for `none`/`manual` or until a positional map's start positions load. A **single subscription** to the [slots derived model](models/derived/CustomLobbySlotsDerivedModel.lua)'s `Teams` aggregate (mode / labels / resolved / per-side rating totals — all computed once there); no side/rating logic of its own. Reference data; never writes. | | [CustomLobbyTabs.lua](CustomLobbyTabs.lua) | a **generic tabbed panel** (strip + content; one panel alive, created on select / destroyed on switch). Tabs **divide the strip evenly** across its width. Construct with a `{ Label, Create, Badge?, Action?, Icon?, Compact? }` list + optional `OnSelect`. A tab's optional `Badge` LazyVar drives a grey **count pill** to the right of the label; its optional `Action` (`{ Create, Visible? }`) is a small button the owner builds **inside the tab, left of the label** (e.g. a config gear), whose `Visible` LazyVar hides it (collapsing it from the layout) when it doesn't apply. The action, label and pill are centred together as one cluster; any absent/hidden/empty piece contributes 0 width so the rest re-centres. A tab can instead be **`Compact`** (a fixed narrow width, excluded from the even division — the flexible tabs share what's left) and/or show an **`Icon`** centred instead of its label (an icon-only utility tab); the default active tab is the first non-compact one. The container just mirrors the LazyVars, the owner decides what they mean. Used for the bottom-left (Logs / Chat / Observers) and the config interface's Options / Mods / Restrictions. | | [social/](social/) | the lobby's **bottom-left** column (the `CustomLobbyTabs` content): [`CustomLobbyChatPanel`](social/CustomLobbyChatPanel.lua) (the **Chat** tab — placeholder until the chat slice lands) and [`CustomLobbyObserversPanel`](social/CustomLobbyObserversPanel.lua) (the **Observers** tab — the shared observer list + a host-authoritative **Become observer** button → `RequestMoveToObserver`). Each is a tab content component (`Create(parent)`, created on select / destroyed on switch). The Chat / Observers tabs mirror the config column's shape — a per-tab **config gear** (`CustomLobbyInterface`'s local `GearAction`; both no-ops with a "coming soon" tooltip for now) + a right-side **count pill**: Observers shows the live observer count, Chat a dummy until the chat slice lands. A third **compact, icon-only [`CustomLobbyLogsPanel`](social/CustomLobbyLogsPanel.lua)** sits left of Chat (the **Logs** tab — a live **tail view** of this peer's network traffic from [`CustomLobbyLog`](CustomLobbyLog.lua): the most recent entries that fit, in columns `time · kind · ⚠ · name`, with a malformed/unauthorised message tinted + a ⚠ icon whose tooltip is the failure reason). | -| [config/](config/) | the lobby's **right** column. [`CustomLobbyConfigInterface.lua`](config/CustomLobbyConfigInterface.lua) is the column **composition**: a bound square `CustomLobbyMapPreview` **pinned** at the top with a vertical **preview tool strip** to its right (local `PreviewTool` icon buttons — toggles for army/start icons, mass+hydro deposits and the dummy water mask, driving `preview.Surface:SetOverlayVisible`, + a host-only **change-map** config icon at the bottom → the map-select dialog), a name + size/players/version facts line under it, and a [`CustomLobbyTabs`](CustomLobbyTabs.lua) (**Options / Mods / Restrictions**) filling the rest. Each tab carries its own **config gear** (`CustomLobbyTabs`' per-tab `Action`, built by the interface's `GearAction` helper) — a skinned button **inside the tab, left of the label** — that opens that tab's editor: Options → `CustomLobbyOptionSelect`, Mods → `CustomLobbyModSelect`, Restrictions → `CustomLobbyUnitSelect` (see [unitselect/](unitselect/)). The Options + Restrictions gears are **host-only**: their `Visible` LazyVar is the `IsHost` field, so they're **hidden** for clients and the label re-centres; the Mods gear shows for everyone (UI mods are local; the sim portion is host-gated inside the dialog). The interface also owns the tabs' **count badges** as computed LazyVars over the launch model — Options shows the non-default-option count (the options derived model's `NonDefaultCount`), Mods shows `sim / ui` (synced sim mods / this peer's UI-mod prefs), Restrictions shows the active restriction count (preset keys in the launch model's `Restrictions`). All three tab panels are now **read-only**: their per-domain action buttons (open editor / reset / manage mods) are removed — the grid/content fills the whole panel — and the action-bar **Settings** button (now joined by this gear) is the edit entry point. [`CustomLobbyOptionsPanel`](config/CustomLobbyOptionsPanel.lua) (options grouped **Lobby / Scenario / Mods** + hide-defaults toggle; map/mod options gold-flagged with an origin tooltip, option help as a label tooltip — all read from the [options derived model](derived/CustomLobbyOptionsDerivedModel.lua), the panel does no schema gathering), [`CustomLobbyModsPanel`](config/CustomLobbyModsPanel.lua) (enabled mods in **Game / UI** sections, each an **icon + name** row with author/version on hover, from the [mods derived model](derived/CustomLobbyModsDerivedModel.lua)), [`CustomLobbyUnitsPanel`](config/CustomLobbyUnitsPanel.lua) (the **Restrictions** read-only list — each active restriction's **preset icon + name** in taller rows, from the [restrictions derived model](derived/CustomLobbyRestrictionsDerivedModel.lua); the icon's tooltip is the preset description). Each self-subscribes to the model and exposes `Initialize()` + `Create(parent)`. **Parked** (built, but unwired): [`CustomLobbyMapPanel`](config/CustomLobbyMapPanel.lua) (the full Map tab — preview + label/value details + Change-map), now superseded by the pinned preview. Churning a preview is safe here because the lobby shows only **one** current map and the engine caches map textures by name (the texture-leak rule only bites the *map-select dialog* — see [mapselect/CLAUDE.md](mapselect/CLAUDE.md)). | +| [config/](config/) | the lobby's **right** column. [`CustomLobbyConfigInterface.lua`](config/CustomLobbyConfigInterface.lua) is the column **composition**: a bound square `CustomLobbyMapPreview` **pinned** at the top with a vertical **preview tool strip** to its right (local `PreviewTool` icon buttons — toggles for army/start icons, mass+hydro deposits and the dummy water mask, driving `preview.Surface:SetOverlayVisible`, + a host-only **change-map** config icon at the bottom → the map-select dialog), a name + size/players/version facts line under it, and a [`CustomLobbyTabs`](CustomLobbyTabs.lua) (**Options / Mods / Restrictions**) filling the rest. Each tab carries its own **config gear** (`CustomLobbyTabs`' per-tab `Action`, built by the interface's `GearAction` helper) — a skinned button **inside the tab, left of the label** — that opens that tab's editor: Options → `CustomLobbyOptionSelect`, Mods → `CustomLobbyModSelect`, Restrictions → `CustomLobbyUnitSelect` (see [unitselect/](unitselect/)). The Options + Restrictions gears are **host-only**: their `Visible` LazyVar is the `IsHost` field, so they're **hidden** for clients and the label re-centres; the Mods gear shows for everyone (UI mods are local; the sim portion is host-gated inside the dialog). The interface also owns the tabs' **count badges** as computed LazyVars over the launch model — Options shows the non-default-option count (the options derived model's `NonDefaultCount`), Mods shows `sim / ui` (synced sim mods / this peer's UI-mod prefs), Restrictions shows the active restriction count (preset keys in the launch model's `Restrictions`). All three tab panels are now **read-only**: their per-domain action buttons (open editor / reset / manage mods) are removed — the grid/content fills the whole panel — and the action-bar **Settings** button (now joined by this gear) is the edit entry point. [`CustomLobbyOptionsPanel`](config/CustomLobbyOptionsPanel.lua) (options grouped **Lobby / Scenario / Mods** + hide-defaults toggle; map/mod options gold-flagged with an origin tooltip, option help as a label tooltip — all read from the [options derived model](models/derived/CustomLobbyOptionsDerivedModel.lua), the panel does no schema gathering), [`CustomLobbyModsPanel`](config/CustomLobbyModsPanel.lua) (enabled mods in **Game / UI** sections, each an **icon + name** row with author/version on hover, from the [mods derived model](models/derived/CustomLobbyModsDerivedModel.lua)), [`CustomLobbyUnitsPanel`](config/CustomLobbyUnitsPanel.lua) (the **Restrictions** read-only list — each active restriction's **preset icon + name** in taller rows, from the [restrictions derived model](models/derived/CustomLobbyRestrictionsDerivedModel.lua); the icon's tooltip is the preset description). Each self-subscribes to the model and exposes `Initialize()` + `Create(parent)`. **Parked** (built, but unwired): [`CustomLobbyMapPanel`](config/CustomLobbyMapPanel.lua) (the full Map tab — preview + label/value details + Change-map), now superseded by the pinned preview. Churning a preview is safe here because the lobby shows only **one** current map and the engine caches map textures by name (the texture-leak rule only bites the *map-select dialog* — see [mapselect/CLAUDE.md](mapselect/CLAUDE.md)). | | [/lua/ui/lobby/lobby.lua](../lobby.lua) | engine entry wrapper (`CreateLobby`/`HostGame`/`JoinGame`) → CustomLobby. Old lobby preserved at `lobby-old.lua`. | Working today: host + clients see each other (host-authoritative player sync), the diff --git a/lua/ui/lobby/customlobby/CustomLobbyController.lua b/lua/ui/lobby/customlobby/CustomLobbyController.lua index 4c5a5f15493..535ab440422 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyController.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyController.lua @@ -37,9 +37,9 @@ -- -- See /lua/ui/lobby/TARGET_ARCHITECTURE.md § 5. -local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") -local CustomLobbySessionModel = import("/lua/ui/lobby/customlobby/customlobbysessionmodel.lua") -local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/customlobbylocalmodel.lua") +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/models/customlobbylaunchmodel.lua") +local CustomLobbySessionModel = import("/lua/ui/lobby/customlobby/models/customlobbysessionmodel.lua") +local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/models/customlobbylocalmodel.lua") local CustomLobbySession = import("/lua/ui/lobby/customlobby/customlobbysession.lua") local CustomLobbyPresets = import("/lua/ui/lobby/customlobby/customlobbypresets.lua") diff --git a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua index 2d4a9787d8d..57667a1f2d0 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua @@ -61,9 +61,9 @@ local Button = import("/lua/maui/button.lua").Button local Combo = import("/lua/ui/controls/combo.lua").Combo local CustomLobbyBackground = import("/lua/ui/lobby/customlobby/customlobbybackground.lua") local CustomLobbyBackgrounds = import("/lua/ui/lobby/customlobby/customlobbybackgrounds.lua") -local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") -local CustomLobbySessionModel = import("/lua/ui/lobby/customlobby/customlobbysessionmodel.lua") -local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/customlobbylocalmodel.lua") +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/models/customlobbylaunchmodel.lua") +local CustomLobbySessionModel = import("/lua/ui/lobby/customlobby/models/customlobbysessionmodel.lua") +local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/models/customlobbylocalmodel.lua") local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") local CustomLobbySlotsInterface = import("/lua/ui/lobby/customlobby/slots/customlobbyslotsinterface.lua") local CustomLobbyConfigInterface = import("/lua/ui/lobby/customlobby/config/customlobbyconfiginterface.lua") diff --git a/lua/ui/lobby/customlobby/CustomLobbyMapPreview.lua b/lua/ui/lobby/customlobby/CustomLobbyMapPreview.lua index 66014831fbf..e1eb8e316ae 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyMapPreview.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyMapPreview.lua @@ -48,8 +48,8 @@ local LayoutHelpers = import("/lua/maui/layouthelpers.lua") local Group = import("/lua/maui/group.lua").Group local Bitmap = import("/lua/maui/bitmap.lua").Bitmap local CustomLobbyScenarioPreview = import("/lua/ui/lobby/customlobby/customlobbyscenariopreview.lua") -local CustomLobbyScenarioDerivedModel = import("/lua/ui/lobby/customlobby/derived/customlobbyscenarioderivedmodel.lua") -local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") +local CustomLobbyScenarioDerivedModel = import("/lua/ui/lobby/customlobby/models/derived/customlobbyscenarioderivedmodel.lua") +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/models/customlobbylaunchmodel.lua") local LazyVarDerive = import("/lua/lazyvar.lua").Derive local Layouter = LayoutHelpers.ReusedLayoutFor diff --git a/lua/ui/lobby/customlobby/CustomLobbyMenus.lua b/lua/ui/lobby/customlobby/CustomLobbyMenus.lua index 1b010a1171f..6f42e6cddbe 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyMenus.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyMenus.lua @@ -32,8 +32,8 @@ -- -- The result feeds CustomLobbyContextMenu.Show (a list of { label, action, enabled }). -local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") -local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/customlobbylocalmodel.lua") +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/models/customlobbylaunchmodel.lua") +local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/models/customlobbylocalmodel.lua") local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") ------------------------------------------------------------------------------- diff --git a/lua/ui/lobby/customlobby/CustomLobbyMessages.lua b/lua/ui/lobby/customlobby/CustomLobbyMessages.lua index 61b60b1072f..b4910eecbe9 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyMessages.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyMessages.lua @@ -34,7 +34,7 @@ -- handlers (and any future tooling) know its exact shape. local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") -local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/customlobbylocalmodel.lua") +local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/models/customlobbylocalmodel.lua") --- Authorisation check: passes only for the host. ---@param lobby UICustomLobbyInstance diff --git a/lua/ui/lobby/customlobby/CustomLobbyObserversInterface.lua b/lua/ui/lobby/customlobby/CustomLobbyObserversInterface.lua index 369c8d4255a..710dfcd56cc 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyObserversInterface.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyObserversInterface.lua @@ -29,7 +29,7 @@ local UIUtil = import("/lua/ui/uiutil.lua") local LayoutHelpers = import("/lua/maui/layouthelpers.lua") local Group = import("/lua/maui/group.lua").Group -local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/models/customlobbylaunchmodel.lua") local LazyVarDerive = import("/lua/lazyvar.lua").Derive diff --git a/lua/ui/lobby/customlobby/CustomLobbyTeamScore.lua b/lua/ui/lobby/customlobby/CustomLobbyTeamScore.lua index dc7ad325d3d..fced699cd95 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyTeamScore.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyTeamScore.lua @@ -40,7 +40,7 @@ local UIUtil = import("/lua/ui/uiutil.lua") local LayoutHelpers = import("/lua/maui/layouthelpers.lua") local Group = import("/lua/maui/group.lua").Group -local CustomLobbySlotsDerivedModel = import("/lua/ui/lobby/customlobby/derived/customlobbyslotsderivedmodel.lua") +local CustomLobbySlotsDerivedModel = import("/lua/ui/lobby/customlobby/models/derived/customlobbyslotsderivedmodel.lua") local LazyVarDerive = import("/lua/lazyvar.lua").Derive local Layouter = LayoutHelpers.ReusedLayoutFor diff --git a/lua/ui/lobby/customlobby/TODO.md b/lua/ui/lobby/customlobby/TODO.md index 8b81604d854..3c65196b4e9 100644 --- a/lua/ui/lobby/customlobby/TODO.md +++ b/lua/ui/lobby/customlobby/TODO.md @@ -11,7 +11,7 @@ Things deliberately deferred, with enough context to pick them up without re-der The launch model's `Restrictions` is a `string[]` of keys. A key is one of two kinds: - a **preset** key (e.g. `"T3"`, `"AIR"`) — fully resolved by - [derived/CustomLobbyRestrictionsDerivedModel.lua](derived/CustomLobbyRestrictionsDerivedModel.lua) + [models/derived/CustomLobbyRestrictionsDerivedModel.lua](models/derived/CustomLobbyRestrictionsDerivedModel.lua) against [`/lua/ui/lobby/unitsrestrictions.lua`](/lua/ui/lobby/unitsrestrictions.lua) → name / icon / tooltip. - a **specific unit id** (e.g. `"uel0201"`) — **not** enriched yet; it currently shows as its raw id (no icon, no name) via the fallback branch in `EnrichKey`. diff --git a/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua b/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua index 10fc761cd60..e91fc2f2413 100644 --- a/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua +++ b/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua @@ -57,12 +57,12 @@ local CustomLobbyMapSelect = import("/lua/ui/lobby/customlobby/mapselect/customl local CustomLobbyOptionSelect = import("/lua/ui/lobby/customlobby/optionselect/customlobbyoptionselect.lua") local CustomLobbyModSelect = import("/lua/ui/lobby/customlobby/modselect/customlobbymodselect.lua") local CustomLobbyUnitSelect = import("/lua/ui/lobby/customlobby/unitselect/customlobbyunitselect.lua") -local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") -local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/customlobbylocalmodel.lua") -local CustomLobbyScenarioDerivedModel = import("/lua/ui/lobby/customlobby/derived/customlobbyscenarioderivedmodel.lua") -local CustomLobbyOptionsDerivedModel = import("/lua/ui/lobby/customlobby/derived/customlobbyoptionsderivedmodel.lua") -local CustomLobbyRestrictionsDerivedModel = import("/lua/ui/lobby/customlobby/derived/customlobbyrestrictionsderivedmodel.lua") -local CustomLobbyModsDerivedModel = import("/lua/ui/lobby/customlobby/derived/customlobbymodsderivedmodel.lua") +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/models/customlobbylaunchmodel.lua") +local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/models/customlobbylocalmodel.lua") +local CustomLobbyScenarioDerivedModel = import("/lua/ui/lobby/customlobby/models/derived/customlobbyscenarioderivedmodel.lua") +local CustomLobbyOptionsDerivedModel = import("/lua/ui/lobby/customlobby/models/derived/customlobbyoptionsderivedmodel.lua") +local CustomLobbyRestrictionsDerivedModel = import("/lua/ui/lobby/customlobby/models/derived/customlobbyrestrictionsderivedmodel.lua") +local CustomLobbyModsDerivedModel = import("/lua/ui/lobby/customlobby/models/derived/customlobbymodsderivedmodel.lua") local LazyVarCreate = import("/lua/lazyvar.lua").Create local LazyVarDerive = import("/lua/lazyvar.lua").Derive diff --git a/lua/ui/lobby/customlobby/config/CustomLobbyMapPanel.lua b/lua/ui/lobby/customlobby/config/CustomLobbyMapPanel.lua index 852164b4f62..c0e5506e4e8 100644 --- a/lua/ui/lobby/customlobby/config/CustomLobbyMapPanel.lua +++ b/lua/ui/lobby/customlobby/config/CustomLobbyMapPanel.lua @@ -48,8 +48,8 @@ local Tooltip = import("/lua/ui/game/tooltip.lua") local Group = import("/lua/maui/group.lua").Group local Bitmap = import("/lua/maui/bitmap.lua").Bitmap local TextArea = import("/lua/ui/controls/textarea.lua").TextArea -local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") -local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/customlobbylocalmodel.lua") +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/models/customlobbylaunchmodel.lua") +local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/models/customlobbylocalmodel.lua") local CustomLobbyMapCatalog = import("/lua/ui/lobby/customlobby/mapselect/customlobbymapcatalog.lua") local CustomLobbyMapSelect = import("/lua/ui/lobby/customlobby/mapselect/customlobbymapselect.lua") local CustomLobbyMapPreview = import("/lua/ui/lobby/customlobby/customlobbymappreview.lua") diff --git a/lua/ui/lobby/customlobby/config/CustomLobbyModsPanel.lua b/lua/ui/lobby/customlobby/config/CustomLobbyModsPanel.lua index 33f155b6d13..f16ecfdf3fc 100644 --- a/lua/ui/lobby/customlobby/config/CustomLobbyModsPanel.lua +++ b/lua/ui/lobby/customlobby/config/CustomLobbyModsPanel.lua @@ -39,7 +39,7 @@ local Group = import("/lua/maui/group.lua").Group local Bitmap = import("/lua/maui/bitmap.lua").Bitmap local Grid = import("/lua/maui/grid.lua").Grid -local CustomLobbyModsDerivedModel = import("/lua/ui/lobby/customlobby/derived/customlobbymodsderivedmodel.lua") +local CustomLobbyModsDerivedModel = import("/lua/ui/lobby/customlobby/models/derived/customlobbymodsderivedmodel.lua") local LazyVarDerive = import("/lua/lazyvar.lua").Derive local Layouter = LayoutHelpers.ReusedLayoutFor diff --git a/lua/ui/lobby/customlobby/config/CustomLobbyOptionsPanel.lua b/lua/ui/lobby/customlobby/config/CustomLobbyOptionsPanel.lua index 653f8fc9895..195228acfb0 100644 --- a/lua/ui/lobby/customlobby/config/CustomLobbyOptionsPanel.lua +++ b/lua/ui/lobby/customlobby/config/CustomLobbyOptionsPanel.lua @@ -44,7 +44,7 @@ local Group = import("/lua/maui/group.lua").Group local Bitmap = import("/lua/maui/bitmap.lua").Bitmap local Grid = import("/lua/maui/grid.lua").Grid -local CustomLobbyOptionsDerivedModel = import("/lua/ui/lobby/customlobby/derived/customlobbyoptionsderivedmodel.lua") +local CustomLobbyOptionsDerivedModel = import("/lua/ui/lobby/customlobby/models/derived/customlobbyoptionsderivedmodel.lua") local LazyVarDerive = import("/lua/lazyvar.lua").Derive local Layouter = LayoutHelpers.ReusedLayoutFor diff --git a/lua/ui/lobby/customlobby/config/CustomLobbyUnitsPanel.lua b/lua/ui/lobby/customlobby/config/CustomLobbyUnitsPanel.lua index c2a8e7fa225..48ba3244bbd 100644 --- a/lua/ui/lobby/customlobby/config/CustomLobbyUnitsPanel.lua +++ b/lua/ui/lobby/customlobby/config/CustomLobbyUnitsPanel.lua @@ -36,7 +36,7 @@ local Group = import("/lua/maui/group.lua").Group local Bitmap = import("/lua/maui/bitmap.lua").Bitmap local Grid = import("/lua/maui/grid.lua").Grid -local CustomLobbyRestrictionsDerivedModel = import("/lua/ui/lobby/customlobby/derived/customlobbyrestrictionsderivedmodel.lua") +local CustomLobbyRestrictionsDerivedModel = import("/lua/ui/lobby/customlobby/models/derived/customlobbyrestrictionsderivedmodel.lua") local LazyVarDerive = import("/lua/lazyvar.lua").Derive local Layouter = LayoutHelpers.ReusedLayoutFor diff --git a/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapSelect.lua b/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapSelect.lua index 273b5d2c36f..e1f30da8dc4 100644 --- a/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapSelect.lua +++ b/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapSelect.lua @@ -58,7 +58,7 @@ local CustomLobbyMapPreview = import("/lua/ui/lobby/customlobby/customlobbymappr local CustomLobbyMapCatalog = import("/lua/ui/lobby/customlobby/mapselect/customlobbymapcatalog.lua") local CustomLobbyMapList = import("/lua/ui/lobby/customlobby/mapselect/customlobbymaplist.lua") local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") -local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/models/customlobbylaunchmodel.lua") local LazyVarDerive = import("/lua/lazyvar.lua").Derive diff --git a/lua/ui/lobby/customlobby/CustomLobbyLaunchModel.lua b/lua/ui/lobby/customlobby/models/CustomLobbyLaunchModel.lua similarity index 100% rename from lua/ui/lobby/customlobby/CustomLobbyLaunchModel.lua rename to lua/ui/lobby/customlobby/models/CustomLobbyLaunchModel.lua diff --git a/lua/ui/lobby/customlobby/CustomLobbyLocalModel.lua b/lua/ui/lobby/customlobby/models/CustomLobbyLocalModel.lua similarity index 100% rename from lua/ui/lobby/customlobby/CustomLobbyLocalModel.lua rename to lua/ui/lobby/customlobby/models/CustomLobbyLocalModel.lua diff --git a/lua/ui/lobby/customlobby/CustomLobbySessionModel.lua b/lua/ui/lobby/customlobby/models/CustomLobbySessionModel.lua similarity index 100% rename from lua/ui/lobby/customlobby/CustomLobbySessionModel.lua rename to lua/ui/lobby/customlobby/models/CustomLobbySessionModel.lua diff --git a/lua/ui/lobby/customlobby/derived/CLAUDE.md b/lua/ui/lobby/customlobby/models/derived/CLAUDE.md similarity index 100% rename from lua/ui/lobby/customlobby/derived/CLAUDE.md rename to lua/ui/lobby/customlobby/models/derived/CLAUDE.md diff --git a/lua/ui/lobby/customlobby/derived/CustomLobbyModsDerivedModel.lua b/lua/ui/lobby/customlobby/models/derived/CustomLobbyModsDerivedModel.lua similarity index 98% rename from lua/ui/lobby/customlobby/derived/CustomLobbyModsDerivedModel.lua rename to lua/ui/lobby/customlobby/models/derived/CustomLobbyModsDerivedModel.lua index a0d0516716d..1f44872ed75 100644 --- a/lua/ui/lobby/customlobby/derived/CustomLobbyModsDerivedModel.lua +++ b/lua/ui/lobby/customlobby/models/derived/CustomLobbyModsDerivedModel.lua @@ -21,7 +21,7 @@ --****************************************************************************************************** -- ============================================================================================ --- DERIVED MODEL — read-only. See /lua/ui/lobby/customlobby/derived/CLAUDE.md. +-- DERIVED MODEL — read-only. See /lua/ui/lobby/customlobby/models/derived/CLAUDE.md. -- -- A derived model is a pure function of the authoritative models: it resolves a compact synced field -- into a rich, ready-to-read bundle and exposes it reactively. Views read it; the **controller never @@ -46,7 +46,7 @@ local Create = import("/lua/lazyvar.lua").Create local Derive = import("/lua/lazyvar.lua").Derive -local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/models/customlobbylaunchmodel.lua") local ModUtilities = import("/lua/ui/modutilities.lua") local Mods = import("/lua/mods.lua") diff --git a/lua/ui/lobby/customlobby/derived/CustomLobbyOptionsDerivedModel.lua b/lua/ui/lobby/customlobby/models/derived/CustomLobbyOptionsDerivedModel.lua similarity index 98% rename from lua/ui/lobby/customlobby/derived/CustomLobbyOptionsDerivedModel.lua rename to lua/ui/lobby/customlobby/models/derived/CustomLobbyOptionsDerivedModel.lua index 8f1c6b10791..afe3afcc8ec 100644 --- a/lua/ui/lobby/customlobby/derived/CustomLobbyOptionsDerivedModel.lua +++ b/lua/ui/lobby/customlobby/models/derived/CustomLobbyOptionsDerivedModel.lua @@ -21,7 +21,7 @@ --****************************************************************************************************** -- ============================================================================================ --- DERIVED MODEL — read-only. See /lua/ui/lobby/customlobby/derived/CLAUDE.md. +-- DERIVED MODEL — read-only. See /lua/ui/lobby/customlobby/models/derived/CLAUDE.md. -- -- A derived model is a pure function of the authoritative models: it resolves compact synced fields -- into a rich, ready-to-read bundle and exposes it reactively. Views read it; the **controller never @@ -53,8 +53,8 @@ local Create = import("/lua/lazyvar.lua").Create local Derive = import("/lua/lazyvar.lua").Derive -local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") -local CustomLobbyScenarioDerivedModel = import("/lua/ui/lobby/customlobby/derived/customlobbyscenarioderivedmodel.lua") +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/models/customlobbylaunchmodel.lua") +local CustomLobbyScenarioDerivedModel = import("/lua/ui/lobby/customlobby/models/derived/customlobbyscenarioderivedmodel.lua") local CustomLobbyMapCatalog = import("/lua/ui/lobby/customlobby/mapselect/customlobbymapcatalog.lua") local OptionUtil = import("/lua/ui/optionutil.lua") diff --git a/lua/ui/lobby/customlobby/derived/CustomLobbyRestrictionsDerivedModel.lua b/lua/ui/lobby/customlobby/models/derived/CustomLobbyRestrictionsDerivedModel.lua similarity index 98% rename from lua/ui/lobby/customlobby/derived/CustomLobbyRestrictionsDerivedModel.lua rename to lua/ui/lobby/customlobby/models/derived/CustomLobbyRestrictionsDerivedModel.lua index 6f815fcdf74..61326d92a75 100644 --- a/lua/ui/lobby/customlobby/derived/CustomLobbyRestrictionsDerivedModel.lua +++ b/lua/ui/lobby/customlobby/models/derived/CustomLobbyRestrictionsDerivedModel.lua @@ -21,7 +21,7 @@ --****************************************************************************************************** -- ============================================================================================ --- DERIVED MODEL — read-only. See /lua/ui/lobby/customlobby/derived/CLAUDE.md. +-- DERIVED MODEL — read-only. See /lua/ui/lobby/customlobby/models/derived/CLAUDE.md. -- -- A derived model is a pure function of the authoritative models: it resolves a compact synced field -- into a rich, ready-to-read bundle and exposes it reactively. Views read it; the **controller never @@ -43,7 +43,7 @@ local Create = import("/lua/lazyvar.lua").Create local Derive = import("/lua/lazyvar.lua").Derive -local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/models/customlobbylaunchmodel.lua") local UnitsRestrictions = import("/lua/ui/lobby/unitsrestrictions.lua") ------------------------------------------------------------------------------- diff --git a/lua/ui/lobby/customlobby/derived/CustomLobbyScenarioDerivedModel.lua b/lua/ui/lobby/customlobby/models/derived/CustomLobbyScenarioDerivedModel.lua similarity index 98% rename from lua/ui/lobby/customlobby/derived/CustomLobbyScenarioDerivedModel.lua rename to lua/ui/lobby/customlobby/models/derived/CustomLobbyScenarioDerivedModel.lua index c586840c692..1ed66b632bc 100644 --- a/lua/ui/lobby/customlobby/derived/CustomLobbyScenarioDerivedModel.lua +++ b/lua/ui/lobby/customlobby/models/derived/CustomLobbyScenarioDerivedModel.lua @@ -21,7 +21,7 @@ --****************************************************************************************************** -- ============================================================================================ --- DERIVED MODEL — read-only. See /lua/ui/lobby/customlobby/derived/CLAUDE.md. +-- DERIVED MODEL — read-only. See /lua/ui/lobby/customlobby/models/derived/CLAUDE.md. -- -- A derived model is a pure function of the authoritative models: it resolves a compact synced field -- into a rich, ready-to-read bundle and exposes it reactively. Views read it; the **controller never @@ -49,7 +49,7 @@ local Create = import("/lua/lazyvar.lua").Create local Derive = import("/lua/lazyvar.lua").Derive -local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/models/customlobbylaunchmodel.lua") local CustomLobbyMapCatalog = import("/lua/ui/lobby/customlobby/mapselect/customlobbymapcatalog.lua") ------------------------------------------------------------------------------- diff --git a/lua/ui/lobby/customlobby/derived/CustomLobbySlotsDerivedModel.lua b/lua/ui/lobby/customlobby/models/derived/CustomLobbySlotsDerivedModel.lua similarity index 98% rename from lua/ui/lobby/customlobby/derived/CustomLobbySlotsDerivedModel.lua rename to lua/ui/lobby/customlobby/models/derived/CustomLobbySlotsDerivedModel.lua index 7a94fdfa7c6..0be86d33a8a 100644 --- a/lua/ui/lobby/customlobby/derived/CustomLobbySlotsDerivedModel.lua +++ b/lua/ui/lobby/customlobby/models/derived/CustomLobbySlotsDerivedModel.lua @@ -21,7 +21,7 @@ --****************************************************************************************************** -- ============================================================================================ --- DERIVED MODEL — read-only. See /lua/ui/lobby/customlobby/derived/CLAUDE.md. +-- DERIVED MODEL — read-only. See /lua/ui/lobby/customlobby/models/derived/CLAUDE.md. -- -- A derived model is a pure function of the authoritative models: it resolves compact synced fields -- into a rich, ready-to-read bundle and exposes it reactively. Views read it; the **controller never @@ -66,10 +66,10 @@ local GameColors = import("/lua/gamecolors.lua").GameColors local Color = import("/lua/shared/color.lua") local Factions = import("/lua/factions.lua").Factions -local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") -local CustomLobbySessionModel = import("/lua/ui/lobby/customlobby/customlobbysessionmodel.lua") -local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/customlobbylocalmodel.lua") -local CustomLobbyScenarioDerivedModel = import("/lua/ui/lobby/customlobby/derived/customlobbyscenarioderivedmodel.lua") +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/models/customlobbylaunchmodel.lua") +local CustomLobbySessionModel = import("/lua/ui/lobby/customlobby/models/customlobbysessionmodel.lua") +local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/models/customlobbylocalmodel.lua") +local CustomLobbyScenarioDerivedModel = import("/lua/ui/lobby/customlobby/models/derived/customlobbyscenarioderivedmodel.lua") local CustomLobbyRules = import("/lua/ui/lobby/customlobby/customlobbyrules.lua") local MaxSlots = CustomLobbyLaunchModel.MaxSlots diff --git a/lua/ui/lobby/customlobby/modselect/CustomLobbyModSelect.lua b/lua/ui/lobby/customlobby/modselect/CustomLobbyModSelect.lua index 3ae123aeaa2..d3cdaf62d82 100644 --- a/lua/ui/lobby/customlobby/modselect/CustomLobbyModSelect.lua +++ b/lua/ui/lobby/customlobby/modselect/CustomLobbyModSelect.lua @@ -57,8 +57,8 @@ local ModUtilities = import("/lua/ui/modutilities.lua") local CustomLobbyModCatalog = import("/lua/ui/lobby/customlobby/modselect/customlobbymodcatalog.lua") local CustomLobbyModList = import("/lua/ui/lobby/customlobby/modselect/customlobbymodlist.lua") local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") -local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") -local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/customlobbylocalmodel.lua") +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/models/customlobbylaunchmodel.lua") +local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/models/customlobbylocalmodel.lua") local LazyVarDerive = import("/lua/lazyvar.lua").Derive diff --git a/lua/ui/lobby/customlobby/optionselect/CustomLobbyOptionSelect.lua b/lua/ui/lobby/customlobby/optionselect/CustomLobbyOptionSelect.lua index 5e5d10800ce..46b0ad1ce05 100644 --- a/lua/ui/lobby/customlobby/optionselect/CustomLobbyOptionSelect.lua +++ b/lua/ui/lobby/customlobby/optionselect/CustomLobbyOptionSelect.lua @@ -51,7 +51,7 @@ local OptionUtil = import("/lua/ui/optionutil.lua") local CustomLobbyMapCatalog = import("/lua/ui/lobby/customlobby/mapselect/customlobbymapcatalog.lua") local CustomLobbyOptionColumn = import("/lua/ui/lobby/customlobby/optionselect/customlobbyoptioncolumn.lua") local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") -local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/models/customlobbylaunchmodel.lua") local Layouter = LayoutHelpers.ReusedLayoutFor diff --git a/lua/ui/lobby/customlobby/slots/CustomLobbySlotBase.lua b/lua/ui/lobby/customlobby/slots/CustomLobbySlotBase.lua index 4681e75e930..7da0d454d45 100644 --- a/lua/ui/lobby/customlobby/slots/CustomLobbySlotBase.lua +++ b/lua/ui/lobby/customlobby/slots/CustomLobbySlotBase.lua @@ -50,11 +50,11 @@ local Group = import("/lua/maui/group.lua").Group local Bitmap = import("/lua/maui/bitmap.lua").Bitmap local Dragger = import("/lua/maui/dragger.lua").Dragger local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") -local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/customlobbylocalmodel.lua") +local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/models/customlobbylocalmodel.lua") local CustomLobbyPerformancePopover = import("/lua/ui/lobby/customlobby/customlobbyperformancepopover.lua") local CustomLobbyContextMenu = import("/lua/ui/lobby/customlobby/customlobbycontextmenu.lua") local CustomLobbyMenus = import("/lua/ui/lobby/customlobby/customlobbymenus.lua") -local CustomLobbySlotsDerivedModel = import("/lua/ui/lobby/customlobby/derived/customlobbyslotsderivedmodel.lua") +local CustomLobbySlotsDerivedModel = import("/lua/ui/lobby/customlobby/models/derived/customlobbyslotsderivedmodel.lua") local LazyVarDerive = import("/lua/lazyvar.lua").Derive diff --git a/lua/ui/lobby/customlobby/slots/CustomLobbySlotsInterface.lua b/lua/ui/lobby/customlobby/slots/CustomLobbySlotsInterface.lua index 28b9846dc20..d8c46b6209d 100644 --- a/lua/ui/lobby/customlobby/slots/CustomLobbySlotsInterface.lua +++ b/lua/ui/lobby/customlobby/slots/CustomLobbySlotsInterface.lua @@ -40,9 +40,9 @@ local Tooltip = import("/lua/ui/game/tooltip.lua") local Group = import("/lua/maui/group.lua").Group local Bitmap = import("/lua/maui/bitmap.lua").Bitmap -local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") -local CustomLobbySessionModel = import("/lua/ui/lobby/customlobby/customlobbysessionmodel.lua") -local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/customlobbylocalmodel.lua") +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/models/customlobbylaunchmodel.lua") +local CustomLobbySessionModel = import("/lua/ui/lobby/customlobby/models/customlobbysessionmodel.lua") +local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/models/customlobbylocalmodel.lua") local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") local CustomLobbyRules = import("/lua/ui/lobby/customlobby/customlobbyrules.lua") local CustomLobbyOneColumnSlots = import("/lua/ui/lobby/customlobby/slots/onecolumn/customlobbyonecolumnslots.lua") diff --git a/lua/ui/lobby/customlobby/slots/onecolumn/CustomLobbyOneColumnSlots.lua b/lua/ui/lobby/customlobby/slots/onecolumn/CustomLobbyOneColumnSlots.lua index 0d43b164564..a614671109f 100644 --- a/lua/ui/lobby/customlobby/slots/onecolumn/CustomLobbyOneColumnSlots.lua +++ b/lua/ui/lobby/customlobby/slots/onecolumn/CustomLobbyOneColumnSlots.lua @@ -32,8 +32,8 @@ local LayoutHelpers = import("/lua/maui/layouthelpers.lua") local Group = import("/lua/maui/group.lua").Group -local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") -local CustomLobbySessionModel = import("/lua/ui/lobby/customlobby/customlobbysessionmodel.lua") +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/models/customlobbylaunchmodel.lua") +local CustomLobbySessionModel = import("/lua/ui/lobby/customlobby/models/customlobbysessionmodel.lua") local CustomLobbySlotRow = import("/lua/ui/lobby/customlobby/slots/onecolumn/customlobbyslotrow.lua") local LazyVarDerive = import("/lua/lazyvar.lua").Derive diff --git a/lua/ui/lobby/customlobby/slots/twocolumn/CustomLobbyTwoColumnSlots.lua b/lua/ui/lobby/customlobby/slots/twocolumn/CustomLobbyTwoColumnSlots.lua index a5a958055d4..6be05480186 100644 --- a/lua/ui/lobby/customlobby/slots/twocolumn/CustomLobbyTwoColumnSlots.lua +++ b/lua/ui/lobby/customlobby/slots/twocolumn/CustomLobbyTwoColumnSlots.lua @@ -40,9 +40,9 @@ local LayoutHelpers = import("/lua/maui/layouthelpers.lua") local Group = import("/lua/maui/group.lua").Group -local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") -local CustomLobbySessionModel = import("/lua/ui/lobby/customlobby/customlobbysessionmodel.lua") -local CustomLobbySlotsDerivedModel = import("/lua/ui/lobby/customlobby/derived/customlobbyslotsderivedmodel.lua") +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/models/customlobbylaunchmodel.lua") +local CustomLobbySessionModel = import("/lua/ui/lobby/customlobby/models/customlobbysessionmodel.lua") +local CustomLobbySlotsDerivedModel = import("/lua/ui/lobby/customlobby/models/derived/customlobbyslotsderivedmodel.lua") local CustomLobbyTeamScore = import("/lua/ui/lobby/customlobby/customlobbyteamscore.lua") local CustomLobbySlotCard = import("/lua/ui/lobby/customlobby/slots/twocolumn/customlobbyslotcard.lua") diff --git a/lua/ui/lobby/customlobby/social/CustomLobbyObserversPanel.lua b/lua/ui/lobby/customlobby/social/CustomLobbyObserversPanel.lua index 1efb6da42ed..e2c6df8899e 100644 --- a/lua/ui/lobby/customlobby/social/CustomLobbyObserversPanel.lua +++ b/lua/ui/lobby/customlobby/social/CustomLobbyObserversPanel.lua @@ -35,8 +35,8 @@ local Tooltip = import("/lua/ui/game/tooltip.lua") local Group = import("/lua/maui/group.lua").Group local CustomLobbyObserversInterface = import("/lua/ui/lobby/customlobby/customlobbyobserversinterface.lua") local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") -local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") -local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/customlobbylocalmodel.lua") +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/models/customlobbylaunchmodel.lua") +local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/models/customlobbylocalmodel.lua") local Layouter = LayoutHelpers.ReusedLayoutFor diff --git a/lua/ui/lobby/customlobby/unitselect/CustomLobbyUnitSelect.lua b/lua/ui/lobby/customlobby/unitselect/CustomLobbyUnitSelect.lua index 64ab3256df5..6a1c0301d2b 100644 --- a/lua/ui/lobby/customlobby/unitselect/CustomLobbyUnitSelect.lua +++ b/lua/ui/lobby/customlobby/unitselect/CustomLobbyUnitSelect.lua @@ -52,8 +52,8 @@ local UnitsAnalyzer = import("/lua/ui/lobby/unitsanalyzer.lua") local UnitsTooltip = import("/lua/ui/lobby/unitstooltip.lua") local CustomLobbyUnitCatalog = import("/lua/ui/lobby/customlobby/unitselect/customlobbyunitcatalog.lua") local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") -local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") -local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/customlobbylocalmodel.lua") +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/models/customlobbylaunchmodel.lua") +local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/models/customlobbylocalmodel.lua") local LazyVarDerive = import("/lua/lazyvar.lua").Derive local Layouter = LayoutHelpers.ReusedLayoutFor diff --git a/lua/ui/lobby/lobby.lua b/lua/ui/lobby/lobby.lua index 93be461a882..0c7bff3b558 100644 --- a/lua/ui/lobby/lobby.lua +++ b/lua/ui/lobby/lobby.lua @@ -35,9 +35,9 @@ local MenuCommon = import("/lua/ui/menus/menucommon.lua") local EscapeHandler = import("/lua/ui/dialogs/eschandler.lua") -local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") -local CustomLobbySessionModel = import("/lua/ui/lobby/customlobby/customlobbysessionmodel.lua") -local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/customlobbylocalmodel.lua") +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/models/customlobbylaunchmodel.lua") +local CustomLobbySessionModel = import("/lua/ui/lobby/customlobby/models/customlobbysessionmodel.lua") +local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/models/customlobbylocalmodel.lua") local CustomLobbyInterface = import("/lua/ui/lobby/customlobby/customlobbyinterface.lua") local CustomLobbySession = import("/lua/ui/lobby/customlobby/customlobbysession.lua") local CustomLobbyLog = import("/lua/ui/lobby/customlobby/customlobbylog.lua") From 5973efaaf6b7ddf1c380ed843b2720f0d42c869f Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sat, 27 Jun 2026 13:24:56 +0200 Subject: [PATCH 71/98] Refactor the derived state to implement Destroyable --- lua/ui/lobby/customlobby/CLAUDE.md | 2 +- .../design/session-trashbag-teardown.md | 18 ++- .../customlobby/models/derived/CLAUDE.md | 14 +- .../CustomLobbyScenarioDerivedModel.lua | 133 +++++++++++------- 4 files changed, 104 insertions(+), 63 deletions(-) diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index 4a1ed9b58ff..dfb159670da 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -45,7 +45,7 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | [CustomLobbyLaunchModel.lua](models/CustomLobbyLaunchModel.lua) | shared + launched state — the launch payload (see above). | | [CustomLobbySessionModel.lua](models/CustomLobbySessionModel.lua) | shared, lobby-room-only state (slot count, closed slots). | | [CustomLobbyLocalModel.lua](models/CustomLobbyLocalModel.lua) | per-peer state, never synced: identity + CPU benchmarks. | -| [models/derived/](models/derived/CLAUDE.md) | **derived models** — read-only reactive projections of the authoritative state, the "fishing" layer between the compact synced fields and the views (never written by the controller). The first is [CustomLobbyScenarioDerivedModel](models/derived/CustomLobbyScenarioDerivedModel.lua): resolves the launch model's compact `ScenarioFile` into a rich `Scenario` bundle — info (for the map texture) + extracted save markers (`Spawns` / `MassPoints` / `HydroPoints` / `Wrecks`, never the raw save) + `MaxDimension` / `ArmyCount` / `Name` / `Size` / `Version`. **Does the fishing once so consumers just read the field they need** (the map preview, the config facts line, `CustomLobbyRules`); an internal observer **dedups by file** so a launch-info rebroadcast of the same map is a no-op (no preview reload). [CustomLobbyOptionsDerivedModel](models/derived/CustomLobbyOptionsDerivedModel.lua) splits `GameOptions` into lobby / scenario / mods categories and enriches each option (label, help, chosen value, is-default), caching the disk-loaded schema; [CustomLobbyRestrictionsDerivedModel](models/derived/CustomLobbyRestrictionsDerivedModel.lua) maps the active restriction preset keys to their name / icon / tooltip; [CustomLobbyModsDerivedModel](models/derived/CustomLobbyModsDerivedModel.lua) splits the enabled mods into game / ui groups enriched with name / icon / author / version; [CustomLobbySlotsDerivedModel](models/derived/CustomLobbySlotsDerivedModel.lua) is the `Slots` **lookup table** (one entry per slot) merging each seat's player + scenario placement + closed flag + CPU benchmark into a ready-to-paint entry (the slot/faction/CPU formatting moved out of the slot controls into here). See [derived/CLAUDE.md](models/derived/CLAUDE.md). | +| [models/derived/](models/derived/CLAUDE.md) | **derived models** — read-only reactive projections of the authoritative state, the "fishing" layer between the compact synced fields and the views (never written by the controller). The first is [CustomLobbyScenarioDerivedModel](models/derived/CustomLobbyScenarioDerivedModel.lua): resolves the launch model's compact `ScenarioFile` into a rich `Scenario` bundle — info (for the map texture) + extracted save markers (`Spawns` / `MassPoints` / `HydroPoints` / `Wrecks`, never the raw save) + `MaxDimension` / `ArmyCount` / `Name` / `Size` / `Version`. **Does the fishing once so consumers just read the field they need** (the map preview, the config facts line, `CustomLobbyRules`); an internal observer **dedups by file** so a launch-info rebroadcast of the same map is a no-op (no preview reload). [CustomLobbyOptionsDerivedModel](models/derived/CustomLobbyOptionsDerivedModel.lua) splits `GameOptions` into lobby / scenario / mods categories and enriches each option (label, help, chosen value, is-default), caching the disk-loaded schema; [CustomLobbyRestrictionsDerivedModel](models/derived/CustomLobbyRestrictionsDerivedModel.lua) maps the active restriction preset keys to their name / icon / tooltip; [CustomLobbyModsDerivedModel](models/derived/CustomLobbyModsDerivedModel.lua) splits the enabled mods into game / ui groups enriched with name / icon / author / version; [CustomLobbySlotsDerivedModel](models/derived/CustomLobbySlotsDerivedModel.lua) is the `Slots` **lookup table** (one entry per slot) merging each seat's player + scenario placement + closed flag + CPU benchmark into a ready-to-paint entry (the slot/faction/CPU formatting moved out of the slot controls into here). See [models/derived/CLAUDE.md](models/derived/CLAUDE.md). | | [CustomLobbyPerformancePopover.lua](CustomLobbyPerformancePopover.lua) | hover popover over the CPU column; hand-built bitmap bar chart of a peer's `PerformanceTrackingV2` history, with a yellow recommended-unit-cap line. | | [CustomLobbyInstance.lua](CustomLobbyInstance.lua) | thin `moho.lobby_methods` shell; validates/dispatches traffic, forwards callbacks to the controller. Also feeds [`CustomLobbyLog`](CustomLobbyLog.lua) from its three network choke points (BroadcastData / SendData / DataReceived). | | [CustomLobbyLog.lua](CustomLobbyLog.lua) | the **network traffic log** — a reactive, per-peer, never-synced ring buffer (`Entries` LazyVar, capped) of every message this peer broadcasts / sends / receives, fed by the instance and rendered by the **Logs** tab. Each peer logs only its own traffic, so host and client views differ naturally. Not one of the three models (no game state); a diagnostic feed. | diff --git a/lua/ui/lobby/customlobby/design/session-trashbag-teardown.md b/lua/ui/lobby/customlobby/design/session-trashbag-teardown.md index dcdb893c1ed..17445ce0945 100644 --- a/lua/ui/lobby/customlobby/design/session-trashbag-teardown.md +++ b/lua/ui/lobby/customlobby/design/session-trashbag-teardown.md @@ -100,8 +100,16 @@ implementation. `UICustomLobbyMapCatalog : Destroyable`: ## Rollout order (proposed) 1. ✅ Map catalog (done — reference implementation). -2. The three models (low-risk, thin `Destroy`). -3. The mod catalog (mirror the map catalog). -4. ~~`CustomLobbyRules` map-dimension cache.~~ — N/A: `CustomLobbyRules` is now a **pure, stateless** kernel (all inputs passed in; the cached map-dimension lookup is gone), so it holds nothing to tear down. -5. The interface + performance popover — **after** the ordering decision (#1 above). -6. Track the lobby instance in the session bag (drop the manual `Instance:Destroy()` in lobby.lua). +2. ✅ Scenario derived model (done — the worked example for the **derived layer**, now under + [`models/derived/CustomLobbyScenarioDerivedModel.lua`](../models/derived/CustomLobbyScenarioDerivedModel.lua)): + a `ClassSimple : Destroyable` whose own `Trash` owns the `Scenario` LazyVar + the launch-model + subscription, registered in the session bag on first access, with an idempotent `Destroy` that + severs the subscription and nils the module singleton. Its de-dup key moved off a module local onto + the instance (`self.LoadedFile`). The other four derived models mirror it (no write helpers to worry + about — they're read-only projections). +3. The three models (low-risk; per open decision #3 their write helpers stay free functions — the + `ClassSimple` just adds an own `Trash` + a `Destroy` that frees the LazyVars and nils the singleton). +4. The mod catalog (mirror the map catalog). +5. ~~`CustomLobbyRules` map-dimension cache.~~ — N/A: `CustomLobbyRules` is now a **pure, stateless** kernel (all inputs passed in; the cached map-dimension lookup is gone), so it holds nothing to tear down. +6. The interface + performance popover — **after** the ordering decision (#1 above). +7. Track the lobby instance in the session bag (drop the manual `Instance:Destroy()` in lobby.lua). diff --git a/lua/ui/lobby/customlobby/models/derived/CLAUDE.md b/lua/ui/lobby/customlobby/models/derived/CLAUDE.md index e8dcb02b269..2f0d30e4eb9 100644 --- a/lua/ui/lobby/customlobby/models/derived/CLAUDE.md +++ b/lua/ui/lobby/customlobby/models/derived/CLAUDE.md @@ -1,7 +1,7 @@ # Derived models Read-only reactive **projections** of the authoritative lobby state. The three models -([`../CustomLobbyLaunchModel`](../CustomLobbyLaunchModel.lua) / `SessionModel` / `LocalModel`) hold +([`CustomLobbyLaunchModel`](/lua/ui/lobby/customlobby/models/CustomLobbyLaunchModel.lua) / `SessionModel` / `LocalModel`) hold the state in its most **compact** form — a map is a single `ScenarioFile`, sim mods are a set of UUIDs, restrictions are preset keys. Turning those into something a view can actually render (the map's name / size / start spots / texture, a mod's title + icon + dependencies, a restriction's @@ -25,7 +25,7 @@ layer between the compact synced fields and the views. - **Not on the wire.** Purely local reactive reference data, like the catalogs — every peer derives its own from the synced compact fields. - **Naming.** File **and** class carry the `DerivedModel` suffix - (`CustomLobbyDerivedModel`), and the file lives here in `derived/`, so it is unmistakable at + (`CustomLobbyDerivedModel`), and the file lives here in `models/derived/`, so it is unmistakable at the import site and in the type that this is derived state — not something a controller writes. - **Lifetime.** A module-level `ModelInstance` singleton (auto-created in `GetSingleton`) with the standard hot-reload hooks. The internal observer is pinned on the model table so it isn't GC'd. @@ -34,11 +34,11 @@ layer between the compact synced fields and the views. | File | Derives | From | Read by | |------|---------|------|---------| -| [CustomLobbyScenarioDerivedModel.lua](CustomLobbyScenarioDerivedModel.lua) | the resolved `Scenario` (info for the texture + extracted save markers `Spawns`/`MassPoints`/`HydroPoints`/`Wrecks` + `MaxDimension`/`ArmyCount`/`Name`/`Size`/`Version`) | the launch model's `ScenarioFile` | the bound [`../CustomLobbyMapPreview`](../CustomLobbyMapPreview.lua), the [`../config/CustomLobbyConfigInterface`](../config/CustomLobbyConfigInterface.lua) facts line, [`../CustomLobbyRules`](../CustomLobbyRules.lua) (map size + start spots) | -| [CustomLobbyOptionsDerivedModel.lua](CustomLobbyOptionsDerivedModel.lua) | the `Options` view: options split into **Categories** lobby / scenario / mods, each option **enriched** (label, help, chosen value-key + display, `IsDefault`, origin), plus `NonDefaultCount` | the launch model's `GameOptions` + `GameMods` and the scenario derived model (map file + name); the scenario `_options.lua` schema via the catalog's `LoadOptions`, the lobby/mod schema + value interpretation via [`/lua/ui/optionutil.lua`](/lua/ui/optionutil.lua) | the [`../config/CustomLobbyOptionsPanel`](../config/CustomLobbyOptionsPanel.lua) and the Options tab badge in [`../config/CustomLobbyConfigInterface`](../config/CustomLobbyConfigInterface.lua) | -| [CustomLobbyRestrictionsDerivedModel.lua](CustomLobbyRestrictionsDerivedModel.lua) | the `Restrictions` view: each active **preset** key **enriched** with its `Name` / `Icon` / `Tooltip`, plus `Count`. De-duped by set (length + sorted compare). Keys can also be **specific unit ids**, but enriching those (name + icon) is **parked** — see [../TODO.md](../TODO.md) — so a unit id currently shows as its raw id. | the launch model's `Restrictions`, joined with the preset table in [`/lua/ui/lobby/unitsrestrictions.lua`](/lua/ui/lobby/unitsrestrictions.lua) | the [`../config/CustomLobbyUnitsPanel`](../config/CustomLobbyUnitsPanel.lua) (icon + name rows) and the Restrictions tab badge | -| [CustomLobbyModsDerivedModel.lua](CustomLobbyModsDerivedModel.lua) | the `Mods` view: enabled mods split into **game** (sim) + **ui** `Groups`, each mod **enriched** (`Name` / `Icon` / `Author` / `Version` / `UiOnly`), plus `GameCount` / `UiCount`. The simplest model — `Mods.AllMods()` is available synchronously, so no disk load. | the launch model's `GameMods` (synced sim mods) + `ModUtilities.GetSelectedUIMods()` (per-peer prefs, re-read on each sim-mod change), joined with `/lua/mods.lua` | the [`../config/CustomLobbyModsPanel`](../config/CustomLobbyModsPanel.lua) (icon + name rows) and the Mods tab badge (`sim / ui`) | -| [CustomLobbySlotsDerivedModel.lua](CustomLobbySlotsDerivedModel.lua) | **two faces** over the seating board. `Slots` — a **lookup table** (one entry per slot, 1..MaxSlots): each seat merges its **player** (resolved `PlayerView`), scenario **placement** (`StartSpot` + map `Position`), **closed** flag, **CPU benchmark** (`CpuView` + raw `Benchmark`/`UnitCap` for the popover), and its binary auto-team **`Side`** (1/2/false). `Teams` — the **aggregate** (`Mode` / `Labels` / `Resolved` / per-side rating `Totals`). The side rule stays in [`../CustomLobbyRules`](../CustomLobbyRules.lua) (`BuildSideResolver`); this model *applies* it once so the two-column layout reads `entry.Side` and the score reads `Teams`. Each face deduped by its **own** signature, so a rating change re-fires `Teams` but not the rows. | the launch model's `Players[slot]` + `GameOptions` (the AutoTeams mode), the session model's `ClosedSlots`, the local model's `CpuBenchmarks` (per-peer, **not launched**), and the scenario derived model (placement + map size, via `CustomLobbyRules`) | the [`../slots/CustomLobbySlotBase`](../slots/CustomLobbySlotBase.lua) (`Slots`), the [`../slots/twocolumn/CustomLobbyTwoColumnSlots`](../slots/twocolumn/CustomLobbyTwoColumnSlots.lua) (`entry.Side`) and [`../CustomLobbyTeamScore`](../CustomLobbyTeamScore.lua) (`Teams`) | +| [CustomLobbyScenarioDerivedModel.lua](CustomLobbyScenarioDerivedModel.lua) | the resolved `Scenario` (info for the texture + extracted save markers `Spawns`/`MassPoints`/`HydroPoints`/`Wrecks` + `MaxDimension`/`ArmyCount`/`Name`/`Size`/`Version`) | the launch model's `ScenarioFile` | the bound [`CustomLobbyMapPreview`](/lua/ui/lobby/customlobby/CustomLobbyMapPreview.lua), the [`config/CustomLobbyConfigInterface`](/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua) facts line, [`CustomLobbyRules`](/lua/ui/lobby/customlobby/CustomLobbyRules.lua) (map size + start spots) | +| [CustomLobbyOptionsDerivedModel.lua](CustomLobbyOptionsDerivedModel.lua) | the `Options` view: options split into **Categories** lobby / scenario / mods, each option **enriched** (label, help, chosen value-key + display, `IsDefault`, origin), plus `NonDefaultCount` | the launch model's `GameOptions` + `GameMods` and the scenario derived model (map file + name); the scenario `_options.lua` schema via the catalog's `LoadOptions`, the lobby/mod schema + value interpretation via [`/lua/ui/optionutil.lua`](/lua/ui/optionutil.lua) | the [`config/CustomLobbyOptionsPanel`](/lua/ui/lobby/customlobby/config/CustomLobbyOptionsPanel.lua) and the Options tab badge in [`config/CustomLobbyConfigInterface`](/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua) | +| [CustomLobbyRestrictionsDerivedModel.lua](CustomLobbyRestrictionsDerivedModel.lua) | the `Restrictions` view: each active **preset** key **enriched** with its `Name` / `Icon` / `Tooltip`, plus `Count`. De-duped by set (length + sorted compare). Keys can also be **specific unit ids**, but enriching those (name + icon) is **parked** — see [TODO.md](/lua/ui/lobby/customlobby/TODO.md) — so a unit id currently shows as its raw id. | the launch model's `Restrictions`, joined with the preset table in [`/lua/ui/lobby/unitsrestrictions.lua`](/lua/ui/lobby/unitsrestrictions.lua) | the [`config/CustomLobbyUnitsPanel`](/lua/ui/lobby/customlobby/config/CustomLobbyUnitsPanel.lua) (icon + name rows) and the Restrictions tab badge | +| [CustomLobbyModsDerivedModel.lua](CustomLobbyModsDerivedModel.lua) | the `Mods` view: enabled mods split into **game** (sim) + **ui** `Groups`, each mod **enriched** (`Name` / `Icon` / `Author` / `Version` / `UiOnly`), plus `GameCount` / `UiCount`. The simplest model — `Mods.AllMods()` is available synchronously, so no disk load. | the launch model's `GameMods` (synced sim mods) + `ModUtilities.GetSelectedUIMods()` (per-peer prefs, re-read on each sim-mod change), joined with `/lua/mods.lua` | the [`config/CustomLobbyModsPanel`](/lua/ui/lobby/customlobby/config/CustomLobbyModsPanel.lua) (icon + name rows) and the Mods tab badge (`sim / ui`) | +| [CustomLobbySlotsDerivedModel.lua](CustomLobbySlotsDerivedModel.lua) | **two faces** over the seating board. `Slots` — a **lookup table** (one entry per slot, 1..MaxSlots): each seat merges its **player** (resolved `PlayerView`), scenario **placement** (`StartSpot` + map `Position`), **closed** flag, **CPU benchmark** (`CpuView` + raw `Benchmark`/`UnitCap` for the popover), and its binary auto-team **`Side`** (1/2/false). `Teams` — the **aggregate** (`Mode` / `Labels` / `Resolved` / per-side rating `Totals`). The side rule stays in [`CustomLobbyRules`](/lua/ui/lobby/customlobby/CustomLobbyRules.lua) (`BuildSideResolver`); this model *applies* it once so the two-column layout reads `entry.Side` and the score reads `Teams`. Each face deduped by its **own** signature, so a rating change re-fires `Teams` but not the rows. | the launch model's `Players[slot]` + `GameOptions` (the AutoTeams mode), the session model's `ClosedSlots`, the local model's `CpuBenchmarks` (per-peer, **not launched**), and the scenario derived model (placement + map size, via `CustomLobbyRules`) | the [`slots/CustomLobbySlotBase`](/lua/ui/lobby/customlobby/slots/CustomLobbySlotBase.lua) (`Slots`), the [`slots/twocolumn/CustomLobbyTwoColumnSlots`](/lua/ui/lobby/customlobby/slots/twocolumn/CustomLobbyTwoColumnSlots.lua) (`entry.Side`) and [`CustomLobbyTeamScore`](/lua/ui/lobby/customlobby/CustomLobbyTeamScore.lua) (`Teams`) | The options model also shows a second trait worth copying: it **caches the expensive part**. Gathering the option schema is disk work (the map's `_options.lua` `doscript`, mod option files) and only changes diff --git a/lua/ui/lobby/customlobby/models/derived/CustomLobbyScenarioDerivedModel.lua b/lua/ui/lobby/customlobby/models/derived/CustomLobbyScenarioDerivedModel.lua index 1ed66b632bc..1f295065b8a 100644 --- a/lua/ui/lobby/customlobby/models/derived/CustomLobbyScenarioDerivedModel.lua +++ b/lua/ui/lobby/customlobby/models/derived/CustomLobbyScenarioDerivedModel.lua @@ -46,11 +46,19 @@ -- -- Scenario is the first derived model. Mods (compact UUIDs → full mod info), restrictions and the slot -- info are the planned siblings; they follow this same shape (see the folder's CLAUDE.md). +-- +-- LIFETIME. It is a `ClassSimple` singleton implementing `Destroyable`, registered in the session +-- trash bag (see CustomLobbySession) on first access. So one `CustomLobbySession.Teardown()` frees its +-- `Scenario` LazyVar and severs its launch-model subscription, instead of leaking them into the +-- persistent front-end Lua state for the whole match. The module functions are thin facades over the +-- singleton. This is the first derived model converted to the singleton-teardown pattern; the catalog +-- (mapselect/CustomLobbyMapCatalog) is the original worked example. local Create = import("/lua/lazyvar.lua").Create local Derive = import("/lua/lazyvar.lua").Derive local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/models/customlobbylaunchmodel.lua") local CustomLobbyMapCatalog = import("/lua/ui/lobby/customlobby/mapselect/customlobbymapcatalog.lua") +local CustomLobbySession = import("/lua/ui/lobby/customlobby/customlobbysession.lua") ------------------------------------------------------------------------------- --#region Shape @@ -72,18 +80,10 @@ local CustomLobbyMapCatalog = import("/lua/ui/lobby/customlobby/mapselect/custom ------------------------------------------------------------------------------- --#region Derived model ---- Reactive derived-scenario singleton. **Read-only** — no write helpers; the controller never ---- touches it. It re-derives itself from the launch model's `ScenarioFile`. ----@class UICustomLobbyScenarioDerivedModel ----@field Scenario LazyVar # the resolved scenario, or false when none / unreadable ----@field Observer LazyVar # internal: resolves ScenarioFile (deduped); pins itself - +-- The singleton, forward-declared above the class so the class methods (`Destroy`) capture it as an +-- upvalue rather than resolving it as a global. Assigned in `SetupSingleton`, cleared in `Destroy`. ---@type UICustomLobbyScenarioDerivedModel | nil -local ModelInstance = nil - ---- The file currently resolved into `Scenario` — the de-dup key (lowercased), or false when none. ----@type string | false -local LoadedFile = false +local Instance = nil --- Number of start spots a scenario declares, from its standard configuration (0 if none). ---@param info UILobbyScenarioInfo @@ -125,52 +125,83 @@ local function Resolve(file) } end ---- The internal observer's body: resolve `file` into the bundle, but skip the work (and the re-fire) ---- when it is the same scenario we already hold — that is the de-dup. ----@param model UICustomLobbyScenarioDerivedModel ----@param file FileName | false -local function OnScenarioFileChanged(model, file) - local key = (type(file) == "string") and string.lower(file) or false - if key == LoadedFile then - return - end - LoadedFile = key +--- Reactive derived-scenario singleton — a `ClassSimple` implementing `Destroyable`, registered in +--- the session trash so one `CustomLobbySession.Teardown()` frees it. **Read-only** — no write +--- helpers; the controller never touches it. It re-derives from the launch model's `ScenarioFile`. +---@class UICustomLobbyScenarioDerivedModel : Destroyable +---@field Trash TrashBag # owns the Scenario var + the observer (freed on Destroy) +---@field Scenario LazyVar # the resolved scenario, or false when none / unreadable +---@field Observer LazyVar # internal: resolves ScenarioFile (deduped) +---@field LoadedFile string | false # the file currently resolved (lowercased) — the de-dup key +---@field Destroyed boolean +local ScenarioModel = ClassSimple { + + ---@param self UICustomLobbyScenarioDerivedModel + __init = function(self) + self.Trash = TrashBag() + self.Scenario = self.Trash:Add(Create(false)) + self.LoadedFile = false + self.Destroyed = false + + -- resolve the current scenario now and on every change (`Derive` fires synchronously on + -- creation). Pinned on `self.Observer` AND in the trash, so it isn't GC'd and Destroy frees it. + local launch = CustomLobbyLaunchModel.GetSingleton() + self.Observer = self.Trash:Add(Derive(launch.ScenarioFile, function(scenarioFileLazy) + self:OnScenarioFileChanged(scenarioFileLazy()) + end)) + end, + + --- Resolves `file` into the bundle, but skips the work (and the re-fire) when it is the same + --- scenario we already hold — that is the de-dup. + ---@param self UICustomLobbyScenarioDerivedModel + ---@param file FileName | false + OnScenarioFileChanged = function(self, file) + local key = (type(file) == "string") and string.lower(file) or false + if key == self.LoadedFile then + return + end + self.LoadedFile = key - if not file then - model.Scenario:Set(false) - return - end - model.Scenario:Set(Resolve(file) or false) -end + if not file then + self.Scenario:Set(false) + return + end + self.Scenario:Set(Resolve(file) or false) + end, + + --- `Destroyable`: frees the `Scenario` var + the observer subscription (so it stops firing) and + --- clears the module singleton, so the next access rebuilds a fresh model and re-registers it in + --- the next session's trash. Idempotent. Called by the session trash on `Teardown()`. + ---@param self UICustomLobbyScenarioDerivedModel + Destroy = function(self) + if self.Destroyed then + return + end + self.Destroyed = true + self.Trash:Destroy() -- frees the Scenario LazyVar + the launch-model subscription + if Instance == self then + Instance = nil + end + end, +} ---- Allocates a fresh scenario-model singleton, replacing any existing instance, and wires its internal ---- observer to the launch model's `ScenarioFile`. The observer is stored on the model so it isn't ---- garbage-collected, and (because `Derive` fires synchronously on creation) it resolves the current ---- scenario immediately. +--- Allocates a fresh scenario-model singleton and registers it in the session trash. (Because `Derive` +--- fires synchronously on creation, the current scenario resolves immediately.) ---@return UICustomLobbyScenarioDerivedModel function SetupSingleton() - ---@type UICustomLobbyScenarioDerivedModel - local model = { - Scenario = Create(false), - } - ModelInstance = model - LoadedFile = false - - local launch = CustomLobbyLaunchModel.GetSingleton() - model.Observer = Derive(launch.ScenarioFile, function(scenarioFileLazy) - OnScenarioFileChanged(model, scenarioFileLazy()) - end) - - return model + Instance = ScenarioModel() + CustomLobbySession.GetTrash():Add(Instance) + return Instance end ---- Returns the scenario-model singleton, creating (and resolving the current scenario) on first access. +--- Returns the scenario-model singleton, creating (and registering) it on first access — including +--- after a teardown, so it is reusable across lobby sessions. ---@return UICustomLobbyScenarioDerivedModel function GetSingleton() - if not ModelInstance then + if not Instance then SetupSingleton() end - return ModelInstance --[[@as UICustomLobbyScenarioDerivedModel]] + return Instance --[[@as UICustomLobbyScenarioDerivedModel]] end --#endregion @@ -195,11 +226,13 @@ end ------------------------------------------------------------------------------- --#region Debugging ---- Hot-reload hook: rebuild the singleton so its observer re-subscribes and re-resolves the current ---- scenario. The bundle is fully derived from `ScenarioFile`, so there is no state to copy across. +--- Hot-reload hook: destroy the old singleton (severing its subscription) and rebuild via the new +--- module so its observer re-subscribes and re-resolves the current scenario. The bundle is fully +--- derived from `ScenarioFile`, so there is no state to copy across. ---@param newModule any function __moduleinfo.OnReload(newModule) - if ModelInstance then + if Instance then + Instance:Destroy() newModule.SetupSingleton() end end From 158caff0b13857649290495312c1db4368aeb65a Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sat, 27 Jun 2026 13:50:33 +0200 Subject: [PATCH 72/98] Make the derived mods state a simple class that can be destroyed --- .../customlobby/models/derived/CLAUDE.md | 4 +- .../derived/CustomLobbyModsDerivedModel.lua | 120 +++++++++++++----- .../CustomLobbyRestrictionsDerivedModel.lua | 39 ++---- 3 files changed, 97 insertions(+), 66 deletions(-) diff --git a/lua/ui/lobby/customlobby/models/derived/CLAUDE.md b/lua/ui/lobby/customlobby/models/derived/CLAUDE.md index 2f0d30e4eb9..901221adf3c 100644 --- a/lua/ui/lobby/customlobby/models/derived/CLAUDE.md +++ b/lua/ui/lobby/customlobby/models/derived/CLAUDE.md @@ -36,8 +36,8 @@ layer between the compact synced fields and the views. |------|---------|------|---------| | [CustomLobbyScenarioDerivedModel.lua](CustomLobbyScenarioDerivedModel.lua) | the resolved `Scenario` (info for the texture + extracted save markers `Spawns`/`MassPoints`/`HydroPoints`/`Wrecks` + `MaxDimension`/`ArmyCount`/`Name`/`Size`/`Version`) | the launch model's `ScenarioFile` | the bound [`CustomLobbyMapPreview`](/lua/ui/lobby/customlobby/CustomLobbyMapPreview.lua), the [`config/CustomLobbyConfigInterface`](/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua) facts line, [`CustomLobbyRules`](/lua/ui/lobby/customlobby/CustomLobbyRules.lua) (map size + start spots) | | [CustomLobbyOptionsDerivedModel.lua](CustomLobbyOptionsDerivedModel.lua) | the `Options` view: options split into **Categories** lobby / scenario / mods, each option **enriched** (label, help, chosen value-key + display, `IsDefault`, origin), plus `NonDefaultCount` | the launch model's `GameOptions` + `GameMods` and the scenario derived model (map file + name); the scenario `_options.lua` schema via the catalog's `LoadOptions`, the lobby/mod schema + value interpretation via [`/lua/ui/optionutil.lua`](/lua/ui/optionutil.lua) | the [`config/CustomLobbyOptionsPanel`](/lua/ui/lobby/customlobby/config/CustomLobbyOptionsPanel.lua) and the Options tab badge in [`config/CustomLobbyConfigInterface`](/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua) | -| [CustomLobbyRestrictionsDerivedModel.lua](CustomLobbyRestrictionsDerivedModel.lua) | the `Restrictions` view: each active **preset** key **enriched** with its `Name` / `Icon` / `Tooltip`, plus `Count`. De-duped by set (length + sorted compare). Keys can also be **specific unit ids**, but enriching those (name + icon) is **parked** — see [TODO.md](/lua/ui/lobby/customlobby/TODO.md) — so a unit id currently shows as its raw id. | the launch model's `Restrictions`, joined with the preset table in [`/lua/ui/lobby/unitsrestrictions.lua`](/lua/ui/lobby/unitsrestrictions.lua) | the [`config/CustomLobbyUnitsPanel`](/lua/ui/lobby/customlobby/config/CustomLobbyUnitsPanel.lua) (icon + name rows) and the Restrictions tab badge | -| [CustomLobbyModsDerivedModel.lua](CustomLobbyModsDerivedModel.lua) | the `Mods` view: enabled mods split into **game** (sim) + **ui** `Groups`, each mod **enriched** (`Name` / `Icon` / `Author` / `Version` / `UiOnly`), plus `GameCount` / `UiCount`. The simplest model — `Mods.AllMods()` is available synchronously, so no disk load. | the launch model's `GameMods` (synced sim mods) + `ModUtilities.GetSelectedUIMods()` (per-peer prefs, re-read on each sim-mod change), joined with `/lua/mods.lua` | the [`config/CustomLobbyModsPanel`](/lua/ui/lobby/customlobby/config/CustomLobbyModsPanel.lua) (icon + name rows) and the Mods tab badge (`sim / ui`) | +| [CustomLobbyRestrictionsDerivedModel.lua](CustomLobbyRestrictionsDerivedModel.lua) | the `Restrictions` view: each active **preset** key **enriched** with its `Name` / `Icon` / `Tooltip`, plus `Count`. De-duped by an order-independent set signature (`table.concat(table.sorted(keys), …)`). Keys can also be **specific unit ids**, but enriching those (name + icon) is **parked** — see [TODO.md](/lua/ui/lobby/customlobby/TODO.md) — so a unit id currently shows as its raw id. | the launch model's `Restrictions`, joined with the preset table in [`/lua/ui/lobby/unitsrestrictions.lua`](/lua/ui/lobby/unitsrestrictions.lua) | the [`config/CustomLobbyUnitsPanel`](/lua/ui/lobby/customlobby/config/CustomLobbyUnitsPanel.lua) (icon + name rows) and the Restrictions tab badge | +| [CustomLobbyModsDerivedModel.lua](CustomLobbyModsDerivedModel.lua) | the `Mods` view: enabled mods split into **game** (sim) + **ui** `Groups`, each mod **enriched** (`Name` / `Icon` / `Author` / `Version` / `UiOnly`), plus `GameCount` / `UiCount`. No disk load (`Mods.AllMods()` is synchronous). De-duped by an order-independent set signature (`table.concatkeys` over the game + ui sets). | the launch model's `GameMods` (synced sim mods) + `ModUtilities.GetSelectedUIMods()` (per-peer prefs, re-read on each sim-mod change), joined with `/lua/mods.lua` | the [`config/CustomLobbyModsPanel`](/lua/ui/lobby/customlobby/config/CustomLobbyModsPanel.lua) (icon + name rows) and the Mods tab badge (`sim / ui`) | | [CustomLobbySlotsDerivedModel.lua](CustomLobbySlotsDerivedModel.lua) | **two faces** over the seating board. `Slots` — a **lookup table** (one entry per slot, 1..MaxSlots): each seat merges its **player** (resolved `PlayerView`), scenario **placement** (`StartSpot` + map `Position`), **closed** flag, **CPU benchmark** (`CpuView` + raw `Benchmark`/`UnitCap` for the popover), and its binary auto-team **`Side`** (1/2/false). `Teams` — the **aggregate** (`Mode` / `Labels` / `Resolved` / per-side rating `Totals`). The side rule stays in [`CustomLobbyRules`](/lua/ui/lobby/customlobby/CustomLobbyRules.lua) (`BuildSideResolver`); this model *applies* it once so the two-column layout reads `entry.Side` and the score reads `Teams`. Each face deduped by its **own** signature, so a rating change re-fires `Teams` but not the rows. | the launch model's `Players[slot]` + `GameOptions` (the AutoTeams mode), the session model's `ClosedSlots`, the local model's `CpuBenchmarks` (per-peer, **not launched**), and the scenario derived model (placement + map size, via `CustomLobbyRules`) | the [`slots/CustomLobbySlotBase`](/lua/ui/lobby/customlobby/slots/CustomLobbySlotBase.lua) (`Slots`), the [`slots/twocolumn/CustomLobbyTwoColumnSlots`](/lua/ui/lobby/customlobby/slots/twocolumn/CustomLobbyTwoColumnSlots.lua) (`entry.Side`) and [`CustomLobbyTeamScore`](/lua/ui/lobby/customlobby/CustomLobbyTeamScore.lua) (`Teams`) | The options model also shows a second trait worth copying: it **caches the expensive part**. Gathering diff --git a/lua/ui/lobby/customlobby/models/derived/CustomLobbyModsDerivedModel.lua b/lua/ui/lobby/customlobby/models/derived/CustomLobbyModsDerivedModel.lua index 1f44872ed75..14e089b6697 100644 --- a/lua/ui/lobby/customlobby/models/derived/CustomLobbyModsDerivedModel.lua +++ b/lua/ui/lobby/customlobby/models/derived/CustomLobbyModsDerivedModel.lua @@ -43,10 +43,17 @@ -- over a big vault leaks (see modselect/CLAUDE.md). Here we only ever show the **enabled** mods (a -- handful), and re-rendering reuses the engine's by-name texture cache, so the icon count is bounded — -- the same bounded trickle the single map preview accepts. +-- +-- LIFETIME. It is a `ClassSimple` singleton implementing `Destroyable`, registered in the session +-- trash bag (see CustomLobbySession) on first access, so one `CustomLobbySession.Teardown()` frees its +-- `Mods` LazyVar and severs its launch-model subscription instead of leaking them for the whole match. +-- The module functions are thin facades. See the scenario derived model + the map catalog for the +-- pattern. local Create = import("/lua/lazyvar.lua").Create local Derive = import("/lua/lazyvar.lua").Derive local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/models/customlobbylaunchmodel.lua") +local CustomLobbySession = import("/lua/ui/lobby/customlobby/customlobbysession.lua") local ModUtilities = import("/lua/ui/modutilities.lua") local Mods = import("/lua/mods.lua") @@ -82,13 +89,10 @@ local FallbackIcon = '/textures/ui/common/dialogs/mod-manager/generic-icon_bmp.d ------------------------------------------------------------------------------- --#region Derived model ---- Reactive derived-mods singleton. **Read-only** — no write helpers; the controller never touches it. ----@class UICustomLobbyModsDerivedModel ----@field Mods LazyVar ----@field Observer LazyVar - +-- The singleton, forward-declared above the class so the class methods (`Destroy`) capture it as an +-- upvalue. Assigned in `SetupSingleton`, cleared in `Destroy`. ---@type UICustomLobbyModsDerivedModel | nil -local ModelInstance = nil +local Instance = nil --- Enriches one mod uid against the all-mods table; returns a minimal entry for an unknown uid. ---@param uid string @@ -145,41 +149,85 @@ local function BuildMods(gameMods, uiMods) } end ---- Re-derives the mods bundle from the current launch state + UI-mod prefs and publishes it. ---- NOTE: UI mods are prefs, not a reactive field, so they're re-read here whenever the (reactive) sim ---- mods change — the same "good enough until the mod dialog is rewired" caveat the panel/badge had. ----@param model UICustomLobbyModsDerivedModel -local function Recompute(model) - model.Mods:Set(BuildMods(CustomLobbyLaunchModel.GetSingleton().GameMods(), ModUtilities.GetSelectedUIMods())) -end +--- Reactive derived-mods singleton — a `ClassSimple` implementing `Destroyable`, registered in the +--- session trash so one `CustomLobbySession.Teardown()` frees it. **Read-only** — no write helpers; +--- the controller never touches it. It re-derives from the launch model's `GameMods` (+ UI-mod prefs). +---@class UICustomLobbyModsDerivedModel : Destroyable +---@field Trash TrashBag # owns the Mods var + the observer (freed on Destroy) +---@field Mods LazyVar +---@field Observer LazyVar # internal: re-derives on GameMods change +---@field LoadedSignature string | false # signature of the last-published game+ui sets — the de-dup key +---@field Destroyed boolean +local ModsModel = ClassSimple { ---- Allocates a fresh mods-model singleton and wires its observer to the launch model's `GameMods`. ---- The observer is pinned on the model so it isn't GC'd, and (because `Derive` fires synchronously on ---- creation) it resolves the current mods immediately. ----@return UICustomLobbyModsDerivedModel -function SetupSingleton() - ---@type UICustomLobbyModsDerivedModel - local model = { - Mods = Create({ Groups = {}, GameCount = 0, UiCount = 0 }), - } - ModelInstance = model + ---@param self UICustomLobbyModsDerivedModel + __init = function(self) + self.Trash = TrashBag() + self.Mods = self.Trash:Add(Create({ Groups = {}, GameCount = 0, UiCount = 0 })) + self.LoadedSignature = false + self.Destroyed = false - local launch = CustomLobbyLaunchModel.GetSingleton() - model.Observer = Derive(launch.GameMods, function(lazy) - lazy() - Recompute(model) - end) + -- re-derive now and whenever the (reactive) sim mods change (`Derive` fires synchronously on + -- creation). Pinned on `self.Observer` AND in the trash, so it isn't GC'd and Destroy frees it. + local launch = CustomLobbyLaunchModel.GetSingleton() + self.Observer = self.Trash:Add(Derive(launch.GameMods, function(lazy) + lazy() + self:Recompute() + end)) + end, - return model + --- Re-derives the mods bundle from the current launch state + UI-mod prefs and publishes it — + --- unless the enabled game+ui sets are unchanged, in which case it's a no-op (the de-dup): the host + --- re-sets `GameMods` to an equal value on every launch-info rebroadcast, so without this the Mods + --- panel would rebuild its icon rows on every unrelated option change. + --- NOTE: UI mods are prefs, not a reactive field, so they're re-read here whenever the (reactive) + --- sim mods change — the "good enough until the mod dialog is rewired" caveat the panel/badge had. + ---@param self UICustomLobbyModsDerivedModel + Recompute = function(self) + local gameMods = CustomLobbyLaunchModel.GetSingleton().GameMods() + local uiMods = ModUtilities.GetSelectedUIMods() + -- order-independent signature of the enabled game + ui sets (sorted uids joined); "\1" between + -- uids and "\2" between the two sets so {game={A}} and {ui={A}} can't share a signature + local signature = table.concatkeys(gameMods, "\1") .. "\2" .. table.concatkeys(uiMods, "\1") + if signature == self.LoadedSignature then + return + end + self.LoadedSignature = signature + self.Mods:Set(BuildMods(gameMods, uiMods)) + end, + + --- `Destroyable`: frees the `Mods` var + the observer subscription and clears the module singleton, + --- so the next access rebuilds and re-registers in the next session's trash. Idempotent. + ---@param self UICustomLobbyModsDerivedModel + Destroy = function(self) + if self.Destroyed then + return + end + self.Destroyed = true + self.Trash:Destroy() -- frees the Mods LazyVar + the launch-model subscription + if Instance == self then + Instance = nil + end + end, +} + +--- Allocates a fresh mods-model singleton and registers it in the session trash. (Because `Derive` +--- fires synchronously on creation, the current mods resolve immediately.) +---@return UICustomLobbyModsDerivedModel +function SetupSingleton() + Instance = ModsModel() + CustomLobbySession.GetTrash():Add(Instance) + return Instance end ---- Returns the mods-model singleton, creating (and deriving the current mods) on first access. +--- Returns the mods-model singleton, creating (and registering) it on first access — including after +--- a teardown, so it is reusable across lobby sessions. ---@return UICustomLobbyModsDerivedModel function GetSingleton() - if not ModelInstance then + if not Instance then SetupSingleton() end - return ModelInstance --[[@as UICustomLobbyModsDerivedModel]] + return Instance --[[@as UICustomLobbyModsDerivedModel]] end --#endregion @@ -204,11 +252,13 @@ end ------------------------------------------------------------------------------- --#region Debugging ---- Hot-reload hook: rebuild the singleton so its observer re-subscribes and re-derives. The bundle is ---- fully derived from the launch model + prefs, so there is no state to copy across. +--- Hot-reload hook: destroy the old singleton (severing its subscription) and rebuild via the new +--- module so its observer re-subscribes and re-derives. The bundle is fully derived from the launch +--- model + prefs, so there is no state to copy across. ---@param newModule any function __moduleinfo.OnReload(newModule) - if ModelInstance then + if Instance then + Instance:Destroy() newModule.SetupSingleton() end end diff --git a/lua/ui/lobby/customlobby/models/derived/CustomLobbyRestrictionsDerivedModel.lua b/lua/ui/lobby/customlobby/models/derived/CustomLobbyRestrictionsDerivedModel.lua index 61326d92a75..4bcf8600395 100644 --- a/lua/ui/lobby/customlobby/models/derived/CustomLobbyRestrictionsDerivedModel.lua +++ b/lua/ui/lobby/customlobby/models/derived/CustomLobbyRestrictionsDerivedModel.lua @@ -75,32 +75,11 @@ local UnitsRestrictions = import("/lua/ui/lobby/unitsrestrictions.lua") ---@type UICustomLobbyRestrictionsDerivedModel | nil local ModelInstance = nil ---- The preset keys the bundle was last built from — the de-dup baseline, or false before the first ---- build. Restrictions are a *set*, so this is compared order-independently (see `SameKeys`). ----@type string[] | false -local LoadedKeys = false - ---- Whether two preset-key lists describe the same *set* — equal length, then equal once both are ---- sorted (so a pure reorder of the same restrictions doesn't count as a change). Restrictions are ---- short lists of strings, so sorting copies each time is cheap. ----@param a string[] ----@param b string[] ----@return boolean -local function SameKeys(a, b) - if table.getn(a) ~= table.getn(b) then - return false - end - local sortedA = table.copy(a) - local sortedB = table.copy(b) - table.sort(sortedA) - table.sort(sortedB) - for i = 1, table.getn(sortedA) do - if sortedA[i] ~= sortedB[i] then - return false - end - end - return true -end +--- Signature of the preset-key set the bundle was last built from — the de-dup baseline, or false +--- before the first build. Restrictions are a *set*, so this is an order-independent signature (the +--- keys sorted + joined); a pure reorder of the same restrictions doesn't count as a change. +---@type string | false +local LoadedSignature = false --- Resolves one restriction key into an enriched item — a preset (the common case), else a specific --- unit blueprint, else an unknown key shown as-is. @@ -143,10 +122,12 @@ end ---@param model UICustomLobbyRestrictionsDerivedModel local function Recompute(model) local keys = CustomLobbyLaunchModel.GetSingleton().Restrictions() - if LoadedKeys and SameKeys(keys, LoadedKeys) then + -- order-independent signature of the active keys (sorted, joined) — a reorder isn't a change + local signature = table.concat(table.sorted(keys), "\1") + if signature == LoadedSignature then return end - LoadedKeys = keys + LoadedSignature = signature model.Restrictions:Set(BuildRestrictions(keys)) end @@ -155,7 +136,7 @@ end --- synchronously on creation) it resolves the current restrictions immediately. ---@return UICustomLobbyRestrictionsDerivedModel function SetupSingleton() - LoadedKeys = false + LoadedSignature = false ---@type UICustomLobbyRestrictionsDerivedModel local model = { From 5be36155b00c53d1629af83d61c482627bd7f1fb Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sat, 27 Jun 2026 14:30:23 +0200 Subject: [PATCH 73/98] Rewrite all derived models into a destroyable class --- .../design/session-trashbag-teardown.md | 21 +- .../customlobby/models/derived/CLAUDE.md | 12 +- .../CustomLobbyOptionsDerivedModel.lua | 168 ++++++----- .../CustomLobbyRestrictionsDerivedModel.lua | 132 +++++---- .../derived/CustomLobbySlotsDerivedModel.lua | 264 ++++++++++-------- 5 files changed, 347 insertions(+), 250 deletions(-) diff --git a/lua/ui/lobby/customlobby/design/session-trashbag-teardown.md b/lua/ui/lobby/customlobby/design/session-trashbag-teardown.md index 17445ce0945..8445a091076 100644 --- a/lua/ui/lobby/customlobby/design/session-trashbag-teardown.md +++ b/lua/ui/lobby/customlobby/design/session-trashbag-teardown.md @@ -100,15 +100,18 @@ implementation. `UICustomLobbyMapCatalog : Destroyable`: ## Rollout order (proposed) 1. ✅ Map catalog (done — reference implementation). -2. ✅ Scenario derived model (done — the worked example for the **derived layer**, now under - [`models/derived/CustomLobbyScenarioDerivedModel.lua`](../models/derived/CustomLobbyScenarioDerivedModel.lua)): - a `ClassSimple : Destroyable` whose own `Trash` owns the `Scenario` LazyVar + the launch-model - subscription, registered in the session bag on first access, with an idempotent `Destroy` that - severs the subscription and nils the module singleton. Its de-dup key moved off a module local onto - the instance (`self.LoadedFile`). The other four derived models mirror it (no write helpers to worry - about — they're read-only projections). -3. The three models (low-risk; per open decision #3 their write helpers stay free functions — the - `ClassSimple` just adds an own `Trash` + a `Destroy` that frees the LazyVars and nils the singleton). +2. ✅ **All five derived models** ([`models/derived/`](../models/derived/CLAUDE.md)) — done. Each is a + `ClassSimple : Destroyable` whose own `Trash` owns its published LazyVar(s) + its observer(s), + registered in the session bag on first access, with an idempotent `Destroy` that severs the + subscriptions and nils the module singleton; `OnReload` destroys-then-rebuilds. Per-session state + that used to be module-locals moved onto the instance: scenario `self.LoadedFile`; restrictions + `self.LoadedSignature`; mods `self.LoadedSignature` (it gained the dedup it lacked, via + `table.concatkeys`); slots `self.LoadedSignature` + `self.LoadedTeamsSignature` + `self.Observers`; + options `self.Schema` + `self.SchemaKey` (the cached schema). The set-based dedup uses stock + `utils.lua` (`table.concatkeys` / `table.sorted` + `table.concat`) — no bespoke helper. +3. The three authoritative models (low-risk; per open decision #3 their write helpers stay free + functions — the `ClassSimple` just adds an own `Trash` + a `Destroy` that frees the LazyVars and + nils the singleton). 4. The mod catalog (mirror the map catalog). 5. ~~`CustomLobbyRules` map-dimension cache.~~ — N/A: `CustomLobbyRules` is now a **pure, stateless** kernel (all inputs passed in; the cached map-dimension lookup is gone), so it holds nothing to tear down. 6. The interface + performance popover — **after** the ordering decision (#1 above). diff --git a/lua/ui/lobby/customlobby/models/derived/CLAUDE.md b/lua/ui/lobby/customlobby/models/derived/CLAUDE.md index 901221adf3c..bf49075c610 100644 --- a/lua/ui/lobby/customlobby/models/derived/CLAUDE.md +++ b/lua/ui/lobby/customlobby/models/derived/CLAUDE.md @@ -27,8 +27,16 @@ layer between the compact synced fields and the views. - **Naming.** File **and** class carry the `DerivedModel` suffix (`CustomLobbyDerivedModel`), and the file lives here in `models/derived/`, so it is unmistakable at the import site and in the type that this is derived state — not something a controller writes. -- **Lifetime.** A module-level `ModelInstance` singleton (auto-created in `GetSingleton`) with the - standard hot-reload hooks. The internal observer is pinned on the model table so it isn't GC'd. +- **Lifetime.** Each is a `ClassSimple` implementing `Destroyable`, registered in the session trash + (see [`../../CustomLobbySession`](/lua/ui/lobby/customlobby/customlobbysession.lua)) on first access, + so one `CustomLobbySession.Teardown()` frees its LazyVar(s) and severs its subscriptions instead of + leaking them into the persistent front-end state for the whole match. Each owns a `Trash` that holds + its published LazyVar(s) **and** its observer(s); `Destroy` is idempotent (`Destroyed` guard), + `Trash:Destroy()`s, and nils the module `Instance` so the next access rebuilds. Observers are pinned + on the instance (`self.Observer` / `self.Observers`) *and* added to the trash — the trash is + weak-valued, so the trash alone wouldn't keep them alive. The map catalog + ([`../../mapselect/CustomLobbyMapCatalog`](/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapCatalog.lua)) + is the original worked example; see [`../../design/session-trashbag-teardown.md`](/lua/ui/lobby/customlobby/design/session-trashbag-teardown.md). ## Files diff --git a/lua/ui/lobby/customlobby/models/derived/CustomLobbyOptionsDerivedModel.lua b/lua/ui/lobby/customlobby/models/derived/CustomLobbyOptionsDerivedModel.lua index afe3afcc8ec..4d288abe3bc 100644 --- a/lua/ui/lobby/customlobby/models/derived/CustomLobbyOptionsDerivedModel.lua +++ b/lua/ui/lobby/customlobby/models/derived/CustomLobbyOptionsDerivedModel.lua @@ -50,12 +50,19 @@ -- -- It reads the scenario from the scenario derived model (already deduped), and the mod set + values -- from the launch model. Reference data, never on the wire — every peer derives its own. +-- +-- LIFETIME. It is a `ClassSimple` implementing `Destroyable`, registered in the session trash bag (see +-- CustomLobbySession) on first access, so one `CustomLobbySession.Teardown()` frees its `Options` +-- LazyVar, severs its 3 subscriptions and drops the cached schema — instead of leaking them for the +-- whole match. The schema cache lives on the instance, so a new session re-gathers it fresh. See the +-- scenario / mods / restrictions / slots models for the pattern. local Create = import("/lua/lazyvar.lua").Create local Derive = import("/lua/lazyvar.lua").Derive local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/models/customlobbylaunchmodel.lua") local CustomLobbyScenarioDerivedModel = import("/lua/ui/lobby/customlobby/models/derived/customlobbyscenarioderivedmodel.lua") local CustomLobbyMapCatalog = import("/lua/ui/lobby/customlobby/mapselect/customlobbymapcatalog.lua") +local CustomLobbySession = import("/lua/ui/lobby/customlobby/customlobbysession.lua") local OptionUtil = import("/lua/ui/optionutil.lua") ------------------------------------------------------------------------------- @@ -94,23 +101,11 @@ local OptionUtil = import("/lua/ui/optionutil.lua") ------------------------------------------------------------------------------- --#region Derived model ---- Reactive derived-options singleton. **Read-only** — no write helpers; the controller never touches ---- it. It re-derives from the launch model's `GameOptions` / `GameMods` and the scenario derived model. ----@class UICustomLobbyOptionsDerivedModel ----@field Options LazyVar ----@field ScenarioObserver LazyVar ----@field ModsObserver LazyVar ----@field OptionsObserver LazyVar - +-- The singleton, forward-declared above the class so the class methods (`Destroy`) capture it as an +-- upvalue. Assigned in `SetupSingleton`, cleared in `Destroy`. The schema cache lives on the instance +-- (`self.Schema` / `self.SchemaKey`), so it resets with each new session's model. ---@type UICustomLobbyOptionsDerivedModel | nil -local ModelInstance = nil - --- The cached option schema and the key it was gathered for, so a value change re-enriches without --- re-reading the map / mod option files. `SchemaKey` = scenario file + sorted mod uids. ----@type string | false -local SchemaKey = false ----@type { Lobby: ScenarioOption[], Scenario: ScenarioOption[], ScenarioName: string|false, ModGroups: table[] } | false -local Schema = false +local Instance = nil --- An empty (no-options) bundle — the initial value, before the first derivation runs. ---@return UICustomLobbyOptions @@ -139,17 +134,18 @@ local function SchemaKeyFor(scenarioFile, gameMods) end --- (Re)gathers the option schema if the scenario / mod set changed since last time; otherwise keeps ---- the cache. This is the only disk-touching step, so it is deduped by `SchemaKey`. +--- the cache. This is the only disk-touching step, so it is deduped by `self.SchemaKey`. +---@param self UICustomLobbyOptionsDerivedModel ---@param scenario UICustomLobbyScenario | false ---@param gameMods table -local function EnsureSchema(scenario, gameMods) +local function EnsureSchema(self, scenario, gameMods) local scenarioFile = scenario and scenario.File or false local key = SchemaKeyFor(scenarioFile, gameMods) - if Schema and key == SchemaKey then + if self.Schema and key == self.SchemaKey then return end - SchemaKey = key - Schema = { + self.SchemaKey = key + self.Schema = { Lobby = OptionUtil.GetLobbyOptions(), Scenario = CustomLobbyMapCatalog.LoadOptions(scenarioFile), ScenarioName = scenario and scenario.Name or false, @@ -181,27 +177,28 @@ local function EnrichOption(option, values, origin) end --- Builds the full enriched, categorized options bundle for the current schema + values. +---@param self UICustomLobbyOptionsDerivedModel ---@param scenario UICustomLobbyScenario | false ---@param gameMods table ---@param values table ---@return UICustomLobbyOptions -local function BuildOptions(scenario, gameMods, values) - EnsureSchema(scenario, gameMods) +local function BuildOptions(self, scenario, gameMods, values) + EnsureSchema(self, scenario, gameMods) values = values or {} local lobby = {} - for _, option in Schema.Lobby do + for _, option in self.Schema.Lobby do table.insert(lobby, EnrichOption(option, values, false)) end - local scenarioOrigin = { Kind = 'scenario', Name = Schema.ScenarioName or "the map" } + local scenarioOrigin = { Kind = 'scenario', Name = self.Schema.ScenarioName or "the map" } local scenarioOpts = {} - for _, option in Schema.Scenario do + for _, option in self.Schema.Scenario do table.insert(scenarioOpts, EnrichOption(option, values, scenarioOrigin)) end local mods = {} - for _, group in Schema.ModGroups do + for _, group in self.Schema.ModGroups do local origin = { Kind = 'mod', Name = group.name } for _, option in group.options do table.insert(mods, EnrichOption(option, values, origin)) @@ -231,52 +228,87 @@ local function BuildOptions(scenario, gameMods, values) } end ---- Re-derives the options bundle from the current sources and publishes it. ----@param model UICustomLobbyOptionsDerivedModel -local function Recompute(model) - local scenario = CustomLobbyScenarioDerivedModel.GetScenario() - local launch = CustomLobbyLaunchModel.GetSingleton() - model.Options:Set(BuildOptions(scenario, launch.GameMods(), launch.GameOptions())) -end +--- Reactive derived-options singleton — a `ClassSimple` implementing `Destroyable`, registered in the +--- session trash so one `CustomLobbySession.Teardown()` frees it. **Read-only** — no write helpers; the +--- controller never touches it. It re-derives from the launch model's `GameOptions` / `GameMods` and +--- the scenario derived model, caching the disk-gathered option schema on the instance. +---@class UICustomLobbyOptionsDerivedModel : Destroyable +---@field Trash TrashBag # owns the Options var + the 3 observers (freed on Destroy) +---@field Options LazyVar +---@field Observers LazyVar[] # internal: scenario/mods/options subscriptions (strong refs; also in Trash) +---@field Schema table | false # cached option schema (lobby/scenario/mod), keyed by SchemaKey +---@field SchemaKey string | false # scenario file + sorted mod uids the Schema was gathered for +---@field Destroyed boolean +local OptionsModel = ClassSimple { + + ---@param self UICustomLobbyOptionsDerivedModel + __init = function(self) + self.Trash = TrashBag() + self.Options = self.Trash:Add(Create(EmptyOptions())) + self.Observers = {} + self.Schema = false + self.SchemaKey = false + self.Destroyed = false + + -- re-derive on a scenario / mod-set / option-value change. Each observer is kept strongly on + -- self.Observers (the trash is weak-valued) AND added to the trash, so it isn't GC'd and Destroy + -- severs all three. `Derive` fires synchronously on creation → the options resolve now. + local function subscribe(source) + table.insert(self.Observers, self.Trash:Add(Derive(source, function(lazy) + lazy() + self:Recompute() + end))) + end ---- Allocates a fresh options-model singleton and wires its observers. The scenario is read through ---- the scenario derived model (so a same-map rebroadcast doesn't re-gather the scenario options); ---- the mod set + values are the launch model's. Observers are pinned on the model so they aren't GC'd. + local launch = CustomLobbyLaunchModel.GetSingleton() + subscribe(CustomLobbyScenarioDerivedModel.GetScenarioVar()) + subscribe(launch.GameMods) + subscribe(launch.GameOptions) + end, + + --- Re-derives the options bundle from the current scenario + mods + values and publishes it. The + --- schema is re-gathered (disk) only when the scenario / mod set changed (the `SchemaKey` cache); + --- a value-only change just re-enriches the cached schema. + ---@param self UICustomLobbyOptionsDerivedModel + Recompute = function(self) + local scenario = CustomLobbyScenarioDerivedModel.GetScenario() + local launch = CustomLobbyLaunchModel.GetSingleton() + self.Options:Set(BuildOptions(self, scenario, launch.GameMods(), launch.GameOptions())) + end, + + --- `Destroyable`: frees the `Options` var + the 3 subscriptions (so they stop firing) and clears the + --- module singleton, so the next access rebuilds and re-registers in the next session's trash. The + --- cached schema dies with the instance. Idempotent. + ---@param self UICustomLobbyOptionsDerivedModel + Destroy = function(self) + if self.Destroyed then + return + end + self.Destroyed = true + self.Trash:Destroy() -- frees the Options LazyVar + all observer subscriptions + if Instance == self then + Instance = nil + end + end, +} + +--- Allocates a fresh options-model singleton and registers it in the session trash. (Because `Derive` +--- fires synchronously on creation, the options resolve immediately.) ---@return UICustomLobbyOptionsDerivedModel function SetupSingleton() - SchemaKey = false - Schema = false - - ---@type UICustomLobbyOptionsDerivedModel - local model = { - Options = Create(EmptyOptions()), - } - ModelInstance = model - - local launch = CustomLobbyLaunchModel.GetSingleton() - model.ScenarioObserver = Derive(CustomLobbyScenarioDerivedModel.GetScenarioVar(), function(lazy) - lazy() - Recompute(model) - end) - model.ModsObserver = Derive(launch.GameMods, function(lazy) - lazy() - Recompute(model) - end) - model.OptionsObserver = Derive(launch.GameOptions, function(lazy) - lazy() - Recompute(model) - end) - - return model + Instance = OptionsModel() + CustomLobbySession.GetTrash():Add(Instance) + return Instance end ---- Returns the options-model singleton, creating (and deriving the current options) on first access. +--- Returns the options-model singleton, creating (and registering) it on first access — including +--- after a teardown, so it is reusable across lobby sessions. ---@return UICustomLobbyOptionsDerivedModel function GetSingleton() - if not ModelInstance then + if not Instance then SetupSingleton() end - return ModelInstance --[[@as UICustomLobbyOptionsDerivedModel]] + return Instance --[[@as UICustomLobbyOptionsDerivedModel]] end --#endregion @@ -301,11 +333,13 @@ end ------------------------------------------------------------------------------- --#region Debugging ---- Hot-reload hook: rebuild the singleton so its observers re-subscribe and re-derive. The bundle is ---- fully derived from the launch model + scenario model, so there is no state to copy across. +--- Hot-reload hook: destroy the old singleton (severing its subscriptions + dropping its schema cache) +--- and rebuild via the new module. The bundle is fully derived from the launch + scenario models, so +--- there is no state to copy across. ---@param newModule any function __moduleinfo.OnReload(newModule) - if ModelInstance then + if Instance then + Instance:Destroy() newModule.SetupSingleton() end end diff --git a/lua/ui/lobby/customlobby/models/derived/CustomLobbyRestrictionsDerivedModel.lua b/lua/ui/lobby/customlobby/models/derived/CustomLobbyRestrictionsDerivedModel.lua index 4bcf8600395..03a4bb56f18 100644 --- a/lua/ui/lobby/customlobby/models/derived/CustomLobbyRestrictionsDerivedModel.lua +++ b/lua/ui/lobby/customlobby/models/derived/CustomLobbyRestrictionsDerivedModel.lua @@ -40,10 +40,16 @@ -- those with a real name + icon is **parked** — `__blueprints` isn't available in the lobby front-end, -- so it needs the heavier `UnitsAnalyzer` path. See /lua/ui/lobby/customlobby/TODO.md. For now a -- non-preset key falls through and shows as its raw id. +-- +-- LIFETIME. It is a `ClassSimple` singleton implementing `Destroyable`, registered in the session +-- trash bag (see CustomLobbySession) on first access, so one `CustomLobbySession.Teardown()` frees its +-- `Restrictions` LazyVar and severs its launch-model subscription. See the scenario / mods derived +-- models + the map catalog for the pattern. local Create = import("/lua/lazyvar.lua").Create local Derive = import("/lua/lazyvar.lua").Derive local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/models/customlobbylaunchmodel.lua") +local CustomLobbySession = import("/lua/ui/lobby/customlobby/customlobbysession.lua") local UnitsRestrictions = import("/lua/ui/lobby/unitsrestrictions.lua") ------------------------------------------------------------------------------- @@ -66,20 +72,10 @@ local UnitsRestrictions = import("/lua/ui/lobby/unitsrestrictions.lua") ------------------------------------------------------------------------------- --#region Derived model ---- Reactive derived-restrictions singleton. **Read-only** — no write helpers; the controller never ---- touches it. It re-derives from the launch model's `Restrictions`. ----@class UICustomLobbyRestrictionsDerivedModel ----@field Restrictions LazyVar ----@field Observer LazyVar - +-- The singleton, forward-declared above the class so the class methods (`Destroy`) capture it as an +-- upvalue. Assigned in `SetupSingleton`, cleared in `Destroy`. ---@type UICustomLobbyRestrictionsDerivedModel | nil -local ModelInstance = nil - ---- Signature of the preset-key set the bundle was last built from — the de-dup baseline, or false ---- before the first build. Restrictions are a *set*, so this is an order-independent signature (the ---- keys sorted + joined); a pure reorder of the same restrictions doesn't count as a change. ----@type string | false -local LoadedSignature = false +local Instance = nil --- Resolves one restriction key into an enriched item — a preset (the common case), else a specific --- unit blueprint, else an unknown key shown as-is. @@ -115,52 +111,81 @@ local function BuildRestrictions(keys) return { Items = items, Count = table.getn(keys) } end ---- Re-derives the restrictions bundle and publishes it — unless the active set is unchanged, in which ---- case it's a no-op (the de-dup): `LazyVar:Set` always re-fires, and the host re-sets `Restrictions` ---- to the same list on every launch-info rebroadcast, so without this the panel would rebuild its icon ---- rows on every unrelated option change. ----@param model UICustomLobbyRestrictionsDerivedModel -local function Recompute(model) - local keys = CustomLobbyLaunchModel.GetSingleton().Restrictions() - -- order-independent signature of the active keys (sorted, joined) — a reorder isn't a change - local signature = table.concat(table.sorted(keys), "\1") - if signature == LoadedSignature then - return - end - LoadedSignature = signature - model.Restrictions:Set(BuildRestrictions(keys)) -end +--- Reactive derived-restrictions singleton — a `ClassSimple` implementing `Destroyable`, registered in +--- the session trash so one `CustomLobbySession.Teardown()` frees it. **Read-only** — no write helpers; +--- the controller never touches it. It re-derives from the launch model's `Restrictions`. +---@class UICustomLobbyRestrictionsDerivedModel : Destroyable +---@field Trash TrashBag # owns the Restrictions var + the observer (freed on Destroy) +---@field Restrictions LazyVar +---@field Observer LazyVar # internal: re-derives on Restrictions change +---@field LoadedSignature string | false # order-independent signature of the last-published key set — the de-dup key +---@field Destroyed boolean +local RestrictionsModel = ClassSimple { + + ---@param self UICustomLobbyRestrictionsDerivedModel + __init = function(self) + self.Trash = TrashBag() + self.Restrictions = self.Trash:Add(Create({ Items = {}, Count = 0 })) + self.LoadedSignature = false + self.Destroyed = false + + -- re-derive now and on every change (`Derive` fires synchronously on creation). Pinned on + -- `self.Observer` AND in the trash, so it isn't GC'd and Destroy frees it. + local launch = CustomLobbyLaunchModel.GetSingleton() + self.Observer = self.Trash:Add(Derive(launch.Restrictions, function(lazy) + lazy() + self:Recompute() + end)) + end, + + --- Re-derives the restrictions bundle and publishes it — unless the active set is unchanged, in + --- which case it's a no-op (the de-dup): `LazyVar:Set` always re-fires, and the host re-sets + --- `Restrictions` to the same list on every launch-info rebroadcast, so without this the panel would + --- rebuild its icon rows on every unrelated option change. + ---@param self UICustomLobbyRestrictionsDerivedModel + Recompute = function(self) + local keys = CustomLobbyLaunchModel.GetSingleton().Restrictions() + -- order-independent signature of the active keys (sorted, joined) — a reorder isn't a change + local signature = table.concat(table.sorted(keys), "\1") + if signature == self.LoadedSignature then + return + end + self.LoadedSignature = signature + self.Restrictions:Set(BuildRestrictions(keys)) + end, + + --- `Destroyable`: frees the `Restrictions` var + the observer subscription and clears the module + --- singleton, so the next access rebuilds and re-registers in the next session's trash. Idempotent. + ---@param self UICustomLobbyRestrictionsDerivedModel + Destroy = function(self) + if self.Destroyed then + return + end + self.Destroyed = true + self.Trash:Destroy() -- frees the Restrictions LazyVar + the launch-model subscription + if Instance == self then + Instance = nil + end + end, +} ---- Allocates a fresh restrictions-model singleton and wires its observer to the launch model's ---- `Restrictions`. The observer is pinned on the model so it isn't GC'd, and (because `Derive` fires ---- synchronously on creation) it resolves the current restrictions immediately. +--- Allocates a fresh restrictions-model singleton and registers it in the session trash. (Because +--- `Derive` fires synchronously on creation, the current restrictions resolve immediately.) ---@return UICustomLobbyRestrictionsDerivedModel function SetupSingleton() - LoadedSignature = false - - ---@type UICustomLobbyRestrictionsDerivedModel - local model = { - Restrictions = Create({ Items = {}, Count = 0 }), - } - ModelInstance = model - - local launch = CustomLobbyLaunchModel.GetSingleton() - model.Observer = Derive(launch.Restrictions, function(lazy) - lazy() - Recompute(model) - end) - - return model + Instance = RestrictionsModel() + CustomLobbySession.GetTrash():Add(Instance) + return Instance end ---- Returns the restrictions-model singleton, creating (and deriving the current restrictions) on ---- first access. +--- Returns the restrictions-model singleton, creating (and registering) it on first access — including +--- after a teardown, so it is reusable across lobby sessions. ---@return UICustomLobbyRestrictionsDerivedModel function GetSingleton() - if not ModelInstance then + if not Instance then SetupSingleton() end - return ModelInstance --[[@as UICustomLobbyRestrictionsDerivedModel]] + return Instance --[[@as UICustomLobbyRestrictionsDerivedModel]] end --#endregion @@ -185,11 +210,12 @@ end ------------------------------------------------------------------------------- --#region Debugging ---- Hot-reload hook: rebuild the singleton so its observer re-subscribes and re-derives. The bundle is ---- fully derived from the launch model, so there is no state to copy across. +--- Hot-reload hook: destroy the old singleton (severing its subscription) and rebuild via the new +--- module. The bundle is fully derived from the launch model, so there is no state to copy across. ---@param newModule any function __moduleinfo.OnReload(newModule) - if ModelInstance then + if Instance then + Instance:Destroy() newModule.SetupSingleton() end end diff --git a/lua/ui/lobby/customlobby/models/derived/CustomLobbySlotsDerivedModel.lua b/lua/ui/lobby/customlobby/models/derived/CustomLobbySlotsDerivedModel.lua index 0be86d33a8a..c04308cecbe 100644 --- a/lua/ui/lobby/customlobby/models/derived/CustomLobbySlotsDerivedModel.lua +++ b/lua/ui/lobby/customlobby/models/derived/CustomLobbySlotsDerivedModel.lua @@ -59,6 +59,12 @@ -- (re-setting every `Players[slot]` to an equal value) on any option tweak. So the rebuild compares a -- signature of the rendered fields and only re-publishes `Slots` when something visible actually -- changed — a rebroadcast of an unchanged board is a no-op, not 16 needless re-renders per seat. +-- +-- LIFETIME. It is a `ClassSimple` implementing `Destroyable`, registered in the session trash bag (see +-- CustomLobbySession) on first access. So one `CustomLobbySession.Teardown()` frees its `Slots` + +-- `Teams` LazyVars and severs *all* its subscriptions (every slot's player, closed slots, CPU +-- benchmarks, the scenario, game options) — instead of leaking ~20 observers for the whole match. The +-- module functions are thin facades. See the scenario / mods / restrictions models for the pattern. local Create = import("/lua/lazyvar.lua").Create local Derive = import("/lua/lazyvar.lua").Derive @@ -71,6 +77,7 @@ local CustomLobbySessionModel = import("/lua/ui/lobby/customlobby/models/customl local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/models/customlobbylocalmodel.lua") local CustomLobbyScenarioDerivedModel = import("/lua/ui/lobby/customlobby/models/derived/customlobbyscenarioderivedmodel.lua") local CustomLobbyRules = import("/lua/ui/lobby/customlobby/customlobbyrules.lua") +local CustomLobbySession = import("/lua/ui/lobby/customlobby/customlobbysession.lua") local MaxSlots = CustomLobbyLaunchModel.MaxSlots @@ -242,28 +249,10 @@ end ------------------------------------------------------------------------------- --#region Derived model ---- Reactive derived-slots singleton. **Read-only** — no write helpers; the controller never touches it. ---- Two read faces over the same board: per-seat `Slots` (the row/card paint these) and the binary ---- auto-team aggregate `Teams` (the team-score strip), each deduped independently so a rating-only ---- change re-fires `Teams` but not `Slots`. ----@class UICustomLobbySlotsDerivedModel ----@field Slots LazyVar # the lookup table, indexed 1..MaxSlots ----@field Teams LazyVar # the binary auto-team aggregate (side labels + per-side rating totals) ----@field Observers LazyVar[] # internal: the source subscriptions; pinned so they aren't GC'd - +-- The singleton, forward-declared above the class so the class methods (`Destroy`) capture it as an +-- upvalue. Assigned in `SetupSingleton`, cleared in `Destroy`. ---@type UICustomLobbySlotsDerivedModel | nil -local ModelInstance = nil - ---- A signature of the currently-published table's rendered fields — the de-dup key. Re-set only when ---- the signature changes, so a rebroadcast of an unchanged board re-fires nothing. ----@type string | false -local LoadedSignature = false - ---- A signature of the currently-published `Teams` aggregate — its own de-dup key, so a rating change ---- re-fires `Teams` (totals moved) without re-rendering the rows, and an option tweak that doesn't move ---- the split re-fires neither face. ----@type string | false -local LoadedTeamsSignature = false +local Instance = nil --- Builds one slot entry by joining every model for that seat. ---@param slot number @@ -334,116 +323,152 @@ local function TeamsSignature(teams) .. "|" .. tostring(teams.Totals[1]) .. "/" .. tostring(teams.Totals[2]) end ---- Rebuilds the whole lookup table from the current launch / session / local / scenario state and ---- publishes it — but only when its signature changed (the de-dup). ----@param model UICustomLobbySlotsDerivedModel -local function Recompute(model) - local launch = CustomLobbyLaunchModel.GetSingleton() - local closedSlots = CustomLobbySessionModel.GetSingleton().ClosedSlots() - local benchmarks = CustomLobbyLocalModel.GetSingleton().CpuBenchmarks() - - local scenario = CustomLobbyScenarioDerivedModel.GetScenario() - local spawns = scenario and scenario.Markers and scenario.Markers.Spawns or nil - local maxDimension = scenario and scenario.MaxDimension or 0 - - -- the recommended cap scales by seated count (same for every seat), so count once up front; we feed - -- the inputs to the pure rule (CustomLobbyRules reads no models itself) - local seatedCount = 0 - for slot = 1, MaxSlots do - if launch.Players[slot]() then - seatedCount = seatedCount + 1 +--- Reactive derived-slots singleton — a `ClassSimple` implementing `Destroyable`, registered in the +--- session trash so one `CustomLobbySession.Teardown()` frees it. **Read-only** — no write helpers; the +--- controller never touches it. Two read faces over the same board: per-seat `Slots` (the row/card +--- paint these) and the binary auto-team aggregate `Teams` (the team-score strip), each deduped +--- independently so a rating-only change re-fires `Teams` but not `Slots`. +---@class UICustomLobbySlotsDerivedModel : Destroyable +---@field Trash TrashBag # owns the Slots/Teams vars + every observer (freed on Destroy) +---@field Slots LazyVar # the lookup table, indexed 1..MaxSlots +---@field Teams LazyVar # the binary auto-team aggregate (side labels + per-side rating totals) +---@field Observers LazyVar[] # internal: the source subscriptions (strong refs; also in Trash) +---@field LoadedSignature string | false # de-dup key for the Slots face +---@field LoadedTeamsSignature string | false # de-dup key for the Teams face +---@field Destroyed boolean +local SlotsModel = ClassSimple { + + ---@param self UICustomLobbySlotsDerivedModel + __init = function(self) + self.Trash = TrashBag() + self.Slots = self.Trash:Add(Create({})) + self.Teams = self.Trash:Add(Create({ Mode = false, Labels = false, Resolved = false, Totals = { 0, 0 } })) + self.Observers = {} + self.LoadedSignature = false + self.LoadedTeamsSignature = false + self.Destroyed = false + + -- subscribe to every source that feeds a seat. Each observer is kept strongly on self.Observers + -- (the trash is weak-valued, so the trash alone wouldn't keep it alive) AND added to the trash, + -- so Destroy severs them all. `Derive` fires synchronously on creation → the table resolves now. + local function subscribe(source) + table.insert(self.Observers, self.Trash:Add(Derive(source, function(lazy) + lazy() + self:Recompute() + end))) end - end - local cap = CustomLobbyRules.RecommendedUnitCap(seatedCount, maxDimension) - - -- the binary auto-team split, applied once here (the rule lives in CustomLobbyRules). A seat's side - -- is keyed on its start spot, falling back to the slot index when empty so empty cards still place; - -- `resolved` is false for a positional mode whose map/start spots aren't loaded yet. - local mode = CustomLobbyRules.AutoTeamMode(launch.GameOptions()) - local labels = CustomLobbyRules.SideLabels(mode) - local resolver, resolved = CustomLobbyRules.BuildSideResolver(mode, scenario) - local slots = {} - local totalA, totalB = 0, 0 - for slot = 1, MaxSlots do - local player = launch.Players[slot]() - local spot = (player and player.StartSpot) or slot - local side = (resolved and resolver and resolver(spot)) or false - slots[slot] = BuildSlot(slot, player, closedSlots[slot] and true or false, benchmarks, spawns, cap, side) - if player then - if side == 1 then - totalA = totalA + (player.PL or 0) - elseif side == 2 then - totalB = totalB + (player.PL or 0) + local launch = CustomLobbyLaunchModel.GetSingleton() + for slot = 1, MaxSlots do + subscribe(launch.Players[slot]) + end + subscribe(CustomLobbySessionModel.GetSingleton().ClosedSlots) + subscribe(CustomLobbyLocalModel.GetSingleton().CpuBenchmarks) + subscribe(CustomLobbyScenarioDerivedModel.GetScenarioVar()) + -- GameOptions feeds the AutoTeams mode (the side split); the per-face dedup keeps an unrelated + -- option tweak from re-firing either var + subscribe(launch.GameOptions) + end, + + --- Rebuilds the whole lookup table + team aggregate from the current launch / session / local / + --- scenario state and publishes each — but only when its own signature changed (the de-dup). + ---@param self UICustomLobbySlotsDerivedModel + Recompute = function(self) + local launch = CustomLobbyLaunchModel.GetSingleton() + local closedSlots = CustomLobbySessionModel.GetSingleton().ClosedSlots() + local benchmarks = CustomLobbyLocalModel.GetSingleton().CpuBenchmarks() + + local scenario = CustomLobbyScenarioDerivedModel.GetScenario() + local spawns = scenario and scenario.Markers and scenario.Markers.Spawns or nil + local maxDimension = scenario and scenario.MaxDimension or 0 + + -- the recommended cap scales by seated count (same for every seat), so count once up front; we + -- feed the inputs to the pure rule (CustomLobbyRules reads no models itself) + local seatedCount = 0 + for slot = 1, MaxSlots do + if launch.Players[slot]() then + seatedCount = seatedCount + 1 + end + end + local cap = CustomLobbyRules.RecommendedUnitCap(seatedCount, maxDimension) + + -- the binary auto-team split, applied once here (the rule lives in CustomLobbyRules). A seat's + -- side is keyed on its start spot, falling back to the slot index when empty so empty cards still + -- place; `resolved` is false for a positional mode whose map/start spots aren't loaded yet. + local mode = CustomLobbyRules.AutoTeamMode(launch.GameOptions()) + local labels = CustomLobbyRules.SideLabels(mode) + local resolver, resolved = CustomLobbyRules.BuildSideResolver(mode, scenario) + + local slots = {} + local totalA, totalB = 0, 0 + for slot = 1, MaxSlots do + local player = launch.Players[slot]() + local spot = (player and player.StartSpot) or slot + local side = (resolved and resolver and resolver(spot)) or false + slots[slot] = BuildSlot(slot, player, closedSlots[slot] and true or false, benchmarks, spawns, cap, side) + if player then + if side == 1 then + totalA = totalA + (player.PL or 0) + elseif side == 2 then + totalB = totalB + (player.PL or 0) + end end end - end - -- per-seat face (rows/cards): re-publish only when a rendered field or a seat's side changed - local signature = Signature(slots) - if signature ~= LoadedSignature then - LoadedSignature = signature - model.Slots:Set(slots) - end + -- per-seat face (rows/cards): re-publish only when a rendered field or a seat's side changed + local signature = Signature(slots) + if signature ~= self.LoadedSignature then + self.LoadedSignature = signature + self.Slots:Set(slots) + end - -- aggregate face (team-score strip): re-publish only when the split or a total moved - local teams = { - Mode = mode or false, - Labels = labels or false, - Resolved = resolved, - Totals = { math.floor(totalA), math.floor(totalB) }, - } - local teamsSignature = TeamsSignature(teams) - if teamsSignature ~= LoadedTeamsSignature then - LoadedTeamsSignature = teamsSignature - model.Teams:Set(teams) - end -end + -- aggregate face (team-score strip): re-publish only when the split or a total moved + local teams = { + Mode = mode or false, + Labels = labels or false, + Resolved = resolved, + Totals = { math.floor(totalA), math.floor(totalB) }, + } + local teamsSignature = TeamsSignature(teams) + if teamsSignature ~= self.LoadedTeamsSignature then + self.LoadedTeamsSignature = teamsSignature + self.Teams:Set(teams) + end + end, + + --- `Destroyable`: frees the Slots/Teams vars + every source subscription (so they stop firing) and + --- clears the module singleton, so the next access rebuilds and re-registers in the next session's + --- trash. Idempotent. + ---@param self UICustomLobbySlotsDerivedModel + Destroy = function(self) + if self.Destroyed then + return + end + self.Destroyed = true + self.Trash:Destroy() -- frees the Slots/Teams LazyVars + all observer subscriptions + if Instance == self then + Instance = nil + end + end, +} ---- Allocates a fresh slots-model singleton and subscribes its internal observers to every source that ---- feeds a seat: each slot's player, the closed slots, the CPU benchmarks and the derived scenario ---- (placement + the size-driven unit cap). The observers are pinned on the model so they aren't GC'd, ---- and (because `Derive` fires synchronously on creation) the table resolves immediately. +--- Allocates a fresh slots-model singleton and registers it in the session trash. (Because `Derive` +--- fires synchronously on creation, the table resolves immediately.) ---@return UICustomLobbySlotsDerivedModel function SetupSingleton() - ---@type UICustomLobbySlotsDerivedModel - local model = { - Slots = Create({}), - Teams = Create({ Mode = false, Labels = false, Resolved = false, Totals = { 0, 0 } }), - Observers = {}, - } - ModelInstance = model - LoadedSignature = false - LoadedTeamsSignature = false - - local function subscribe(source) - table.insert(model.Observers, Derive(source, function(lazy) - lazy() - Recompute(model) - end)) - end - - local launch = CustomLobbyLaunchModel.GetSingleton() - for slot = 1, MaxSlots do - subscribe(launch.Players[slot]) - end - subscribe(CustomLobbySessionModel.GetSingleton().ClosedSlots) - subscribe(CustomLobbyLocalModel.GetSingleton().CpuBenchmarks) - subscribe(CustomLobbyScenarioDerivedModel.GetScenarioVar()) - -- GameOptions feeds the AutoTeams mode (the side split); the per-face dedup keeps an unrelated - -- option tweak from re-firing either var - subscribe(launch.GameOptions) - - return model + Instance = SlotsModel() + CustomLobbySession.GetTrash():Add(Instance) + return Instance end ---- Returns the slots-model singleton, creating (and resolving the current table) on first access. +--- Returns the slots-model singleton, creating (and registering) it on first access — including after +--- a teardown, so it is reusable across lobby sessions. ---@return UICustomLobbySlotsDerivedModel function GetSingleton() - if not ModelInstance then + if not Instance then SetupSingleton() end - return ModelInstance --[[@as UICustomLobbySlotsDerivedModel]] + return Instance --[[@as UICustomLobbySlotsDerivedModel]] end --#endregion @@ -488,11 +513,12 @@ end ------------------------------------------------------------------------------- --#region Debugging ---- Hot-reload hook: rebuild the singleton so its observers re-subscribe and re-derive. The table is ---- fully derived from the source models, so there is no state to copy across. +--- Hot-reload hook: destroy the old singleton (severing all its subscriptions) and rebuild via the new +--- module. The table is fully derived from the source models, so there is no state to copy across. ---@param newModule any function __moduleinfo.OnReload(newModule) - if ModelInstance then + if Instance then + Instance:Destroy() newModule.SetupSingleton() end end From 8dbe2cf36137fb56aaa35ee07a4bb76634a29c1d Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sat, 27 Jun 2026 15:43:32 +0200 Subject: [PATCH 74/98] Turn authoritative models into classes that can be destroyed --- lua/ui/lobby/customlobby/CLAUDE.md | 2 +- .../design/session-trashbag-teardown.md | 23 +++-- .../models/CustomLobbyLaunchModel.lua | 97 ++++++++++++------- .../models/CustomLobbyLocalModel.lua | 73 ++++++++++---- .../models/CustomLobbySessionModel.lua | 69 +++++++++---- 5 files changed, 178 insertions(+), 86 deletions(-) diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index dfb159670da..ce01bfe4cf8 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -52,7 +52,7 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | [CustomLobbyController.lua](CustomLobbyController.lua) | host-authority logic (free functions): seating, `Process*` handlers, intents (`RequestSetReady`, `RequestTakeSlot`, `RequestSwapSlots`, `RequestEject`, `RequestMoveToObserver`, `RequestSetScenario`, `RequestSetGameMods/Options`, `RequestResetGameOptions`, `RequestSetSlotsPinned`, `RequestAutoBalance` (stub), `RequestReopenClosedSlots`, `RequestLaunch`, `RequestSaveSetupPreset`/`RequestLoadSetupPreset` — all keyed by slot/bool/file/name so a chat command can call them too; permission is gated separately), sharing the stored CPU benchmark. **Launch:** `RequestLaunch` (host-only, readiness-validated) → auto-saves the `lastGame` preset (`BuildSetupSnapshot`) → `BuildGameConfiguration` (seed option defaults + scenario, resolve random factions, assign army numbers + push to server, stamp ratings/clan tags, resolve sim mods via `Mods.GetGameMods`) → broadcast `LaunchGame` + `instance:LaunchGame`; clients run `ProcessLaunchGame`. **Presets:** `BuildSetupSnapshot` (read the launch state → serializable **setup-only** snapshot: scenario / options / mods / restrictions) + `ApplySetup` (host-only: write scenario/mods/restrictions, reconcile options to the current schema, one broadcast); players, observers and teams/spawn-mex are not stored (see [presetselect/](presetselect/CLAUDE.md)). | | [CustomLobbyRules.lua](CustomLobbyRules.lua) | the game-rule **pure kernel** (not view, not networking, **reads no models**): `RecommendedUnitCap(seatedCount, maxDimension)` (per-player cap by map size), `AutoTeamMode(gameOptions)` / `SideLabels(mode)` / `BuildSideResolver(mode, scenario)` (binary auto-team split). Every input is passed in, so the same functions serve the reactive [slots derived model](models/derived/CustomLobbySlotsDerivedModel.lua) (which applies them to publish `Side` / `Teams`) and, later, the host's launch-time team assignment — neither path makes this module read state. | | [CustomLobbyPresets.lua](CustomLobbyPresets.lua) | **named setup presets** (setup-only: scenario / options / mods / restrictions — no players/observers) — pure prefs CRUD (`GetPresets`/`GetPreset`/`SavePreset`/`DeletePreset`/`RenamePreset`) over one key (`customlobby_setup_presets`), an ordered `{ Name, Setup }` array mirroring the mod presets in [`/lua/ui/modutilities.lua`](/lua/ui/modutilities.lua). Holds no models, no network. A reserved `LastGamePresetName` entry is auto-saved at launch (the rehost source). Capturing/applying a setup lives in the controller (`BuildSetupSnapshot`/`ApplySetup`); see [presetselect/](presetselect/CLAUDE.md). | -| [CustomLobbySession.lua](CustomLobbySession.lua) | the lobby session's **main trash bag**. The lobby lives in the persistent front-end Lua state, which is *not* reset when the game launches in its own state — so anything left reachable (a running thread, a cache, a singleton) leaks for the whole match. This module owns one session-lifetime `TrashBag` (`GetTrash()`); every lobby-scoped `Destroyable` registers in it, and one `Teardown()` (clean-slate in `CreateLobby`, on leave, on `OnGameLaunched`) frees the lot. Works despite the bag being weak-valued because each singleton is pinned by its own module `Instance` local. **Rollout in progress** — the map catalog is the first resource converted; models / interface / instance follow. | +| [CustomLobbySession.lua](CustomLobbySession.lua) | the lobby session's **main trash bag**. The lobby lives in the persistent front-end Lua state, which is *not* reset when the game launches in its own state — so anything left reachable (a running thread, a cache, a singleton) leaks for the whole match. This module owns one session-lifetime `TrashBag` (`GetTrash()`); every lobby-scoped `Destroyable` registers in it, and one `Teardown()` (clean-slate in `CreateLobby`, on leave, on `OnGameLaunched`) frees the lot. Works despite the bag being weak-valued because each singleton is pinned by its own module `Instance` local. **Rollout in progress** — converted: the map catalog, all five [derived models](models/derived/CLAUDE.md), and the three authoritative [models/](models/) (Launch/Session/Local, with thin `Destroy` per the design's decision #3). Still to do: the mod catalog, the interface + performance popover (pending the teardown-ordering decision), and the lobby instance. See [design/session-trashbag-teardown.md](design/session-trashbag-teardown.md). | | [CustomLobbyMessages.lua](CustomLobbyMessages.lua) | message registry: `AddPlayer`, `SetPlayers` (launch model: players + observers), `SentLaunchInfo` (launch model: scenario / options / mods / teams / spawn mex), `SetSessionState` (session model: slot count / closed slots), `SetReady`, `TakeSlot`, `DisconnectPeer`, `LaunchGame` (host's final game config → `instance:LaunchGame`), `ReportCpuBenchmark`, `SetCpuBenchmarks`. | | [CustomLobbyContextMenu.lua](CustomLobbyContextMenu.lua) | generic framed floating menu; `Show(entries, x, y)` renders any `{label, action, enabled}` list, dismisses on item click / click-outside / Esc. Knows nothing about the lobby. | | [CustomLobbyMenus.lua](CustomLobbyMenus.lua) | declarative menu **definitions**: entry lists with `when(ctx)`/`action(ctx)` filtered by lobby state (`BuildSlotMenu`). Adding/state-gating an item is a one-liner here. | diff --git a/lua/ui/lobby/customlobby/design/session-trashbag-teardown.md b/lua/ui/lobby/customlobby/design/session-trashbag-teardown.md index 8445a091076..1d6ae21f212 100644 --- a/lua/ui/lobby/customlobby/design/session-trashbag-teardown.md +++ b/lua/ui/lobby/customlobby/design/session-trashbag-teardown.md @@ -82,11 +82,14 @@ implementation. `UICustomLobbyMapCatalog : Destroyable`: (`GetSingleton():LoadInfo()`) removes a layer but touches callers. Current lean: **keep** for query-style services like the catalog. -3. **Models stay free-function-style.** The three models are intentionally plain data tables with - free-function writers (`SetPlayer(model, …)`) — the documented autolobby idiom. For them, - `Destroy` should be **thin**: nil `ModelInstance`, let the LazyVars GC. Do **not** convert their - write helpers to methods. Accept that "service" singletons (catalog: real methods) and "data" - singletons (models: free functions + minimal `Destroy`) legitimately differ in shape. +3. **✅ Resolved (implemented). Models keep free-function writers; `Destroy` is thin.** The three + models are now `ClassSimple : Destroyable` instances, but their writers stay **free functions** + (`SetPlayer(model, …)` — the documented autolobby idiom; *not* converted to methods), and `Destroy` + is **thin**: nil the module singleton and let the LazyVars GC once the views observing them are torn + down (no own `Trash` proactively freeing them — see #1, the interface isn't in the bag yet). + "Service" singletons (catalog / derived models: real methods, own `Trash` that frees its resources) + and "data" singletons (these models: free-function writers + thin `Destroy`) legitimately differ in + shape. ### Minor wrinkles (non-blocking) @@ -109,9 +112,13 @@ implementation. `UICustomLobbyMapCatalog : Destroyable`: `table.concatkeys`); slots `self.LoadedSignature` + `self.LoadedTeamsSignature` + `self.Observers`; options `self.Schema` + `self.SchemaKey` (the cached schema). The set-based dedup uses stock `utils.lua` (`table.concatkeys` / `table.sorted` + `table.concat`) — no bespoke helper. -3. The three authoritative models (low-risk; per open decision #3 their write helpers stay free - functions — the `ClassSimple` just adds an own `Trash` + a `Destroy` that frees the LazyVars and - nils the singleton). +3. ✅ The three authoritative models (Launch / Session / Local) — done. Each is a + `ClassSimple : Destroyable` registered in the session bag; per open decision #3 the **write helpers + stay free functions** and `Destroy` is **thin** — it nils the module singleton and lets the LazyVars + GC once the views observing them are torn down. (We deliberately do *not* give them an own `Trash` + that proactively destroys the LazyVars: the interface that subscribes to them isn't in the bag yet + (#5 below), so destroying them on `Teardown` could fire an observer into freed state. The thin + teardown sidesteps the ordering risk in open decision #1.) 4. The mod catalog (mirror the map catalog). 5. ~~`CustomLobbyRules` map-dimension cache.~~ — N/A: `CustomLobbyRules` is now a **pure, stateless** kernel (all inputs passed in; the cached map-dimension lookup is gone), so it holds nothing to tear down. 6. The interface + performance popover — **after** the ordering decision (#1 above). diff --git a/lua/ui/lobby/customlobby/models/CustomLobbyLaunchModel.lua b/lua/ui/lobby/customlobby/models/CustomLobbyLaunchModel.lua index c9ba8fe9537..d3aa9ee9942 100644 --- a/lua/ui/lobby/customlobby/models/CustomLobbyLaunchModel.lua +++ b/lua/ui/lobby/customlobby/models/CustomLobbyLaunchModel.lua @@ -35,6 +35,7 @@ -- § 2 — never mutate a held table in place). local Create = import("/lua/lazyvar.lua").Create +local CustomLobbySession = import("/lua/ui/lobby/customlobby/customlobbysession.lua") --- Maximum number of player slots the engine supports. MaxSlots = 16 @@ -69,8 +70,20 @@ MaxSlots = 16 ------------------------------------------------------------------------------- --#region Reactive model +-- LIFETIME. A `ClassSimple` implementing `Destroyable`, registered in the session trash bag (see +-- CustomLobbySession) on first access, so one `CustomLobbySession.Teardown()` resets it. Per the +-- teardown design's decision #3 the write helpers stay **free functions** (below) and `Destroy` is +-- **thin** (nil the module singleton; the LazyVars — the 16 per-slot vars + the rest — are freed by GC +-- once the views observing them are torn down, not proactively, since the interface that subscribes to +-- them isn't in the bag yet). See design/session-trashbag-teardown.md. + +-- The singleton, forward-declared above the class so `Destroy` captures it as an upvalue. Assigned in +-- `SetupSingleton`, cleared in `Destroy`. +---@type UICustomLobbyLaunchModel | nil +local Instance = nil + --- Reactive launch-state singleton (shared, host-dictated, part of the launch). ----@class UICustomLobbyLaunchModel +---@class UICustomLobbyLaunchModel : Destroyable ---@field Players LazyVar[] # one LazyVar per slot (1..MaxSlots); false = empty ---@field Observers LazyVar # observer list ---@field SpawnMex LazyVar> # adaptive-map spawn-mex flags (embedded into the scenario at launch) @@ -79,41 +92,55 @@ MaxSlots = 16 ---@field GameMods LazyVar

$K|;@r@yRJQKQGRTw8o$=WEx+ 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 z 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%0ASP$`u=RcNW9sNV3%cx? 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 z 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 literal 0 HcmV?d00001 diff --git a/textures/ui/common/lobby/backgrounds/uef-summit.png b/textures/ui/common/lobby/backgrounds/uef-summit.png new file mode 100644 index 0000000000000000000000000000000000000000..1b4d0a67afbb4008303d81cb2983a74cf051a79f GIT binary patch literal 169560 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 literal 0 HcmV?d00001 From ecb3fdf2dc803de0fa21500ea65b35e348c8593d Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Thu, 25 Jun 2026 22:25:45 +0200 Subject: [PATCH 59/98] Add support for basic lobby presets --- lua/ui/lobby/USER_STORIES.md | 8 +- lua/ui/lobby/customlobby/CLAUDE.md | 12 +- .../customlobby/CustomLobbyController.lua | 149 ++++++ .../customlobby/CustomLobbyInterface.lua | 13 + .../lobby/customlobby/CustomLobbyPresets.lua | 132 +++++ .../lobby/customlobby/presetselect/CLAUDE.md | 45 ++ .../presetselect/CustomLobbyPresetSelect.lua | 464 ++++++++++++++++++ 7 files changed, 816 insertions(+), 7 deletions(-) create mode 100644 lua/ui/lobby/customlobby/CustomLobbyPresets.lua create mode 100644 lua/ui/lobby/customlobby/presetselect/CLAUDE.md create mode 100644 lua/ui/lobby/customlobby/presetselect/CustomLobbyPresetSelect.lua diff --git a/lua/ui/lobby/USER_STORIES.md b/lua/ui/lobby/USER_STORIES.md index 294208ac436..2c947e966e5 100644 --- a/lua/ui/lobby/USER_STORIES.md +++ b/lua/ui/lobby/USER_STORIES.md @@ -142,10 +142,10 @@ Roles: **Host**, **Player** (human in a slot), **Observer**, **Joining client** ## O. Presets & rehost -- ⬜ As a **host**, I want to save the current setup (map, options, mods, restrictions, players) as a named preset, so that I can reuse it. *(Only **mod** selections have presets so far — not the full setup.)* -- ⬜ As a **host**, I want to load/delete/rename presets, so that I can manage reusable configurations. -- ⬜ As a **system**, I want the last game auto-saved as a preset at launch, so that rehost can restore it. -- ⬜ As a **host**, I want rehost to reseat returning players to their prior slots (displacing AIs/others as needed), so that a replay setup reconstitutes quickly. +- 🟡 As a **host**, I want to save the current setup (map, options, mods, restrictions, players) as a named preset, so that I can reuse it. *(Save/load of map, options, mods and restrictions works via the action-bar **Presets** dialog; players are captured in the snapshot but **not reseated on load yet** — that needs AI-add + per-player slot intents.)* +- ✅ As a **host**, I want to load/delete/rename presets, so that I can manage reusable configurations. *(The Presets dialog loads/deletes/renames; loading applies the setup host-authoritatively and reconciles options to the current map+mods.)* +- 🟡 As a **system**, I want the last game auto-saved as a preset at launch, so that rehost can restore it. *(Launch auto-saves the reserved `lastGame` snapshot; the rehost **restore** (in-lobby button + `/rehost` arg) is deferred with player reseating.)* +- ⬜ As a **host**, I want rehost to reseat returning players to their prior slots (displacing AIs/others as needed), so that a replay setup reconstitutes quickly. *(Blocked on AI-add + per-player slot intents; the `lastGame` snapshot stores player login names for the future match.)* ## P. Lobby preferences (per user) diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index f3608755ecf..0b4890d84fd 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -46,8 +46,9 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | [CustomLobbyPerformancePopover.lua](CustomLobbyPerformancePopover.lua) | hover popover over the CPU column; hand-built bitmap bar chart of a peer's `PerformanceTrackingV2` history, with a yellow recommended-unit-cap line. | | [CustomLobbyInstance.lua](CustomLobbyInstance.lua) | thin `moho.lobby_methods` shell; validates/dispatches traffic, forwards callbacks to the controller. Also feeds [`CustomLobbyLog`](CustomLobbyLog.lua) from its three network choke points (BroadcastData / SendData / DataReceived). | | [CustomLobbyLog.lua](CustomLobbyLog.lua) | the **network traffic log** — a reactive, per-peer, never-synced ring buffer (`Entries` LazyVar, capped) of every message this peer broadcasts / sends / receives, fed by the instance and rendered by the **Logs** tab. Each peer logs only its own traffic, so host and client views differ naturally. Not one of the three models (no game state); a diagnostic feed. | -| [CustomLobbyController.lua](CustomLobbyController.lua) | host-authority logic (free functions): seating, `Process*` handlers, intents (`RequestSetReady`, `RequestTakeSlot`, `RequestSwapSlots`, `RequestEject`, `RequestMoveToObserver`, `RequestSetScenario`, `RequestSetGameMods/Options`, `RequestResetGameOptions`, `RequestSetSlotsPinned`, `RequestAutoBalance` (stub), `RequestReopenClosedSlots`, `RequestLaunch` — all keyed by slot/bool/file so a chat command can call them too; permission is gated separately), sharing the stored CPU benchmark. **Launch:** `RequestLaunch` (host-only, readiness-validated) → `BuildGameConfiguration` (seed option defaults + scenario, resolve random factions, assign army numbers + push to server, stamp ratings/clan tags, resolve sim mods via `Mods.GetGameMods`) → broadcast `LaunchGame` + `instance:LaunchGame`; clients run `ProcessLaunchGame`. | +| [CustomLobbyController.lua](CustomLobbyController.lua) | host-authority logic (free functions): seating, `Process*` handlers, intents (`RequestSetReady`, `RequestTakeSlot`, `RequestSwapSlots`, `RequestEject`, `RequestMoveToObserver`, `RequestSetScenario`, `RequestSetGameMods/Options`, `RequestResetGameOptions`, `RequestSetSlotsPinned`, `RequestAutoBalance` (stub), `RequestReopenClosedSlots`, `RequestLaunch`, `RequestSaveSetupPreset`/`RequestLoadSetupPreset` — all keyed by slot/bool/file/name so a chat command can call them too; permission is gated separately), sharing the stored CPU benchmark. **Launch:** `RequestLaunch` (host-only, readiness-validated) → auto-saves the `lastGame` preset (`BuildSetupSnapshot`) → `BuildGameConfiguration` (seed option defaults + scenario, resolve random factions, assign army numbers + push to server, stamp ratings/clan tags, resolve sim mods via `Mods.GetGameMods`) → broadcast `LaunchGame` + `instance:LaunchGame`; clients run `ProcessLaunchGame`. **Presets:** `BuildSetupSnapshot` (read the launch state → serializable snapshot) + `ApplySetup` (host-only: write scenario/mods/restrictions/teams, reconcile options to the current schema, one broadcast); players are captured but not reseated (see [presetselect/](presetselect/CLAUDE.md)). | | [CustomLobbyRules.lua](CustomLobbyRules.lua) | game-rule derivations from lobby state (not view, not networking): `RecommendedUnitCap()` (per-player cap by map size, memoised scenario lookup). | +| [CustomLobbyPresets.lua](CustomLobbyPresets.lua) | **named full-setup presets** — pure prefs CRUD (`GetPresets`/`GetPreset`/`SavePreset`/`DeletePreset`/`RenamePreset`) over one key (`customlobby_setup_presets`), an ordered `{ Name, Setup }` array mirroring the mod presets in [`/lua/ui/modutilities.lua`](/lua/ui/modutilities.lua). Holds no models, no network. A reserved `LastGamePresetName` entry is auto-saved at launch (the rehost source). Capturing/applying a setup lives in the controller (`BuildSetupSnapshot`/`ApplySetup`); see [presetselect/](presetselect/CLAUDE.md). | | [CustomLobbySession.lua](CustomLobbySession.lua) | the lobby session's **main trash bag**. The lobby lives in the persistent front-end Lua state, which is *not* reset when the game launches in its own state — so anything left reachable (a running thread, a cache, a singleton) leaks for the whole match. This module owns one session-lifetime `TrashBag` (`GetTrash()`); every lobby-scoped `Destroyable` registers in it, and one `Teardown()` (clean-slate in `CreateLobby`, on leave, on `OnGameLaunched`) frees the lot. Works despite the bag being weak-valued because each singleton is pinned by its own module `Instance` local. **Rollout in progress** — the map catalog is the first resource converted; models / interface / instance follow. | | [CustomLobbyMessages.lua](CustomLobbyMessages.lua) | message registry: `AddPlayer`, `SetPlayers` (launch model: players + observers), `SentLaunchInfo` (launch model: scenario / options / mods / teams / spawn mex), `SetSessionState` (session model: slot count / closed slots), `SetReady`, `TakeSlot`, `DisconnectPeer`, `LaunchGame` (host's final game config → `instance:LaunchGame`), `ReportCpuBenchmark`, `SetCpuBenchmarks`. | | [CustomLobbyContextMenu.lua](CustomLobbyContextMenu.lua) | generic framed floating menu; `Show(entries, x, y)` renders any `{label, action, enabled}` list, dismisses on item click / click-outside / Esc. Knows nothing about the lobby. | @@ -58,8 +59,9 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | [CustomLobbyMapPreview.lua](CustomLobbyMapPreview.lua) | the map preview **as one whole** — the chrome (glow border on top + dark backdrop, surface inset by `Padding`), the surface, and the faction spawn icon (local `MapPreviewSpawn`). Used by **both** consumers: created `Bound = true` it subscribes to the launch model and renders the committed `ScenarioFile` with per-slot faction spawns (no reload on take/swap); created unbound (the map-select dialog) it does no model wiring and the owner drives `preview.Surface` directly (numbered-dot spawns). Exposes `.Surface` for owners to drive / anchor overlays to. | | [mapselect/](mapselect/CLAUDE.md) | the **map-select dialog** + its catalog and list, in their own folder (host-only `Popup`: searchable, filterable scenario list → `RequestSetScenario`). Self-contained sub-MVC; see [mapselect/CLAUDE.md](mapselect/CLAUDE.md) — including the **`MapPreview` texture-leak** writeup that shaped its design. | | [modselect/](modselect/CLAUDE.md) | the **mod-select dialog** + its catalog and list, built to the map-select shape (checkbox list + type filters + detail panel + **presets**). Returns a uid set; the opener routes it — sim mods → `RequestSetGameMods` (synced), UI mods → local prefs — or persists the lot standalone. Mod domain logic lives in [`/lua/ui/modutilities.lua`](/lua/ui/modutilities.lua) (the `maputil.lua` sibling, fronting `/lua/mods.lua`). See [modselect/CLAUDE.md](modselect/CLAUDE.md). | +| [presetselect/](presetselect/CLAUDE.md) | the **setup-presets dialog** (host-only `Popup`): a list of named full-setup snapshots (map / options / mods / restrictions) with **Load / Save / Rename / Delete**, opened by the action-bar **Presets** button. Owns no synced state — Save/Load route through the controller's host-authoritative `RequestSaveSetupPreset` / `RequestLoadSetupPreset` intents; the persistence is [`CustomLobbyPresets`](CustomLobbyPresets.lua). Players are captured in the snapshot but **not reseated on load yet** (deferred — see presetselect/CLAUDE.md). | | [optionselect/](optionselect/CLAUDE.md) | the **options dialog**: three columns (lobby / scenario / mod options) over the selected scenario + mods, with search + hide-defaults filters; non-default options are marked. Derives the option *schema* per-peer (reference data) via [`/lua/ui/optionutil.lua`](/lua/ui/optionutil.lua); edits a working copy of the *values* and on OK routes the reconciled set through `RequestSetGameOptions` (synced via `GameOptions`). Host-only. See [optionselect/CLAUDE.md](optionselect/CLAUDE.md). | -| [CustomLobbyInterface.lua](CustomLobbyInterface.lua) | composition root, laid out in **areas** (flip the module `Debug` flag to tint them), sized for the **1024×768** floor. A **one-column** layout (the two-column variant was reverted by community request): a **title bar** (title · team score) over a **left column** split vertically — the **slots** on top (a single column of rows, up to 16, height tracking the slot count) and the **Chat / Observers** tabs below — beside a fixed-width **right column** (the config component: a pinned map preview + facts line over Options / Mods / Restrictions tabs). A full-width **action bar** at the bottom holds the global actions: **Leave** + status on the left, the host-only **Settings** button (opens the options editor) + the host-only **Launch** on the right. Reads IsHost (action-bar buttons) + SlotCount (to size the slot area via `CustomLobbySlotsInterface.HeightForSlots`); Leave (Esc handler) + **Become observer** (`RequestMoveToObserver`, in the Observers tab). The slot rows + their drag coordination now live in [`CustomLobbySlotsInterface`](CustomLobbySlotsInterface.lua), which fills the slot area. `OpenDebug()` / hot-reload. | +| [CustomLobbyInterface.lua](CustomLobbyInterface.lua) | composition root, laid out in **areas** (flip the module `Debug` flag to tint them), sized for the **1024×768** floor. A **one-column** layout (the two-column variant was reverted by community request): a **title bar** (title · team score) over a **left column** split vertically — the **slots** on top (a single column of rows, up to 16, height tracking the slot count) and the **Chat / Observers** tabs below — beside a fixed-width **right column** (the config component: a pinned map preview + facts line over Options / Mods / Restrictions tabs). A full-width **action bar** at the bottom holds the global actions: **Leave** + status on the left, the host-only **Presets** button (opens the setup-presets dialog — see [presetselect/](presetselect/CLAUDE.md)) + the host-only **Launch** on the right. Reads IsHost (action-bar buttons) + SlotCount (to size the slot area via `CustomLobbySlotsInterface.HeightForSlots`); Leave (Esc handler) + **Become observer** (`RequestMoveToObserver`, in the Observers tab). The slot rows + their drag coordination now live in [`CustomLobbySlotsInterface`](CustomLobbySlotsInterface.lua), which fills the slot area. `OpenDebug()` / hot-reload. | | [CustomLobbyTeamScore.lua](CustomLobbyTeamScore.lua) | the **accumulated team rating** side indicator — `Side A N · M Side B`. Hosted in the strip atop the two-column slot layout (it doubles as the columns' Left/Right header). Shown only for the binary auto-team formations; **hidden** for `none`/`manual` or until a positional map's start positions load. Reads the mode + side split from [`CustomLobbyRules`](CustomLobbyRules.lua) (`AutoTeamMode` / `SideLabels` / `BuildSideResolver`) and the ratings from each slot's `PL`. Reference data; never writes. | | [CustomLobbyTabs.lua](CustomLobbyTabs.lua) | a **generic tabbed panel** (strip + content; one panel alive, created on select / destroyed on switch). Tabs **divide the strip evenly** across its width. Construct with a `{ Label, Create, Badge?, Action?, Icon?, Compact? }` list + optional `OnSelect`. A tab's optional `Badge` LazyVar drives a grey **count pill** to the right of the label; its optional `Action` (`{ Create, Visible? }`) is a small button the owner builds **inside the tab, left of the label** (e.g. a config gear), whose `Visible` LazyVar hides it (collapsing it from the layout) when it doesn't apply. The action, label and pill are centred together as one cluster; any absent/hidden/empty piece contributes 0 width so the rest re-centres. A tab can instead be **`Compact`** (a fixed narrow width, excluded from the even division — the flexible tabs share what's left) and/or show an **`Icon`** centred instead of its label (an icon-only utility tab); the default active tab is the first non-compact one. The container just mirrors the LazyVars, the owner decides what they mean. Used for the bottom-left (Logs / Chat / Observers) and the config interface's Options / Mods / Restrictions. | | [social/](social/) | the lobby's **bottom-left** column (the `CustomLobbyTabs` content): [`CustomLobbyChatPanel`](social/CustomLobbyChatPanel.lua) (the **Chat** tab — placeholder until the chat slice lands) and [`CustomLobbyObserversPanel`](social/CustomLobbyObserversPanel.lua) (the **Observers** tab — the shared observer list + a host-authoritative **Become observer** button → `RequestMoveToObserver`). Each is a tab content component (`Create(parent)`, created on select / destroyed on switch). The Chat / Observers tabs mirror the config column's shape — a per-tab **config gear** (`CustomLobbyInterface`'s local `GearAction`; both no-ops with a "coming soon" tooltip for now) + a right-side **count pill**: Observers shows the live observer count, Chat a dummy until the chat slice lands. A third **compact, icon-only [`CustomLobbyLogsPanel`](social/CustomLobbyLogsPanel.lua)** sits left of Chat (the **Logs** tab — a live **tail view** of this peer's network traffic from [`CustomLobbyLog`](CustomLobbyLog.lua): the most recent entries that fit, in columns `time · kind · ⚠ · name`, with a malformed/unauthorised message tinted + a ⚠ icon whose tooltip is the failure reason). | @@ -84,7 +86,11 @@ the dialogs themselves still exist and will be rewired once it lands; `RequestSe **launch the game** (Launch button → `RequestLaunch`): once a map is picked, ≥1 slot is seated and every other human is ready, it builds the game config, broadcasts `LaunchGame` and hands it to the engine, so all peers start together. (Lobby-UI teardown on launch and reactive enable/disable -of the button are still TODO.) Launched via +of the button are still TODO.) The host can **save / load named setup presets** (the action-bar +**Presets** button → the presets dialog): a preset captures map / options / mods / restrictions and +loading applies them host-authoritatively (options reconciled to the current map+mods); launch +auto-saves the `lastGame` preset. (Player reseating + rehost restore are deferred — see +[presetselect/](presetselect/CLAUDE.md).) Launched via `scripts/LaunchCustomLobby.ps1`, or inspect UI only with `UI_Lua import("/lua/ui/lobby/customlobby/customlobbyinterface.lua").OpenDebug()`. diff --git a/lua/ui/lobby/customlobby/CustomLobbyController.lua b/lua/ui/lobby/customlobby/CustomLobbyController.lua index f5a3271c33d..5f2e75f37ab 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyController.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyController.lua @@ -41,6 +41,7 @@ local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaun local CustomLobbySessionModel = import("/lua/ui/lobby/customlobby/customlobbysessionmodel.lua") local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/customlobbylocalmodel.lua") local CustomLobbySession = import("/lua/ui/lobby/customlobby/customlobbysession.lua") +local CustomLobbyPresets = import("/lua/ui/lobby/customlobby/customlobbypresets.lua") --- The live lobby object, set by the first engine callback. UI-triggered intents --- (RequestSetReady, …) reach the network through it without threading it everywhere. @@ -859,6 +860,15 @@ function RequestLaunch() return end + -- auto-save the launched setup as the reserved "last game" preset, so a rehost can restore it + -- (USER_STORIES.md § O); failure to persist must not block the launch + local ok, err = pcall(function() + CustomLobbyPresets.SavePreset(CustomLobbyPresets.LastGamePresetName, BuildSetupSnapshot()) + end) + if not ok then + WARN("CustomLobby: failed to auto-save the last-game preset — " .. tostring(err)) + end + local gameConfiguration = BuildGameConfiguration(instance) instance:BroadcastData({ Type = 'LaunchGame', GameConfig = gameConfiguration }) instance:LaunchGame(gameConfiguration) @@ -866,6 +876,145 @@ end --#endregion +------------------------------------------------------------------------------- +--#region Setup presets (save / load named full-setup snapshots; § O) +-- +-- Persistence is in CustomLobbyPresets (pure prefs). Capturing the live launch state into a +-- snapshot and applying one back touch the synced model + network, so they live here (the host +-- is the only writer). Players are captured for a future rehost reseat but are NOT applied on a +-- normal load — restoring players/AIs needs infra the new lobby doesn't have yet (no AI-add, no +-- per-player faction/colour/team intents). See the deferred slice in CLAUDE.md. + +--- The player fields a preset captures. `OwnerID` / `Ready` / rating fields are deliberately +--- dropped (connection-specific or recomputed); `PlayerName` is kept as the stable key a future +--- rehost reseat matches returning humans on. +local PlayerPresetFields = { + 'PlayerName', 'Human', 'Faction', 'Team', 'PlayerColor', 'ArmyColor', 'StartSpot', 'AIPersonality', +} + +--- A serializable copy of a player, trimmed to the preset fields. +---@param player UICustomLobbyPlayer +---@return table +local function TrimPlayerForPreset(player) + local trimmed = {} + for _, key in PlayerPresetFields do + trimmed[key] = player[key] + end + return trimmed +end + +--- Reads the current launch state into a plain serializable setup snapshot. Used both by the +--- save-preset intent and the launch auto-save. Pure read — never mutates the model. +---@return UICustomLobbySetupSnapshot +function BuildSetupSnapshot() + local launch = CustomLobbyLaunchModel.GetSingleton() + + local players = {} + for slot = 1, CustomLobbyLaunchModel.MaxSlots do + local player = launch.Players[slot]() + players[slot] = player and TrimPlayerForPreset(player) or false + end + + local observers = {} + for _, observer in launch.Observers() do + table.insert(observers, TrimPlayerForPreset(observer)) + end + + -- per-player Ratings / ClanTags are stamped fresh at launch; drop them from the saved options + local options = table.deepcopy(launch.GameOptions()) + options.Ratings = nil + options.ClanTags = nil + + return { + ScenarioFile = launch.ScenarioFile(), + GameOptions = options, + GameMods = table.deepcopy(launch.GameMods()), + Restrictions = table.deepcopy(launch.Restrictions()), + AutoTeams = table.deepcopy(launch.AutoTeams()), + SpawnMex = table.deepcopy(launch.SpawnMex()), + Players = players, + Observers = observers, + } +end + +--- Host-side: applies a setup snapshot to the launch model and broadcasts it. Sets the scenario +--- (re-sizing the lobby room), sim mods, restrictions and teams/spawn-mex, then reconciles the +--- game options against the now-current scenario+mods (drop stale keys, seed defaults) — the same +--- reconcile the options dialog / reset use — and broadcasts once. Players are left untouched +--- (see the region note). Mirrors the legacy `ApplyGameSettings` → single `UpdateGame`. +---@param setup UICustomLobbySetupSnapshot +function ApplySetup(setup) + local instance = LobbyInstance + if not instance then + WARN("CustomLobby: ApplySetup ignored — no lobby instance (UI-only, or the controller was " + .. "hot-reloaded; re-host to restore it)") + return + end + + if not CustomLobbyLocalModel.GetSingleton().IsHost() then + WARN("CustomLobby: only the host can load a setup preset") + return + end + + if not setup then + WARN("CustomLobby: ApplySetup ignored — empty setup") + return + end + + local launch = CustomLobbyLaunchModel.GetSingleton() + + -- scenario first: it sizes the lobby room and drives the option schema below + local scenario = setup.ScenarioFile or false + CustomLobbyLaunchModel.SetScenario(launch, scenario) + local count = ScenarioSlotCount(scenario) + if count > 0 then + local session = CustomLobbySessionModel.GetSingleton() + session.SlotCount:Set(math.min(count, CustomLobbyLaunchModel.MaxSlots)) + BroadcastSessionState(instance) + end + + CustomLobbyLaunchModel.SetGameMods(launch, setup.GameMods or {}) + CustomLobbyLaunchModel.SetRestrictions(launch, setup.Restrictions or {}) + launch.AutoTeams:Set(table.deepcopy(setup.AutoTeams or {})) + launch.SpawnMex:Set(table.deepcopy(setup.SpawnMex or {})) + + -- reconcile the saved option values against the current scenario+mods schema + local OptionUtil = import("/lua/ui/optionutil.lua") + local schema = {} + for _, option in OptionUtil.GetLobbyOptions() do table.insert(schema, option) end + for _, option in OptionUtil.GetScenarioOptions(scenario) do table.insert(schema, option) end + for _, option in OptionUtil.GetModOptions(launch.GameMods()) do table.insert(schema, option) end + CustomLobbyLaunchModel.SetGameOptions(launch, OptionUtil.SeedDefaults(schema, setup.GameOptions or {})) + + BroadcastLaunchInfo(instance) +end + +--- Saves the current setup under `name` (host-local prefs). Reads the live model, so a client may +--- also capture the host-dictated setup to reuse later; the save itself never mutates the lobby. +---@param name string +function RequestSaveSetupPreset(name) + if not name or name == "" then + WARN("CustomLobby: RequestSaveSetupPreset ignored — empty name") + return + end + CustomLobbyPresets.SavePreset(name, BuildSetupSnapshot()) + LOG("CustomLobby: setup preset saved (" .. name .. ")") +end + +--- Loads the named setup preset and applies it. Host-only (enforced in `ApplySetup`). +---@param name string +function RequestLoadSetupPreset(name) + local setup = CustomLobbyPresets.GetPreset(name) + if not setup then + WARN("CustomLobby: no setup preset named " .. tostring(name)) + return + end + ApplySetup(setup) + LOG("CustomLobby: setup preset loaded (" .. tostring(name) .. ")") +end + +--#endregion + --- The host swaps the contents of two slots. Host-only (a client request isn't --- offered) — backs a host-side drag/menu and a `/swap ` chat command. ---@param slotA number diff --git a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua index d2967aec4fe..a0cbd232881 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua @@ -68,6 +68,7 @@ local CustomLobbyTabs = import("/lua/ui/lobby/customlobby/customlobbytabs.lua") local CustomLobbyChatPanel = import("/lua/ui/lobby/customlobby/social/customlobbychatpanel.lua") local CustomLobbyObserversPanel = import("/lua/ui/lobby/customlobby/social/customlobbyobserverspanel.lua") local CustomLobbyLogsPanel = import("/lua/ui/lobby/customlobby/social/customlobbylogspanel.lua") +local CustomLobbyPresetSelect = import("/lua/ui/lobby/customlobby/presetselect/customlobbypresetselect.lua") local LazyVarCreate = import("/lua/lazyvar.lua").Create local LazyVarDerive = import("/lua/lazyvar.lua").Derive @@ -155,6 +156,7 @@ end ---@field ActionArea Group ---@field StatusLabel Text ---@field LaunchButton Button +---@field PresetsButton Button # host-only: opens the setup-presets dialog ---@field IsHostObserver LazyVar local CustomLobbyInterface = Class(Group) { @@ -237,6 +239,14 @@ local CustomLobbyInterface = Class(Group) { self.LaunchButton.OnClick = function(button, modifiers) CustomLobbyController.RequestLaunch() end + + -- host-only: save / load named full-setup presets (map, options, mods, restrictions) + self.PresetsButton = UIUtil.CreateButtonWithDropshadow(self.ActionArea, '/BUTTON/medium/', "Presets") + self.PresetsButton.OnClick = function(button, modifiers) + CustomLobbyPresetSelect.Open(GetFrame(0)) + end + Tooltip.AddControlTooltipManual(self.PresetsButton, "Presets", + "Save the current setup as a named preset, or load a saved one (host only).") Tooltip.AddControlTooltipManual(self.LaunchButton, "Launch", "Start the game with the current setup (host only). Everyone else must be ready.") --#endregion @@ -306,6 +316,7 @@ local CustomLobbyInterface = Class(Group) { Layouter(self.LeaveButton):AtLeftIn(self.ActionArea):AtVerticalCenterIn(self.ActionArea):End() Layouter(self.StatusLabel):AnchorToRight(self.LeaveButton, 8):AtVerticalCenterIn(self.ActionArea):End() Layouter(self.LaunchButton):AtRightIn(self.ActionArea):AtVerticalCenterIn(self.ActionArea):End() + Layouter(self.PresetsButton):AnchorToLeft(self.LaunchButton, 8):AtVerticalCenterIn(self.LaunchButton):End() --#endregion -- size-dependent children build their scrollbars / first render now that they're sized @@ -323,8 +334,10 @@ local CustomLobbyInterface = Class(Group) { self.StatusLabel:SetText(isHost and "You are the host." or "The host controls the game.") if isHost then self.LaunchButton:Show() + self.PresetsButton:Show() else self.LaunchButton:Hide() + self.PresetsButton:Hide() end end, diff --git a/lua/ui/lobby/customlobby/CustomLobbyPresets.lua b/lua/ui/lobby/customlobby/CustomLobbyPresets.lua new file mode 100644 index 00000000000..ac23e0edbd8 --- /dev/null +++ b/lua/ui/lobby/customlobby/CustomLobbyPresets.lua @@ -0,0 +1,132 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- Named full-setup presets for the custom lobby — the customlobby-native rebuild of the legacy +-- `/lua/ui/lobby/presets.lua` (which keyed `LobbyPresets`). Pure persistence: this module only +-- reads and writes the prefs; it never touches the lobby models or the network. Capturing the +-- current setup into a snapshot and applying a snapshot back are host-authoritative and live in +-- the controller (`CustomLobbyController.BuildSetupSnapshot` / `ApplySetup`). +-- +-- A preset is `{ Name = string, Setup = UICustomLobbySetupSnapshot }`, stored as an ordered array +-- under one prefs key so the user's creation order is preserved — mirroring the mod presets in +-- `/lua/ui/modutilities.lua`. A reserved `LastGamePresetName` entry is auto-saved at launch so a +-- rehost can restore the last game (see USER_STORIES.md § O). + +local Prefs = import("/lua/user/prefs.lua") + +--- The prefs key holding the ordered array of setup presets (the host's machine only). +local PrefsKey = "customlobby_setup_presets" + +--- The reserved preset name auto-saved at launch (the rehost source). Shown to the user as a +--- pinned "Last game" entry rather than a normal named preset. +LastGamePresetName = "lastGame" + +--- A serializable snapshot of the launch setup (written to disk). Players are captured for a future +--- rehost reseat but are not applied on a normal load (see the deferred slice in CLAUDE.md). +---@class UICustomLobbySetupSnapshot +---@field ScenarioFile FileName | false +---@field GameOptions table # the host's option values, minus per-player Ratings/ClanTags +---@field GameMods table # sim-mod uid set +---@field Restrictions string[] # unit-restriction preset keys +---@field AutoTeams table # best-effort (no host setter yet) +---@field SpawnMex table# best-effort +---@field Players table # [slot] = trimmed player or false (captured, not yet applied) +---@field Observers table # trimmed observer list + +--- All saved presets, in creation order (the `lastGame` entry included). +---@return { Name: string, Setup: UICustomLobbySetupSnapshot }[] +function GetPresets() + return Prefs.GetFromCurrentProfile(PrefsKey) or {} +end + +--- Finds a preset's setup snapshot by name, or nil. +---@param name string +---@return UICustomLobbySetupSnapshot | nil +function GetPreset(name) + for _, preset in GetPresets() do + if preset.Name == name then + return preset.Setup + end + end + return nil +end + +--- Saves `setup` under `name`, overwriting an existing preset with the same name. The caller owns +--- the snapshot (it is stored by reference — pass a fresh `BuildSetupSnapshot()` result). +---@param name string +---@param setup UICustomLobbySetupSnapshot +function SavePreset(name, setup) + local presets = GetPresets() + for _, preset in presets do + if preset.Name == name then + preset.Setup = setup + Prefs.SetToCurrentProfile(PrefsKey, presets) + SavePreferences() + return + end + end + table.insert(presets, { Name = name, Setup = setup }) + Prefs.SetToCurrentProfile(PrefsKey, presets) + SavePreferences() +end + +--- Removes the preset with the given name (no-op if absent). +---@param name string +function DeletePreset(name) + local presets = GetPresets() + local kept = {} + for _, preset in presets do + if preset.Name ~= name then + table.insert(kept, preset) + end + end + Prefs.SetToCurrentProfile(PrefsKey, kept) + SavePreferences() +end + +--- Renames a preset, preserving its position. Rejects a blank name or a collision with another +--- existing preset (returns false); returns true on success. +---@param oldName string +---@param newName string +---@return boolean +function RenamePreset(oldName, newName) + if not newName or newName == "" or newName == oldName then + return false + end + local presets = GetPresets() + local target + for _, preset in presets do + if preset.Name == newName then + return false -- collision with a different preset + end + if preset.Name == oldName then + target = preset + end + end + if not target then + return false + end + target.Name = newName + Prefs.SetToCurrentProfile(PrefsKey, presets) + SavePreferences() + return true +end diff --git a/lua/ui/lobby/customlobby/presetselect/CLAUDE.md b/lua/ui/lobby/customlobby/presetselect/CLAUDE.md new file mode 100644 index 00000000000..9db941c246a --- /dev/null +++ b/lua/ui/lobby/customlobby/presetselect/CLAUDE.md @@ -0,0 +1,45 @@ +# Preset select + +The custom lobby's **setup-presets dialog** — named full-setup snapshots (map / options / mods / +restrictions) the host can Load / Save / Rename / Delete. The customlobby-native rebuild of the +legacy [`/lua/ui/lobby/presets.lua`](../../presets.lua) dialog (which keyed `LobbyPresets`), built +to the same shape as the [`../mapselect/`](../mapselect/CLAUDE.md) / [`../modselect/`](../modselect/CLAUDE.md) +dialogs. Opened by the host-only **Presets** button in the action bar +([`../CustomLobbyInterface.lua`](../CustomLobbyInterface.lua)). + +> Read [`../CLAUDE.md`](../CLAUDE.md) first (the lobby's MVC + the layout/init gotchas). + +## Files + +| File | Role | +|------|------| +| [CustomLobbyPresetSelect.lua](CustomLobbyPresetSelect.lua) | the dialog (transient `Popup`). **Areas** layout (title / preset list (left) / detail (right) / actions — flip the module `Debug` flag to tint them), three-phase init. Left: an `ItemList` of saved presets (the reserved `lastGame` entry shows as "Last game"). Right: a read-only `ItemList` of the selected preset's facts (map · #mods · #restrictions · #players). Bottom: **Load / Save / Rename / Delete** + Close. Owns **no** synced state — Load/Save go through the controller's host-authoritative intents; Delete/Rename are host-local prefs (called straight on `CustomLobbyPresets`). | + +The persistence lives one level up in [`../CustomLobbyPresets.lua`](../CustomLobbyPresets.lua) (pure +prefs CRUD, key `customlobby_setup_presets`, mirroring the mod presets in +[`/lua/ui/modutilities.lua`](/lua/ui/modutilities.lua)). Capturing the live launch state into a +snapshot (`BuildSetupSnapshot`) and applying one back (`ApplySetup`) touch the synced model + the +network, so they live in [`../CustomLobbyController.lua`](../CustomLobbyController.lua) (the host is +the only writer) — the dialog only calls the `RequestSaveSetupPreset` / `RequestLoadSetupPreset` +intents. + +## The MVC boundary (where the setup goes) + +- **Save** → `RequestSaveSetupPreset(name)` → `CustomLobbyPresets.SavePreset(name, BuildSetupSnapshot())`. + A pure read of the model + a local prefs write; never mutates the lobby. +- **Load** → `RequestLoadSetupPreset(name)` → `ApplySetup(setup)`: host-only. Sets scenario (re-sizing + the room), mods, restrictions and teams/spawn-mex on the launch model, **reconciles** the saved + option values against the now-current scenario+mods schema (drop stale keys, seed defaults — same + path as the options dialog / reset), then **one** `BroadcastLaunchInfo`. Mirrors the legacy + `ApplyGameSettings` → single `UpdateGame`. +- **Delete / Rename** → straight to `CustomLobbyPresets` (host-local prefs; no sync). + +## Players & rehost are deferred (§ O) + +The snapshot **captures** players (trimmed: `PlayerName` + seating/faction/colour, no `OwnerID`/rating) +and observers, but **`ApplySetup` does not reseat them** — restoring players/AIs needs infra the new +lobby doesn't have yet (no AI-add, no per-player faction/colour/team intents — roadmap slice #3). The +launch auto-saves the reserved `lastGame` preset (so the data is there), but the rehost **restore** +(an in-lobby "Rehost last game" button + `/rehost` command-line detection at `CreateLobby`, matching +the FAF client) is part of that later slice. `PlayerName` is stored as the stable key the future +reseat will match returning humans on. diff --git a/lua/ui/lobby/customlobby/presetselect/CustomLobbyPresetSelect.lua b/lua/ui/lobby/customlobby/presetselect/CustomLobbyPresetSelect.lua new file mode 100644 index 00000000000..e6601597312 --- /dev/null +++ b/lua/ui/lobby/customlobby/presetselect/CustomLobbyPresetSelect.lua @@ -0,0 +1,464 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- The setup-presets dialog: a host-only list of named full-setup snapshots (map / options / mods / +-- restrictions) you can Load / Save / Rename / Delete — the customlobby-native rebuild of the legacy +-- `/lua/ui/lobby/presets.lua` dialog. Built to the map/mod-select shape (areas layout, three-phase +-- init, Popup singleton). +-- +-- It is a transient picker, NOT a model component: it owns no synced state. Persistence is in +-- CustomLobbyPresets (pure prefs); applying a preset to the synced launch state is host-authoritative +-- and goes through the controller intents (`RequestLoadSetupPreset` / `RequestSaveSetupPreset`). +-- +-- Scope note (§ O): a preset captures the *players* too, but loading does NOT reseat them yet — +-- restoring players/AIs needs infra the new lobby doesn't have (no AI-add, no per-player intents). +-- The reserved `lastGame` preset (auto-saved at launch) is shown as a pinned "Last game" entry; the +-- future rehost feature loads it. See ../CLAUDE.md. + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") +local Tooltip = import("/lua/ui/game/tooltip.lua") + +local Group = import("/lua/maui/group.lua").Group +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap +local Popup = import("/lua/ui/controls/popups/popup.lua").Popup +local ItemList = import("/lua/maui/itemlist.lua").ItemList + +local CustomLobbyPresets = import("/lua/ui/lobby/customlobby/customlobbypresets.lua") +local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") + +local Layouter = LayoutHelpers.ReusedLayoutFor + +-- flip to tint each layout area so the regions are visible while iterating +local Debug = false + +local DialogWidth = 620 +local DialogHeight = 470 +local Pad = 12 +local ColumnGap = 20 +local ListWidth = 250 +local ScrollbarInset = 20 +local TitleHeight = 32 +local ActionHeight = 44 + +local LabelColor = 'ffc8ccd0' + +--- Creates a layout area (an invisible Group with an optional debug tint). +---@param parent Control +---@param name string +---@param color string +---@return Group +local function CreateArea(parent, name, color) + local area = Group(parent, name) + local bg = Bitmap(area) + bg:SetSolidColor(color) + bg:SetAlpha(Debug and 0.18 or 0.0) + bg:DisableHitTest() + Layouter(bg):Fill(area):End() + area.Bg = bg + return area +end + +--- The user-facing label for a preset name (the reserved last-game key shows as "Last game"). +---@param name string +---@return string +local function DisplayName(name) + if name == CustomLobbyPresets.LastGamePresetName then + return "Last game" + end + return name +end + +--- The number of seated players a snapshot captured. +---@param setup UICustomLobbySetupSnapshot +---@return number +local function CountPlayers(setup) + local n = 0 + for _, player in setup.Players or {} do + if player then + n = n + 1 + end + end + return n +end + +--- The map's display name for a snapshot, or "(none)" / the raw file when it can't be read. +---@param setup UICustomLobbySetupSnapshot +---@return string +local function MapNameOf(setup) + local scenarioFile = setup.ScenarioFile + if not scenarioFile then + return "(none)" + end + local ok, info = pcall(function() + return import("/lua/ui/maputil.lua").LoadScenario(scenarioFile) + end) + if ok and info and info.name then + return info.name + end + return tostring(scenarioFile) +end + +--- The read-only fact lines shown for the selected preset. +---@param setup UICustomLobbySetupSnapshot +---@return string[] +local function FactsFor(setup) + return { + "Map: " .. MapNameOf(setup), + "Mods: " .. table.getsize(setup.GameMods or {}), + "Restrictions: " .. table.getn(setup.Restrictions or {}), + "Players: " .. CountPlayers(setup), + } +end + +---@class UICustomLobbyPresetSelect : Group +---@field Trash TrashBag +---@field OnCloseCb fun() +---@field TitleArea Group +---@field ListArea Group +---@field DetailArea Group +---@field ActionArea Group +---@field Title Text +---@field PresetList ItemList +---@field DetailList ItemList +---@field EmptyLabel Text +---@field LoadButton Button +---@field SaveButton Button +---@field RenameButton Button +---@field DeleteButton Button +---@field CloseButton Button +---@field OrderedNames string[] # actual preset names, parallel to the list rows (0-based +1) +---@field Ready boolean +local CustomLobbyPresetSelect = ClassUI(Group) { + + ---@param self UICustomLobbyPresetSelect + ---@param parent Control + ---@param options { onClose: fun() } + __init = function(self, parent, options) + Group.__init(self, parent, "CustomLobbyPresetSelect") + + self.Trash = TrashBag() + self.OnCloseCb = options.onClose + self.Ready = false + self.OrderedNames = {} + + -- areas + self.TitleArea = CreateArea(self, "TitleArea", 'ffcc4040') + self.ListArea = CreateArea(self, "ListArea", 'ff4060cc') + self.DetailArea = CreateArea(self, "DetailArea", 'ffcc40cc') + self.ActionArea = CreateArea(self, "ActionArea", 'ff808080') + + self.Title = UIUtil.CreateText(self.TitleArea, "Setup presets", 22, UIUtil.titleFont) + self.Title:DisableHitTest() + + --#region preset list (left) + self.PresetList = ItemList(self.ListArea) + self.PresetList:SetFont(UIUtil.bodyFont, 14) + self.PresetList:ShowMouseoverItem(true) + self.PresetList.OnClick = function(control, row, event) + control:SetSelection(row) + self:OnSelectRow(row) + end + self.PresetList.OnKeySelect = function(control, row) + self:OnSelectRow(row) + end + self.PresetList.OnDoubleClick = function(control, row) + self:OnSelectRow(row) + self:LoadSelected() + end + + self.EmptyLabel = UIUtil.CreateText(self.ListArea, "No saved presets", 14, UIUtil.bodyFont) + self.EmptyLabel:SetColor('ff8a909a') + self.EmptyLabel:DisableHitTest() + self.EmptyLabel:Hide() + --#endregion + + --#region detail (right) — a read-only fact list + self.DetailList = ItemList(self.DetailArea) + self.DetailList:SetFont(UIUtil.bodyFont, 13) + self.DetailList:SetColors(LabelColor, "00000000") + self.DetailList:DisableHitTest() + --#endregion + + --#region actions + self.LoadButton = UIUtil.CreateButtonStd(self.ActionArea, '/scx_menu/small-btn/small', "Load", 16, 2) + self.LoadButton.OnClick = function(button, modifiers) + self:LoadSelected() + end + Tooltip.AddControlTooltipManual(self.LoadButton, "Load preset", + "Apply the selected preset's map, options, mods and restrictions to the lobby.") + + self.SaveButton = UIUtil.CreateButtonStd(self.ActionArea, '/scx_menu/small-btn/small', "Save", 16, 2) + self.SaveButton.OnClick = function(button, modifiers) + self:PromptSave() + end + Tooltip.AddControlTooltipManual(self.SaveButton, "Save preset", "Save the current lobby setup as a named preset.") + + self.RenameButton = UIUtil.CreateButtonStd(self.ActionArea, '/scx_menu/small-btn/small', "Rename", 16, 2) + self.RenameButton.OnClick = function(button, modifiers) + self:PromptRename() + end + Tooltip.AddControlTooltipManual(self.RenameButton, "Rename preset", "Rename the selected preset.") + + self.DeleteButton = UIUtil.CreateButtonStd(self.ActionArea, '/scx_menu/small-btn/small', "Delete", 16, 2) + self.DeleteButton.OnClick = function(button, modifiers) + self:DeleteSelected() + end + Tooltip.AddControlTooltipManual(self.DeleteButton, "Delete preset", "Delete the selected preset.") + + self.CloseButton = UIUtil.CreateButtonStd(self.ActionArea, '/scx_menu/small-btn/small', "Close", 16, 2) + self.CloseButton.OnClick = function(button, modifiers) + self.OnCloseCb() + end + --#endregion + end, + + ---@param self UICustomLobbyPresetSelect + __post_init = function(self) + self.Width:Set(LayoutHelpers.ScaleNumber(DialogWidth)) + self.Height:Set(LayoutHelpers.ScaleNumber(DialogHeight)) + + --#region areas + Layouter(self.TitleArea):AtLeftIn(self, Pad):AtRightIn(self, Pad):AtTopIn(self, Pad):Height(TitleHeight):End() + Layouter(self.ActionArea):AtLeftIn(self, Pad):AtRightIn(self, Pad):AtBottomIn(self, Pad):Height(ActionHeight):End() + Layouter(self.ListArea) + :AtLeftIn(self, Pad):Width(ListWidth) + :AnchorToBottom(self.TitleArea, Pad):AnchorToTop(self.ActionArea, Pad) + :End() + Layouter(self.DetailArea) + :AnchorToRight(self.ListArea, ColumnGap):AtRightIn(self, Pad) + :AnchorToBottom(self.TitleArea, Pad):AnchorToTop(self.ActionArea, Pad) + :End() + --#endregion + + Layouter(self.Title):AtHorizontalCenterIn(self.TitleArea):AtVerticalCenterIn(self.TitleArea):End() + + --#region list + detail + Layouter(self.PresetList):AtLeftIn(self.ListArea):AtTopIn(self.ListArea):AtBottomIn(self.ListArea):End() + self.PresetList.Right:Set(function() return self.ListArea.Right() - LayoutHelpers.ScaleNumber(ScrollbarInset) end) + UIUtil.CreateLobbyVertScrollbar(self.PresetList, 2) + Layouter(self.EmptyLabel):AtHorizontalCenterIn(self.ListArea):AtVerticalCenterIn(self.ListArea):End() + + Layouter(self.DetailList):AtLeftIn(self.DetailArea):AtTopIn(self.DetailArea):AtBottomIn(self.DetailArea):End() + self.DetailList.Right:Set(function() return self.DetailArea.Right() - LayoutHelpers.ScaleNumber(ScrollbarInset) end) + --#endregion + + --#region actions: Load / Save / Rename / Delete on the left, Close on the right + Layouter(self.LoadButton):AtLeftIn(self.ActionArea):AtVerticalCenterIn(self.ActionArea):End() + Layouter(self.SaveButton):AnchorToRight(self.LoadButton, 8):AtVerticalCenterIn(self.ActionArea):End() + Layouter(self.RenameButton):AnchorToRight(self.SaveButton, 8):AtVerticalCenterIn(self.ActionArea):End() + Layouter(self.DeleteButton):AnchorToRight(self.RenameButton, 8):AtVerticalCenterIn(self.ActionArea):End() + Layouter(self.CloseButton):AtRightIn(self.ActionArea):AtVerticalCenterIn(self.ActionArea):End() + --#endregion + end, + + --- Post-mount first render (the opener calls this after Popup centres the dialog). + ---@param self UICustomLobbyPresetSelect + Initialize = function(self) + self.Ready = true + self:RefreshList() + end, + + --- Rebuilds the preset list from prefs, preserving the selection by name where possible. + ---@param self UICustomLobbyPresetSelect + ---@param keepName? string + RefreshList = function(self, keepName) + local presets = CustomLobbyPresets.GetPresets() + self.OrderedNames = {} + self.PresetList:DeleteAllItems() + for _, preset in presets do + table.insert(self.OrderedNames, preset.Name) + self.PresetList:AddItem(DisplayName(preset.Name)) + end + + local count = table.getn(self.OrderedNames) + if count == 0 then + self.EmptyLabel:Show() + self:OnSelectRow(nil) + return + end + self.EmptyLabel:Hide() + + -- restore the previous selection by name, else select the first row + local select = 0 + if keepName then + for index, name in self.OrderedNames do + if name == keepName then + select = index - 1 + break + end + end + end + self.PresetList:SetSelection(select) + self:OnSelectRow(select) + end, + + --- The actual preset name for a 0-based list row, or nil. + ---@param self UICustomLobbyPresetSelect + ---@param row number | nil + ---@return string | nil + NameForRow = function(self, row) + if type(row) ~= 'number' then + return nil + end + return self.OrderedNames[row + 1] + end, + + --- Updates the detail panel + button enablement for the selected row (nil = no selection). + ---@param self UICustomLobbyPresetSelect + ---@param row number | nil + OnSelectRow = function(self, row) + local name = self:NameForRow(row) + self.DetailList:DeleteAllItems() + if not name then + self.LoadButton:Disable() + self.RenameButton:Disable() + self.DeleteButton:Disable() + return + end + + self.LoadButton:Enable() + self.DeleteButton:Enable() + -- the reserved last-game entry can't be renamed (its name is the rehost contract) + if name == CustomLobbyPresets.LastGamePresetName then + self.RenameButton:Disable() + else + self.RenameButton:Enable() + end + + local setup = CustomLobbyPresets.GetPreset(name) + if setup then + for _, line in FactsFor(setup) do + self.DetailList:AddItem(line) + end + end + end, + + --- Loads (applies) the selected preset and closes the dialog. + ---@param self UICustomLobbyPresetSelect + LoadSelected = function(self) + local name = self:NameForRow(self.PresetList:GetSelection()) + if not name then + return + end + CustomLobbyController.RequestLoadSetupPreset(name) + self.OnCloseCb() + end, + + --- Prompts for a name and saves the current lobby setup as a preset. + ---@param self UICustomLobbyPresetSelect + PromptSave = function(self) + UIUtil.CreateInputDialog(GetFrame(0), "Name this preset", function(dialog, name) + if not name or name == "" then + return + end + CustomLobbyController.RequestSaveSetupPreset(name) + self:RefreshList(name) + end) + end, + + --- Prompts for a new name and renames the selected preset. + ---@param self UICustomLobbyPresetSelect + PromptRename = function(self) + local oldName = self:NameForRow(self.PresetList:GetSelection()) + if not oldName or oldName == CustomLobbyPresets.LastGamePresetName then + return + end + UIUtil.CreateInputDialog(GetFrame(0), "Rename preset", function(dialog, newName) + if CustomLobbyPresets.RenamePreset(oldName, newName) then + self:RefreshList(newName) + end + end) + end, + + --- Deletes the selected preset. + ---@param self UICustomLobbyPresetSelect + DeleteSelected = function(self) + local name = self:NameForRow(self.PresetList:GetSelection()) + if not name then + return + end + CustomLobbyPresets.DeletePreset(name) + self:RefreshList() + end, + + ---@param self UICustomLobbyPresetSelect + OnDestroy = function(self) + self.Trash:Destroy() + end, +} + +------------------------------------------------------------------------------- +--#region Singleton + open / close + +---@type Popup | false +local Instance = false + +--- Opens the setup-presets dialog over `parent` (host-only entry point — the action-bar button). +---@param parent? Control +function Open(parent) + parent = parent or GetFrame(0) + + if Instance then + Instance:Close() + end + + local popup + local content = CustomLobbyPresetSelect(parent, { + onClose = function() + if popup then + popup:Close() + end + end, + }) + + popup = Popup(parent, content) + local baseOnClosed = popup.OnClosed + popup.OnClosed = function(self) + baseOnClosed(self) + Instance = false + end + Instance = popup + + -- now that Popup has mounted + centred the content, it's safe to populate (the lists read + -- concrete geometry) + content:Initialize() +end + +--- Closes the dialog if open. +function Close() + if Instance then + Instance:Close() + Instance = false + end +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Debugging + +function __moduleinfo.OnDirty() + Close() +end + +--#endregion From 8f3d9063565d961a2c6acbfead92470167fd2a78 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Thu, 25 Jun 2026 22:26:00 +0200 Subject: [PATCH 60/98] Help Claude understand texture size --- lua/ui/CLAUDE.md | 11 + lua/ui/lobby/customlobby/CLAUDE.md | 4 +- textures/texture-dimensions.csv | 8293 ++++++++++++++++++++++++++++ 3 files changed, 8307 insertions(+), 1 deletion(-) create mode 100644 textures/texture-dimensions.csv diff --git a/lua/ui/CLAUDE.md b/lua/ui/CLAUDE.md index 077ae6a3f36..568bc11ca82 100644 --- a/lua/ui/CLAUDE.md +++ b/lua/ui/CLAUDE.md @@ -250,6 +250,17 @@ self.SomeLine.Top:Set(scaled(20)) If you find yourself reaching for `ScaleNumber` a lot, that's usually a sign you should be using `Layouter` instead. +### Standard asset sizes — look them up, don't guess + +A `Button` (and most textured controls) sizes to its art by default, so the texture's width *is* the +control width unless you override it. Don't eyeball these. Every texture's native (unscaled) pixel +size is dumped in [`/textures/texture-dimensions.csv`](/textures/texture-dimensions.csv) +(`RelativePath, Width, Height, Format`) — grep it for the resolved path. `SkinnableFile` maps +`/BUTTON/` → `ui/common/widgets/`; `CreateButtonStd`/`CreateButtonWithDropshadow` append +`_btn_{up,…}.dds`. Common standard buttons (unscaled W × H): `/BUTTON/large/` **392 × 92**, +`/BUTTON/medium/` **276 × 72**, `/BUTTON/small/` **152 × 40**, `/scx_menu/small-btn/small` +**200 × 72**, gear/close menu-btns **24 × 24**. Remember these are pre-scale — multiply by the UI scale. + ### Scrollbars — reserve the gutter on the content, not the bar `UIUtil.CreateVertScrollbarFor(content, offset_right)` sets `bar.Left = content.Right + offset_right` (`AnchorToRight`). The convention here: **inset the content's right edge by the gutter and attach the bar at offset 0** — the bar then sits in that reserved strip. diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index 0b4890d84fd..e497fc40ff6 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -61,7 +61,9 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | [modselect/](modselect/CLAUDE.md) | the **mod-select dialog** + its catalog and list, built to the map-select shape (checkbox list + type filters + detail panel + **presets**). Returns a uid set; the opener routes it — sim mods → `RequestSetGameMods` (synced), UI mods → local prefs — or persists the lot standalone. Mod domain logic lives in [`/lua/ui/modutilities.lua`](/lua/ui/modutilities.lua) (the `maputil.lua` sibling, fronting `/lua/mods.lua`). See [modselect/CLAUDE.md](modselect/CLAUDE.md). | | [presetselect/](presetselect/CLAUDE.md) | the **setup-presets dialog** (host-only `Popup`): a list of named full-setup snapshots (map / options / mods / restrictions) with **Load / Save / Rename / Delete**, opened by the action-bar **Presets** button. Owns no synced state — Save/Load route through the controller's host-authoritative `RequestSaveSetupPreset` / `RequestLoadSetupPreset` intents; the persistence is [`CustomLobbyPresets`](CustomLobbyPresets.lua). Players are captured in the snapshot but **not reseated on load yet** (deferred — see presetselect/CLAUDE.md). | | [optionselect/](optionselect/CLAUDE.md) | the **options dialog**: three columns (lobby / scenario / mod options) over the selected scenario + mods, with search + hide-defaults filters; non-default options are marked. Derives the option *schema* per-peer (reference data) via [`/lua/ui/optionutil.lua`](/lua/ui/optionutil.lua); edits a working copy of the *values* and on OK routes the reconciled set through `RequestSetGameOptions` (synced via `GameOptions`). Host-only. See [optionselect/CLAUDE.md](optionselect/CLAUDE.md). | -| [CustomLobbyInterface.lua](CustomLobbyInterface.lua) | composition root, laid out in **areas** (flip the module `Debug` flag to tint them), sized for the **1024×768** floor. A **one-column** layout (the two-column variant was reverted by community request): a **title bar** (title · team score) over a **left column** split vertically — the **slots** on top (a single column of rows, up to 16, height tracking the slot count) and the **Chat / Observers** tabs below — beside a fixed-width **right column** (the config component: a pinned map preview + facts line over Options / Mods / Restrictions tabs). A full-width **action bar** at the bottom holds the global actions: **Leave** + status on the left, the host-only **Presets** button (opens the setup-presets dialog — see [presetselect/](presetselect/CLAUDE.md)) + the host-only **Launch** on the right. Reads IsHost (action-bar buttons) + SlotCount (to size the slot area via `CustomLobbySlotsInterface.HeightForSlots`); Leave (Esc handler) + **Become observer** (`RequestMoveToObserver`, in the Observers tab). The slot rows + their drag coordination now live in [`CustomLobbySlotsInterface`](CustomLobbySlotsInterface.lua), which fills the slot area. `OpenDebug()` / hot-reload. | +| [CustomLobbyInterface.lua](CustomLobbyInterface.lua) | composition root, laid out in **areas** (flip the module `Debug` flag to tint them), sized for the **1024×768** floor. A **one-column** layout (the two-column variant was reverted by community request): a **title bar** (title · background picker) over a **left column** split vertically — the **slots** on top (a single column of rows, up to 16, height tracking the slot count) and the **Chat / Observers** tabs below — beside a fixed-width **right column** (the config component: a pinned map preview + facts line over Options / Mods / Restrictions tabs). A full-width **action bar** at the bottom holds the global actions: **Leave** + status on the left, the host-only **Presets** button (opens the setup-presets dialog — see [presetselect/](presetselect/CLAUDE.md)) + the host-only **Launch** on the right. Reads IsHost (action-bar buttons) + SlotCount (to size the slot area via `CustomLobbySlotsInterface.HeightForSlots`); Leave (Esc handler) + **Become observer** (`RequestMoveToObserver`, in the Observers tab). The slot rows + their drag coordination now live in [`CustomLobbySlotsInterface`](CustomLobbySlotsInterface.lua), which fills the slot area. `OpenDebug()` / hot-reload. | +| [CustomLobbyBackgrounds.lua](CustomLobbyBackgrounds.lua) | **background discovery + selection** — pure data/prefs (no models, no network). Scans `textures/ui/common/lobby/backgrounds` for `*.png` (`Discover()` → `{ Path, Name }`), and holds the per-peer chosen path in a reactive `Selected` LazyVar persisted to prefs (`GetSelectedLazy`/`GetSelected`/`Select`). A purely cosmetic, never-synced local choice (like a skin); defaults to the first image found when nothing is stored. | +| [CustomLobbyBackground.lua](CustomLobbyBackground.lua) | the **full-window background surface** — a single `Bitmap` that paints the selected image so it **covers** the whole parent while keeping aspect ratio (scale to the larger axis ratio, centre, crop the overflow at the screen edge), with a solid backdrop fallback when none is chosen / the file can't be read. Subscribes to [`CustomLobbyBackgrounds`](CustomLobbyBackgrounds.lua) itself; `Width`/`Height` are bound to the parent rect so a resize re-covers for free. | | [CustomLobbyTeamScore.lua](CustomLobbyTeamScore.lua) | the **accumulated team rating** side indicator — `Side A N · M Side B`. Hosted in the strip atop the two-column slot layout (it doubles as the columns' Left/Right header). Shown only for the binary auto-team formations; **hidden** for `none`/`manual` or until a positional map's start positions load. Reads the mode + side split from [`CustomLobbyRules`](CustomLobbyRules.lua) (`AutoTeamMode` / `SideLabels` / `BuildSideResolver`) and the ratings from each slot's `PL`. Reference data; never writes. | | [CustomLobbyTabs.lua](CustomLobbyTabs.lua) | a **generic tabbed panel** (strip + content; one panel alive, created on select / destroyed on switch). Tabs **divide the strip evenly** across its width. Construct with a `{ Label, Create, Badge?, Action?, Icon?, Compact? }` list + optional `OnSelect`. A tab's optional `Badge` LazyVar drives a grey **count pill** to the right of the label; its optional `Action` (`{ Create, Visible? }`) is a small button the owner builds **inside the tab, left of the label** (e.g. a config gear), whose `Visible` LazyVar hides it (collapsing it from the layout) when it doesn't apply. The action, label and pill are centred together as one cluster; any absent/hidden/empty piece contributes 0 width so the rest re-centres. A tab can instead be **`Compact`** (a fixed narrow width, excluded from the even division — the flexible tabs share what's left) and/or show an **`Icon`** centred instead of its label (an icon-only utility tab); the default active tab is the first non-compact one. The container just mirrors the LazyVars, the owner decides what they mean. Used for the bottom-left (Logs / Chat / Observers) and the config interface's Options / Mods / Restrictions. | | [social/](social/) | the lobby's **bottom-left** column (the `CustomLobbyTabs` content): [`CustomLobbyChatPanel`](social/CustomLobbyChatPanel.lua) (the **Chat** tab — placeholder until the chat slice lands) and [`CustomLobbyObserversPanel`](social/CustomLobbyObserversPanel.lua) (the **Observers** tab — the shared observer list + a host-authoritative **Become observer** button → `RequestMoveToObserver`). Each is a tab content component (`Create(parent)`, created on select / destroyed on switch). The Chat / Observers tabs mirror the config column's shape — a per-tab **config gear** (`CustomLobbyInterface`'s local `GearAction`; both no-ops with a "coming soon" tooltip for now) + a right-side **count pill**: Observers shows the live observer count, Chat a dummy until the chat slice lands. A third **compact, icon-only [`CustomLobbyLogsPanel`](social/CustomLobbyLogsPanel.lua)** sits left of Chat (the **Logs** tab — a live **tail view** of this peer's network traffic from [`CustomLobbyLog`](CustomLobbyLog.lua): the most recent entries that fit, in columns `time · kind · ⚠ · name`, with a malformed/unauthorised message tinted + a ⚠ icon whose tooltip is the failure reason). | diff --git a/textures/texture-dimensions.csv b/textures/texture-dimensions.csv new file mode 100644 index 00000000000..a0663b64d97 --- /dev/null +++ b/textures/texture-dimensions.csv @@ -0,0 +1,8293 @@ +"RelativePath" , "Width", "Height", "Format" +"coordinateNotify.dds" , "64" , "64" , "dds" +"damage\scorch_01.dds" , "512" , "512" , "dds" +"editor\marker_generic.bmp" , "32" , "32" , "bmp" +"editor\marker_mass.bmp" , "32" , "32" , "bmp" +"effects\AeonBuildSpecular.dds" , "256" , "256" , "dds" +"effects\CybranBuildSpecular.dds" , "64" , "64" , "dds" +"effects\SeraphimBuildSpecular.dds" , "256" , "256" , "dds" +"effects\UEFBuildSpecular.dds" , "64" , "64" , "dds" +"engine\anisotropiclookup.dds" , "256" , "256" , "dds" +"engine\b_fails_to_load.dds" , "64" , "64" , "dds" +"engine\b_placeholder.dds" , "64" , "64" , "dds" +"engine\BlackSkirt.dds" , "16" , "16" , "dds" +"engine\Cloud01.DDS" , "1024" , "1024" , "dds" +"engine\Cloud02.DDS" , "1024" , "1024" , "dds" +"engine\Cloud03.DDS" , "1024" , "1024" , "dds" +"engine\Cloud04.DDS" , "1024" , "1024" , "dds" +"engine\decalMask.dds" , "512" , "512" , "dds" +"engine\editor_gizmo.dds" , "256" , "256" , "dds" +"engine\editor_group_gizmo.dds" , "256" , "256" , "dds" +"engine\FoamTest001.dds" , "512" , "512" , "dds" +"engine\FoamTest002.dds" , "512" , "512" , "dds" +"engine\FoamTest003.dds" , "512" , "512" , "dds" +"engine\FoamTest004.dds" , "512" , "512" , "dds" +"engine\GridTest.DDS" , "512" , "512" , "dds" +"engine\insectlookup.dds" , "256" , "256" , "dds" +"engine\SkyBoxTest01.dds" , "512" , "512" , "dds" +"engine\underConstruction.dds" , "256" , "256" , "dds" +"engine\waterCubemap.dds" , "512" , "512" , "dds" +"engine\waterFresnel.dds" , "256" , "1" , "dds" +"engine\waterramp_desert.dds" , "256" , "4" , "dds" +"engine\waterramp_desert02.dds" , "256" , "4" , "dds" +"engine\waterramp_desert03.dds" , "256" , "4" , "dds" +"engine\waterramp_desert03a.dds" , "256" , "4" , "dds" +"engine\waterramp_eg.dds" , "256" , "4" , "dds" +"engine\waterramp_evergreen02.dds" , "256" , "4" , "dds" +"engine\waterramp_FS2.dds" , "256" , "4" , "dds" +"engine\waterramp_lava.dds" , "256" , "4" , "dds" +"engine\waterramp_redrock.dds" , "256" , "4" , "dds" +"engine\waterramp_redrock01.dds" , "256" , "4" , "dds" +"engine\waterramp_redrock02.dds" , "256" , "4" , "dds" +"engine\waterramp_redrock03.dds" , "256" , "4" , "dds" +"engine\waterramp_tropical.dds" , "256" , "4" , "dds" +"engine\waterramp_tropical02.dds" , "256" , "4" , "dds" +"engine\waterramp_tundra.dds" , "256" , "4" , "dds" +"engine\waterramp.dds" , "256" , "4" , "dds" +"engine\waterramp03_Eop5.dds" , "256" , "4" , "dds" +"engine\waterrampSwamp01.dds" , "256" , "4" , "dds" +"engine\waterrampSwamp02.dds" , "256" , "4" , "dds" +"engine\waves.dds" , "256" , "256" , "dds" +"engine\waves000.dds" , "256" , "256" , "dds" +"engine\waves001.dds" , "256" , "256" , "dds" +"engine\waves6.dds" , "256" , "256" , "dds" +"engine\waves7.dds" , "512" , "512" , "dds" +"engine\wavesbump.dds" , "256" , "256" , "dds" +"environment\blackbackground.dds" , "16" , "16" , "dds" +"environment\BrightCloud.dds" , "512" , "512" , "dds" +"environment\Capella_bmp.dds" , "1024" , "1024" , "dds" +"environment\cirrus000.dds" , "256" , "256" , "dds" +"environment\cirrus001_512.dds" , "512" , "512" , "dds" +"environment\cumulus000.dds" , "1024" , "1024" , "dds" +"environment\cumulusDispersion000.dds" , "256" , "1" , "dds" +"environment\cumulusRamp000.dds" , "256" , "1" , "dds" +"environment\Decal_test_Albedo000.dds" , "256" , "256" , "dds" +"environment\Decal_test_Albedo001.dds" , "256" , "256" , "dds" +"environment\Decal_test_Albedo003.dds" , "512" , "512" , "dds" +"environment\Decal_test_Albedo004.dds" , "512" , "512" , "dds" +"environment\Decal_test_Glow000.dds" , "256" , "256" , "dds" +"environment\Decal_test_Glow001.dds" , "256" , "256" , "dds" +"environment\Decal_test_Glow003.dds" , "512" , "512" , "dds" +"environment\Decal_test_Glow004.dds" , "512" , "512" , "dds" +"environment\DefaultBackground.dds" , "1024" , "1024" , "dds" +"environment\DefaultEnvCube_TEST.dds" , "512" , "512" , "dds" +"environment\DefaultEnvCube.dds" , "512" , "512" , "dds" +"environment\DefaultSkyCube.dds" , "512" , "512" , "dds" +"environment\dissolve.dds" , "256" , "256" , "dds" +"environment\Earth_bmp.dds" , "1024" , "1024" , "dds" +"environment\EnvCube_aeon_aliencrystal.dds" , "128" , "128" , "dds" +"environment\EnvCube_aeon_desert.dds" , "128" , "128" , "dds" +"environment\EnvCube_aeon_Evergreen.dds" , "128" , "128" , "dds" +"environment\EnvCube_aeon_geothermal.dds" , "128" , "128" , "dds" +"environment\EnvCube_aeon_lava.dds" , "128" , "128" , "dds" +"environment\EnvCube_aeon_RedRocks.dds" , "128" , "128" , "dds" +"environment\EnvCube_aeon_tropical.dds" , "128" , "128" , "dds" +"environment\EnvCube_aeon_tundra.dds" , "128" , "128" , "dds" +"environment\EnvCube_Desert01a.dds" , "512" , "512" , "dds" +"environment\EnvCube_Desert02a.dds" , "512" , "512" , "dds" +"environment\EnvCube_Desert03a.dds" , "512" , "512" , "dds" +"environment\EnvCube_Evergreen01a.dds" , "512" , "512" , "dds" +"environment\EnvCube_Evergreen03a.dds" , "512" , "512" , "dds" +"environment\EnvCube_Evergreen05a.dds" , "512" , "512" , "dds" +"environment\EnvCube_Geothermal02a.dds" , "512" , "512" , "dds" +"environment\EnvCube_Lava01a.dds" , "512" , "512" , "dds" +"environment\EnvCube_RedRocks05a.dds" , "512" , "512" , "dds" +"environment\EnvCube_RedRocks06.dds" , "512" , "512" , "dds" +"environment\EnvCube_RedRocks08a.dds" , "512" , "512" , "dds" +"environment\EnvCube_RedRocks09a.dds" , "512" , "512" , "dds" +"environment\EnvCube_RedRocks10.dds" , "512" , "512" , "dds" +"environment\EnvCube_Scx1Proto02.dds" , "512" , "512" , "dds" +"environment\EnvCube_seraphim_aliencrystal.dds" , "128" , "128" , "dds" +"environment\EnvCube_seraphim_desert.dds" , "128" , "128" , "dds" +"environment\EnvCube_seraphim_Evergreen.dds" , "128" , "128" , "dds" +"environment\EnvCube_seraphim_geothermal.dds" , "128" , "128" , "dds" +"environment\EnvCube_seraphim_lava.dds" , "128" , "128" , "dds" +"environment\EnvCube_seraphim_redrocks.dds" , "128" , "128" , "dds" +"environment\EnvCube_seraphim_tropical.dds" , "128" , "128" , "dds" +"environment\EnvCube_seraphim_tundra.dds" , "128" , "128" , "dds" +"environment\EnvCube_Tropical01a.dds" , "512" , "512" , "dds" +"environment\EnvCube_TropicalOp06a.dds" , "512" , "512" , "dds" +"environment\EnvCube_Tundra02a.dds" , "512" , "512" , "dds" +"environment\EnvCube_Tundra03a.dds" , "512" , "512" , "dds" +"environment\EnvCube_Tundra04a.dds" , "512" , "512" , "dds" +"environment\Eridani_bmp.dds" , "1024" , "1024" , "dds" +"environment\Falloff_seraphim_lookup.dds" , "512" , "512" , "dds" +"environment\horizonLookup.dds" , "128" , "4" , "dds" +"environment\Luthien_bmp.dds" , "1024" , "1024" , "dds" +"environment\Matar_bmp.dds" , "1024" , "1024" , "dds" +"environment\Minerva_bmp.dds" , "1024" , "1024" , "dds" +"environment\moonAlbedo000.dds" , "256" , "256" , "dds" +"environment\moonGlow000.dds" , "256" , "256" , "dds" +"environment\Orionis_bmp.dds" , "1024" , "1024" , "dds" +"environment\Pisces IV_bmp.dds" , "1024" , "1024" , "dds" +"environment\Pollux_bmp.dds" , "1024" , "1024" , "dds" +"environment\Procyon_bmp.dds" , "1024" , "1024" , "dds" +"environment\reveal.dds" , "32" , "32" , "dds" +"environment\Rigel_bmp.dds" , "1024" , "1024" , "dds" +"environment\SkyCube_blue.dds" , "512" , "512" , "dds" +"environment\SkyCube_Desert01.dds" , "512" , "512" , "dds" +"environment\SkyCube_Desert01a.dds" , "512" , "512" , "dds" +"environment\SkyCube_Desert02.dds" , "512" , "512" , "dds" +"environment\SkyCube_Desert02a.dds" , "512" , "512" , "dds" +"environment\SkyCube_Desert03a.dds" , "512" , "512" , "dds" +"environment\SkyCube_Evergreen01.dds" , "512" , "512" , "dds" +"environment\SkyCube_Evergreen01a.dds" , "512" , "512" , "dds" +"environment\SkyCube_Evergreen02.dds" , "512" , "512" , "dds" +"environment\SkyCube_Evergreen03.dds" , "512" , "512" , "dds" +"environment\SkyCube_Evergreen03a.dds" , "512" , "512" , "dds" +"environment\SkyCube_Evergreen05a.dds" , "512" , "512" , "dds" +"environment\SkyCube_EvStormy.dds" , "512" , "512" , "dds" +"environment\SkyCube_Geothermal01.dds" , "512" , "512" , "dds" +"environment\SkyCube_Geothermal02.dds" , "512" , "512" , "dds" +"environment\SkyCube_Geothermal02a.dds" , "512" , "512" , "dds" +"environment\SkyCube_Lava01.dds" , "512" , "512" , "dds" +"environment\SkyCube_Lava01a.dds" , "512" , "512" , "dds" +"environment\SkyCube_Leipzig_Demo.dds" , "512" , "512" , "dds" +"environment\SkyCube_RedRock03.dds" , "512" , "512" , "dds" +"environment\SkyCube_RedRocks01.dds" , "512" , "512" , "dds" +"environment\SkyCube_RedRocks02.dds" , "512" , "512" , "dds" +"environment\SkyCube_RedRocks02a.dds" , "512" , "512" , "dds" +"environment\SkyCube_RedRocks03.dds" , "512" , "512" , "dds" +"environment\SkyCube_RedRocks04.dds" , "512" , "512" , "dds" +"environment\SkyCube_RedRocks05.dds" , "512" , "512" , "dds" +"environment\SkyCube_RedRocks05a.dds" , "512" , "512" , "dds" +"environment\SkyCube_RedRocks06.dds" , "512" , "512" , "dds" +"environment\SkyCube_RedRocks08a.dds" , "512" , "512" , "dds" +"environment\SkyCube_RedRocks09a.dds" , "512" , "512" , "dds" +"environment\SkyCube_RedRocks10.dds" , "512" , "512" , "dds" +"environment\SkyCube_Scx1Proto01.dds" , "512" , "512" , "dds" +"environment\SkyCube_Scx1Proto02.dds" , "512" , "512" , "dds" +"environment\SkyCube_Tropical01.DDS" , "512" , "512" , "dds" +"environment\SkyCube_Tropical01a.dds" , "512" , "512" , "dds" +"environment\SkyCube_Tropical04.DDS" , "512" , "512" , "dds" +"environment\SkyCube_TropicalOp06.DDS" , "512" , "512" , "dds" +"environment\SkyCube_TropicalOp06a.dds" , "512" , "512" , "dds" +"environment\SkyCube_Tundra01.dds" , "512" , "512" , "dds" +"environment\SkyCube_Tundra02.dds" , "512" , "512" , "dds" +"environment\SkyCube_Tundra02a.dds" , "512" , "512" , "dds" +"environment\SkyCube_Tundra03.dds" , "512" , "512" , "dds" +"environment\SkyCube_Tundra03a.dds" , "512" , "512" , "dds" +"environment\SkyCube_Tundra04a.dds" , "512" , "512" , "dds" +"environment\Thiban_bmp.dds" , "1024" , "1024" , "dds" +"environment\Zeta Canis_bmp.dds" , "1024" , "1024" , "dds" +"highlight_bracket_enemy_sm.dds" , "64" , "64" , "dds" +"highlight_bracket_neutral_sm.dds" , "64" , "64" , "dds" +"highlight_bracket_player_sm.dds" , "64" , "64" , "dds" +"particles\_ramp_firetest_02.dds" , "256" , "16" , "dds" +"particles\_ramp_firetest.dds" , "256" , "16" , "dds" +"particles\_ramp_gatesparks.dds" , "256" , "16" , "dds" +"particles\_ramp_gatesparks2.dds" , "256" , "16" , "dds" +"particles\_swirl01.dds" , "128" , "128" , "dds" +"particles\_swirl02.dds" , "128" , "128" , "dds" +"particles\_swirl03.dds" , "128" , "128" , "dds" +"particles\_swirl04.dds" , "128" , "128" , "dds" +"particles\_swirl05.dds" , "128" , "128" , "dds" +"particles\_test_cloud_nuke03.dds" , "128" , "512" , "dds" +"particles\_test_cloud_smoke_alpha_02.dds" , "256" , "256" , "dds" +"particles\_test_cloud_smoke_alpha_03.dds" , "256" , "256" , "dds" +"particles\a_ramp_gas_test_01.dds" , "256" , "4" , "dds" +"particles\adjacency_aeon_beam_01.dds" , "32" , "32" , "dds" +"particles\adjacency_aeon_beam_02.dds" , "32" , "32" , "dds" +"particles\adjacency_aeon_beam_03.dds" , "32" , "32" , "dds" +"particles\aeon_gunship_chassis_01.dds" , "128" , "128" , "dds" +"particles\aeon_plasma01.dds" , "256" , "256" , "dds" +"particles\aeon_plasma02.dds" , "512" , "512" , "dds" +"particles\aeon_plasma03.dds" , "256" , "256" , "dds" +"particles\aeon_ring_distort_01.dds" , "256" , "256" , "dds" +"particles\air_contrail_01.dds" , "32" , "32" , "dds" +"particles\air_contrail_02.dds" , "32" , "32" , "dds" +"particles\antimatter_01.dds" , "128" , "128" , "dds" +"particles\arc_mirror_01.dds" , "128" , "128" , "dds" +"particles\arc_streak_01.dds" , "256" , "256" , "dds" +"particles\arc_streak_02.dds" , "256" , "256" , "dds" +"particles\ash_alpha_01.dds" , "32" , "128" , "dds" +"particles\backwards_muzzle_flash_add_01.dds" , "128" , "128" , "dds" +"particles\beam_blue_01.dds" , "32" , "128" , "dds" +"particles\beam_blue_02.dds" , "64" , "128" , "dds" +"particles\beam_blue_03.dds" , "128" , "128" , "dds" +"particles\beam_blue_04.dds" , "64" , "128" , "dds" +"particles\beam_cyan_03.dds" , "64" , "128" , "dds" +"particles\beam_data_01.dds" , "128" , "128" , "dds" +"particles\beam_disruptor.dds" , "64" , "64" , "dds" +"particles\beam_exhaust_stream_blue_01.dds" , "64" , "128" , "dds" +"particles\beam_exhaust_stream_green_01.dds" , "64" , "128" , "dds" +"particles\beam_exhaust_stream_purple_01.dds" , "64" , "128" , "dds" +"particles\beam_exhaust_stream_red_01.dds" , "64" , "128" , "dds" +"particles\beam_green_01.dds" , "64" , "128" , "dds" +"particles\beam_green_02.dds" , "64" , "128" , "dds" +"particles\beam_missile_exhaust_01.dds" , "64" , "256" , "dds" +"particles\beam_missile_exhaust_02.dds" , "128" , "128" , "dds" +"particles\beam_missile_exhaust_03.dds" , "64" , "128" , "dds" +"particles\beam_missile_exhaust_04.dds" , "128" , "128" , "dds" +"particles\beam_phason_01.dds" , "128" , "128" , "dds" +"particles\beam_red_01.dds" , "64" , "128" , "dds" +"particles\beam_red_02.dds" , "32" , "128" , "dds" +"particles\beam_red_03.dds" , "32" , "64" , "dds" +"particles\beam_red_04.dds" , "64" , "512" , "dds" +"particles\beam_red_05.dds" , "64" , "512" , "dds" +"particles\beam_red_06.dds" , "128" , "1024" , "dds" +"particles\beam_red_07.dds" , "32" , "64" , "dds" +"particles\beam_shell_01.dds" , "64" , "64" , "dds" +"particles\beam_uef_hiro_01.dds" , "64" , "128" , "dds" +"particles\beam_uef_orbital_01.dds" , "64" , "128" , "dds" +"particles\beam_ultrachrom_01.dds" , "128" , "128" , "dds" +"particles\beam_white_01.dds" , "256" , "256" , "dds" +"particles\beam_white_02.dds" , "64" , "64" , "dds" +"particles\beam_white_03.dds" , "128" , "128" , "dds" +"particles\beam_white_04.dds" , "128" , "128" , "dds" +"particles\beam_white_05.dds" , "128" , "128" , "dds" +"particles\beam_white_06.dds" , "64" , "64" , "dds" +"particles\beam_white_07.dds" , "64" , "64" , "dds" +"particles\beam_yellow_01.dds" , "64" , "128" , "dds" +"particles\beam_zapper_01.dds" , "64" , "512" , "dds" +"particles\blast_cloud_01.dds" , "256" , "256" , "dds" +"particles\blobs_01.dds" , "128" , "128" , "dds" +"particles\blurry_rectangle_add_01.dds" , "64" , "64" , "dds" +"particles\bomb_blue_aeon_add_01.dds" , "128" , "128" , "dds" +"particles\bubble_01.dds" , "128" , "128" , "dds" +"particles\bubble_02.dds" , "128" , "128" , "dds" +"particles\bubble_03.dds" , "128" , "128" , "dds" +"particles\build_laser_add_01.dds" , "128" , "128" , "dds" +"particles\build_paint_spray_01.dds" , "256" , "64" , "dds" +"particles\build_shield_01.dds" , "128" , "128" , "dds" +"particles\circuitboard_01.dds" , "128" , "512" , "dds" +"particles\cirrus_01.dds" , "512" , "512" , "dds" +"particles\cirrus_02.dds" , "512" , "512" , "dds" +"particles\cirrus_03.dds" , "512" , "512" , "dds" +"particles\cirrus_04.dds" , "512" , "512" , "dds" +"particles\cirrus_strip.dds" , "512" , "1024" , "dds" +"particles\cloud_alpha_01.dds" , "256" , "256" , "dds" +"particles\cloud_puff_alpha_01.dds" , "64" , "64" , "dds" +"particles\cloud_smoke_alpha_01.dds" , "256" , "256" , "dds" +"particles\cloud_smoke_alpha_02.dds" , "256" , "256" , "dds" +"particles\cloud_smoke_alpha_03.dds" , "256" , "256" , "dds" +"particles\cloud_smoke_alpha_04.dds" , "128" , "512" , "dds" +"particles\cloud_smoke_alpha_05.dds" , "128" , "512" , "dds" +"particles\cloud_smoke_alpha_08.dds" , "256" , "256" , "dds" +"particles\cloud_smoke_alpha_09.dds" , "128" , "128" , "dds" +"particles\cloud_smoke_alpha_10.dds" , "256" , "256" , "dds" +"particles\cloud_smoke_alpha_11.dds" , "256" , "256" , "dds" +"particles\cloud_smoke_alpha_12_blur.dds" , "256" , "256" , "dds" +"particles\cloud_smoke_alpha_12.dds" , "256" , "256" , "dds" +"particles\cloud_smoke_alpha_13.dds" , "128" , "128" , "dds" +"particles\cloud_smoke_alpha_14.dds" , "256" , "256" , "dds" +"particles\cloud_smoke_alpha_15.dds" , "256" , "256" , "dds" +"particles\cloud_smoke_alpha_16.dds" , "256" , "1024" , "dds" +"particles\cloud_smoke_alpha_17.dds" , "256" , "1024" , "dds" +"particles\cloud_smoke_alpha_18.dds" , "256" , "256" , "dds" +"particles\cloud_smoke_alpha_19.dds" , "128" , "512" , "dds" +"particles\cloud_speck_alpha_01.dds" , "64" , "64" , "dds" +"particles\cloud_strip_01.dds" , "256" , "512" , "dds" +"particles\cloud_white_alpha_01.dds" , "256" , "256" , "dds" +"particles\corona_02.dds" , "256" , "256" , "dds" +"particles\corona_03.dds" , "256" , "256" , "dds" +"particles\corona.dds" , "256" , "256" , "dds" +"particles\crystal_debris01.dds" , "256" , "256" , "dds" +"particles\curvetrail_beam_particle_01.dds" , "64" , "64" , "dds" +"particles\dark_purple_01.dds" , "256" , "4" , "dds" +"particles\debris_alpha_01.dds" , "256" , "256" , "dds" +"particles\debris_alpha_02.dds" , "32" , "512" , "dds" +"particles\debris_alpha_03.dds" , "256" , "256" , "dds" +"particles\debris_alpha_04.dds" , "256" , "256" , "dds" +"particles\debris_scorch_alpha_01.dds" , "256" , "256" , "dds" +"particles\debris_scorch_alpha_02.dds" , "256" , "256" , "dds" +"particles\dirt_chunks_02.dds" , "128" , "128" , "dds" +"particles\dirt_chunks_03.dds" , "64" , "64" , "dds" +"particles\dirt_chunks_04.dds" , "128" , "128" , "dds" +"particles\dirt_chunks_05.dds" , "64" , "64" , "dds" +"particles\dirt_chunks_06.dds" , "128" , "128" , "dds" +"particles\dirt_chunks.dds" , "128" , "128" , "dds" +"particles\dirt_debris01.dds" , "256" , "256" , "dds" +"particles\dirt_spray_01.dds" , "256" , "256" , "dds" +"particles\dirt_spray_02.dds" , "256" , "256" , "dds" +"particles\dirt_spray_03.dds" , "256" , "256" , "dds" +"particles\disk_white_01.dds" , "256" , "256" , "dds" +"particles\disk_white_02.dds" , "256" , "256" , "dds" +"particles\disk_white_03.dds" , "256" , "256" , "dds" +"particles\distort_ring_02.dds" , "256" , "256" , "dds" +"particles\distorted_ring.dds" , "256" , "256" , "dds" +"particles\dust_cloud_01.dds" , "128" , "128" , "dds" +"particles\dust_cloud_02.dds" , "128" , "128" , "dds" +"particles\dust_cloud_03.dds" , "128" , "128" , "dds" +"particles\dust_cloud_04.dds" , "64" , "64" , "dds" +"particles\dust_cloud_05.dds" , "128" , "128" , "dds" +"particles\electric_split_01.dds" , "128" , "128" , "dds" +"particles\electricity_01.dds" , "64" , "512" , "dds" +"particles\electricity_02.dds" , "128" , "256" , "dds" +"particles\electricity_03.dds" , "128" , "128" , "dds" +"particles\electricity_04.dds" , "128" , "128" , "dds" +"particles\electricity_05.dds" , "128" , "128" , "dds" +"particles\electricity_06.dds" , "256" , "256" , "dds" +"particles\electricity_07.dds" , "64" , "64" , "dds" +"particles\electricity_08.dds" , "256" , "1024" , "dds" +"particles\electricity_09.dds" , "256" , "512" , "dds" +"particles\electricity_beam_01.dds" , "128" , "512" , "dds" +"particles\electricity_beam_02.dds" , "256" , "512" , "dds" +"particles\electricity_beam_03.dds" , "256" , "512" , "dds" +"particles\electricity_beam_04.dds" , "256" , "512" , "dds" +"particles\electricity_bolts_add_01.dds" , "128" , "512" , "dds" +"particles\electricity_bolts_add_02.dds" , "128" , "512" , "dds" +"particles\electricity_ring_02.dds" , "256" , "256" , "dds" +"particles\electricity_ring.dds" , "128" , "128" , "dds" +"particles\electricity_stream_01.dds" , "128" , "128" , "dds" +"particles\electron_bolter_flash_01.dds" , "128" , "256" , "dds" +"particles\electron_burst_cloud_mod_01.dds" , "256" , "256" , "dds" +"particles\energy_01.dds" , "256" , "256" , "dds" +"particles\explosion_add_01.dds" , "64" , "128" , "dds" +"particles\fake_shadow_01.dds" , "128" , "128" , "dds" +"particles\fake_shadow_napalm_add.dds" , "128" , "128" , "dds" +"particles\fake_shadow_napalm.dds" , "128" , "128" , "dds" +"particles\fire_cloud_add_01.dds" , "64" , "128" , "dds" +"particles\fire_cloud_add_02.dds" , "256" , "256" , "dds" +"particles\fire_cloud_alpha_01.dds" , "128" , "128" , "dds" +"particles\fire_cloud_alpha_02.dds" , "128" , "128" , "dds" +"particles\fire_cloud_alpha_03.dds" , "256" , "256" , "dds" +"particles\fire_cloud_premod_01.dds" , "256" , "256" , "dds" +"particles\fire_cloud_premod_02.dds" , "128" , "256" , "dds" +"particles\fire_cloud_premod_03.dds" , "128" , "128" , "dds" +"particles\fire_cloud_premod_04.dds" , "256" , "256" , "dds" +"particles\fire_cloud_premod_05.dds" , "256" , "256" , "dds" +"particles\fire_explode_01.dds" , "128" , "128" , "dds" +"particles\fire_flame_add_01.dds" , "128" , "128" , "dds" +"particles\fire_flame_add_02.dds" , "128" , "384" , "dds" +"particles\fire_flame_add_03.dds" , "256" , "256" , "dds" +"particles\fire_flame_add_04.dds" , "256" , "256" , "dds" +"particles\fire_flame_add_05.dds" , "256" , "1024" , "dds" +"particles\fire_flame_premod_add_01.dds" , "128" , "128" , "dds" +"particles\fire_multiply_01.dds" , "256" , "256" , "dds" +"particles\fireball.dds" , "1024" , "128" , "dds" +"particles\flare_01.dds" , "256" , "256" , "dds" +"particles\flare_lens_add_01.dds" , "64" , "64" , "dds" +"particles\flare_lens_add_02.dds" , "128" , "128" , "dds" +"particles\flare_lens_add_03.dds" , "128" , "128" , "dds" +"particles\frontal_glow_01.dds" , "64" , "64" , "dds" +"particles\gas_alpha_01.dds" , "256" , "256" , "dds" +"particles\gatling_plasma_munition_01.dds" , "512" , "64" , "dds" +"particles\gatling_plasma_munition_02.dds" , "512" , "64" , "dds" +"particles\gatling_plasma_munition_03.dds" , "512" , "64" , "dds" +"particles\geyser_01.dds" , "256" , "256" , "dds" +"particles\glow_01.dds" , "256" , "256" , "dds" +"particles\glow_02.dds" , "256" , "256" , "dds" +"particles\glow_03.dds" , "128" , "128" , "dds" +"particles\glow_04.dds" , "256" , "256" , "dds" +"particles\glow_05.dds" , "256" , "256" , "dds" +"particles\glow_alpha_01.dds" , "256" , "256" , "dds" +"particles\glow_alpha_03.dds" , "128" , "128" , "dds" +"particles\glow_offset.dds" , "256" , "256" , "dds" +"particles\glow_streak_red.dds" , "32" , "128" , "dds" +"particles\glow_streak.dds" , "128" , "128" , "dds" +"particles\glow.dds" , "128" , "128" , "dds" +"particles\half_moon_01.dds" , "256" , "256" , "dds" +"particles\half_moon_blue_01.dds" , "256" , "256" , "dds" +"particles\halfmoon_gauss_cannon_01.dds" , "256" , "256" , "dds" +"particles\halo.dds" , "256" , "256" , "dds" +"particles\heavy_plasma_shot.dds" , "16" , "256" , "dds" +"particles\heavy_quarnoncannon_muzzle_flash_01.dds" , "128" , "384" , "dds" +"particles\heavy_quarnoncannon_muzzle_flash_02.dds" , "128" , "128" , "dds" +"particles\heavy_quarnoncannon_trail_01.dds" , "256" , "256" , "dds" +"particles\heavy_quarnoncannon_trail_02.dds" , "256" , "256" , "dds" +"particles\heavy_quarnoncannon_trail_03.dds" , "256" , "256" , "dds" +"particles\ice_chunks_01.dds" , "128" , "128" , "dds" +"particles\infected_glow_01.dds" , "128" , "128" , "dds" +"particles\laser_lens_flare_02.dds" , "128" , "128" , "dds" +"particles\line_orange_add_01.dds" , "64" , "64" , "dds" +"particles\line_white_add_01.dds" , "64" , "64" , "dds" +"particles\line_white_add_02.dds" , "128" , "128" , "dds" +"particles\line_white_add_03.dds" , "16" , "16" , "dds" +"particles\line_white_add_04.dds" , "128" , "128" , "dds" +"particles\line_white_add_05.dds" , "256" , "256" , "dds" +"particles\line_white_add_06.dds" , "128" , "128" , "dds" +"particles\line_white_add_07.dds" , "128" , "128" , "dds" +"particles\line_white_add_08.dds" , "256" , "256" , "dds" +"particles\line_white_add_09.dds" , "128" , "128" , "dds" +"particles\line_white_add_10.dds" , "256" , "256" , "dds" +"particles\line_white_add_11.dds" , "128" , "512" , "dds" +"particles\line_white_add_12.dds" , "512" , "512" , "dds" +"particles\line_white_add_13.dds" , "128" , "128" , "dds" +"particles\line_white_add_14.dds" , "128" , "128" , "dds" +"particles\line_white_add_15.dds" , "256" , "256" , "dds" +"particles\line_white_alpha_04.dds" , "128" , "128" , "dds" +"particles\line_white_premod_01.dds" , "128" , "128" , "dds" +"particles\line_white_premod_02.dds" , "64" , "64" , "dds" +"particles\line_white_premod_03.dds" , "128" , "384" , "dds" +"particles\miasma_alpha_01.dds" , "256" , "256" , "dds" +"particles\miasma_mod_01.dds" , "256" , "256" , "dds" +"particles\miasma_mod_02.dds" , "256" , "256" , "dds" +"particles\missile_exhaust_fire_01.dds" , "64" , "128" , "dds" +"particles\molecular_01.dds" , "256" , "256" , "dds" +"particles\molecular_ripper_01.dds" , "64" , "64" , "dds" +"particles\moon_glow.dds" , "128" , "128" , "dds" +"particles\mudpot_bubble_anim_01.dds" , "512" , "64" , "dds" +"particles\muzzle_flash_add_01.dds" , "128" , "128" , "dds" +"particles\muzzle_flash_add_02.dds" , "128" , "128" , "dds" +"particles\muzzle_flash_add_03.dds" , "128" , "128" , "dds" +"particles\muzzle_flash_add_04.dds" , "128" , "128" , "dds" +"particles\muzzle_flash_add_05.dds" , "128" , "128" , "dds" +"particles\muzzle_flash_add_06.dds" , "256" , "256" , "dds" +"particles\muzzle_flash_add_07.dds" , "128" , "128" , "dds" +"particles\muzzle_flash_add_08.dds" , "128" , "128" , "dds" +"particles\muzzle_flash_add_09.dds" , "128" , "128" , "dds" +"particles\muzzle_flash_cool_01.dds" , "64" , "64" , "dds" +"particles\muzzle_flash_strip_add_01.dds" , "128" , "384" , "dds" +"particles\muzzle_flash_strip_add_02.dds" , "128" , "384" , "dds" +"particles\nano_dart_trail.dds" , "32" , "256" , "dds" +"particles\napalm_thick_smoke.dds" , "128" , "128" , "dds" +"particles\neutron_bomb_01.dds" , "128" , "128" , "dds" +"particles\neutron_bomb_02.dds" , "128" , "128" , "dds" +"particles\oilslick_01.dds" , "256" , "256" , "dds" +"particles\outrush01.dds" , "256" , "256" , "dds" +"particles\outrush02.dds" , "256" , "256" , "dds" +"particles\outrush03.dds" , "256" , "256" , "dds" +"particles\phalanx_munition_01.dds" , "512" , "64" , "dds" +"particles\plasma_02.dds" , "256" , "256" , "dds" +"particles\plasma_03.dds" , "256" , "256" , "dds" +"particles\plasma_alpha_01.dds" , "128" , "128" , "dds" +"particles\plasma_alpha_03.dds" , "128" , "128" , "dds" +"particles\plasma_alpha_04.dds" , "128" , "128" , "dds" +"particles\plasma_alpha_05.dds" , "128" , "128" , "dds" +"particles\plasma_alpha_06.dds" , "128" , "128" , "dds" +"particles\plasma_cannon_01.dds" , "32" , "256" , "dds" +"particles\plasma_green_add_01.dds" , "128" , "128" , "dds" +"particles\plasma_green_add_02.dds" , "128" , "128" , "dds" +"particles\plasma_green_add_03.dds" , "128" , "128" , "dds" +"particles\plasma_green_add_04.dds" , "128" , "128" , "dds" +"particles\plasma_green_add_05.dds" , "128" , "128" , "dds" +"particles\plasma_line_add_01.dds" , "324" , "324" , "dds" +"particles\plasma_red_add_01.dds" , "128" , "128" , "dds" +"particles\plasma_red_add_02.dds" , "128" , "128" , "dds" +"particles\plasma_red_add_03.dds" , "256" , "256" , "dds" +"particles\plasma_trail_01.dds" , "128" , "32" , "dds" +"particles\plasma_white_add_01.dds" , "128" , "128" , "dds" +"particles\plasma_white_premod_01.dds" , "128" , "128" , "dds" +"particles\plasma01.dds" , "256" , "256" , "dds" +"particles\proton_cannon_flash_01.dds" , "128" , "256" , "dds" +"particles\proton_red_add_01.dds" , "128" , "128" , "dds" +"particles\proton_yellow_add_01.dds" , "128" , "128" , "dds" +"particles\quantum_beam_01.dds" , "64" , "256" , "dds" +"particles\quantum_beamlines_01.dds" , "128" , "128" , "dds" +"particles\quantum_blue_add_04.dds" , "128" , "128" , "dds" +"particles\quantum_cannon_trail_01.dds" , "64" , "256" , "dds" +"particles\quantum_displacement_mod_01.dds" , "256" , "256" , "dds" +"particles\quantum_displacement_mod_02.dds" , "256" , "256" , "dds" +"particles\quantum_generator_add_01.dds" , "128" , "128" , "dds" +"particles\quantum_generator_add_02.dds" , "128" , "128" , "dds" +"particles\quantum_generator_add_03.dds" , "128" , "128" , "dds" +"particles\quantum_plasma_01.dds" , "128" , "128" , "dds" +"particles\quantum_plasma_ring01.dds" , "128" , "128" , "dds" +"particles\quantum_ring_01.dds" , "256" , "256" , "dds" +"particles\quantum_ring_02.dds" , "256" , "256" , "dds" +"particles\quantum_ring_03.dds" , "128" , "128" , "dds" +"particles\quantum_ring_04.dds" , "128" , "128" , "dds" +"particles\radial_rays_01.dds" , "128" , "128" , "dds" +"particles\radial_rays_bright_01.dds" , "128" , "128" , "dds" +"particles\railgun_flash_01.dds" , "128" , "256" , "dds" +"particles\railgun_trail_01.dds" , "32" , "32" , "dds" +"particles\rainfall_01.dds" , "512" , "512" , "dds" +"particles\ramp_aeon_01.dds" , "256" , "4" , "dds" +"particles\ramp_aeon_02.dds" , "256" , "4" , "dds" +"particles\ramp_antimatter_01.dds" , "256" , "4" , "dds" +"particles\ramp_antimatter_02.dds" , "256" , "4" , "dds" +"particles\ramp_antimatter_mod2x_01.dds" , "256" , "4" , "dds" +"particles\ramp_black_01.dds" , "256" , "4" , "dds" +"particles\ramp_black_02.dds" , "256" , "4" , "dds" +"particles\ramp_black_03.dds" , "256" , "4" , "dds" +"particles\ramp_black_04.dds" , "256" , "4" , "dds" +"particles\ramp_black_05.dds" , "256" , "4" , "dds" +"particles\ramp_black_grey_01.dds" , "256" , "4" , "dds" +"particles\ramp_blue_01.dds" , "256" , "4" , "dds" +"particles\ramp_blue_02.dds" , "256" , "4" , "dds" +"particles\ramp_blue_03.dds" , "128" , "4" , "dds" +"particles\ramp_blue_04.dds" , "256" , "4" , "dds" +"particles\ramp_blue_05.dds" , "256" , "4" , "dds" +"particles\ramp_blue_06.dds" , "256" , "4" , "dds" +"particles\ramp_blue_07.dds" , "256" , "4" , "dds" +"particles\ramp_blue_08.dds" , "256" , "4" , "dds" +"particles\ramp_blue_09.dds" , "256" , "4" , "dds" +"particles\ramp_blue_10.dds" , "256" , "64" , "dds" +"particles\ramp_blue_11.dds" , "256" , "4" , "dds" +"particles\ramp_blue_12.dds" , "256" , "4" , "dds" +"particles\ramp_blue_13.dds" , "256" , "4" , "dds" +"particles\ramp_blue_14.dds" , "256" , "4" , "dds" +"particles\ramp_blue_15.dds" , "256" , "4" , "dds" +"particles\ramp_blue_16.dds" , "256" , "4" , "dds" +"particles\ramp_blue_17.dds" , "256" , "4" , "dds" +"particles\ramp_blue_18.dds" , "512" , "32" , "dds" +"particles\ramp_blue_19.dds" , "256" , "4" , "dds" +"particles\ramp_blue_20.dds" , "256" , "16" , "dds" +"particles\ramp_blue_21.dds" , "256" , "12" , "dds" +"particles\ramp_blue_22.dds" , "256" , "4" , "dds" +"particles\ramp_blue_23.dds" , "256" , "4" , "dds" +"particles\ramp_blue_24.dds" , "256" , "4" , "dds" +"particles\ramp_blue_25.dds" , "256" , "4" , "dds" +"particles\ramp_blue_26.dds" , "256" , "4" , "dds" +"particles\ramp_blue_27.dds" , "256" , "4" , "dds" +"particles\ramp_blue_28.dds" , "256" , "4" , "dds" +"particles\ramp_blue_29.dds" , "256" , "4" , "dds" +"particles\ramp_blue_30.dds" , "256" , "4" , "dds" +"particles\ramp_blue_build_spray.dds" , "64" , "4" , "dds" +"particles\ramp_blue_green.dds" , "64" , "4" , "dds" +"particles\ramp_blue_orange_01.dds" , "256" , "8" , "dds" +"particles\ramp_blue_yellow_01.dds" , "256" , "4" , "dds" +"particles\ramp_blue_yellow_02.dds" , "256" , "4" , "dds" +"particles\ramp_brown_01.dds" , "256" , "4" , "dds" +"particles\ramp_brown_02.dds" , "256" , "4" , "dds" +"particles\ramp_brown_03.dds" , "256" , "4" , "dds" +"particles\ramp_brown_04.dds" , "256" , "4" , "dds" +"particles\ramp_brown_05.dds" , "256" , "4" , "dds" +"particles\ramp_brown_06.dds" , "256" , "4" , "dds" +"particles\ramp_brown_07.dds" , "256" , "4" , "dds" +"particles\ramp_brown_08.dds" , "256" , "4" , "dds" +"particles\ramp_brown_09.dds" , "256" , "4" , "dds" +"particles\ramp_brown_10.dds" , "256" , "4" , "dds" +"particles\ramp_brown_11.dds" , "256" , "4" , "dds" +"particles\ramp_brown_12.dds" , "256" , "4" , "dds" +"particles\ramp_brown_13.dds" , "256" , "4" , "dds" +"particles\ramp_chrono_dampener.dds" , "256" , "4" , "dds" +"particles\ramp_cloud_01.dds" , "128" , "4" , "dds" +"particles\ramp_cloud_02.dds" , "512" , "16" , "dds" +"particles\ramp_cloud_03.dds" , "512" , "16" , "dds" +"particles\ramp_color_build_spray.dds" , "128" , "4" , "dds" +"particles\ramp_contrail_01.dds" , "256" , "4" , "dds" +"particles\ramp_contrail_02.dds" , "256" , "4" , "dds" +"particles\ramp_cyan_build_spray.dds" , "64" , "4" , "dds" +"particles\ramp_cybran_missile_01.dds" , "128" , "16" , "dds" +"particles\ramp_disintegrator_01.dds" , "256" , "32" , "dds" +"particles\ramp_disintegrator_02.dds" , "256" , "16" , "dds" +"particles\ramp_electron_burst_cloud_01.dds" , "256" , "4" , "dds" +"particles\ramp_electron_polytrail_01.dds" , "128" , "16" , "dds" +"particles\ramp_energy_01.dds" , "64" , "4" , "dds" +"particles\ramp_fire_01.dds" , "256" , "4" , "dds" +"particles\ramp_fire_02.dds" , "256" , "4" , "dds" +"particles\ramp_fire_03.dds" , "256" , "4" , "dds" +"particles\ramp_fire_04.dds" , "256" , "4" , "dds" +"particles\ramp_fire_05.dds" , "256" , "4" , "dds" +"particles\ramp_fire_06.dds" , "256" , "4" , "dds" +"particles\ramp_fire_07.dds" , "256" , "4" , "dds" +"particles\ramp_fire_08.dds" , "256" , "4" , "dds" +"particles\ramp_fire_09.dds" , "256" , "4" , "dds" +"particles\ramp_fire_11.dds" , "256" , "4" , "dds" +"particles\ramp_fire_12.dds" , "256" , "16" , "dds" +"particles\ramp_fire_13.dds" , "256" , "4" , "dds" +"particles\ramp_fire_14.dds" , "256" , "16" , "dds" +"particles\ramp_fire_red_01.dds" , "256" , "4" , "dds" +"particles\ramp_fire_smoke_01.dds" , "256" , "4" , "dds" +"particles\ramp_fire_smoke_03.dds" , "256" , "4" , "dds" +"particles\ramp_fire_tall_01.dds" , "4" , "256" , "dds" +"particles\ramp_flare_01.dds" , "256" , "4" , "dds" +"particles\ramp_flare_02.dds" , "256" , "4" , "dds" +"particles\ramp_gate_01.dds" , "128" , "4" , "dds" +"particles\ramp_green_01.dds" , "16" , "4" , "dds" +"particles\ramp_green_02.dds" , "128" , "4" , "dds" +"particles\ramp_green_03.dds" , "128" , "4" , "dds" +"particles\ramp_green_05.dds" , "128" , "16" , "dds" +"particles\ramp_green_06.dds" , "256" , "16" , "dds" +"particles\ramp_green_07.dds" , "256" , "16" , "dds" +"particles\ramp_green_08.dds" , "128" , "4" , "dds" +"particles\ramp_green_09.dds" , "128" , "8" , "dds" +"particles\ramp_green_10.dds" , "128" , "4" , "dds" +"particles\ramp_green_11.dds" , "128" , "16" , "dds" +"particles\ramp_green_12.dds" , "128" , "8" , "dds" +"particles\ramp_green_build_spray.dds" , "64" , "4" , "dds" +"particles\ramp_grey_01.dds" , "256" , "4" , "dds" +"particles\ramp_grey_02.dds" , "256" , "4" , "dds" +"particles\ramp_grey_03.dds" , "256" , "4" , "dds" +"particles\ramp_jammer_01.dds" , "256" , "12" , "dds" +"particles\ramp_lightblue_build_spray.dds" , "64" , "4" , "dds" +"particles\ramp_lightning_01.dds" , "256" , "4" , "dds" +"particles\ramp_miasma_01.dds" , "256" , "4" , "dds" +"particles\ramp_napalm_fire.dds" , "256" , "4" , "dds" +"particles\ramp_napalm_thick_smoke.dds" , "256" , "4" , "dds" +"particles\ramp_nuke_01.dds" , "256" , "4" , "dds" +"particles\ramp_nuke_02.dds" , "256" , "4" , "dds" +"particles\ramp_nuke_03.dds" , "256" , "4" , "dds" +"particles\ramp_nuke_04.dds" , "256" , "4" , "dds" +"particles\ramp_orang_blue_01.dds" , "256" , "8" , "dds" +"particles\ramp_orang_blue_02.dds" , "256" , "8" , "dds" +"particles\ramp_orange_01.dds" , "16" , "4" , "dds" +"particles\ramp_orange_02.dds" , "512" , "4" , "dds" +"particles\ramp_orange_03.dds" , "16" , "4" , "dds" +"particles\ramp_overcharge_modinv.dds" , "256" , "4" , "dds" +"particles\ramp_peach_build_spray.dds" , "64" , "4" , "dds" +"particles\ramp_phalanx_munition_01.dds" , "32" , "256" , "dds" +"particles\ramp_phalanx_premod.dds" , "256" , "4" , "dds" +"particles\ramp_plasma_mod_01.dds" , "256" , "4" , "dds" +"particles\ramp_proton_artillery_01.dds" , "256" , "16" , "dds" +"particles\ramp_proton_artillery_02.dds" , "256" , "16" , "dds" +"particles\ramp_proton_flash_02.dds" , "256" , "4" , "dds" +"particles\ramp_proton_flash.dds" , "256" , "4" , "dds" +"particles\ramp_pulsar_01.dds" , "256" , "4" , "dds" +"particles\ramp_purple_01.dds" , "256" , "4" , "dds" +"particles\ramp_purple_02.dds" , "256" , "4" , "dds" +"particles\ramp_purple_03.dds" , "256" , "4" , "dds" +"particles\ramp_purple_04.dds" , "256" , "4" , "dds" +"particles\ramp_purple_05.dds" , "256" , "4" , "dds" +"particles\ramp_purple_06.dds" , "256" , "4" , "dds" +"particles\ramp_purple_07.dds" , "256" , "4" , "dds" +"particles\ramp_purple_08.dds" , "256" , "4" , "dds" +"particles\ramp_purple_11.dds" , "256" , "4" , "dds" +"particles\ramp_purple_build_spray.dds" , "64" , "4" , "dds" +"particles\ramp_purple_to_white_01.dds" , "256" , "4" , "dds" +"particles\ramp_quantum_01.dds" , "512" , "8" , "dds" +"particles\ramp_quantum_gate.dds" , "64" , "8" , "dds" +"particles\ramp_quantum_warhead_flash_01.dds" , "512" , "4" , "dds" +"particles\ramp_railgun_01.dds" , "256" , "4" , "dds" +"particles\ramp_reacton_01.dds" , "256" , "4" , "dds" +"particles\ramp_reacton_02.dds" , "256" , "4" , "dds" +"particles\ramp_reacton_03.dds" , "256" , "4" , "dds" +"particles\ramp_red_01.dds" , "16" , "4" , "dds" +"particles\ramp_red_02.dds" , "256" , "4" , "dds" +"particles\ramp_red_03.dds" , "256" , "4" , "dds" +"particles\ramp_red_04.dds" , "256" , "16" , "dds" +"particles\ramp_red_05.dds" , "128" , "16" , "dds" +"particles\ramp_red_06.dds" , "256" , "4" , "dds" +"particles\ramp_red_07.dds" , "16" , "4" , "dds" +"particles\ramp_red_08.dds" , "32" , "4" , "dds" +"particles\ramp_red_09.dds" , "256" , "4" , "dds" +"particles\ramp_red_10.dds" , "16" , "4" , "dds" +"particles\ramp_red_11.dds" , "256" , "4" , "dds" +"particles\ramp_red_12.dds" , "256" , "4" , "dds" +"particles\ramp_red_13.dds" , "256" , "4" , "dds" +"particles\ramp_red_14.dds" , "256" , "4" , "dds" +"particles\ramp_red_build_spray.dds" , "64" , "4" , "dds" +"particles\ramp_riotgun_01.dds" , "32" , "256" , "dds" +"particles\ramp_riotgun_02.dds" , "32" , "256" , "dds" +"particles\ramp_riotgun_03.dds" , "32" , "256" , "dds" +"particles\ramp_rust_01.dds" , "256" , "4" , "dds" +"particles\ramp_rust_02.dds" , "256" , "4" , "dds" +"particles\ramp_rust_03.dds" , "256" , "4" , "dds" +"particles\ramp_saint_01.dds" , "256" , "4" , "dds" +"particles\ramp_sand_01.dds" , "256" , "4" , "dds" +"particles\ramp_sand_02.dds" , "256" , "4" , "dds" +"particles\ramp_ser_01_reverse.dds" , "256" , "4" , "dds" +"particles\ramp_ser_01.dds" , "256" , "4" , "dds" +"particles\ramp_ser_02.dds" , "256" , "4" , "dds" +"particles\ramp_ser_03.dds" , "256" , "4" , "dds" +"particles\ramp_ser_04.dds" , "256" , "12" , "dds" +"particles\ramp_ser_05.dds" , "256" , "16" , "dds" +"particles\ramp_ser_06.dds" , "256" , "12" , "dds" +"particles\ramp_ser_07.dds" , "256" , "12" , "dds" +"particles\ramp_ser_08.dds" , "256" , "12" , "dds" +"particles\ramp_ser_09.dds" , "256" , "4" , "dds" +"particles\ramp_ser_10.dds" , "256" , "12" , "dds" +"particles\ramp_ser_11.dds" , "256" , "4" , "dds" +"particles\ramp_ser_12.dds" , "256" , "4" , "dds" +"particles\ramp_ser_13.dds" , "256" , "12" , "dds" +"particles\ramp_smoke_01.dds" , "256" , "4" , "dds" +"particles\ramp_smoke_02.dds" , "256" , "4" , "dds" +"particles\ramp_smoke_03.dds" , "256" , "4" , "dds" +"particles\ramp_smoke_04.dds" , "256" , "4" , "dds" +"particles\ramp_smoke_05.dds" , "256" , "4" , "dds" +"particles\ramp_smoke_exhaust_01.dds" , "256" , "4" , "dds" +"particles\ramp_sonic_pulse_01.dds" , "256" , "16" , "dds" +"particles\ramp_sonic_pulse_02.dds" , "128" , "4" , "dds" +"particles\ramp_tan_01.dds" , "256" , "4" , "dds" +"particles\ramp_tan_02.dds" , "256" , "4" , "dds" +"particles\ramp_tan_03.dds" , "256" , "4" , "dds" +"particles\ramp_tan_04.dds" , "256" , "4" , "dds" +"particles\ramp_test.dds" , "256" , "8" , "dds" +"particles\ramp_trail_01.dds" , "508" , "128" , "dds" +"particles\ramp_trail_02.dds" , "508" , "128" , "dds" +"particles\ramp_trail_03.dds" , "508" , "128" , "dds" +"particles\ramp_trail_04.dds" , "508" , "128" , "dds" +"particles\ramp_trail_05.dds" , "508" , "128" , "dds" +"particles\ramp_trail_06.dds" , "256" , "64" , "dds" +"particles\ramp_trail_07.dds" , "512" , "128" , "dds" +"particles\ramp_trail_08.dds" , "128" , "32" , "dds" +"particles\ramp_trail_09.dds" , "256" , "12" , "dds" +"particles\ramp_trail_10.dds" , "256" , "12" , "dds" +"particles\ramp_trail_11.dds" , "256" , "12" , "dds" +"particles\ramp_trail_12.dds" , "128" , "32" , "dds" +"particles\ramp_trail_13.dds" , "256" , "32" , "dds" +"particles\ramp_trail_14.dds" , "128" , "32" , "dds" +"particles\ramp_trail_15.dds" , "256" , "12" , "dds" +"particles\ramp_trail_16.dds" , "508" , "128" , "dds" +"particles\ramp_trail_17.dds" , "256" , "12" , "dds" +"particles\ramp_trail_18.dds" , "508" , "128" , "dds" +"particles\ramp_trail_aeon_01.dds" , "256" , "64" , "dds" +"particles\ramp_trail_purple_01.dds" , "512" , "128" , "dds" +"particles\ramp_trail_purple_02.dds" , "128" , "32" , "dds" +"particles\ramp_trail_red_01.dds" , "128" , "32" , "dds" +"particles\ramp_trail_ser_01.dds" , "256" , "64" , "dds" +"particles\ramp_trail_ser_02.dds" , "256" , "64" , "dds" +"particles\ramp_trail_ser_03.dds" , "256" , "64" , "dds" +"particles\ramp_water_01.dds" , "256" , "4" , "dds" +"particles\ramp_water_02.dds" , "64" , "4" , "dds" +"particles\ramp_water_03.dds" , "256" , "64" , "dds" +"particles\ramp_water_04.dds" , "256" , "4" , "dds" +"particles\ramp_water_05.dds" , "256" , "4" , "dds" +"particles\ramp_water_06.dds" , "256" , "4" , "dds" +"particles\ramp_water_07.dds" , "256" , "4" , "dds" +"particles\ramp_water_08.dds" , "256" , "4" , "dds" +"particles\ramp_white_01.dds" , "256" , "4" , "dds" +"particles\ramp_white_02.dds" , "256" , "4" , "dds" +"particles\ramp_white_03.dds" , "256" , "4" , "dds" +"particles\ramp_white_04.dds" , "256" , "4" , "dds" +"particles\ramp_white_05.dds" , "256" , "4" , "dds" +"particles\ramp_white_06.dds" , "256" , "4" , "dds" +"particles\ramp_white_07.dds" , "256" , "4" , "dds" +"particles\ramp_white_08.dds" , "256" , "4" , "dds" +"particles\ramp_white_09.dds" , "256" , "4" , "dds" +"particles\ramp_white_10.dds" , "512" , "4" , "dds" +"particles\ramp_white_11.dds" , "512" , "4" , "dds" +"particles\ramp_white_12.dds" , "512" , "64" , "dds" +"particles\ramp_white_13.dds" , "256" , "4" , "dds" +"particles\ramp_white_14.dds" , "256" , "4" , "dds" +"particles\ramp_white_15.dds" , "128" , "4" , "dds" +"particles\ramp_white_19.dds" , "512" , "4" , "dds" +"particles\ramp_white_20.dds" , "256" , "4" , "dds" +"particles\ramp_white_21.dds" , "256" , "16" , "dds" +"particles\ramp_white_22.dds" , "256" , "4" , "dds" +"particles\ramp_white_23.dds" , "256" , "4" , "dds" +"particles\ramp_white_24.dds" , "256" , "4" , "dds" +"particles\ramp_white_25.dds" , "256" , "4" , "dds" +"particles\ramp_white_26.dds" , "256" , "4" , "dds" +"particles\ramp_white_27.dds" , "256" , "4" , "dds" +"particles\ramp_white_28.dds" , "256" , "64" , "dds" +"particles\ramp_white_29.dds" , "256" , "64" , "dds" +"particles\ramp_white_build_spray.dds" , "64" , "4" , "dds" +"particles\ramp_white_mod_01.dds" , "256" , "4" , "dds" +"particles\ramp_white_orange_01.dds" , "256" , "8" , "dds" +"particles\ramp_white_orange_lllf.dds" , "256" , "8" , "dds" +"particles\ramp_white_premod_01.dds" , "256" , "4" , "dds" +"particles\ramp_white_premod_02.dds" , "256" , "4" , "dds" +"particles\ramp_white_premod_03_even.dds" , "256" , "4" , "dds" +"particles\ramp_white_premod_03.dds" , "256" , "4" , "dds" +"particles\ramp_white_to_purple_01.dds" , "256" , "4" , "dds" +"particles\ramp_yellow_01.dds" , "256" , "4" , "dds" +"particles\ramp_yellow_02.dds" , "128" , "16" , "dds" +"particles\ramp_yellow_03.dds" , "128" , "16" , "dds" +"particles\ramp_yellow_04.dds" , "256" , "4" , "dds" +"particles\ramp_yellow_05.dds" , "256" , "4" , "dds" +"particles\ramp_yellow_06.dds" , "256" , "4" , "dds" +"particles\ramp_yellow_blue_01.dds" , "256" , "4" , "dds" +"particles\ramp_yellow_build_spray.dds" , "64" , "4" , "dds" +"particles\ramp_yellow_purple_01.dds" , "256" , "4" , "dds" +"particles\ramp_yellow_purple_03.dds" , "256" , "4" , "dds" +"particles\ramp_yellow_to_purple_01.dds" , "256" , "4" , "dds" +"particles\reacton_cannon_trail_01.dds" , "64" , "256" , "dds" +"particles\reacton_hit_flare_01.dds" , "256" , "256" , "dds" +"particles\ring_01.dds" , "256" , "256" , "dds" +"particles\ring_02.dds" , "256" , "256" , "dds" +"particles\ring_03.dds" , "512" , "512" , "dds" +"particles\ring_04.dds" , "512" , "512" , "dds" +"particles\ring_05.dds" , "512" , "512" , "dds" +"particles\ring_06.dds" , "256" , "256" , "dds" +"particles\ring_07.dds" , "256" , "256" , "dds" +"particles\ring_08.dds" , "256" , "256" , "dds" +"particles\ring_09.dds" , "128" , "128" , "dds" +"particles\ring_10.dds" , "128" , "128" , "dds" +"particles\ring_11.dds" , "512" , "512" , "dds" +"particles\ring_12.dds" , "256" , "256" , "dds" +"particles\ring_13.dds" , "512" , "512" , "dds" +"particles\ring_14.dds" , "512" , "512" , "dds" +"particles\ring_15.dds" , "512" , "512" , "dds" +"particles\ring_16.dds" , "512" , "512" , "dds" +"particles\ring_distort_01.dds" , "256" , "256" , "dds" +"particles\ring_texture.dds" , "256" , "256" , "dds" +"particles\ring_white_01.dds" , "256" , "256" , "dds" +"particles\ring_white_02.dds" , "256" , "256" , "dds" +"particles\ring_white_03.dds" , "256" , "256" , "dds" +"particles\ring_white_04.dds" , "256" , "256" , "dds" +"particles\ring_white_05.dds" , "256" , "256" , "dds" +"particles\ring_white_06.dds" , "256" , "256" , "dds" +"particles\riotgun_beam_01.dds" , "512" , "64" , "dds" +"particles\riotgun_munition_01.dds" , "512" , "64" , "dds" +"particles\riotgun_munition_02.dds" , "512" , "64" , "dds" +"particles\riotgun_munition_03.dds" , "512" , "64" , "dds" +"particles\riotgun_munition_04.dds" , "64" , "64" , "dds" +"particles\riotgun_munition_05.dds" , "512" , "64" , "dds" +"particles\riotgun_munition_06.dds" , "512" , "64" , "dds" +"particles\riotgun_munition_07.dds" , "512" , "64" , "dds" +"particles\riotgun_munition_08.dds" , "128" , "64" , "dds" +"particles\riotgun_munition_09.dds" , "128" , "64" , "dds" +"particles\ser_aireau_munition_01.dds" , "1024" , "128" , "dds" +"particles\ser_aireau_munition_02.dds" , "1024" , "128" , "dds" +"particles\ser_aireau_munition_03.dds" , "1024" , "128" , "dds" +"particles\ser_contrail_01.dds" , "64" , "128" , "dds" +"particles\ser_experimental_beam_01.dds" , "512" , "512" , "dds" +"particles\ser_experimental_beam_02.dds" , "512" , "512" , "dds" +"particles\ser_plasma_trail01.dds" , "128" , "256" , "dds" +"particles\ser_plasma01.dds" , "256" , "256" , "dds" +"particles\ser_plasma02.dds" , "256" , "256" , "dds" +"particles\ser_plasma03.dds" , "256" , "256" , "dds" +"particles\ser_plasma04.dds" , "256" , "256" , "dds" +"particles\ser_plasma05.dds" , "256" , "256" , "dds" +"particles\ser_plasma06.dds" , "128" , "128" , "dds" +"particles\ser_plasma07.dds" , "256" , "256" , "dds" +"particles\ser_plasma08.dds" , "256" , "256" , "dds" +"particles\ser_plasma09.dds" , "256" , "256" , "dds" +"particles\ser_plasma10.dds" , "256" , "256" , "dds" +"particles\ser_plasma11.dds" , "256" , "1024" , "dds" +"particles\ser_plasma12.dds" , "512" , "512" , "dds" +"particles\ser_plasma13.dds" , "256" , "256" , "dds" +"particles\shell_01.dds" , "32" , "32" , "dds" +"particles\shrapnel_01.dds" , "256" , "256" , "dds" +"particles\smoke.dds" , "128" , "128" , "dds" +"particles\smokeramp.dds" , "256" , "16" , "dds" +"particles\sonic_gun.dds" , "128" , "128" , "dds" +"particles\sonic_pulsar.dds" , "128" , "128" , "dds" +"particles\sonic_pulse_01.dds" , "32" , "256" , "dds" +"particles\spark_anim_01.dds" , "256" , "64" , "dds" +"particles\spark_anim_02.dds" , "512" , "64" , "dds" +"particles\sparkle_02.dds" , "256" , "256" , "dds" +"particles\sparkle_03.dds" , "512" , "512" , "dds" +"particles\sparkle_04.dds" , "512" , "512" , "dds" +"particles\sparkle_05.dds" , "512" , "512" , "dds" +"particles\sparkle_06.dds" , "512" , "512" , "dds" +"particles\sparkle_07.dds" , "128" , "128" , "dds" +"particles\sparkle_08.dds" , "128" , "128" , "dds" +"particles\sparkle_09.dds" , "128" , "128" , "dds" +"particles\sparkle_10.dds" , "512" , "512" , "dds" +"particles\sparkle_11.dds" , "256" , "256" , "dds" +"particles\sparkle_blue_add_01.dds" , "128" , "128" , "dds" +"particles\sparkle_blue_add_02.dds" , "64" , "64" , "dds" +"particles\sparkle_blue_add_03.dds" , "128" , "128" , "dds" +"particles\sparkle_blue_add_05.dds" , "256" , "256" , "dds" +"particles\sparkle_gold_alpha_01.dds" , "128" , "128" , "dds" +"particles\sparkle_green_add_01.dds" , "128" , "128" , "dds" +"particles\sparkle_green_add_02.dds" , "128" , "128" , "dds" +"particles\sparkle_purple_add_05.dds" , "256" , "256" , "dds" +"particles\sparkle_red_add_01.dds" , "128" , "128" , "dds" +"particles\sparkle_red_add_02.dds" , "128" , "128" , "dds" +"particles\sparkle_red_add_03.dds" , "128" , "128" , "dds" +"particles\sparkle_red_add_04.dds" , "128" , "128" , "dds" +"particles\sparkle_white_add_01.dds" , "128" , "128" , "dds" +"particles\sparkle_white_add_02.dds" , "128" , "128" , "dds" +"particles\sparkle_white_add_03.dds" , "64" , "64" , "dds" +"particles\sparkle_white_add_04.dds" , "16" , "128" , "dds" +"particles\sparkle_white_add_05.dds" , "128" , "128" , "dds" +"particles\sparkle_white_add_06.dds" , "128" , "128" , "dds" +"particles\sparkle_white_add_07.dds" , "256" , "256" , "dds" +"particles\sparkle_white_add_08.dds" , "128" , "128" , "dds" +"particles\sparkle_white_premod_04.dds" , "16" , "128" , "dds" +"particles\sparkle_yellow_add_01.dds" , "256" , "256" , "dds" +"particles\sparkle_yellow_add_02.dds" , "256" , "256" , "dds" +"particles\sparks_01.dds" , "128" , "256" , "dds" +"particles\temporal_bubble_01.dds" , "256" , "256" , "dds" +"particles\terran_transport_beam_01.dds" , "64" , "128" , "dds" +"particles\terran_transport_beam_02.dds" , "64" , "128" , "dds" +"particles\terran_transport_beam_03.dds" , "64" , "128" , "dds" +"particles\test_marker_01.dds" , "128" , "128" , "dds" +"particles\test_neutron_bomb_01.dds" , "512" , "512" , "dds" +"particles\test_neutron_bomb_premod_01.dds" , "512" , "512" , "dds" +"particles\test_ramp_red_blue_01.dds" , "256" , "16" , "dds" +"particles\testramp.dds" , "256" , "16" , "dds" +"particles\trail_aeon_01.dds" , "128" , "256" , "dds" +"particles\trail_ser_01.dds" , "64" , "256" , "dds" +"particles\trail_ser_02.dds" , "64" , "256" , "dds" +"particles\trail_ser_03.dds" , "64" , "256" , "dds" +"particles\trail_ser_04.dds" , "64" , "256" , "dds" +"particles\trail_ser_05.dds" , "64" , "256" , "dds" +"particles\trail_ser_06.dds" , "64" , "256" , "dds" +"particles\trail_uef_01.dds" , "128" , "256" , "dds" +"particles\trail_white_01.dds" , "32" , "256" , "dds" +"particles\trail_white_02_blankmips.dds" , "32" , "32" , "dds" +"particles\trail_white_02.dds" , "32" , "32" , "dds" +"particles\trail_white_03.dds" , "32" , "256" , "dds" +"particles\trail_white_04.dds" , "32" , "32" , "dds" +"particles\trail_white_05.dds" , "32" , "128" , "dds" +"particles\transport_beam_03.dds" , "32" , "256" , "dds" +"particles\UEF_adjacency_beam_01.dds" , "64" , "64" , "dds" +"particles\uef_plasma_trail_01.dds" , "64" , "256" , "dds" +"particles\under_water_bubble_01.dds" , "128" , "128" , "dds" +"particles\vegetation_kickup_01.dds" , "128" , "128" , "dds" +"particles\wake_back02.dds" , "128" , "128" , "dds" +"particles\water_bubbles.dds" , "32" , "32" , "dds" +"particles\water_churn_alpha_01.dds" , "128" , "128" , "dds" +"particles\water_fire_premod_01.dds" , "128" , "128" , "dds" +"particles\water_plume_alpha_01.dds" , "1024" , "256" , "dds" +"particles\water_plume_alpha_02.dds" , "256" , "256" , "dds" +"particles\water_ripple_alpha_01.dds" , "128" , "128" , "dds" +"particles\water_ripple_alpha_02.dds" , "128" , "128" , "dds" +"particles\water_ripple_alpha_03.dds" , "128" , "128" , "dds" +"particles\water_ripple_alpha_04.dds" , "128" , "128" , "dds" +"particles\water_ripple_alpha_05.dds" , "128" , "128" , "dds" +"particles\water_splash_alpha_01.dds" , "128" , "128" , "dds" +"particles\water_splash_alpha_02.dds" , "128" , "128" , "dds" +"particles\water_splash_alpha_03.dds" , "128" , "128" , "dds" +"particles\water_splash_alpha_04.dds" , "128" , "128" , "dds" +"particles\water_splash_alpha_05.dds" , "128" , "128" , "dds" +"particles\water_splash_alpha_06.dds" , "128" , "128" , "dds" +"particles\water_splash_alpha_07.dds" , "128" , "128" , "dds" +"particles\water_splash_alpha_08.dds" , "128" , "128" , "dds" +"particles\water_splash_alpha_09.dds" , "256" , "256" , "dds" +"particles\water_splash_alpha_10.dds" , "128" , "128" , "dds" +"particles\water_splash_alpha_11.dds" , "256" , "256" , "dds" +"particles\water_splash_alpha_12.dds" , "128" , "128" , "dds" +"particles\wedge_01.dds" , "128" , "128" , "dds" +"particles\white_blast_01.dds" , "256" , "256" , "dds" +"particles\white_blast_02.dds" , "512" , "512" , "dds" +"particles\white_rays_01.dds" , "256" , "256" , "dds" +"quadtest.bmp" , "256" , "256" , "bmp" +"scrollArrows.dds" , "64" , "64" , "dds" +"selection_bracket_enemy_md.dds" , "64" , "64" , "dds" +"selection_bracket_enemy_sm.dds" , "64" , "64" , "dds" +"selection_bracket_player_md.dds" , "64" , "64" , "dds" +"selection_bracket_player_sm.dds" , "64" , "64" , "dds" +"shapesCircle.dds" , "32" , "32" , "dds" +"shapesCircle.png" , "128" , "128" , "png" +"shapesDownTriangle.png" , "128" , "128" , "png" +"shapesSquare.png" , "128" , "128" , "png" +"shapesTriangle.png" , "128" , "128" , "png" +"Snow.dds" , "512" , "512" , "dds" +"strategicPath.dds" , "1024" , "1024" , "dds" +"stratNotify.png" , "64" , "59" , "png" +"test1.png" , "128" , "128" , "png" +"tilesets\_global\n_bloop.dds" , "512" , "512" , "dds" +"tilesets\_global\n_button.dds" , "512" , "512" , "dds" +"tilesets\_global\n_cliff.dds" , "512" , "512" , "dds" +"tilesets\_global\n_crater.dds" , "512" , "512" , "dds" +"tilesets\_global\n_erosion.dds" , "512" , "512" , "dds" +"tilesets\_global\n_erosion001.dds" , "1024" , "1024" , "dds" +"tilesets\_global\n_erosion002.dds" , "1024" , "1024" , "dds" +"tilesets\_global\n_erosion003.dds" , "512" , "512" , "dds" +"tilesets\_global\n_erosion004.dds" , "512" , "512" , "dds" +"tilesets\_global\n_erosion005.dds" , "1024" , "1024" , "dds" +"tilesets\_global\n_erosion006.dds" , "512" , "512" , "dds" +"tilesets\_global\n_erosion007.dds" , "1024" , "1024" , "dds" +"tilesets\_global\n_erosion008.dds" , "512" , "512" , "dds" +"tilesets\_global\n_flat.dds" , "256" , "256" , "dds" +"tilesets\_global\n_fractal.dds" , "1024" , "1024" , "dds" +"tilesets\_global\n_noise.dds" , "1024" , "1024" , "dds" +"tilesets\_global\n_spectest.dds" , "256" , "256" , "dds" +"tilesets\_global\n_thetaxi.dds" , "512" , "512" , "dds" +"tilesets\_global\scorch_mark.dds" , "1024" , "1024" , "dds" +"tilesets\_global\tank_treads.dds" , "256" , "256" , "dds" +"tilesets\_global\up.dds" , "128" , "128" , "dds" +"tilesets\dunes\base\black_n.dds" , "64" , "64" , "dds" +"tilesets\dunes\base\black.dds" , "64" , "64" , "dds" +"tilesets\dunes\base\grass000_n.dds" , "512" , "512" , "dds" +"tilesets\dunes\base\grass000.dds" , "512" , "512" , "dds" +"tilesets\dunes\base\grass001_N.dds" , "512" , "512" , "dds" +"tilesets\dunes\base\grass001.dds" , "512" , "512" , "dds" +"tilesets\dunes\base\Ice001_N.dds" , "512" , "512" , "dds" +"tilesets\dunes\base\Ice001.dds" , "512" , "512" , "dds" +"tilesets\dunes\base\Ice002_n.dds" , "512" , "512" , "dds" +"tilesets\dunes\base\Ice002.dds" , "512" , "512" , "dds" +"tilesets\dunes\base\macrotexture000.dds" , "256" , "256" , "dds" +"tilesets\dunes\base\macrotexture001.dds" , "256" , "256" , "dds" +"tilesets\dunes\base\RockLight_n.dds" , "512" , "512" , "dds" +"tilesets\dunes\base\RockLight.dds" , "512" , "512" , "dds" +"tilesets\dunes\base\RockMed_N.dds" , "512" , "512" , "dds" +"tilesets\dunes\base\RockMed.dds" , "512" , "512" , "dds" +"tilesets\dunes\base\SandDark_N.dds" , "512" , "512" , "dds" +"tilesets\dunes\base\SandDark.dds" , "512" , "512" , "dds" +"tilesets\dunes\base\SandLight_N.dds" , "512" , "512" , "dds" +"tilesets\dunes\base\SandLight.dds" , "512" , "512" , "dds" +"tilesets\dunes\base\SandMed_N.dds" , "512" , "512" , "dds" +"tilesets\dunes\base\SandMed.dds" , "512" , "512" , "dds" +"tilesets\dunes\base\snow001_N.dds" , "512" , "512" , "dds" +"tilesets\dunes\base\snow001.dds" , "512" , "512" , "dds" +"tilesets\dunes\fill\_atlas_n.dds" , "2048" , "2048" , "dds" +"tilesets\dunes\fill\_atlas.dds" , "2048" , "2048" , "dds" +"tilesets\dunes\fill\D_RDL001_N.dds" , "256" , "256" , "dds" +"tilesets\dunes\fill\D_RDL001.dds" , "256" , "256" , "dds" +"tilesets\dunes\fill\D_RLL001_N.dds" , "256" , "256" , "dds" +"tilesets\dunes\fill\D_RLL001.dds" , "256" , "256" , "dds" +"tilesets\dunes\fill\D_SDL001_N.dds" , "256" , "256" , "dds" +"tilesets\dunes\fill\D_SDL001.dds" , "256" , "256" , "dds" +"tilesets\dunes\fill\D_SDM001_N.dds" , "128" , "128" , "dds" +"tilesets\dunes\fill\D_SDM001.dds" , "128" , "128" , "dds" +"tilesets\dunes\fill\D_SDS001_N.dds" , "64" , "64" , "dds" +"tilesets\dunes\fill\D_SDS001.dds" , "64" , "64" , "dds" +"tilesets\dunes\fill\D_SIL001_N.dds" , "256" , "256" , "dds" +"tilesets\dunes\fill\D_SIL001.dds" , "256" , "256" , "dds" +"tilesets\dunes\fill\D_SIL002.dds" , "256" , "256" , "dds" +"tilesets\dunes\fill\D_SIM001_N.dds" , "128" , "128" , "dds" +"tilesets\dunes\fill\D_SIM001.dds" , "128" , "128" , "dds" +"tilesets\dunes\fill\D_SIM002_N.dds" , "128" , "128" , "dds" +"tilesets\dunes\fill\D_SIM002.dds" , "128" , "128" , "dds" +"tilesets\dunes\fill\D_SIS001_N.dds" , "64" , "64" , "dds" +"tilesets\dunes\fill\D_SIS001.dds" , "64" , "64" , "dds" +"tilesets\dunes\fill\D_SLL001_N.dds" , "256" , "256" , "dds" +"tilesets\dunes\fill\D_SLL001.dds" , "256" , "256" , "dds" +"tilesets\dunes\fill\D_SLM001_N.dds" , "128" , "128" , "dds" +"tilesets\dunes\fill\D_SLM001.dds" , "128" , "128" , "dds" +"tilesets\dunes\fill\D_SLS001_N.dds" , "64" , "64" , "dds" +"tilesets\dunes\fill\D_SLS001.dds" , "64" , "64" , "dds" +"tilesets\dunes\fill\P_BM001_N.dds" , "64" , "64" , "dds" +"tilesets\dunes\fill\P_BM001.dds" , "64" , "64" , "dds" +"tilesets\dunes\fill\P_BM002_N.dds" , "1024" , "1024" , "dds" +"tilesets\dunes\fill\P_BM002.dds" , "1024" , "1024" , "dds" +"tilesets\dunes\fill\P_BM003_N.dds" , "512" , "512" , "dds" +"tilesets\dunes\fill\P_BM003.dds" , "512" , "512" , "dds" +"tilesets\dunes\fill\P_BM004_N.dds" , "512" , "512" , "dds" +"tilesets\dunes\fill\P_BM004.dds" , "512" , "512" , "dds" +"tilesets\dunes\fill\P_BM005_N.dds" , "64" , "64" , "dds" +"tilesets\dunes\fill\P_BM005.dds" , "64" , "64" , "dds" +"tilesets\dunes\fill\P_BM006_N.dds" , "32" , "32" , "dds" +"tilesets\dunes\fill\P_BM006.dds" , "32" , "32" , "dds" +"tilesets\dunes\fill\P_BS001_N.dds" , "16" , "16" , "dds" +"tilesets\dunes\fill\P_BS001.dds" , "16" , "16" , "dds" +"tilesets\dunes\fill\P_DDL001_N.dds" , "256" , "256" , "dds" +"tilesets\dunes\fill\P_DDL001.dds" , "256" , "256" , "dds" +"tilesets\dunes\fill\P_DDM001_N.dds" , "128" , "128" , "dds" +"tilesets\dunes\fill\P_DDM001.dds" , "128" , "128" , "dds" +"tilesets\dunes\fill\P_GDL001_N.dds" , "128" , "128" , "dds" +"tilesets\dunes\fill\P_GDL001.dds" , "128" , "128" , "dds" +"tilesets\dunes\fill\P_GDL002_N.dds" , "256" , "256" , "dds" +"tilesets\dunes\fill\P_GDL002.dds" , "256" , "256" , "dds" +"tilesets\dunes\fill\P_GDM001_N.dds" , "128" , "128" , "dds" +"tilesets\dunes\fill\P_GDM001.dds" , "128" , "128" , "dds" +"tilesets\dunes\fill\P_GDM002_N.dds" , "128" , "128" , "dds" +"tilesets\dunes\fill\P_GDM002.dds" , "128" , "128" , "dds" +"tilesets\Evergreen\base\Dirt001_n.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\base\Dirt001.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\base\grass000_n.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\base\grass000.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\base\grass001_N.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\base\grass001.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\base\macrotexture000.dds" , "256" , "256" , "dds" +"tilesets\Evergreen\base\macrotexture001.dds" , "256" , "256" , "dds" +"tilesets\Evergreen\base\RockLight_n.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\base\RockLight.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\base\RockMed_N.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\base\RockMed.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\base\SandLight_N.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\base\SandLight.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\base\SandLight002_n.dds" , "256" , "256" , "dds" +"tilesets\Evergreen\base\SandLight002.dds" , "256" , "256" , "dds" +"tilesets\Evergreen\base\SandRock_n.dds" , "256" , "256" , "dds" +"tilesets\Evergreen\base\SandRock.dds" , "256" , "256" , "dds" +"tilesets\Evergreen\base\Sandwet_n.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\base\Sandwet.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\base\snow001_N.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\base\snow001.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\edge\nice000.dds" , "512" , "256" , "dds" +"tilesets\Evergreen\fill\_atlas_n.dds" , "2048" , "2048" , "dds" +"tilesets\Evergreen\fill\_atlas.dds" , "2048" , "2048" , "dds" +"tilesets\Evergreen\fill\E_Bush001.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\fill\E_Bush002.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\fill\E_Bush003.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\fill\E_Bush004.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\fill\E_Bush005.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\fill\E_Bush006.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\fill\E_DL001_N.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\fill\E_DL001.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\fill\E_MUD001.dds" , "256" , "256" , "dds" +"tilesets\Evergreen\fill\E_MUD002.dds" , "256" , "256" , "dds" +"tilesets\Evergreen\fill\E_MUD003.dds" , "256" , "256" , "dds" +"tilesets\Evergreen\fill\E_Tarmac_Road.dds" , "256" , "256" , "dds" +"tilesets\Evergreen\fill\E_Tarmac04.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\fill\E_TATR001.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\fill\E_TATR002.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\fill\E_TATR003.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\fill\E_TATR004.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\fill\E_TATR005.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\fill\E_TATR006.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\fill\E_TATR007.dds" , "1024" , "1024" , "dds" +"tilesets\Evergreen\fill\E_TATR008.dds" , "1024" , "1024" , "dds" +"tilesets\Evergreen\fill\E_TATR009.dds" , "1024" , "1024" , "dds" +"tilesets\Evergreen\fill\E_TDR001.dds" , "1024" , "1024" , "dds" +"tilesets\Evergreen\fill\E_TL001.dds" , "2048" , "2048" , "dds" +"tilesets\Evergreen\fill\E_TM001.dds" , "1024" , "1024" , "dds" +"tilesets\Evergreen\fill\E_TRW001.dds" , "1024" , "1024" , "dds" +"tilesets\Evergreen\fill\Evergreen_Runway01.dds" , "2048" , "2048" , "dds" +"tilesets\Evergreen\fill\Evergreen_Tarmac_Round01.dds" , "2048" , "2048" , "dds" +"tilesets\Evergreen\fill\Evergreen_Tarmac_ThreeSquares.dds" , "1024" , "1024" , "dds" +"tilesets\Evergreen\fill\Evergreen_Tarmac.dds" , "2048" , "2048" , "dds" +"tilesets\Evergreen\fill\Evergreen_Tarmac02.dds" , "2048" , "2048" , "dds" +"tilesets\Evergreen\fill\P_BM002_N.dds" , "1024" , "1024" , "dds" +"tilesets\Evergreen\fill\P_BM002.dds" , "1024" , "1024" , "dds" +"tilesets\Evergreen\fill\P_BM003_N.dds" , "128" , "128" , "dds" +"tilesets\Evergreen\fill\P_BM003.dds" , "128" , "128" , "dds" +"tilesets\Evergreen\fill\P_BM004_N.dds" , "256" , "256" , "dds" +"tilesets\Evergreen\fill\P_BM004.dds" , "256" , "256" , "dds" +"tilesets\Evergreen\fill\P_BM005_N.dds" , "64" , "64" , "dds" +"tilesets\Evergreen\fill\P_BM005.dds" , "64" , "64" , "dds" +"tilesets\Evergreen\fill\P_BM006_N.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\fill\P_BM006.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\fill\P_BS001_N.dds" , "16" , "16" , "dds" +"tilesets\Evergreen\fill\P_BS001.dds" , "16" , "16" , "dds" +"tilesets\Evergreen\WaterNormal.dds" , "256" , "256" , "dds" +"tilesets\Evergreen\WaterTint.dds" , "256" , "256" , "dds" +"tilesets\ice\base\Ice001_N.dds" , "512" , "512" , "dds" +"tilesets\ice\base\Ice001.dds" , "512" , "512" , "dds" +"tilesets\ice\base\Ice002_n.dds" , "512" , "512" , "dds" +"tilesets\ice\base\Ice002.dds" , "512" , "512" , "dds" +"tilesets\ice\base\macrotexture000.dds" , "256" , "256" , "dds" +"tilesets\ice\base\macrotexture001.dds" , "256" , "256" , "dds" +"tilesets\ice\base\snow001_N.dds" , "512" , "512" , "dds" +"tilesets\ice\base\snow001.dds" , "512" , "512" , "dds" +"tilesets\ice\fill\_atlas_n.dds" , "2048" , "2048" , "dds" +"tilesets\ice\fill\_atlas.dds" , "2048" , "2048" , "dds" +"tilesets\paradise\base\black_n.dds" , "64" , "64" , "dds" +"tilesets\paradise\base\black.dds" , "64" , "64" , "dds" +"tilesets\paradise\base\devtest_n.dds" , "128" , "128" , "dds" +"tilesets\paradise\base\devtest.dds" , "128" , "128" , "dds" +"tilesets\paradise\base\dirt000_n.dds" , "512" , "512" , "dds" +"tilesets\paradise\base\dirt000.dds" , "512" , "512" , "dds" +"tilesets\paradise\base\grass000_n.dds" , "512" , "512" , "dds" +"tilesets\paradise\base\grass000.dds" , "512" , "512" , "dds" +"tilesets\paradise\base\grass001_n.dds" , "512" , "512" , "dds" +"tilesets\paradise\base\grass001.dds" , "512" , "512" , "dds" +"tilesets\paradise\base\Ice001_N.dds" , "512" , "512" , "dds" +"tilesets\paradise\base\Ice001.dds" , "512" , "512" , "dds" +"tilesets\paradise\base\Ice002_n.dds" , "512" , "512" , "dds" +"tilesets\paradise\base\Ice002.dds" , "512" , "512" , "dds" +"tilesets\paradise\base\macrotexture000.dds" , "256" , "256" , "dds" +"tilesets\paradise\base\macrotexture001.dds" , "256" , "256" , "dds" +"tilesets\paradise\base\Rock001_n.dds" , "512" , "512" , "dds" +"tilesets\paradise\base\Rock001.dds" , "512" , "512" , "dds" +"tilesets\paradise\base\sand000_n.dds" , "512" , "512" , "dds" +"tilesets\paradise\base\sand000.dds" , "512" , "512" , "dds" +"tilesets\paradise\base\SandMed_n.dds" , "512" , "512" , "dds" +"tilesets\paradise\base\SandMed.dds" , "512" , "512" , "dds" +"tilesets\paradise\base\snow001_N.dds" , "512" , "512" , "dds" +"tilesets\paradise\base\snow001.dds" , "512" , "512" , "dds" +"tilesets\paradise\fill\_atlas_n.dds" , "2048" , "2048" , "dds" +"tilesets\paradise\fill\_atlas.dds" , "2048" , "2048" , "dds" +"tilesets\paradise\fill\P_BM001_N.dds" , "64" , "64" , "dds" +"tilesets\paradise\fill\P_BM001.dds" , "64" , "64" , "dds" +"tilesets\paradise\fill\P_BM002_N.dds" , "1024" , "1024" , "dds" +"tilesets\paradise\fill\P_BM002.dds" , "1024" , "1024" , "dds" +"tilesets\paradise\fill\P_BM003_N.dds" , "512" , "512" , "dds" +"tilesets\paradise\fill\P_BM003.dds" , "512" , "512" , "dds" +"tilesets\paradise\fill\P_BM004_N.dds" , "32" , "32" , "dds" +"tilesets\paradise\fill\P_BM004.dds" , "32" , "32" , "dds" +"tilesets\paradise\fill\P_BM005_N.dds" , "64" , "64" , "dds" +"tilesets\paradise\fill\P_BM005.dds" , "64" , "64" , "dds" +"tilesets\paradise\fill\P_BM006_N.dds" , "32" , "32" , "dds" +"tilesets\paradise\fill\P_BM006.dds" , "32" , "32" , "dds" +"tilesets\paradise\fill\P_BS001_N.dds" , "16" , "16" , "dds" +"tilesets\paradise\fill\P_BS001.dds" , "16" , "16" , "dds" +"tilesets\paradise\fill\P_DDL001_N.dds" , "256" , "256" , "dds" +"tilesets\paradise\fill\P_DDL001.dds" , "256" , "256" , "dds" +"tilesets\paradise\fill\P_DDM001_N.dds" , "128" , "128" , "dds" +"tilesets\paradise\fill\P_DDM001.dds" , "128" , "128" , "dds" +"tilesets\paradise\fill\P_DDS001_N.dds" , "32" , "32" , "dds" +"tilesets\paradise\fill\P_DDS001.dds" , "32" , "32" , "dds" +"tilesets\paradise\fill\P_DLL001_N.dds" , "256" , "256" , "dds" +"tilesets\paradise\fill\P_DLL001.dds" , "256" , "256" , "dds" +"tilesets\paradise\fill\P_DLM001_N.dds" , "128" , "128" , "dds" +"tilesets\paradise\fill\P_DLM001.dds" , "128" , "128" , "dds" +"tilesets\paradise\fill\P_DLS001_N.dds" , "64" , "64" , "dds" +"tilesets\paradise\fill\P_DLS001.dds" , "64" , "64" , "dds" +"tilesets\paradise\fill\P_GDL001_N.dds" , "128" , "128" , "dds" +"tilesets\paradise\fill\P_GDL001.dds" , "128" , "128" , "dds" +"tilesets\paradise\fill\P_GDL002_N.dds" , "256" , "256" , "dds" +"tilesets\paradise\fill\P_GDL002.dds" , "256" , "256" , "dds" +"tilesets\paradise\fill\P_GDM001_N.dds" , "128" , "128" , "dds" +"tilesets\paradise\fill\P_GDM001.dds" , "128" , "128" , "dds" +"tilesets\paradise\fill\P_GDM002_N.dds" , "128" , "128" , "dds" +"tilesets\paradise\fill\P_GDM002.dds" , "128" , "128" , "dds" +"tilesets\paradise\fill\P_GDS001_N.dds" , "64" , "64" , "dds" +"tilesets\paradise\fill\P_GDS001.dds" , "64" , "64" , "dds" +"tilesets\paradise\fill\P_GDS002_N.dds" , "32" , "32" , "dds" +"tilesets\paradise\fill\P_GDS002.dds" , "32" , "32" , "dds" +"tilesets\paradise\fill\P_GLL001_N.dds" , "256" , "256" , "dds" +"tilesets\paradise\fill\P_GLL001.dds" , "256" , "256" , "dds" +"tilesets\paradise\fill\P_GLL002_N.dds" , "256" , "256" , "dds" +"tilesets\paradise\fill\P_GLL002.dds" , "256" , "256" , "dds" +"tilesets\paradise\fill\P_GLM001_N.dds" , "128" , "128" , "dds" +"tilesets\paradise\fill\P_GLM001.dds" , "128" , "128" , "dds" +"tilesets\paradise\fill\P_GLM002_N.dds" , "128" , "128" , "dds" +"tilesets\paradise\fill\P_GLM002.dds" , "128" , "128" , "dds" +"tilesets\paradise\fill\P_GLM003_N.dds" , "128" , "128" , "dds" +"tilesets\paradise\fill\P_GLM003.dds" , "128" , "128" , "dds" +"tilesets\paradise\fill\P_GLS001_N.dds" , "64" , "64" , "dds" +"tilesets\paradise\fill\P_GLS001.dds" , "64" , "64" , "dds" +"tilesets\paradise\fill\P_GLS002_N.dds" , "32" , "32" , "dds" +"tilesets\paradise\fill\P_GLS002.dds" , "32" , "32" , "dds" +"tilesets\paradise\fill\P_RLL001_N.dds" , "512" , "512" , "dds" +"tilesets\paradise\fill\P_RLL001.dds" , "1024" , "1024" , "dds" +"tilesets\paradise\fill\P_RLL002.dds" , "512" , "512" , "dds" +"tilesets\paradise\fill\P_SIL001_N.dds" , "256" , "256" , "dds" +"tilesets\paradise\fill\P_SIL001.dds" , "256" , "256" , "dds" +"tilesets\paradise\fill\P_SIM001_N.dds" , "128" , "128" , "dds" +"tilesets\paradise\fill\P_SIM001.dds" , "128" , "128" , "dds" +"tilesets\paradise\fill\P_SIS001_N.dds" , "64" , "64" , "dds" +"tilesets\paradise\fill\P_SIS001.dds" , "64" , "64" , "dds" +"tilesets\paradise\fill\P_SLL001_N.dds" , "256" , "256" , "dds" +"tilesets\paradise\fill\P_SLL001.dds" , "256" , "256" , "dds" +"tilesets\paradise\fill\P_SLM001_N.dds" , "128" , "128" , "dds" +"tilesets\paradise\fill\P_SLM001.dds" , "128" , "128" , "dds" +"tilesets\paradise\fill\P_SLS001_N.dds" , "32" , "32" , "dds" +"tilesets\paradise\fill\P_SLS001.dds" , "32" , "32" , "dds" +"tilesets\paradise\fill\P_SLS002_N.dds" , "64" , "64" , "dds" +"tilesets\paradise\fill\P_SLS002.dds" , "64" , "64" , "dds" +"ui\aeon\dialogs\menu-btns\close_btn_dis.dds" , "24" , "24" , "dds" +"ui\aeon\dialogs\menu-btns\close_btn_down.dds" , "24" , "24" , "dds" +"ui\aeon\dialogs\menu-btns\close_btn_over.dds" , "24" , "24" , "dds" +"ui\aeon\dialogs\menu-btns\close_btn_up.dds" , "24" , "24" , "dds" +"ui\aeon\dialogs\menu-btns\config_btn_dis.dds" , "24" , "24" , "dds" +"ui\aeon\dialogs\menu-btns\config_btn_down.dds" , "24" , "24" , "dds" +"ui\aeon\dialogs\menu-btns\config_btn_over.dds" , "24" , "24" , "dds" +"ui\aeon\dialogs\menu-btns\config_btn_up.dds" , "24" , "24" , "dds" +"ui\aeon\dialogs\menu-btns\default_btn_dis.dds" , "24" , "24" , "dds" +"ui\aeon\dialogs\menu-btns\default_btn_down.dds" , "24" , "24" , "dds" +"ui\aeon\dialogs\menu-btns\default_btn_over.dds" , "24" , "24" , "dds" +"ui\aeon\dialogs\menu-btns\default_btn_up.dds" , "24" , "24" , "dds" +"ui\aeon\dialogs\menu-btns\pin_btn_dis.dds" , "24" , "24" , "dds" +"ui\aeon\dialogs\menu-btns\pin_btn_down.dds" , "24" , "24" , "dds" +"ui\aeon\dialogs\menu-btns\pin_btn_over.dds" , "24" , "24" , "dds" +"ui\aeon\dialogs\menu-btns\pin_btn_up.dds" , "24" , "24" , "dds" +"ui\aeon\dialogs\menu-btns\pinned_btn_dis.dds" , "24" , "24" , "dds" +"ui\aeon\dialogs\menu-btns\pinned_btn_down.dds" , "24" , "24" , "dds" +"ui\aeon\dialogs\menu-btns\pinned_btn_over.dds" , "24" , "24" , "dds" +"ui\aeon\dialogs\menu-btns\pinned_btn_up.dds" , "24" , "24" , "dds" +"ui\aeon\dialogs\time-units-tabs\energy_bmp.dds" , "16" , "28" , "dds" +"ui\aeon\dialogs\time-units-tabs\panel-tracking_bmp_d.dds" , "12" , "44" , "dds" +"ui\aeon\dialogs\time-units-tabs\panel-tracking_bmp_l.dds" , "24" , "44" , "dds" +"ui\aeon\dialogs\time-units-tabs\panel-tracking_bmp_m.dds" , "4" , "44" , "dds" +"ui\aeon\dialogs\time-units-tabs\panel-tracking_bmp_r.dds" , "24" , "44" , "dds" +"ui\aeon\dialogs\time-units-tabs\tracking-icon_bmp.dds" , "24" , "20" , "dds" +"ui\aeon\game\avatar-arrow_btn\tab-close_btn_dis.dds" , "12" , "32" , "dds" +"ui\aeon\game\avatar-arrow_btn\tab-close_btn_down.dds" , "12" , "32" , "dds" +"ui\aeon\game\avatar-arrow_btn\tab-close_btn_over.dds" , "12" , "32" , "dds" +"ui\aeon\game\avatar-arrow_btn\tab-close_btn_up.dds" , "12" , "32" , "dds" +"ui\aeon\game\avatar-arrow_btn\tab-open_btn_dis.dds" , "12" , "32" , "dds" +"ui\aeon\game\avatar-arrow_btn\tab-open_btn_down.dds" , "12" , "32" , "dds" +"ui\aeon\game\avatar-arrow_btn\tab-open_btn_over.dds" , "12" , "32" , "dds" +"ui\aeon\game\avatar-arrow_btn\tab-open_btn_up.dds" , "12" , "32" , "dds" +"ui\aeon\game\avatar-engineers-panel\bracket_bmp.dds" , "28" , "52" , "dds" +"ui\aeon\game\avatar-engineers-panel\panel-eng_bmp_b.dds" , "92" , "12" , "dds" +"ui\aeon\game\avatar-engineers-panel\panel-eng_bmp_m.dds" , "92" , "44" , "dds" +"ui\aeon\game\avatar-engineers-panel\panel-eng_bmp_t.dds" , "92" , "12" , "dds" +"ui\aeon\game\avatar-engineers-panel\tech-1_bmp.dds" , "28" , "28" , "dds" +"ui\aeon\game\avatar-engineers-panel\tech-2_bmp.dds" , "28" , "28" , "dds" +"ui\aeon\game\avatar-engineers-panel\tech-3_bmp.dds" , "28" , "28" , "dds" +"ui\aeon\game\avatar-factory-panel\avatar-s-e-f_bmp.dds" , "48" , "52" , "dds" +"ui\aeon\game\avatar-factory-panel\bracket_bmp.dds" , "24" , "48" , "dds" +"ui\aeon\game\avatar-factory-panel\factory-panel_bmp.dds" , "188" , "164" , "dds" +"ui\aeon\game\avatar-factory-panel\factory-panel_brd_horz_um.dds" , "44" , "20" , "dds" +"ui\aeon\game\avatar-factory-panel\factory-panel_brd_ll.dds" , "40" , "56" , "dds" +"ui\aeon\game\avatar-factory-panel\factory-panel_brd_lm.dds" , "44" , "56" , "dds" +"ui\aeon\game\avatar-factory-panel\factory-panel_brd_lr.dds" , "64" , "56" , "dds" +"ui\aeon\game\avatar-factory-panel\factory-panel_brd_ul.dds" , "40" , "20" , "dds" +"ui\aeon\game\avatar-factory-panel\factory-panel_brd_ur.dds" , "64" , "20" , "dds" +"ui\aeon\game\avatar-factory-panel\factory-panel_brd_vert_l.dds" , "40" , "44" , "dds" +"ui\aeon\game\avatar-factory-panel\factory-panel_brd_vert_r.dds" , "64" , "44" , "dds" +"ui\aeon\game\avatar-factory-panel\strat-icon-air_bmp.dds" , "8" , "8" , "dds" +"ui\aeon\game\avatar-factory-panel\strat-icon-land_bmp.dds" , "8" , "8" , "dds" +"ui\aeon\game\avatar-factory-panel\strat-icon-sea_bmp.dds" , "8" , "8" , "dds" +"ui\aeon\game\avatar-factory-panel\tech-1_bmp.dds" , "20" , "24" , "dds" +"ui\aeon\game\avatar-factory-panel\tech-2_bmp.dds" , "20" , "24" , "dds" +"ui\aeon\game\avatar-factory-panel\tech-3_bmp.dds" , "20" , "20" , "dds" +"ui\aeon\game\avatar\avatar_bmp.dds" , "64" , "68" , "dds" +"ui\aeon\game\avatar\avatar-control-group_bmp.dds" , "48" , "36" , "dds" +"ui\aeon\game\avatar\avatar-s-e-f_bmp.dds" , "56" , "52" , "dds" +"ui\aeon\game\avatar\health-bar-back_bmp.dds" , "52" , "16" , "dds" +"ui\aeon\game\avatar\health-bar.dds" , "44" , "12" , "dds" +"ui\aeon\game\avatar\pulse-bars_bmp.dds" , "64" , "64" , "dds" +"ui\aeon\game\bracket-left-energy\bracket_bmp_b.dds" , "20" , "16" , "dds" +"ui\aeon\game\bracket-left-energy\bracket_bmp_m.dds" , "12" , "4" , "dds" +"ui\aeon\game\bracket-left-energy\bracket_bmp_t.dds" , "20" , "20" , "dds" +"ui\aeon\game\bracket-left-energy\bracket_bmp.dds" , "20" , "68" , "dds" +"ui\aeon\game\bracket-left\bracket_bmp_b.dds" , "32" , "28" , "dds" +"ui\aeon\game\bracket-left\bracket_bmp_m.dds" , "16" , "4" , "dds" +"ui\aeon\game\bracket-left\bracket_bmp_t.dds" , "32" , "68" , "dds" +"ui\aeon\game\bracket-left\bracket_bmp.dds" , "32" , "116" , "dds" +"ui\aeon\game\bracket-min-small\bracket-sm-left_bmp.dds" , "24" , "72" , "dds" +"ui\aeon\game\bracket-min-small\bracket-sm-right_bmp.dds" , "24" , "68" , "dds" +"ui\aeon\game\bracket-right-energy\bracket_bmp_b.dds" , "20" , "16" , "dds" +"ui\aeon\game\bracket-right-energy\bracket_bmp_m.dds" , "12" , "4" , "dds" +"ui\aeon\game\bracket-right-energy\bracket_bmp_t.dds" , "20" , "20" , "dds" +"ui\aeon\game\bracket-right-energy\bracket_bmp.dds" , "20" , "68" , "dds" +"ui\aeon\game\bracket-right\bracket_bmp_b.dds" , "32" , "28" , "dds" +"ui\aeon\game\bracket-right\bracket_bmp_m.dds" , "16" , "4" , "dds" +"ui\aeon\game\bracket-right\bracket_bmp_t.dds" , "32" , "68" , "dds" +"ui\aeon\game\bracket-right\bracket_bmp.dds" , "32" , "116" , "dds" +"ui\aeon\game\c-q-e-panel\arrow_bmp.dds" , "28" , "16" , "dds" +"ui\aeon\game\c-q-e-panel\arrow_vert_bmp.dds" , "16" , "24" , "dds" +"ui\aeon\game\c-q-e-panel\construct-panel_bmp_l.dds" , "84" , "60" , "dds" +"ui\aeon\game\c-q-e-panel\divider_bmp.dds" , "12" , "56" , "dds" +"ui\aeon\game\c-q-e-panel\divider_horizontal_bmp.dds" , "64" , "12" , "dds" +"ui\aeon\game\camera-btn\pinned_btn_dis.dds" , "24" , "24" , "dds" +"ui\aeon\game\camera-btn\pinned_btn_down.dds" , "24" , "24" , "dds" +"ui\aeon\game\camera-btn\pinned_btn_over.dds" , "24" , "24" , "dds" +"ui\aeon\game\camera-btn\pinned_btn_up.dds" , "24" , "24" , "dds" +"ui\aeon\game\chat_brd\chat_brd_horz_um.dds" , "8" , "48" , "dds" +"ui\aeon\game\chat_brd\chat_brd_ll.dds" , "44" , "48" , "dds" +"ui\aeon\game\chat_brd\chat_brd_lm.dds" , "8" , "48" , "dds" +"ui\aeon\game\chat_brd\chat_brd_lr.dds" , "44" , "48" , "dds" +"ui\aeon\game\chat_brd\chat_brd_m.dds" , "8" , "8" , "dds" +"ui\aeon\game\chat_brd\chat_brd_ul.dds" , "44" , "48" , "dds" +"ui\aeon\game\chat_brd\chat_brd_ur.dds" , "44" , "48" , "dds" +"ui\aeon\game\chat_brd\chat_brd_vert_l.dds" , "44" , "8" , "dds" +"ui\aeon\game\chat_brd\chat_brd_vert_r.dds" , "44" , "8" , "dds" +"ui\aeon\game\chat_brd\drop-box_brd_horz_um.dds" , "8" , "16" , "dds" +"ui\aeon\game\chat_brd\drop-box_brd_ll.dds" , "16" , "12" , "dds" +"ui\aeon\game\chat_brd\drop-box_brd_lm.dds" , "8" , "12" , "dds" +"ui\aeon\game\chat_brd\drop-box_brd_lr.dds" , "16" , "12" , "dds" +"ui\aeon\game\chat_brd\drop-box_brd_m.dds" , "8" , "8" , "dds" +"ui\aeon\game\chat_brd\drop-box_brd_ul.dds" , "16" , "16" , "dds" +"ui\aeon\game\chat_brd\drop-box_brd_ur.dds" , "16" , "16" , "dds" +"ui\aeon\game\chat_brd\drop-box_brd_vert_l.dds" , "16" , "8" , "dds" +"ui\aeon\game\chat_brd\drop-box_brd_vert_r.dds" , "16" , "8" , "dds" +"ui\aeon\game\chat-box_btn\radio_btn_dis.dds" , "28" , "28" , "dds" +"ui\aeon\game\chat-box_btn\radio_btn_down.dds" , "28" , "28" , "dds" +"ui\aeon\game\chat-box_btn\radio_btn_over.dds" , "28" , "28" , "dds" +"ui\aeon\game\chat-box_btn\radio_btn_up.dds" , "28" , "28" , "dds" +"ui\aeon\game\construct-panel_vert\construct-panel_bmp_b.dds" , "172" , "12" , "dds" +"ui\aeon\game\construct-panel_vert\construct-panel_bmp_m.dds" , "172" , "8" , "dds" +"ui\aeon\game\construct-panel_vert\construct-panel_bmp_t.dds" , "172" , "64" , "dds" +"ui\aeon\game\construct-panel_vert\que-panel_bmp_b.dds" , "60" , "8" , "dds" +"ui\aeon\game\construct-panel_vert\que-panel_bmp_m.dds" , "60" , "8" , "dds" +"ui\aeon\game\construct-panel_vert\que-panel_bmp_t.dds" , "60" , "8" , "dds" +"ui\aeon\game\construct-panel\construct-panel_bmp_l.dds" , "84" , "140" , "dds" +"ui\aeon\game\construct-panel\construct-panel_bmp_m1.dds" , "8" , "140" , "dds" +"ui\aeon\game\construct-panel\construct-panel_bmp_m2.dds" , "16" , "140" , "dds" +"ui\aeon\game\construct-panel\construct-panel_bmp_m3.dds" , "8" , "112" , "dds" +"ui\aeon\game\construct-panel\construct-panel_bmp_r.dds" , "16" , "112" , "dds" +"ui\aeon\game\construct-panel\construct-panel_s_bmp_l.dds" , "16" , "112" , "dds" +"ui\aeon\game\construct-panel\construct-panel_s_bmp_m.dds" , "8" , "112" , "dds" +"ui\aeon\game\construct-panel\construct-panel_s_bmp_r.dds" , "16" , "112" , "dds" +"ui\aeon\game\construct-panel\que-panel_bmp_l.dds" , "12" , "52" , "dds" +"ui\aeon\game\construct-panel\que-panel_bmp_m.dds" , "8" , "52" , "dds" +"ui\aeon\game\construct-panel\que-panel_bmp_r.dds" , "12" , "52" , "dds" +"ui\aeon\game\construct-sm_btn\back_off.dds" , "24" , "52" , "dds" +"ui\aeon\game\construct-sm_btn\back_on.dds" , "24" , "52" , "dds" +"ui\aeon\game\construct-sm_btn\fforward_off.dds" , "24" , "52" , "dds" +"ui\aeon\game\construct-sm_btn\fforward_on.dds" , "24" , "52" , "dds" +"ui\aeon\game\construct-sm_btn\forward_off.dds" , "24" , "52" , "dds" +"ui\aeon\game\construct-sm_btn\forward_on.dds" , "24" , "52" , "dds" +"ui\aeon\game\construct-sm_btn\infinite_off.dds" , "24" , "52" , "dds" +"ui\aeon\game\construct-sm_btn\infinite_on.dds" , "24" , "52" , "dds" +"ui\aeon\game\construct-sm_btn\left_btn_dis.dds" , "24" , "52" , "dds" +"ui\aeon\game\construct-sm_btn\left_btn_over.dds" , "24" , "52" , "dds" +"ui\aeon\game\construct-sm_btn\left_btn_up.dds" , "24" , "52" , "dds" +"ui\aeon\game\construct-sm_btn\mid_btn_dis.dds" , "24" , "52" , "dds" +"ui\aeon\game\construct-sm_btn\mid_btn_over.dds" , "24" , "52" , "dds" +"ui\aeon\game\construct-sm_btn\mid_btn_selected.dds" , "24" , "52" , "dds" +"ui\aeon\game\construct-sm_btn\mid_btn_up.dds" , "24" , "52" , "dds" +"ui\aeon\game\construct-sm_btn\pause_off.dds" , "24" , "52" , "dds" +"ui\aeon\game\construct-sm_btn\pause_on.dds" , "24" , "52" , "dds" +"ui\aeon\game\construct-sm_btn\rewind_off.dds" , "24" , "52" , "dds" +"ui\aeon\game\construct-sm_btn\rewind_on.dds" , "24" , "52" , "dds" +"ui\aeon\game\construct-sm_btn\right_btn_dis.dds" , "24" , "52" , "dds" +"ui\aeon\game\construct-sm_btn\right_btn_over.dds" , "24" , "52" , "dds" +"ui\aeon\game\construct-sm_btn\right_btn_up.dds" , "24" , "52" , "dds" +"ui\aeon\game\construct-sm_btn\template_off.dds" , "24" , "52" , "dds" +"ui\aeon\game\construct-sm_btn\template_on.dds" , "24" , "52" , "dds" +"ui\aeon\game\construct-sm_horiz_btn\infinite_off.dds" , "60" , "28" , "dds" +"ui\aeon\game\construct-sm_horiz_btn\infinite_on.dds" , "60" , "28" , "dds" +"ui\aeon\game\construct-sm_horiz_btn\pause_off.dds" , "60" , "28" , "dds" +"ui\aeon\game\construct-sm_horiz_btn\pause_on.dds" , "60" , "28" , "dds" +"ui\aeon\game\construct-sm_horiz_btn\que_btn_dis.dds" , "60" , "28" , "dds" +"ui\aeon\game\construct-sm_horiz_btn\que_btn_over.dds" , "60" , "28" , "dds" +"ui\aeon\game\construct-sm_horiz_btn\que_btn_selected.dds" , "60" , "28" , "dds" +"ui\aeon\game\construct-sm_horiz_btn\que_btn_up.dds" , "60" , "28" , "dds" +"ui\aeon\game\construct-sm_horiz_btn\template_off.dds" , "60" , "28" , "dds" +"ui\aeon\game\construct-sm_horiz_btn\template_on.dds" , "60" , "28" , "dds" +"ui\aeon\game\construct-sm_nav_horiz_btn\bottom_btn_dis.dds" , "60" , "16" , "dds" +"ui\aeon\game\construct-sm_nav_horiz_btn\bottom_btn_over.dds" , "60" , "16" , "dds" +"ui\aeon\game\construct-sm_nav_horiz_btn\bottom_btn_up.dds" , "60" , "16" , "dds" +"ui\aeon\game\construct-sm_nav_horiz_btn\down_off.dds" , "60" , "16" , "dds" +"ui\aeon\game\construct-sm_nav_horiz_btn\down_on.dds" , "60" , "16" , "dds" +"ui\aeon\game\construct-sm_nav_horiz_btn\end_off.dds" , "60" , "16" , "dds" +"ui\aeon\game\construct-sm_nav_horiz_btn\end_on.dds" , "60" , "16" , "dds" +"ui\aeon\game\construct-sm_nav_horiz_btn\home_off.dds" , "60" , "16" , "dds" +"ui\aeon\game\construct-sm_nav_horiz_btn\home_on.dds" , "60" , "16" , "dds" +"ui\aeon\game\construct-sm_nav_horiz_btn\mid_btn_dis.dds" , "60" , "16" , "dds" +"ui\aeon\game\construct-sm_nav_horiz_btn\mid_btn_over.dds" , "60" , "16" , "dds" +"ui\aeon\game\construct-sm_nav_horiz_btn\mid_btn_up.dds" , "60" , "16" , "dds" +"ui\aeon\game\construct-sm_nav_horiz_btn\top_btn_dis.dds" , "60" , "16" , "dds" +"ui\aeon\game\construct-sm_nav_horiz_btn\top_btn_over.dds" , "60" , "16" , "dds" +"ui\aeon\game\construct-sm_nav_horiz_btn\top_btn_up.dds" , "60" , "16" , "dds" +"ui\aeon\game\construct-sm_nav_horiz_btn\up_off.dds" , "60" , "16" , "dds" +"ui\aeon\game\construct-sm_nav_horiz_btn\up_on.dds" , "60" , "16" , "dds" +"ui\aeon\game\construct-tab_btn\bot_tab_btn_dis_bmp.dds" , "76" , "56" , "dds" +"ui\aeon\game\construct-tab_btn\bot_tab_btn_down_bmp.dds" , "76" , "56" , "dds" +"ui\aeon\game\construct-tab_btn\bot_tab_btn_over_bmp.dds" , "76" , "56" , "dds" +"ui\aeon\game\construct-tab_btn\bot_tab_btn_sel_bmp.dds" , "76" , "56" , "dds" +"ui\aeon\game\construct-tab_btn\bot_tab_btn_up_bmp.dds" , "76" , "56" , "dds" +"ui\aeon\game\construct-tab_btn\mid_tab_btn_dis_bmp.dds" , "76" , "56" , "dds" +"ui\aeon\game\construct-tab_btn\mid_tab_btn_down_bmp.dds" , "76" , "56" , "dds" +"ui\aeon\game\construct-tab_btn\mid_tab_btn_over_bmp.dds" , "76" , "56" , "dds" +"ui\aeon\game\construct-tab_btn\mid_tab_btn_sel_bmp.dds" , "76" , "56" , "dds" +"ui\aeon\game\construct-tab_btn\mid_tab_btn_up_bmp.dds" , "76" , "56" , "dds" +"ui\aeon\game\construct-tab_btn\top_tab_btn_dis_bmp.dds" , "76" , "56" , "dds" +"ui\aeon\game\construct-tab_btn\top_tab_btn_down_bmp.dds" , "76" , "56" , "dds" +"ui\aeon\game\construct-tab_btn\top_tab_btn_over_bmp.dds" , "76" , "56" , "dds" +"ui\aeon\game\construct-tab_btn\top_tab_btn_sel_bmp.dds" , "76" , "56" , "dds" +"ui\aeon\game\construct-tab_btn\top_tab_btn_up_bmp.dds" , "76" , "56" , "dds" +"ui\aeon\game\construct-tab_top_btn\bot_tab_btn_dis_bmp.dds" , "60" , "32" , "dds" +"ui\aeon\game\construct-tab_top_btn\bot_tab_btn_down_bmp.dds" , "60" , "32" , "dds" +"ui\aeon\game\construct-tab_top_btn\bot_tab_btn_over_bmp.dds" , "60" , "32" , "dds" +"ui\aeon\game\construct-tab_top_btn\bot_tab_btn_sel_bmp.dds" , "60" , "32" , "dds" +"ui\aeon\game\construct-tab_top_btn\bot_tab_btn_up_bmp.dds" , "60" , "32" , "dds" +"ui\aeon\game\construct-tab_top_btn\mid_tab_btn_dis_bmp.dds" , "60" , "32" , "dds" +"ui\aeon\game\construct-tab_top_btn\mid_tab_btn_down_bmp.dds" , "60" , "32" , "dds" +"ui\aeon\game\construct-tab_top_btn\mid_tab_btn_over_bmp.dds" , "60" , "32" , "dds" +"ui\aeon\game\construct-tab_top_btn\mid_tab_btn_sel_bmp.dds" , "60" , "32" , "dds" +"ui\aeon\game\construct-tab_top_btn\mid_tab_btn_up_bmp.dds" , "60" , "32" , "dds" +"ui\aeon\game\construct-tab_top_btn\top_tab_btn_dis_bmp.dds" , "60" , "32" , "dds" +"ui\aeon\game\construct-tab_top_btn\top_tab_btn_down_bmp.dds" , "60" , "32" , "dds" +"ui\aeon\game\construct-tab_top_btn\top_tab_btn_over_bmp.dds" , "60" , "32" , "dds" +"ui\aeon\game\construct-tab_top_btn\top_tab_btn_sel_bmp.dds" , "60" , "32" , "dds" +"ui\aeon\game\construct-tab_top_btn\top_tab_btn_up_bmp.dds" , "60" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\left_upgrade_btn_dis.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\left_upgrade_btn_down.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\left_upgrade_btn_over.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\left_upgrade_btn_selected.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\left_upgrade_btn_up.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\m_upgrade_btn_dis.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\m_upgrade_btn_down.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\m_upgrade_btn_over.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\m_upgrade_btn_selected.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\m_upgrade_btn_up.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\r_upgrade_btn_dis.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\r_upgrade_btn_down.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\r_upgrade_btn_over.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\r_upgrade_btn_selected.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\r_upgrade_btn_up.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\t1_btn_dis.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\t1_btn_down.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\t1_btn_over.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\t1_btn_selected.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\t1_btn_up.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\t2_btn_dis.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\t2_btn_down.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\t2_btn_over.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\t2_btn_selected.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\t2_btn_up.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\t3_btn_dis.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\t3_btn_down.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\t3_btn_over.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\t3_btn_selected.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\t3_btn_up.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\t4_btn_dis.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\t4_btn_down.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\t4_btn_over.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\t4_btn_selected.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\t4_btn_up.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\template_btn_dis.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\template_btn_down.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\template_btn_over.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\template_btn_selected.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\template_btn_up.dds" , "32" , "32" , "dds" +"ui\aeon\game\construction-pause_btn\pause_btn_dis.dds" , "32" , "36" , "dds" +"ui\aeon\game\construction-pause_btn\pause_btn_down.dds" , "32" , "36" , "dds" +"ui\aeon\game\construction-pause_btn\pause_btn_over.dds" , "32" , "36" , "dds" +"ui\aeon\game\construction-pause_btn\pause_btn_up.dds" , "32" , "36" , "dds" +"ui\aeon\game\construction-tab_btn\enhance-back_icon_bmp.dds" , "56" , "32" , "dds" +"ui\aeon\game\construction-tab_btn\enhance-back_icon_bmp.png" , "56" , "32" , "png" +"ui\aeon\game\construction-tab_btn\enhance-l-arm_icon_bmp.dds" , "56" , "32" , "dds" +"ui\aeon\game\construction-tab_btn\enhance-l-arm_icon_bmp.png" , "56" , "32" , "png" +"ui\aeon\game\construction-tab_btn\enhance-r-arm_icon_bmp.dds" , "56" , "32" , "dds" +"ui\aeon\game\construction-tab_btn\enhance-r-arm_icon_bmp.png" , "56" , "32" , "png" +"ui\aeon\game\construction-tab_btn\experimental_icon_bmp.dds" , "56" , "32" , "dds" +"ui\aeon\game\construction-tab_btn\experimental_icon_bmp.png" , "56" , "32" , "png" +"ui\aeon\game\construction-tab_btn\tech-level-1_icon_bmp.dds" , "56" , "32" , "dds" +"ui\aeon\game\construction-tab_btn\tech-level-1_icon_bmp.png" , "56" , "32" , "png" +"ui\aeon\game\construction-tab_btn\tech-level-2_icon_bmp.dds" , "56" , "32" , "dds" +"ui\aeon\game\construction-tab_btn\tech-level-2_icon_bmp.png" , "56" , "32" , "png" +"ui\aeon\game\construction-tab_btn\tech-level-3_icon_bmp.dds" , "56" , "32" , "dds" +"ui\aeon\game\construction-tab_btn\tech-level-3_icon_bmp.png" , "56" , "32" , "png" +"ui\aeon\game\construction-tab_btn\tech-tab_btn_dis.dds" , "56" , "32" , "dds" +"ui\aeon\game\construction-tab_btn\tech-tab_btn_dis.png" , "56" , "32" , "png" +"ui\aeon\game\construction-tab_btn\tech-tab_btn_down.dds" , "56" , "32" , "dds" +"ui\aeon\game\construction-tab_btn\tech-tab_btn_down.png" , "56" , "32" , "png" +"ui\aeon\game\construction-tab_btn\tech-tab_btn_over.dds" , "56" , "32" , "dds" +"ui\aeon\game\construction-tab_btn\tech-tab_btn_over.png" , "56" , "32" , "png" +"ui\aeon\game\construction-tab_btn\tech-tab_btn_selected.dds" , "56" , "32" , "dds" +"ui\aeon\game\construction-tab_btn\tech-tab_btn_selected.png" , "56" , "32" , "png" +"ui\aeon\game\construction-tab_btn\tech-tab_btn_up.dds" , "56" , "32" , "dds" +"ui\aeon\game\construction-tab_btn\tech-tab_btn_up.png" , "56" , "32" , "png" +"ui\aeon\game\construction-tab_btn\units-attached_icon_bmp.dds" , "56" , "32" , "dds" +"ui\aeon\game\construction-tab_btn\units-attached_icon_bmp.png" , "56" , "32" , "png" +"ui\aeon\game\construction-tab_btn\units-select_icon_bmp.dds" , "56" , "32" , "dds" +"ui\aeon\game\construction-tab_btn\units-select_icon_bmp.png" , "56" , "32" , "png" +"ui\aeon\game\control-group-bracket\panel-score_bmp_b.dds" , "72" , "24" , "dds" +"ui\aeon\game\control-group-bracket\panel-score_bmp_m.dds" , "76" , "4" , "dds" +"ui\aeon\game\control-group-bracket\panel-score_bmp_t.dds" , "76" , "48" , "dds" +"ui\aeon\game\drag-handle\drag-handle-ll_btn_dis.dds" , "80" , "96" , "dds" +"ui\aeon\game\drag-handle\drag-handle-ll_btn_down.dds" , "80" , "96" , "dds" +"ui\aeon\game\drag-handle\drag-handle-ll_btn_over.dds" , "80" , "96" , "dds" +"ui\aeon\game\drag-handle\drag-handle-ll_btn_up.dds" , "80" , "96" , "dds" +"ui\aeon\game\drag-handle\drag-handle-lr_btn_dis.dds" , "80" , "96" , "dds" +"ui\aeon\game\drag-handle\drag-handle-lr_btn_down.dds" , "80" , "96" , "dds" +"ui\aeon\game\drag-handle\drag-handle-lr_btn_over.dds" , "80" , "96" , "dds" +"ui\aeon\game\drag-handle\drag-handle-lr_btn_up.dds" , "80" , "96" , "dds" +"ui\aeon\game\drag-handle\drag-handle-ul_btn_dis.dds" , "80" , "96" , "dds" +"ui\aeon\game\drag-handle\drag-handle-ul_btn_down.dds" , "80" , "96" , "dds" +"ui\aeon\game\drag-handle\drag-handle-ul_btn_over.dds" , "80" , "96" , "dds" +"ui\aeon\game\drag-handle\drag-handle-ul_btn_up.dds" , "80" , "96" , "dds" +"ui\aeon\game\drag-handle\drag-handle-ur_btn_dis.dds" , "80" , "96" , "dds" +"ui\aeon\game\drag-handle\drag-handle-ur_btn_down.dds" , "80" , "96" , "dds" +"ui\aeon\game\drag-handle\drag-handle-ur_btn_over.dds" , "80" , "96" , "dds" +"ui\aeon\game\drag-handle\drag-handle-ur_btn_up.dds" , "80" , "96" , "dds" +"ui\aeon\game\economic-overlay\econ_bmp_l.dds" , "24" , "32" , "dds" +"ui\aeon\game\economic-overlay\econ_bmp_m.dds" , "4" , "32" , "dds" +"ui\aeon\game\economic-overlay\econ_bmp_r.dds" , "12" , "32" , "dds" +"ui\aeon\game\filter-ping-list-panel\energy-bar_bmp.dds" , "28" , "16" , "dds" +"ui\aeon\game\filter-ping-list-panel\panel_bmp_b.dds" , "128" , "24" , "dds" +"ui\aeon\game\filter-ping-list-panel\panel_bmp_m.dds" , "128" , "8" , "dds" +"ui\aeon\game\filter-ping-list-panel\panel_bmp_t.dds" , "128" , "24" , "dds" +"ui\aeon\game\filter-ping-list-panel\panel_brd_horz_um.dds" , "8" , "20" , "dds" +"ui\aeon\game\filter-ping-list-panel\panel_brd_ll.dds" , "24" , "24" , "dds" +"ui\aeon\game\filter-ping-list-panel\panel_brd_lm.dds" , "8" , "20" , "dds" +"ui\aeon\game\filter-ping-list-panel\panel_brd_lr.dds" , "24" , "24" , "dds" +"ui\aeon\game\filter-ping-list-panel\panel_brd_m.dds" , "8" , "8" , "dds" +"ui\aeon\game\filter-ping-list-panel\panel_brd_ul.dds" , "24" , "24" , "dds" +"ui\aeon\game\filter-ping-list-panel\panel_brd_ur.dds" , "24" , "24" , "dds" +"ui\aeon\game\filter-ping-list-panel\panel_brd_vert_l.dds" , "24" , "8" , "dds" +"ui\aeon\game\filter-ping-list-panel\panel_brd_vert_r.dds" , "24" , "8" , "dds" +"ui\aeon\game\filter-ping-panel\bracket-energy-l_bmp.dds" , "24" , "72" , "dds" +"ui\aeon\game\filter-ping-panel\bracket-energy-r_bmp.dds" , "24" , "72" , "dds" +"ui\aeon\game\filter-ping-panel\bracket-left_bmp.dds" , "28" , "68" , "dds" +"ui\aeon\game\filter-ping-panel\filter-ping-panel02_bmp.dds" , "180" , "72" , "dds" +"ui\aeon\game\infinite_btn\infinite_btn_dis.dds" , "32" , "36" , "dds" +"ui\aeon\game\infinite_btn\infinite_btn_down.dds" , "32" , "36" , "dds" +"ui\aeon\game\infinite_btn\infinite_btn_over.dds" , "32" , "36" , "dds" +"ui\aeon\game\infinite_btn\infinite_btn_up.dds" , "32" , "36" , "dds" +"ui\aeon\game\medium-btn\medium_btn_dis.dds" , "296" , "72" , "dds" +"ui\aeon\game\medium-btn\medium_btn_down.dds" , "296" , "72" , "dds" +"ui\aeon\game\medium-btn\medium_btn_glow.dds" , "296" , "72" , "dds" +"ui\aeon\game\medium-btn\medium_btn_over.dds" , "296" , "72" , "dds" +"ui\aeon\game\medium-btn\medium_btn_up.dds" , "296" , "72" , "dds" +"ui\aeon\game\medium-btn\medium-btn_dis.dds" , "296" , "72" , "dds" +"ui\aeon\game\medium-btn\medium-btn_down.dds" , "296" , "72" , "dds" +"ui\aeon\game\medium-btn\medium-btn_glow.dds" , "296" , "72" , "dds" +"ui\aeon\game\medium-btn\medium-btn_over.dds" , "296" , "72" , "dds" +"ui\aeon\game\medium-btn\medium-btn_up.dds" , "296" , "72" , "dds" +"ui\aeon\game\menu-btns\close_btn_dis.dds" , "24" , "24" , "dds" +"ui\aeon\game\menu-btns\close_btn_down.dds" , "24" , "24" , "dds" +"ui\aeon\game\menu-btns\close_btn_over.dds" , "24" , "24" , "dds" +"ui\aeon\game\menu-btns\close_btn_up.dds" , "24" , "24" , "dds" +"ui\aeon\game\menu-btns\config_btn_dis.dds" , "24" , "24" , "dds" +"ui\aeon\game\menu-btns\config_btn_down.dds" , "24" , "24" , "dds" +"ui\aeon\game\menu-btns\config_btn_over.dds" , "24" , "24" , "dds" +"ui\aeon\game\menu-btns\config_btn_up.dds" , "24" , "24" , "dds" +"ui\aeon\game\menu-btns\default_btn_dis.dds" , "24" , "24" , "dds" +"ui\aeon\game\menu-btns\default_btn_down.dds" , "24" , "24" , "dds" +"ui\aeon\game\menu-btns\default_btn_over.dds" , "24" , "24" , "dds" +"ui\aeon\game\menu-btns\default_btn_up.dds" , "24" , "24" , "dds" +"ui\aeon\game\menu-btns\pin_btn_dis.dds" , "24" , "24" , "dds" +"ui\aeon\game\menu-btns\pin_btn_down.dds" , "24" , "24" , "dds" +"ui\aeon\game\menu-btns\pin_btn_over.dds" , "24" , "24" , "dds" +"ui\aeon\game\menu-btns\pin_btn_up.dds" , "24" , "24" , "dds" +"ui\aeon\game\menu-btns\pinned_btn_dis.dds" , "24" , "24" , "dds" +"ui\aeon\game\menu-btns\pinned_btn_down.dds" , "24" , "24" , "dds" +"ui\aeon\game\menu-btns\pinned_btn_over.dds" , "24" , "24" , "dds" +"ui\aeon\game\menu-btns\pinned_btn_up.dds" , "24" , "24" , "dds" +"ui\aeon\game\mfd_btn\control_btn_dis.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\control_btn_down.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\control_btn_over.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\control_btn_up.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\defenses_btn_dis.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\defenses_btn_down.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\defenses_btn_over.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\defenses_btn_up.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\economy_btn_dis.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\economy_btn_down.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\economy_btn_over.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\economy_btn_up.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\intelligence_btn_dis.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\intelligence_btn_down.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\intelligence_btn_over.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\intelligence_btn_up.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\military_btn_dis.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\military_btn_down.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\military_btn_over.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\military_btn_up.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\military-radar_btn_dis.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\military-radar_btn_down.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\military-radar_btn_over.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\military-radar_btn_up.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\ping_btn_dis.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\ping_btn_down.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\ping_btn_over.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\ping_btn_up.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\ping-alert_btn_dis.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\ping-alert_btn_down.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\ping-alert_btn_over.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\ping-alert_btn_up.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\ping-attack_btn_dis.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\ping-attack_btn_down.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\ping-attack_btn_over.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\ping-attack_btn_up.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\ping-marker_btn_dis.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\ping-marker_btn_down.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\ping-marker_btn_over.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\ping-marker_btn_up.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\ping-move_btn_dis.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\ping-move_btn_down.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\ping-move_btn_over.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\ping-move_btn_up.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\team-color_btn_dis.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\team-color_btn_down.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\team-color_btn_over.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\team-color_btn_up.dds" , "44" , "32" , "dds" +"ui\aeon\game\min-control-tab-btn\tab-close_btn_dis.dds" , "44" , "24" , "dds" +"ui\aeon\game\min-control-tab-btn\tab-close_btn_down.dds" , "44" , "24" , "dds" +"ui\aeon\game\min-control-tab-btn\tab-close_btn_over.dds" , "44" , "24" , "dds" +"ui\aeon\game\min-control-tab-btn\tab-close_btn_up.dds" , "44" , "24" , "dds" +"ui\aeon\game\min-control-tab-btn\tab-open_btn_dis.dds" , "44" , "24" , "dds" +"ui\aeon\game\min-control-tab-btn\tab-open_btn_down.dds" , "44" , "24" , "dds" +"ui\aeon\game\min-control-tab-btn\tab-open_btn_over.dds" , "44" , "24" , "dds" +"ui\aeon\game\min-control-tab-btn\tab-open_btn_up.dds" , "44" , "24" , "dds" +"ui\aeon\game\mini-map-brd\mini-map_brd_horz_um.dds" , "12" , "36" , "dds" +"ui\aeon\game\mini-map-brd\mini-map_brd_ll.dds" , "16" , "12" , "dds" +"ui\aeon\game\mini-map-brd\mini-map_brd_lm.dds" , "12" , "12" , "dds" +"ui\aeon\game\mini-map-brd\mini-map_brd_lr.dds" , "16" , "12" , "dds" +"ui\aeon\game\mini-map-brd\mini-map_brd_m.dds" , "12" , "12" , "dds" +"ui\aeon\game\mini-map-brd\mini-map_brd_ul.dds" , "36" , "36" , "dds" +"ui\aeon\game\mini-map-brd\mini-map_brd_ur.dds" , "36" , "36" , "dds" +"ui\aeon\game\mini-map-brd\mini-map_brd_vert_l.dds" , "16" , "12" , "dds" +"ui\aeon\game\mini-map-brd\mini-map_brd_vert_r.dds" , "16" , "12" , "dds" +"ui\aeon\game\mini-map-brd\mini-map-glow_bmp.dds" , "208" , "208" , "dds" +"ui\aeon\game\mini-map-glow-brd\mini-map-glow_brd_horz_um.dds" , "8" , "24" , "dds" +"ui\aeon\game\mini-map-glow-brd\mini-map-glow_brd_ll copy.dds" , "40" , "40" , "dds" +"ui\aeon\game\mini-map-glow-brd\mini-map-glow_brd_ll.dds" , "40" , "40" , "dds" +"ui\aeon\game\mini-map-glow-brd\mini-map-glow_brd_lm.dds" , "8" , "24" , "dds" +"ui\aeon\game\mini-map-glow-brd\mini-map-glow_brd_lr.dds" , "40" , "40" , "dds" +"ui\aeon\game\mini-map-glow-brd\mini-map-glow_brd_ul.dds" , "40" , "40" , "dds" +"ui\aeon\game\mini-map-glow-brd\mini-map-glow_brd_ur.dds" , "40" , "40" , "dds" +"ui\aeon\game\mini-map-glow-brd\mini-map-glow_brd_vert_l.dds" , "24" , "8" , "dds" +"ui\aeon\game\mini-map-glow-brd\mini-map-glow_brd_vert_r.dds" , "24" , "8" , "dds" +"ui\aeon\game\objective-icons\panel-icon_bmp.dds" , "60" , "68" , "dds" +"ui\aeon\game\objective-icons\primary-ring_bmp.dds" , "52" , "48" , "dds" +"ui\aeon\game\objective-icons\secondary-ring_bmp.dds" , "52" , "48" , "dds" +"ui\aeon\game\options_tab\diplomacy_btn_dis.dds" , "56" , "48" , "dds" +"ui\aeon\game\options_tab\diplomacy_btn_down.dds" , "56" , "48" , "dds" +"ui\aeon\game\options_tab\diplomacy_btn_over.dds" , "56" , "48" , "dds" +"ui\aeon\game\options_tab\diplomacy_btn_selected.dds" , "56" , "48" , "dds" +"ui\aeon\game\options_tab\diplomacy_btn_up.dds" , "56" , "48" , "dds" +"ui\aeon\game\options_tab\glow_bmp.dds" , "56" , "48" , "dds" +"ui\aeon\game\options_tab\menu_btn_dis.dds" , "56" , "48" , "dds" +"ui\aeon\game\options_tab\menu_btn_down.dds" , "56" , "48" , "dds" +"ui\aeon\game\options_tab\menu_btn_over.dds" , "56" , "48" , "dds" +"ui\aeon\game\options_tab\menu_btn_selected.dds" , "56" , "48" , "dds" +"ui\aeon\game\options_tab\menu_btn_up.dds" , "56" , "48" , "dds" +"ui\aeon\game\options_tab\objectives_btn_dis.dds" , "56" , "48" , "dds" +"ui\aeon\game\options_tab\objectives_btn_down.dds" , "56" , "48" , "dds" +"ui\aeon\game\options_tab\objectives_btn_over.dds" , "56" , "48" , "dds" +"ui\aeon\game\options_tab\objectives_btn_selected.dds" , "56" , "48" , "dds" +"ui\aeon\game\options_tab\objectives_btn_up.dds" , "56" , "48" , "dds" +"ui\aeon\game\options_tab\pause_btn_dis.dds" , "56" , "48" , "dds" +"ui\aeon\game\options_tab\pause_btn_down.dds" , "56" , "48" , "dds" +"ui\aeon\game\options_tab\pause_btn_over.dds" , "56" , "48" , "dds" +"ui\aeon\game\options_tab\pause_btn_selected.dds" , "56" , "48" , "dds" +"ui\aeon\game\options_tab\pause_btn_up.dds" , "56" , "48" , "dds" +"ui\aeon\game\options_tab\play_btn_dis.dds" , "56" , "48" , "dds" +"ui\aeon\game\options_tab\play_btn_down.dds" , "56" , "48" , "dds" +"ui\aeon\game\options_tab\play_btn_over.dds" , "56" , "48" , "dds" +"ui\aeon\game\options_tab\play_btn_selected.dds" , "56" , "48" , "dds" +"ui\aeon\game\options_tab\play_btn_up.dds" , "56" , "48" , "dds" +"ui\aeon\game\options-panel\options_brd_horz_lm.dds" , "4" , "28" , "dds" +"ui\aeon\game\options-panel\options_brd_horz_um.dds" , "80" , "36" , "dds" +"ui\aeon\game\options-panel\options_brd_horz_uml.dds" , "4" , "28" , "dds" +"ui\aeon\game\options-panel\options_brd_horz_umr.dds" , "4" , "28" , "dds" +"ui\aeon\game\options-panel\options_brd_ll.dds" , "44" , "36" , "dds" +"ui\aeon\game\options-panel\options_brd_lr.dds" , "44" , "36" , "dds" +"ui\aeon\game\options-panel\options_brd_m.dds" , "4" , "8" , "dds" +"ui\aeon\game\options-panel\options_brd_ul.dds" , "44" , "36" , "dds" +"ui\aeon\game\options-panel\options_brd_ur.dds" , "44" , "36" , "dds" +"ui\aeon\game\options-panel\options_brd_vert_l.dds" , "36" , "8" , "dds" +"ui\aeon\game\options-panel\options_brd_vert_ll.dds" , "40" , "24" , "dds" +"ui\aeon\game\options-panel\options_brd_vert_lr.dds" , "40" , "24" , "dds" +"ui\aeon\game\options-panel\options_brd_vert_r.dds" , "36" , "8" , "dds" +"ui\aeon\game\options-panel\options_brd_vert_ul.dds" , "40" , "24" , "dds" +"ui\aeon\game\options-panel\options_brd_vert_ur.dds" , "40" , "24" , "dds" +"ui\aeon\game\orders-panel_vert\bracket_bmp.dds" , "36" , "228" , "dds" +"ui\aeon\game\orders-panel_vert\order-panel_bmp.dds" , "188" , "224" , "dds" +"ui\aeon\game\orders-panel\bracket_bmp.dds" , "36" , "124" , "dds" +"ui\aeon\game\orders-panel\no-parking_bmp.dds" , "56" , "56" , "dds" +"ui\aeon\game\orders-panel\order-panel_bmp.dds" , "332" , "120" , "dds" +"ui\aeon\game\orders-panel\question-mark_bmp.dds" , "16" , "24" , "dds" +"ui\aeon\game\panel\panel_brd_horz_um.dds" , "8" , "28" , "dds" +"ui\aeon\game\panel\panel_brd_ll.dds" , "28" , "28" , "dds" +"ui\aeon\game\panel\panel_brd_lm.dds" , "8" , "28" , "dds" +"ui\aeon\game\panel\panel_brd_lr.dds" , "28" , "28" , "dds" +"ui\aeon\game\panel\panel_brd_m.dds" , "8" , "8" , "dds" +"ui\aeon\game\panel\panel_brd_ul.dds" , "28" , "28" , "dds" +"ui\aeon\game\panel\panel_brd_ur.dds" , "28" , "28" , "dds" +"ui\aeon\game\panel\panel_brd_vert_l.dds" , "28" , "8" , "dds" +"ui\aeon\game\panel\panel_brd_vert_r.dds" , "28" , "8" , "dds" +"ui\aeon\game\pause_btn\glow_bmp.dds" , "56" , "48" , "dds" +"ui\aeon\game\pause_btn\pause_btn_dis.dds" , "56" , "48" , "dds" +"ui\aeon\game\pause_btn\pause_btn_down.dds" , "56" , "48" , "dds" +"ui\aeon\game\pause_btn\pause_btn_over.dds" , "56" , "48" , "dds" +"ui\aeon\game\pause_btn\pause_btn_up.dds" , "56" , "48" , "dds" +"ui\aeon\game\pause_btn\play_btn_dis.dds" , "56" , "48" , "dds" +"ui\aeon\game\pause_btn\play_btn_down.dds" , "56" , "48" , "dds" +"ui\aeon\game\pause_btn\play_btn_over.dds" , "56" , "48" , "dds" +"ui\aeon\game\pause_btn\play_btn_up.dds" , "56" , "48" , "dds" +"ui\aeon\game\pause-indicator\bottom.dds" , "20" , "20" , "dds" +"ui\aeon\game\pause-indicator\left.dds" , "20" , "20" , "dds" +"ui\aeon\game\pause-indicator\right.dds" , "20" , "20" , "dds" +"ui\aeon\game\pause-indicator\top.dds" , "20" , "20" , "dds" +"ui\aeon\game\pda-panel\bracket-right_bmp.dds" , "32" , "156" , "dds" +"ui\aeon\game\pda-panel\panel-objectives_bmp_l.dds" , "20" , "76" , "dds" +"ui\aeon\game\pda-panel\panel-objectives_bmp_m.dds" , "4" , "72" , "dds" +"ui\aeon\game\pda-panel\panel-objectives_bmp_r.dds" , "24" , "72" , "dds" +"ui\aeon\game\pda-panel\panel-ping_bmp_l.dds" , "20" , "60" , "dds" +"ui\aeon\game\pda-panel\panel-ping_bmp_m.dds" , "4" , "56" , "dds" +"ui\aeon\game\pda-panel\panel-ping_bmp_r.dds" , "24" , "56" , "dds" +"ui\aeon\game\pda-panel\panel-time-units_bmp_l.dds" , "20" , "28" , "dds" +"ui\aeon\game\pda-panel\panel-time-units_bmp_m.dds" , "4" , "24" , "dds" +"ui\aeon\game\pda-panel\panel-time-units_bmp_r.dds" , "24" , "24" , "dds" +"ui\aeon\game\pda-panel\title-bar_bmp.dds" , "136" , "20" , "dds" +"ui\aeon\game\pda-panel\video-panel_bmp.dds" , "168" , "152" , "dds" +"ui\aeon\game\ping-icons\panel-icon_bmp.dds" , "56" , "56" , "dds" +"ui\aeon\game\ping-icons\panel-icon-ring_bmp.dds" , "56" , "56" , "dds" +"ui\aeon\game\resource-bars\mini-energy-bar_bmp.dds" , "160" , "16" , "dds" +"ui\aeon\game\resource-bars\mini-energy-bar-back_bmp.dds" , "160" , "16" , "dds" +"ui\aeon\game\resource-bars\mini-mass-bar_bmp.dds" , "160" , "16" , "dds" +"ui\aeon\game\resource-bars\mini-mass-bar-back_bmp.dds" , "160" , "16" , "dds" +"ui\aeon\game\resource-panel\alert-caution_bmp.dds" , "48" , "44" , "dds" +"ui\aeon\game\resource-panel\alert-energy-panel_bmp.dds" , "316" , "32" , "dds" +"ui\aeon\game\resource-panel\alert-icon_bmp.dds" , "28" , "36" , "dds" +"ui\aeon\game\resource-panel\alert-mass-panel_bmp.dds" , "316" , "32" , "dds" +"ui\aeon\game\resource-panel\alert-no-parking_bmp.dds" , "52" , "52" , "dds" +"ui\aeon\game\resource-panel\bracket-energy-l_bmp.dds" , "20" , "68" , "dds" +"ui\aeon\game\resource-panel\bracket-energy-r_bmp.dds" , "20" , "68" , "dds" +"ui\aeon\game\resource-panel\bracket-left_bmp.dds" , "32" , "80" , "dds" +"ui\aeon\game\resource-panel\bracket-right_bmp.dds" , "28" , "68" , "dds" +"ui\aeon\game\resource-panel\caution-energy-panel_bmp.dds" , "316" , "32" , "dds" +"ui\aeon\game\resource-panel\caution-mass-panel_bmp.dds" , "316" , "32" , "dds" +"ui\aeon\game\resource-panel\resources_panel_bmp.dds" , "324" , "72" , "dds" +"ui\aeon\game\score-panel\icon-square_bmp.dds" , "24" , "24" , "dds" +"ui\aeon\game\score-panel\panel-score_bmp_b.dds" , "232" , "20" , "dds" +"ui\aeon\game\score-panel\panel-score_bmp_m.dds" , "232" , "4" , "dds" +"ui\aeon\game\score-panel\panel-score_bmp_t.dds" , "232" , "44" , "dds" +"ui\aeon\game\tab-l-btn\tab-close_btn_dis.dds" , "20" , "28" , "dds" +"ui\aeon\game\tab-l-btn\tab-close_btn_down.dds" , "20" , "28" , "dds" +"ui\aeon\game\tab-l-btn\tab-close_btn_over.dds" , "20" , "28" , "dds" +"ui\aeon\game\tab-l-btn\tab-close_btn_up.dds" , "20" , "28" , "dds" +"ui\aeon\game\tab-l-btn\tab-open_btn_dis.dds" , "20" , "28" , "dds" +"ui\aeon\game\tab-l-btn\tab-open_btn_down.dds" , "20" , "28" , "dds" +"ui\aeon\game\tab-l-btn\tab-open_btn_over.dds" , "20" , "28" , "dds" +"ui\aeon\game\tab-l-btn\tab-open_btn_up.dds" , "20" , "28" , "dds" +"ui\aeon\game\tab-r-btn\tab-close_btn_dis.dds" , "20" , "28" , "dds" +"ui\aeon\game\tab-r-btn\tab-close_btn_down.dds" , "20" , "28" , "dds" +"ui\aeon\game\tab-r-btn\tab-close_btn_over.dds" , "20" , "28" , "dds" +"ui\aeon\game\tab-r-btn\tab-close_btn_up.dds" , "20" , "28" , "dds" +"ui\aeon\game\tab-r-btn\tab-open_btn_dis.dds" , "20" , "28" , "dds" +"ui\aeon\game\tab-r-btn\tab-open_btn_down.dds" , "20" , "28" , "dds" +"ui\aeon\game\tab-r-btn\tab-open_btn_over.dds" , "20" , "28" , "dds" +"ui\aeon\game\tab-r-btn\tab-open_btn_up.dds" , "20" , "28" , "dds" +"ui\aeon\game\tab-t-btn\tab-close_btn_dis.dds" , "28" , "20" , "dds" +"ui\aeon\game\tab-t-btn\tab-close_btn_down.dds" , "28" , "20" , "dds" +"ui\aeon\game\tab-t-btn\tab-close_btn_over.dds" , "28" , "20" , "dds" +"ui\aeon\game\tab-t-btn\tab-close_btn_up.dds" , "28" , "20" , "dds" +"ui\aeon\game\tab-t-btn\tab-open_btn_dis.dds" , "28" , "20" , "dds" +"ui\aeon\game\tab-t-btn\tab-open_btn_down.dds" , "28" , "20" , "dds" +"ui\aeon\game\tab-t-btn\tab-open_btn_over.dds" , "28" , "20" , "dds" +"ui\aeon\game\tab-t-btn\tab-open_btn_up.dds" , "28" , "20" , "dds" +"ui\aeon\game\temp_textures\checkmark_dark.dds" , "16" , "16" , "dds" +"ui\aeon\game\temp_textures\checkmark.dds" , "16" , "16" , "dds" +"ui\aeon\game\temp_textures\combo_sel.dds" , "20" , "20" , "dds" +"ui\aeon\game\temp_textures\combo_up.dds" , "20" , "20" , "dds" +"ui\aeon\game\transmission\title-bar_bmp.dds" , "192" , "20" , "dds" +"ui\aeon\game\transmission\video-brackets.dds" , "224" , "216" , "dds" +"ui\aeon\game\transmission\video-panel_bmp.dds" , "236" , "216" , "dds" +"ui\aeon\game\transmission\video-panel.dds" , "208" , "208" , "dds" +"ui\aeon\game\unit-build-over-panel\bracket-build_bmp.dds" , "32" , "116" , "dds" +"ui\aeon\game\unit-build-over-panel\bracket-unit_bmp.dds" , "32" , "116" , "dds" +"ui\aeon\game\unit-build-over-panel\build-over-back_bmp.dds" , "332" , "116" , "dds" +"ui\aeon\game\unit-build-over-panel\unit-over-back_bmp.dds" , "332" , "116" , "dds" +"ui\aeon\small-vert_scroll\arrow-down_scr_dis.dds" , "36" , "28" , "dds" +"ui\aeon\small-vert_scroll\arrow-down_scr_down.dds" , "36" , "28" , "dds" +"ui\aeon\small-vert_scroll\arrow-down_scr_over.dds" , "36" , "28" , "dds" +"ui\aeon\small-vert_scroll\arrow-down_scr_up.dds" , "36" , "28" , "dds" +"ui\aeon\small-vert_scroll\arrow-up_scr_dis.dds" , "36" , "28" , "dds" +"ui\aeon\small-vert_scroll\arrow-up_scr_down.dds" , "36" , "28" , "dds" +"ui\aeon\small-vert_scroll\arrow-up_scr_over.dds" , "36" , "28" , "dds" +"ui\aeon\small-vert_scroll\arrow-up_scr_up.dds" , "36" , "28" , "dds" +"ui\aeon\small-vert_scroll\back_scr_bot.dds" , "28" , "12" , "dds" +"ui\aeon\small-vert_scroll\back_scr_mid.dds" , "36" , "8" , "dds" +"ui\aeon\small-vert_scroll\back_scr_top.dds" , "28" , "12" , "dds" +"ui\aeon\small-vert_scroll\bar-bot_scr_dis.dds" , "36" , "8" , "dds" +"ui\aeon\small-vert_scroll\bar-bot_scr_down.dds" , "36" , "8" , "dds" +"ui\aeon\small-vert_scroll\bar-bot_scr_over.dds" , "36" , "8" , "dds" +"ui\aeon\small-vert_scroll\bar-bot_scr_up.dds" , "36" , "8" , "dds" +"ui\aeon\small-vert_scroll\bar-mid_scr_dis.dds" , "36" , "8" , "dds" +"ui\aeon\small-vert_scroll\bar-mid_scr_down.dds" , "36" , "8" , "dds" +"ui\aeon\small-vert_scroll\bar-mid_scr_over.dds" , "36" , "8" , "dds" +"ui\aeon\small-vert_scroll\bar-mid_scr_up.dds" , "36" , "8" , "dds" +"ui\aeon\small-vert_scroll\bar-top_scr_dis.dds" , "36" , "8" , "dds" +"ui\aeon\small-vert_scroll\bar-top_scr_down.dds" , "36" , "8" , "dds" +"ui\aeon\small-vert_scroll\bar-top_scr_over.dds" , "36" , "8" , "dds" +"ui\aeon\small-vert_scroll\bar-top_scr_up.dds" , "36" , "8" , "dds" +"ui\aeon\widgets02\small_btn_dis.dds" , "148" , "36" , "dds" +"ui\aeon\widgets02\small_btn_down.dds" , "148" , "36" , "dds" +"ui\aeon\widgets02\small_btn_over.dds" , "148" , "36" , "dds" +"ui\aeon\widgets02\small_btn_up.dds" , "148" , "36" , "dds" +"ui\common\aeon_load.dds" , "1280" , "1024" , "dds" +"ui\common\aeon-btn-med\medium_btn_dis.dds" , "296" , "88" , "dds" +"ui\common\aeon-btn-med\medium_btn_down.dds" , "296" , "88" , "dds" +"ui\common\aeon-btn-med\medium_btn_over.dds" , "296" , "88" , "dds" +"ui\common\aeon-btn-med\medium_btn_up.dds" , "296" , "88" , "dds" +"ui\common\aeon-btn-small-op\small_btn_dis.dds" , "196" , "76" , "dds" +"ui\common\aeon-btn-small-op\small_btn_down.dds" , "196" , "76" , "dds" +"ui\common\aeon-btn-small-op\small_btn_over.dds" , "196" , "76" , "dds" +"ui\common\aeon-btn-small-op\small_btn_up.dds" , "196" , "76" , "dds" +"ui\common\aeon-btn-small-stretch\small_btn_dis.dds" , "220" , "48" , "dds" +"ui\common\aeon-btn-small-stretch\small_btn_down.dds" , "220" , "48" , "dds" +"ui\common\aeon-btn-small-stretch\small_btn_over.dds" , "220" , "48" , "dds" +"ui\common\aeon-btn-small-stretch\small_btn_up.dds" , "220" , "48" , "dds" +"ui\common\aeon-btn-small\small_btn_dis.dds" , "196" , "76" , "dds" +"ui\common\aeon-btn-small\small_btn_down.dds" , "196" , "76" , "dds" +"ui\common\aeon-btn-small\small_btn_over.dds" , "196" , "76" , "dds" +"ui\common\aeon-btn-small\small_btn_up.dds" , "196" , "76" , "dds" +"ui\common\campaign\campaign-select-border\back_brd_horz_lml.dds" , "8" , "68" , "dds" +"ui\common\campaign\campaign-select-border\back_brd_horz_um.dds" , "928" , "68" , "dds" +"ui\common\campaign\campaign-select-border\back_brd_horz_uml.dds" , "8" , "56" , "dds" +"ui\common\campaign\campaign-select-border\back_brd_horz_umr.dds" , "8" , "56" , "dds" +"ui\common\campaign\campaign-select-border\back_brd_ll.dds" , "200" , "140" , "dds" +"ui\common\campaign\campaign-select-border\back_brd_lr.dds" , "816" , "144" , "dds" +"ui\common\campaign\campaign-select-border\back_brd_ul.dds" , "40" , "120" , "dds" +"ui\common\campaign\campaign-select-border\back_brd_ur.dds" , "40" , "120" , "dds" +"ui\common\campaign\campaign-select-border\icon-video-black_bmp.dds" , "64" , "36" , "dds" +"ui\common\campaign\campaign-select-border\icon-video-white_bmp.dds" , "64" , "36" , "dds" +"ui\common\campaign\logo-btn\logo-aeon__btn_dis.dds" , "108" , "112" , "dds" +"ui\common\campaign\logo-btn\logo-aeon__btn_up.dds" , "108" , "112" , "dds" +"ui\common\campaign\logo-btn\logo-aeon_btn_dis.dds" , "108" , "112" , "dds" +"ui\common\campaign\logo-btn\logo-aeon_btn_over_sel.dds" , "108" , "112" , "dds" +"ui\common\campaign\logo-btn\logo-aeon_btn_over.dds" , "108" , "112" , "dds" +"ui\common\campaign\logo-btn\logo-aeon_btn_sel.dds" , "108" , "112" , "dds" +"ui\common\campaign\logo-btn\logo-aeon_btn_up.dds" , "108" , "112" , "dds" +"ui\common\campaign\logo-btn\logo-cybran_btn_dis.dds" , "108" , "112" , "dds" +"ui\common\campaign\logo-btn\logo-cybran_btn_over_sel.dds" , "108" , "112" , "dds" +"ui\common\campaign\logo-btn\logo-cybran_btn_over.dds" , "108" , "112" , "dds" +"ui\common\campaign\logo-btn\logo-cybran_btn_sel.dds" , "108" , "112" , "dds" +"ui\common\campaign\logo-btn\logo-cybran_btn_up.dds" , "108" , "112" , "dds" +"ui\common\campaign\logo-btn\logo-uef_btn_dis.dds" , "108" , "112" , "dds" +"ui\common\campaign\logo-btn\logo-uef_btn_over_sel.dds" , "108" , "112" , "dds" +"ui\common\campaign\logo-btn\logo-uef_btn_over.dds" , "108" , "112" , "dds" +"ui\common\campaign\logo-btn\logo-uef_btn_sel.dds" , "108" , "112" , "dds" +"ui\common\campaign\logo-btn\logo-uef_btn_up.dds" , "108" , "112" , "dds" +"ui\common\campaign\op-select-btn\op-select_btn_dis.dds" , "288" , "60" , "dds" +"ui\common\campaign\op-select-btn\op-select_btn_down.dds" , "288" , "60" , "dds" +"ui\common\campaign\op-select-btn\op-select_btn_over.dds" , "288" , "60" , "dds" +"ui\common\campaign\op-select-btn\op-select_btn_up.dds" , "288" , "60" , "dds" +"ui\common\campaign\op-select-btn\op-select-aeon_btn_dis.dds" , "288" , "60" , "dds" +"ui\common\campaign\op-select-btn\op-select-aeon_btn_down.dds" , "288" , "60" , "dds" +"ui\common\campaign\op-select-btn\op-select-aeon_btn_over.dds" , "288" , "60" , "dds" +"ui\common\campaign\op-select-btn\op-select-aeon_btn_up.dds" , "288" , "60" , "dds" +"ui\common\campaign\op-select-btn\op-select-cybran_btn_dis.dds" , "288" , "60" , "dds" +"ui\common\campaign\op-select-btn\op-select-cybran_btn_down.dds" , "288" , "60" , "dds" +"ui\common\campaign\op-select-btn\op-select-cybran_btn_over.dds" , "288" , "60" , "dds" +"ui\common\campaign\op-select-btn\op-select-cybran_btn_up.dds" , "288" , "60" , "dds" +"ui\common\campaign\op-select-btn\op-select-uef_btn_dis.dds" , "288" , "60" , "dds" +"ui\common\campaign\op-select-btn\op-select-uef_btn_down.dds" , "288" , "60" , "dds" +"ui\common\campaign\op-select-btn\op-select-uef_btn_over.dds" , "288" , "60" , "dds" +"ui\common\campaign\op-select-btn\op-select-uef_btn_up.dds" , "288" , "60" , "dds" +"ui\common\campaign\operations-02\arrow-off_bmp.dds" , "24" , "16" , "dds" +"ui\common\campaign\operations-02\arrow-on_bmp.dds" , "24" , "16" , "dds" +"ui\common\campaign\operations-02\back_brd_horz_lm.dds" , "656" , "188" , "dds" +"ui\common\campaign\operations-02\back_brd_horz_lml.dds" , "16" , "144" , "dds" +"ui\common\campaign\operations-02\back_brd_horz_lmr.dds" , "16" , "168" , "dds" +"ui\common\campaign\operations-02\back_brd_horz_um.dds" , "656" , "96" , "dds" +"ui\common\campaign\operations-02\back_brd_horz_uml.dds" , "16" , "80" , "dds" +"ui\common\campaign\operations-02\back_brd_horz_umr.dds" , "16" , "80" , "dds" +"ui\common\campaign\operations-02\back_brd_ll.dds" , "168" , "500" , "dds" +"ui\common\campaign\operations-02\back_brd_lr.dds" , "168" , "500" , "dds" +"ui\common\campaign\operations-02\back_brd_ul.dds" , "168" , "260" , "dds" +"ui\common\campaign\operations-02\back_brd_ur.dds" , "168" , "260" , "dds" +"ui\common\campaign\operations-02\back_brd_vert_l.dds" , "84" , "8" , "dds" +"ui\common\campaign\operations-02\back_brd_vert_r.dds" , "84" , "8" , "dds" +"ui\common\campaign\operations-02\back-back_bmp.dds" , "168" , "52" , "dds" +"ui\common\campaign\operations-02\background_bmp.dds" , "1024" , "768" , "dds" +"ui\common\campaign\operations-02\border_bmp.dds" , "1024" , "768" , "dds" +"ui\common\campaign\operations-02\l-teleport-text.dds" , "28" , "8" , "dds" +"ui\common\campaign\operations-02\large02_btn_up.dds" , "252" , "52" , "dds" +"ui\common\campaign\operations-02\launch-back_bmp.dds" , "288" , "72" , "dds" +"ui\common\campaign\operations-02\launch-line_bmp.dds" , "292" , "20" , "dds" +"ui\common\campaign\operations-02\launch04_btn_dis.dds" , "156" , "120" , "dds" +"ui\common\campaign\operations-02\launch04_btn_down.dds" , "156" , "128" , "dds" +"ui\common\campaign\operations-02\launch04_btn_over.dds" , "156" , "128" , "dds" +"ui\common\campaign\operations-02\launch04_btn_up.dds" , "156" , "128" , "dds" +"ui\common\campaign\operations-02\lights-blue_bmp.dds" , "96" , "104" , "dds" +"ui\common\campaign\operations-02\lights-white_bmp.dds" , "264" , "208" , "dds" +"ui\common\campaign\operations-02\nav-back_btn_dis.dds" , "36" , "28" , "dds" +"ui\common\campaign\operations-02\nav-back_btn_down.dds" , "40" , "32" , "dds" +"ui\common\campaign\operations-02\nav-back_btn_over.dds" , "44" , "36" , "dds" +"ui\common\campaign\operations-02\nav-back_btn_up.dds" , "36" , "32" , "dds" +"ui\common\campaign\operations-02\nav-chapter_btn_dis.dds" , "36" , "28" , "dds" +"ui\common\campaign\operations-02\nav-chapter_btn_down.dds" , "44" , "36" , "dds" +"ui\common\campaign\operations-02\nav-chapter_btn_over.dds" , "36" , "28" , "dds" +"ui\common\campaign\operations-02\nav-chapter_btn_sel.dds" , "36" , "28" , "dds" +"ui\common\campaign\operations-02\nav-chapter_btn_up.dds" , "36" , "28" , "dds" +"ui\common\campaign\operations-02\nav-end_btn_dis.dds" , "36" , "28" , "dds" +"ui\common\campaign\operations-02\nav-end_btn_down.dds" , "44" , "36" , "dds" +"ui\common\campaign\operations-02\nav-end_btn_over.dds" , "44" , "36" , "dds" +"ui\common\campaign\operations-02\nav-end_btn_up.dds" , "44" , "36" , "dds" +"ui\common\campaign\operations-02\nav-ff_btn_dis.dds" , "36" , "28" , "dds" +"ui\common\campaign\operations-02\nav-ff_btn_down.dds" , "40" , "36" , "dds" +"ui\common\campaign\operations-02\nav-ff_btn_over.dds" , "40" , "36" , "dds" +"ui\common\campaign\operations-02\nav-ff_btn_up.dds" , "36" , "32" , "dds" +"ui\common\campaign\operations-02\nav-pause_btn_dis.dds" , "36" , "28" , "dds" +"ui\common\campaign\operations-02\nav-pause_btn_down.dds" , "36" , "36" , "dds" +"ui\common\campaign\operations-02\nav-pause_btn_over.dds" , "36" , "36" , "dds" +"ui\common\campaign\operations-02\nav-pause_btn_up.dds" , "36" , "36" , "dds" +"ui\common\campaign\operations-02\nav-play_btn_dis.dds" , "36" , "28" , "dds" +"ui\common\campaign\operations-02\nav-play_btn_down.dds" , "36" , "36" , "dds" +"ui\common\campaign\operations-02\nav-play_btn_over.dds" , "36" , "36" , "dds" +"ui\common\campaign\operations-02\nav-play_btn_up.dds" , "36" , "36" , "dds" +"ui\common\campaign\operations-02\teleport_bmp.dds" , "128" , "104" , "dds" +"ui\common\campaign\operations-02\teleport-100_bmp.dds" , "128" , "104" , "dds" +"ui\common\campaign\operations-02\teleport-25_bmp.dds" , "64" , "48" , "dds" +"ui\common\campaign\operations-02\teleport-50_bmp.dds" , "64" , "104" , "dds" +"ui\common\campaign\operations-02\teleport-75_bmp.dds" , "128" , "104" , "dds" +"ui\common\campaign\operations-02\teleport-empty_bmp.dds" , "112" , "92" , "dds" +"ui\common\campaign\operations-02\text-panel_bmp.dds" , "700" , "120" , "dds" +"ui\common\campaign\starfield.dds" , "1600" , "1200" , "dds" +"ui\common\cybran_load.dds" , "1280" , "1024" , "dds" +"ui\common\cybran-btn-med\medium_btn_dis.dds" , "296" , "88" , "dds" +"ui\common\cybran-btn-med\medium_btn_down.dds" , "296" , "88" , "dds" +"ui\common\cybran-btn-med\medium_btn_over.dds" , "296" , "88" , "dds" +"ui\common\cybran-btn-med\medium_btn_up.dds" , "296" , "88" , "dds" +"ui\common\cybran-btn-small-stretch\small_btn_dis.dds" , "220" , "48" , "dds" +"ui\common\cybran-btn-small-stretch\small_btn_down.dds" , "220" , "48" , "dds" +"ui\common\cybran-btn-small-stretch\small_btn_over.dds" , "220" , "48" , "dds" +"ui\common\cybran-btn-small-stretch\small_btn_up.dds" , "220" , "48" , "dds" +"ui\common\cybran-btn-small\small_btn_dis.dds" , "196" , "76" , "dds" +"ui\common\cybran-btn-small\small_btn_down.dds" , "196" , "76" , "dds" +"ui\common\cybran-btn-small\small_btn_over.dds" , "196" , "76" , "dds" +"ui\common\cybran-btn-small\small_btn_up.dds" , "196" , "76" , "dds" +"ui\common\dialogs\alert-mass-energy\alert-bracket01_bmp.dds" , "320" , "80" , "dds" +"ui\common\dialogs\alert-mass-energy\alert-bracket02_bmp.dds" , "320" , "80" , "dds" +"ui\common\dialogs\alert-mass-energy\alert-bracket03_bmp.dds" , "320" , "80" , "dds" +"ui\common\dialogs\alert-mass-energy\alert-energy_bmp.dds" , "280" , "80" , "dds" +"ui\common\dialogs\alert-mass-energy\alert-energy01_bmp.dds" , "320" , "80" , "dds" +"ui\common\dialogs\alert-mass-energy\alert-energy02_bmp.dds" , "320" , "80" , "dds" +"ui\common\dialogs\alert-mass-energy\alert-energy03_bmp.dds" , "320" , "80" , "dds" +"ui\common\dialogs\alert-mass-energy\alert-mass_bmp.dds" , "280" , "80" , "dds" +"ui\common\dialogs\alert-mass-energy\alert-mass01_bmp.dds" , "320" , "80" , "dds" +"ui\common\dialogs\alert-mass-energy\alert-mass02_bmp.dds" , "320" , "80" , "dds" +"ui\common\dialogs\alert-mass-energy\alert-mass03_bmp.dds" , "320" , "80" , "dds" +"ui\common\dialogs\check-box_btn\radio-d_btn_dis.dds" , "28" , "28" , "dds" +"ui\common\dialogs\check-box_btn\radio-d_btn_down.dds" , "28" , "28" , "dds" +"ui\common\dialogs\check-box_btn\radio-d_btn_over.dds" , "28" , "28" , "dds" +"ui\common\dialogs\check-box_btn\radio-d_btn_up.dds" , "28" , "28" , "dds" +"ui\common\dialogs\check-box_btn\radio-s_btn_dis.dds" , "28" , "28" , "dds" +"ui\common\dialogs\check-box_btn\radio-s_btn_down.dds" , "28" , "28" , "dds" +"ui\common\dialogs\check-box_btn\radio-s_btn_over.dds" , "28" , "28" , "dds" +"ui\common\dialogs\check-box_btn\radio-s_btn_up.dds" , "28" , "28" , "dds" +"ui\common\dialogs\close_btn\close_btn_dis.dds" , "24" , "24" , "dds" +"ui\common\dialogs\close_btn\close_btn_down.dds" , "24" , "24" , "dds" +"ui\common\dialogs\close_btn\close_btn_over.dds" , "24" , "24" , "dds" +"ui\common\dialogs\close_btn\close_btn_up.dds" , "24" , "24" , "dds" +"ui\common\dialogs\close_btn02\close_btn_dis.dds" , "24" , "24" , "dds" +"ui\common\dialogs\close_btn02\close_btn_down.dds" , "24" , "24" , "dds" +"ui\common\dialogs\close_btn02\close_btn_over.dds" , "24" , "24" , "dds" +"ui\common\dialogs\close_btn02\close_btn_up.dds" , "24" , "24" , "dds" +"ui\common\dialogs\config_btn\config_btn_dis.dds" , "24" , "24" , "dds" +"ui\common\dialogs\config_btn\config_btn_down.dds" , "24" , "24" , "dds" +"ui\common\dialogs\config_btn\config_btn_over.dds" , "24" , "24" , "dds" +"ui\common\dialogs\config_btn\config_btn_up.dds" , "24" , "24" , "dds" +"ui\common\dialogs\dialog_02\panel_bmp_b.dds" , "656" , "76" , "dds" +"ui\common\dialogs\dialog_02\panel_bmp_m.dds" , "656" , "8" , "dds" +"ui\common\dialogs\dialog_02\panel_bmp_T.dds" , "656" , "28" , "dds" +"ui\common\dialogs\dialog_02\panel_bmp.dds" , "664" , "168" , "dds" +"ui\common\dialogs\dialog_02\title-text_bmp.dds" , "392" , "36" , "dds" +"ui\common\dialogs\dialog\bracket_bmp.dds" , "440" , "176" , "dds" +"ui\common\dialogs\dialog\bracket_brd_ll.dds" , "44" , "24" , "dds" +"ui\common\dialogs\dialog\bracket_brd_lr.dds" , "44" , "24" , "dds" +"ui\common\dialogs\dialog\bracket_brd_ul.dds" , "44" , "24" , "dds" +"ui\common\dialogs\dialog\bracket_brd_ur.dds" , "44" , "24" , "dds" +"ui\common\dialogs\dialog\bracket_brd_vert_l.dds" , "20" , "36" , "dds" +"ui\common\dialogs\dialog\bracket_brd_vert_r.dds" , "20" , "36" , "dds" +"ui\common\dialogs\dialog\bracket-w_brd_ll.dds" , "68" , "36" , "dds" +"ui\common\dialogs\dialog\bracket-w_brd_lr.dds" , "64" , "36" , "dds" +"ui\common\dialogs\dialog\bracket-w_brd_ul.dds" , "68" , "36" , "dds" +"ui\common\dialogs\dialog\bracket-w_brd_ur.dds" , "64" , "36" , "dds" +"ui\common\dialogs\dialog\bracket-w_brd_vert_l.dds" , "52" , "36" , "dds" +"ui\common\dialogs\dialog\bracket-w_brd_vert_r.dds" , "52" , "36" , "dds" +"ui\common\dialogs\dialog\panel_bmp_alt_b.dds" , "424" , "28" , "dds" +"ui\common\dialogs\dialog\panel_bmp_b.dds" , "424" , "76" , "dds" +"ui\common\dialogs\dialog\panel_bmp_m.dds" , "424" , "8" , "dds" +"ui\common\dialogs\dialog\panel_bmp_t.dds" , "424" , "28" , "dds" +"ui\common\dialogs\dialog\panel_bmp.dds" , "432" , "168" , "dds" +"ui\common\dialogs\dialog\title-text_bmp.dds" , "392" , "36" , "dds" +"ui\common\dialogs\diplomacy_btn\chat-all_btn_dis.dds" , "48" , "40" , "dds" +"ui\common\dialogs\diplomacy_btn\chat-all_btn_down.dds" , "48" , "40" , "dds" +"ui\common\dialogs\diplomacy_btn\chat-all_btn_over.dds" , "48" , "40" , "dds" +"ui\common\dialogs\diplomacy_btn\chat-all_btn_up.dds" , "48" , "40" , "dds" +"ui\common\dialogs\diplomacy_btn\chat-allies_btn_dis.dds" , "48" , "40" , "dds" +"ui\common\dialogs\diplomacy_btn\chat-allies_btn_down.dds" , "48" , "40" , "dds" +"ui\common\dialogs\diplomacy_btn\chat-allies_btn_over.dds" , "48" , "40" , "dds" +"ui\common\dialogs\diplomacy_btn\chat-allies_btn_up.dds" , "48" , "40" , "dds" +"ui\common\dialogs\diplomacy_btn\chat-observer_btn_dis.dds" , "48" , "40" , "dds" +"ui\common\dialogs\diplomacy_btn\chat-observer_btn_down.dds" , "48" , "40" , "dds" +"ui\common\dialogs\diplomacy_btn\chat-observer_btn_over.dds" , "48" , "40" , "dds" +"ui\common\dialogs\diplomacy_btn\chat-observer_btn_up.dds" , "48" , "40" , "dds" +"ui\common\dialogs\diplomacy_btn\give-resource_btn_dis.dds" , "36" , "32" , "dds" +"ui\common\dialogs\diplomacy_btn\give-resource_btn_down.dds" , "36" , "32" , "dds" +"ui\common\dialogs\diplomacy_btn\give-resource_btn_over.dds" , "36" , "32" , "dds" +"ui\common\dialogs\diplomacy_btn\give-resource_btn_up.dds" , "36" , "32" , "dds" +"ui\common\dialogs\diplomacy_btn\give-unit_btn_dis.dds" , "36" , "32" , "dds" +"ui\common\dialogs\diplomacy_btn\give-unit_btn_down.dds" , "36" , "32" , "dds" +"ui\common\dialogs\diplomacy_btn\give-unit_btn_over.dds" , "36" , "32" , "dds" +"ui\common\dialogs\diplomacy_btn\give-unit_btn_up.dds" , "36" , "32" , "dds" +"ui\common\dialogs\diplomacy_btn\set-sharing_btn_dis.dds" , "48" , "40" , "dds" +"ui\common\dialogs\diplomacy_btn\set-sharing_btn_down.dds" , "48" , "40" , "dds" +"ui\common\dialogs\diplomacy_btn\set-sharing_btn_over.dds" , "48" , "40" , "dds" +"ui\common\dialogs\diplomacy_btn\set-sharing_btn_up.dds" , "48" , "40" , "dds" +"ui\common\dialogs\diplomacy-resources_options\diplomacy-panel_bmp.dds" , "456" , "296" , "dds" +"ui\common\dialogs\diplomacy-resources_options\energy_btn_up.dds" , "48" , "48" , "dds" +"ui\common\dialogs\diplomacy-resources_options\energy-bar_bmp.dds" , "248" , "12" , "dds" +"ui\common\dialogs\diplomacy-resources_options\energy-bar-back_bmp.dds" , "248" , "12" , "dds" +"ui\common\dialogs\diplomacy-resources_options\mass_btn_up.dds" , "48" , "48" , "dds" +"ui\common\dialogs\diplomacy-resources_options\mass-bar_bmp.dds" , "248" , "12" , "dds" +"ui\common\dialogs\diplomacy-resources_options\mass-bar-back_bmp.dds" , "248" , "12" , "dds" +"ui\common\dialogs\diplomacy-resources_options\slider-text_bmp.dds" , "60" , "28" , "dds" +"ui\common\dialogs\diplomacy-team-alliance\team-panel_bmp.dds" , "456" , "196" , "dds" +"ui\common\dialogs\diplomacy-team-alliance\team-panel-noback_bmp.dds" , "456" , "236" , "dds" +"ui\common\dialogs\diplomacy-unit-transfer\team-panel_bmp.png" , "456" , "192" , "png" +"ui\common\dialogs\diplomacy\diplomacy-bar_bmp.dds" , "204" , "28" , "dds" +"ui\common\dialogs\diplomacy\diplomacy-bar02_bmp.dds" , "264" , "28" , "dds" +"ui\common\dialogs\diplomacy\diplomacy-panel_bmp.dds" , "456" , "344" , "dds" +"ui\common\dialogs\drop-box_brd\drop-box_brd_horz_um.dds" , "8" , "8" , "dds" +"ui\common\dialogs\drop-box_brd\drop-box_brd_ll.dds" , "8" , "8" , "dds" +"ui\common\dialogs\drop-box_brd\drop-box_brd_lm.dds" , "8" , "8" , "dds" +"ui\common\dialogs\drop-box_brd\drop-box_brd_lr.dds" , "8" , "8" , "dds" +"ui\common\dialogs\drop-box_brd\drop-box_brd_m.dds" , "8" , "8" , "dds" +"ui\common\dialogs\drop-box_brd\drop-box_brd_ul.dds" , "8" , "8" , "dds" +"ui\common\dialogs\drop-box_brd\drop-box_brd_ur.dds" , "8" , "8" , "dds" +"ui\common\dialogs\drop-box_brd\drop-box_brd_vert_l.dds" , "8" , "8" , "dds" +"ui\common\dialogs\drop-box_brd\drop-box_brd_vert_r.dds" , "8" , "8" , "dds" +"ui\common\dialogs\drop-box_btn\drop-box_btn_dis.dds" , "28" , "28" , "dds" +"ui\common\dialogs\drop-box_btn\drop-box_btn_down.dds" , "28" , "28" , "dds" +"ui\common\dialogs\drop-box_btn\drop-box_btn_over.dds" , "28" , "28" , "dds" +"ui\common\dialogs\drop-box_btn\drop-box_btn_up.dds" , "28" , "28" , "dds" +"ui\common\dialogs\enhance-panel_btn\infinite_btn_dis.dds" , "36" , "64" , "dds" +"ui\common\dialogs\enhance-panel_btn\infinite_btn_down.dds" , "36" , "64" , "dds" +"ui\common\dialogs\enhance-panel_btn\infinite_btn_glow.dds" , "36" , "64" , "dds" +"ui\common\dialogs\enhance-panel_btn\infinite_btn_over.dds" , "36" , "64" , "dds" +"ui\common\dialogs\enhance-panel_btn\infinite_btn_up.dds" , "36" , "64" , "dds" +"ui\common\dialogs\enhance-panel-small_btn\infinite_btn_dis.dds" , "36" , "36" , "dds" +"ui\common\dialogs\enhance-panel-small_btn\infinite_btn_down.dds" , "36" , "36" , "dds" +"ui\common\dialogs\enhance-panel-small_btn\infinite_btn_glow.dds" , "36" , "36" , "dds" +"ui\common\dialogs\enhance-panel-small_btn\infinite_btn_over.dds" , "36" , "36" , "dds" +"ui\common\dialogs\enhance-panel-small_btn\infinite_btn_up.dds" , "36" , "36" , "dds" +"ui\common\dialogs\enhance-panel\panel_bmp.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\ual0001_bmp.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\ual0001-b_btn_down.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\ual0001-b_btn_over.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\ual0001-b_btn_sel.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\ual0001-b_btn_up.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\ual0001-la_btn_down.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\ual0001-la_btn_over.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\ual0001-la_btn_sel.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\ual0001-la_btn_up.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\ual0001-ra_btn_down.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\ual0001-ra_btn_over.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\ual0001-ra_btn_sel.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\ual0001-ra_btn_up.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\ual0301_bmp.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\ual0301-b_btn_down.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\ual0301-b_btn_over.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\ual0301-b_btn_sel.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\ual0301-b_btn_up.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\ual0301-la_btn_down.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\ual0301-la_btn_over.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\ual0301-la_btn_sel.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\ual0301-la_btn_up.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\ual0301-ra_btn_down.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\ual0301-ra_btn_over.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\ual0301-ra_btn_sel.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\ual0301-ra_btn_up.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\uel0001_bmp.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\uel0001-b_btn_down.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\uel0001-b_btn_over.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\uel0001-b_btn_sel.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\uel0001-b_btn_up.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\uel0001-la_btn_down.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\uel0001-la_btn_over.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\uel0001-la_btn_sel.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\uel0001-la_btn_up.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\uel0001-ra_btn_down.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\uel0001-ra_btn_over.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\uel0001-ra_btn_sel.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\uel0001-ra_btn_up.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\uel0301_bmp.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\uel0301-b_btn_down.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\uel0301-b_btn_over.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\uel0301-b_btn_sel.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\uel0301-b_btn_up.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\uel0301-la_btn_down.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\uel0301-la_btn_over.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\uel0301-la_btn_sel.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\uel0301-la_btn_up.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\uel0301-ra_btn_down.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\uel0301-ra_btn_over.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\uel0301-ra_btn_sel.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\uel0301-ra_btn_up.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\urb4202_bmp.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\urb4202-ch_btn_down.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\urb4202-ch_btn_over.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\urb4202-ch_btn_sel.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\urb4202-ch_btn_up.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\url0001_bmp.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\url0001-b_btn_down.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\url0001-b_btn_over.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\url0001-b_btn_sel.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\url0001-b_btn_up.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\url0001-la_btn_down.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\url0001-la_btn_over.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\url0001-la_btn_sel.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\url0001-la_btn_up.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\url0001-ra_btn_down.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\url0001-ra_btn_over.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\url0001-ra_btn_sel.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\url0001-ra_btn_up.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\url0301_bmp.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\url0301-b_btn_down.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\url0301-b_btn_over.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\url0301-b_btn_sel.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\url0301-b_btn_up.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\url0301-la_btn_down.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\url0301-la_btn_over.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\url0301-la_btn_sel.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\url0301-la_btn_up.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\url0301-ra_btn_down.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\url0301-ra_btn_over.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\url0301-ra_btn_sel.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\url0301-ra_btn_up.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\xsl0001_bmp.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\xsl0001-b_btn_down.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\xsl0001-b_btn_over.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\xsl0001-b_btn_sel.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\xsl0001-b_btn_up.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\xsl0001-la_btn_down.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\xsl0001-la_btn_over.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\xsl0001-la_btn_sel.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\xsl0001-la_btn_up.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\xsl0001-ra_btn_down.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\xsl0001-ra_btn_over.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\xsl0001-ra_btn_sel.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\xsl0001-ra_btn_up.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\xsl0301_bmp.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\xsl0301-b_btn_down.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\xsl0301-b_btn_over.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\xsl0301-b_btn_sel.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\xsl0301-b_btn_up.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\xsl0301-la_btn_down.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\xsl0301-la_btn_over.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\xsl0301-la_btn_sel.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\xsl0301-la_btn_up.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\xsl0301-ra_btn_down.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\xsl0301-ra_btn_over.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\xsl0301-ra_btn_sel.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\xsl0301-ra_btn_up.dds" , "360" , "240" , "dds" +"ui\common\dialogs\exit-dialog\brackets_bmp.dds" , "380" , "488" , "dds" +"ui\common\dialogs\exit-dialog\glow-bar_bmp.dds" , "176" , "516" , "dds" +"ui\common\dialogs\exit-dialog\graphic_bmp.dds" , "588" , "512" , "dds" +"ui\common\dialogs\exit-dialog\panel_bmp.dds" , "324" , "348" , "dds" +"ui\common\dialogs\exit-dialog\panel-white_bmp.dds" , "316" , "492" , "dds" +"ui\common\dialogs\exit-dialog\panel02_bmp.dds" , "324" , "348" , "dds" +"ui\common\dialogs\exit-dialog\panel03_bmp.dds" , "324" , "500" , "dds" +"ui\common\dialogs\faction_ico\aeon_ico.dds" , "24" , "24" , "dds" +"ui\common\dialogs\faction_ico\cybran_ico.dds" , "24" , "24" , "dds" +"ui\common\dialogs\faction_ico\uef_ico.dds" , "24" , "24" , "dds" +"ui\common\dialogs\game-select-faction-panel\panel_bmp.dds" , "312" , "172" , "dds" +"ui\common\dialogs\help\help-sm_btn.dds" , "44" , "40" , "dds" +"ui\common\dialogs\help\objectives-d_bmp.dds" , "596" , "308" , "dds" +"ui\common\dialogs\help\panel_bmp.dds" , "888" , "540" , "dds" +"ui\common\dialogs\help\text-over_bmp.dds" , "412" , "20" , "dds" +"ui\common\dialogs\load\panel_bmp.dds" , "732" , "600" , "dds" +"ui\common\dialogs\logo-btn\logo-aeon_btn_dis.dds" , "80" , "80" , "dds" +"ui\common\dialogs\logo-btn\logo-aeon_btn_over_sel.dds" , "80" , "80" , "dds" +"ui\common\dialogs\logo-btn\logo-aeon_btn_over.dds" , "80" , "80" , "dds" +"ui\common\dialogs\logo-btn\logo-aeon_btn_sel.dds" , "80" , "80" , "dds" +"ui\common\dialogs\logo-btn\logo-aeon_btn_up.dds" , "80" , "80" , "dds" +"ui\common\dialogs\logo-btn\logo-cybran_btn_dis.dds" , "80" , "80" , "dds" +"ui\common\dialogs\logo-btn\logo-cybran_btn_over_sel.dds" , "80" , "80" , "dds" +"ui\common\dialogs\logo-btn\logo-cybran_btn_over.dds" , "80" , "80" , "dds" +"ui\common\dialogs\logo-btn\logo-cybran_btn_sel.dds" , "80" , "80" , "dds" +"ui\common\dialogs\logo-btn\logo-cybran_btn_up.dds" , "80" , "80" , "dds" +"ui\common\dialogs\logo-btn\logo-uef_btn_dis.dds" , "80" , "80" , "dds" +"ui\common\dialogs\logo-btn\logo-uef_btn_over_sel.dds" , "80" , "80" , "dds" +"ui\common\dialogs\logo-btn\logo-uef_btn_over.dds" , "80" , "80" , "dds" +"ui\common\dialogs\logo-btn\logo-uef_btn_sel.dds" , "80" , "80" , "dds" +"ui\common\dialogs\logo-btn\logo-uef_btn_up.dds" , "80" , "80" , "dds" +"ui\common\dialogs\mapselect02\commander_alpha.dds" , "16" , "16" , "dds" +"ui\common\dialogs\mapselect02\commander.dds" , "16" , "16" , "dds" +"ui\common\dialogs\mapselect02\drop-down-w_bmp_b.dds" , "116" , "16" , "dds" +"ui\common\dialogs\mapselect02\drop-down-w_bmp_m.dds" , "116" , "8" , "dds" +"ui\common\dialogs\mapselect02\drop-down-w_bmp_t.dds" , "116" , "16" , "dds" +"ui\common\dialogs\mapselect02\drop-down-w_bmp.dds" , "124" , "44" , "dds" +"ui\common\dialogs\mapselect02\highlight_bmp.dds" , "112" , "20" , "dds" +"ui\common\dialogs\mapselect02\map-panel-glow_bmp.dds" , "332" , "332" , "dds" +"ui\common\dialogs\mapselect02\panel_bmp.dds" , "856" , "628" , "dds" +"ui\common\dialogs\mapselect02\square-w_btn_dis.dds" , "124" , "32" , "dds" +"ui\common\dialogs\mapselect02\square-w_btn_down.dds" , "124" , "32" , "dds" +"ui\common\dialogs\mapselect02\square-w_btn_over.dds" , "124" , "32" , "dds" +"ui\common\dialogs\mapselect02\square-w_btn_up.dds" , "124" , "32" , "dds" +"ui\common\dialogs\mapselect02\text-over_bmp.dds" , "328" , "20" , "dds" +"ui\common\dialogs\mapselect03\drop-down-w_bmp_b.dds" , "116" , "16" , "dds" +"ui\common\dialogs\mapselect03\drop-down-w_bmp_m.dds" , "116" , "8" , "dds" +"ui\common\dialogs\mapselect03\drop-down-w_bmp_t.dds" , "116" , "16" , "dds" +"ui\common\dialogs\mapselect03\drop-down-w_bmp.dds" , "124" , "44" , "dds" +"ui\common\dialogs\mapselect03\filter-panel-bar_bmp.dds" , "312" , "24" , "dds" +"ui\common\dialogs\mapselect03\highlight_bmp.dds" , "112" , "20" , "dds" +"ui\common\dialogs\mapselect03\map-panel-glow_bmp.dds" , "332" , "332" , "dds" +"ui\common\dialogs\mapselect03\options-panel-bar_bmp.dds" , "260" , "24" , "dds" +"ui\common\dialogs\mapselect03\panel_bmp.dds" , "1016" , "768" , "dds" +"ui\common\dialogs\mapselect03\square-w_btn_dis.dds" , "124" , "32" , "dds" +"ui\common\dialogs\mapselect03\square-w_btn_down.dds" , "124" , "32" , "dds" +"ui\common\dialogs\mapselect03\square-w_btn_over.dds" , "124" , "32" , "dds" +"ui\common\dialogs\mapselect03\square-w_btn_up.dds" , "124" , "32" , "dds" +"ui\common\dialogs\mapselect03\text-over_bmp.dds" , "328" , "20" , "dds" +"ui\common\dialogs\mission\panel_bmp_l.dds" , "140" , "136" , "dds" +"ui\common\dialogs\mission\panel_bmp_m.dds" , "52" , "136" , "dds" +"ui\common\dialogs\mission\panel_bmp_r.dds" , "140" , "136" , "dds" +"ui\common\dialogs\mission\panel_bmp.dds" , "328" , "140" , "dds" +"ui\common\dialogs\mission\panel-white_bmp_l.dds" , "140" , "136" , "dds" +"ui\common\dialogs\mission\panel-white_bmp_m.dds" , "52" , "136" , "dds" +"ui\common\dialogs\mission\panel-white_bmp_r.dds" , "140" , "136" , "dds" +"ui\common\dialogs\mission\panel-white_bmp.dds" , "328" , "136" , "dds" +"ui\common\dialogs\mod_btn\mod-d_btn_up.dds" , "640" , "76" , "dds" +"ui\common\dialogs\mod_btn\mod-s_btn_up.dds" , "640" , "76" , "dds" +"ui\common\dialogs\mod-manager\generic-icon_bmp.dds" , "56" , "68" , "dds" +"ui\common\dialogs\mod-manager\panel_bmp.dds" , "760" , "600" , "dds" +"ui\common\dialogs\movie-control\nav-back_btn_dis.dds" , "36" , "36" , "dds" +"ui\common\dialogs\movie-control\nav-back_btn_down.dds" , "36" , "36" , "dds" +"ui\common\dialogs\movie-control\nav-back_btn_over.dds" , "36" , "36" , "dds" +"ui\common\dialogs\movie-control\nav-back_btn_up.dds" , "36" , "36" , "dds" +"ui\common\dialogs\movie-control\nav-end_btn_dis.dds" , "36" , "36" , "dds" +"ui\common\dialogs\movie-control\nav-end_btn_down.dds" , "36" , "36" , "dds" +"ui\common\dialogs\movie-control\nav-end_btn_over.dds" , "36" , "36" , "dds" +"ui\common\dialogs\movie-control\nav-end_btn_up.dds" , "36" , "36" , "dds" +"ui\common\dialogs\movie-control\nav-ff_btn_dis.dds" , "36" , "36" , "dds" +"ui\common\dialogs\movie-control\nav-ff_btn_down.dds" , "36" , "36" , "dds" +"ui\common\dialogs\movie-control\nav-ff_btn_over.dds" , "36" , "36" , "dds" +"ui\common\dialogs\movie-control\nav-ff_btn_up.dds" , "36" , "36" , "dds" +"ui\common\dialogs\movie-control\nav-pause_btn_dis.dds" , "36" , "36" , "dds" +"ui\common\dialogs\movie-control\nav-pause_btn_down.dds" , "36" , "36" , "dds" +"ui\common\dialogs\movie-control\nav-pause_btn_over.dds" , "36" , "36" , "dds" +"ui\common\dialogs\movie-control\nav-pause_btn_up.dds" , "36" , "36" , "dds" +"ui\common\dialogs\movie-control\nav-play_btn_dis.dds" , "36" , "36" , "dds" +"ui\common\dialogs\movie-control\nav-play_btn_down.dds" , "36" , "36" , "dds" +"ui\common\dialogs\movie-control\nav-play_btn_over.dds" , "36" , "36" , "dds" +"ui\common\dialogs\movie-control\nav-play_btn_up.dds" , "36" , "36" , "dds" +"ui\common\dialogs\movie-control\nav-rr_btn_dis.dds" , "36" , "36" , "dds" +"ui\common\dialogs\movie-control\nav-rr_btn_down.dds" , "36" , "36" , "dds" +"ui\common\dialogs\movie-control\nav-rr_btn_over.dds" , "36" , "36" , "dds" +"ui\common\dialogs\movie-control\nav-rr_btn_up.dds" , "36" , "36" , "dds" +"ui\common\dialogs\networkwindow\panel_bmp_b.dds" , "448" , "80" , "dds" +"ui\common\dialogs\networkwindow\panel_bmp_m.dds" , "448" , "60" , "dds" +"ui\common\dialogs\networkwindow\panel_bmp_t.dds" , "448" , "52" , "dds" +"ui\common\dialogs\objective-log-02\bar_btn_over.dds" , "580" , "72" , "dds" +"ui\common\dialogs\objective-log-02\bar_btn_select.dds" , "584" , "72" , "dds" +"ui\common\dialogs\objective-log-02\bar_btn_up.dds" , "580" , "72" , "dds" +"ui\common\dialogs\objective-log-02\bar-bottom_btn_over.dds" , "580" , "72" , "dds" +"ui\common\dialogs\objective-log-02\bar-bottom_btn_select.dds" , "580" , "72" , "dds" +"ui\common\dialogs\objective-log-02\bar-bottom_btn_up.dds" , "580" , "72" , "dds" +"ui\common\dialogs\objective-log-02\panel_bmp_b.dds" , "692" , "220" , "dds" +"ui\common\dialogs\objective-log-02\panel_bmp_m.dds" , "692" , "52" , "dds" +"ui\common\dialogs\objective-log-02\panel_bmp_t.dds" , "692" , "100" , "dds" +"ui\common\dialogs\objective-log-02\tab_bmp.dds" , "584" , "60" , "dds" +"ui\common\dialogs\objective-log-btn-bar\bar_btn_over.dds" , "580" , "64" , "dds" +"ui\common\dialogs\objective-log-btn-bar\bar_btn_select.dds" , "580" , "64" , "dds" +"ui\common\dialogs\objective-log-btn-bar\bar_btn_up.dds" , "580" , "64" , "dds" +"ui\common\dialogs\objective-log-btn-bar\bar-bottom_btn_over.dds" , "580" , "64" , "dds" +"ui\common\dialogs\objective-log-btn-bar\bar-bottom_btn_select.dds" , "580" , "64" , "dds" +"ui\common\dialogs\objective-log-btn-bar\bar-bottom_btn_up.dds" , "580" , "64" , "dds" +"ui\common\dialogs\objective-log-btn-bar\tab_bmp.dds" , "580" , "64" , "dds" +"ui\common\dialogs\objective-unit\help-lg_bmp_down.dds" , "76" , "76" , "dds" +"ui\common\dialogs\objective-unit\help-lg_bmp_over.dds" , "76" , "76" , "dds" +"ui\common\dialogs\objective-unit\help-lg_bmp.dds" , "76" , "76" , "dds" +"ui\common\dialogs\objective-unit\help-lg-graphics_bmp.dds" , "92" , "92" , "dds" +"ui\common\dialogs\objective-unit\help-sm_bmp.dds" , "44" , "40" , "dds" +"ui\common\dialogs\objective-unit\panel_bmp_b.dds" , "484" , "220" , "dds" +"ui\common\dialogs\objective-unit\panel_bmp_m.dds" , "484" , "20" , "dds" +"ui\common\dialogs\objective-unit\panel_bmp_t.dds" , "484" , "32" , "dds" +"ui\common\dialogs\objective-unit\panel_bmp.dds" , "416" , "200" , "dds" +"ui\common\dialogs\objective-unit\panel-icon_bmp.dds" , "112" , "112" , "dds" +"ui\common\dialogs\objective-unit\panel-icon-sm_bmp.dds" , "104" , "104" , "dds" +"ui\common\dialogs\objective-unit\panel02_bmp.dds" , "492" , "276" , "dds" +"ui\common\dialogs\options-02\content-box_bmp.dds" , "604" , "36" , "dds" +"ui\common\dialogs\options-02\content-btn-line_bmp.dds" , "232" , "4" , "dds" +"ui\common\dialogs\options-02\gameplay_bmp.dds" , "604" , "372" , "dds" +"ui\common\dialogs\options-02\panel_bmp.dds" , "732" , "608" , "dds" +"ui\common\dialogs\options-02\slider-back_bmp.dds" , "200" , "24" , "dds" +"ui\common\dialogs\options\content-box_bmp.dds" , "468" , "36" , "dds" +"ui\common\dialogs\options\content-btn-line_bmp.dds" , "244" , "4" , "dds" +"ui\common\dialogs\options\gameplay_bmp.dds" , "468" , "276" , "dds" +"ui\common\dialogs\options\panel-front-end_bmp.dds" , "612" , "488" , "dds" +"ui\common\dialogs\options\panel-ingame_bmp.dds" , "604" , "488" , "dds" +"ui\common\dialogs\options\slider-back_bmp.dds" , "196" , "20" , "dds" +"ui\common\dialogs\options\sound_bmp.dds" , "468" , "284" , "dds" +"ui\common\dialogs\options\video_bmp.dds" , "468" , "296" , "dds" +"ui\common\dialogs\pause\brackets_bmp_b.dds" , "312" , "68" , "dds" +"ui\common\dialogs\pause\brackets_bmp_t.dds" , "312" , "68" , "dds" +"ui\common\dialogs\pause\brackets_bmp.dds" , "312" , "148" , "dds" +"ui\common\dialogs\pause\panel_bmp_b.dds" , "300" , "104" , "dds" +"ui\common\dialogs\pause\panel_bmp_m.dds" , "300" , "8" , "dds" +"ui\common\dialogs\pause\panel_bmp_t.dds" , "300" , "32" , "dds" +"ui\common\dialogs\pause\panel_bmp.dds" , "304" , "148" , "dds" +"ui\common\dialogs\pin\pin_dis.dds" , "24" , "24" , "dds" +"ui\common\dialogs\pin\pin_down.dds" , "24" , "24" , "dds" +"ui\common\dialogs\pin\pin_over.dds" , "24" , "24" , "dds" +"ui\common\dialogs\pin\pin_up.dds" , "24" , "24" , "dds" +"ui\common\dialogs\pin\pinned_dis.dds" , "24" , "24" , "dds" +"ui\common\dialogs\pin\pinned_down.dds" , "24" , "24" , "dds" +"ui\common\dialogs\pin\pinned_over.dds" , "24" , "24" , "dds" +"ui\common\dialogs\pin\pinned_up.dds" , "24" , "24" , "dds" +"ui\common\dialogs\profile02\panel_bmp.dds" , "536" , "460" , "dds" +"ui\common\dialogs\profile02\text-over_bmp.dds" , "412" , "20" , "dds" +"ui\common\dialogs\radio_btn\radio-d_btn_dis.dds" , "28" , "28" , "dds" +"ui\common\dialogs\radio_btn\radio-d_btn_down.dds" , "28" , "28" , "dds" +"ui\common\dialogs\radio_btn\radio-d_btn_over.dds" , "28" , "28" , "dds" +"ui\common\dialogs\radio_btn\radio-d_btn_up.dds" , "28" , "28" , "dds" +"ui\common\dialogs\radio_btn\radio-s_btn_dis.dds" , "28" , "28" , "dds" +"ui\common\dialogs\radio_btn\radio-s_btn_down.dds" , "28" , "28" , "dds" +"ui\common\dialogs\radio_btn\radio-s_btn_over.dds" , "28" , "28" , "dds" +"ui\common\dialogs\radio_btn\radio-s_btn_up.dds" , "28" , "28" , "dds" +"ui\common\dialogs\rect-box_btn\rect-box-d_btn_dis.dds" , "104" , "52" , "dds" +"ui\common\dialogs\rect-box_btn\rect-box-d_btn_down.dds" , "104" , "52" , "dds" +"ui\common\dialogs\rect-box_btn\rect-box-d_btn_over.dds" , "104" , "52" , "dds" +"ui\common\dialogs\rect-box_btn\rect-box-d_btn_up.dds" , "104" , "52" , "dds" +"ui\common\dialogs\rect-box_btn\rect-box-s_btn_dis.dds" , "104" , "52" , "dds" +"ui\common\dialogs\rect-box_btn\rect-box-s_btn_down.dds" , "104" , "52" , "dds" +"ui\common\dialogs\rect-box_btn\rect-box-s_btn_over.dds" , "104" , "52" , "dds" +"ui\common\dialogs\rect-box_btn\rect-box-s_btn_up.dds" , "104" , "52" , "dds" +"ui\common\dialogs\score_btn\chart_btn_dis.dds" , "68" , "52" , "dds" +"ui\common\dialogs\score_btn\chart_btn_down.dds" , "68" , "52" , "dds" +"ui\common\dialogs\score_btn\chart_btn_over.dds" , "68" , "52" , "dds" +"ui\common\dialogs\score_btn\chart_btn_up.dds" , "68" , "52" , "dds" +"ui\common\dialogs\score_btn\grid_btn_dis.dds" , "68" , "52" , "dds" +"ui\common\dialogs\score_btn\grid_btn_down.dds" , "68" , "52" , "dds" +"ui\common\dialogs\score_btn\grid_btn_over.dds" , "68" , "52" , "dds" +"ui\common\dialogs\score_btn\grid_btn_up.dds" , "68" , "52" , "dds" +"ui\common\dialogs\score-aeon\back_brd_horz_lml.dds" , "8" , "68" , "dds" +"ui\common\dialogs\score-aeon\back_brd_horz_um.dds" , "880" , "64" , "dds" +"ui\common\dialogs\score-aeon\back_brd_horz_uml.dds" , "8" , "16" , "dds" +"ui\common\dialogs\score-aeon\back_brd_horz_umr.dds" , "8" , "16" , "dds" +"ui\common\dialogs\score-aeon\back_brd_ll.dds" , "312" , "220" , "dds" +"ui\common\dialogs\score-aeon\back_brd_lr.dds" , "704" , "212" , "dds" +"ui\common\dialogs\score-aeon\back_brd_ul.dds" , "64" , "140" , "dds" +"ui\common\dialogs\score-aeon\back_brd_ur.dds" , "64" , "156" , "dds" +"ui\common\dialogs\score-aeon\background_bmp.dds" , "1024" , "768" , "dds" +"ui\common\dialogs\score-aeon\icon_experimental.dds" , "28" , "28" , "dds" +"ui\common\dialogs\score-aeon\icon-air_bmp.dds" , "28" , "28" , "dds" +"ui\common\dialogs\score-aeon\icon-cdr_bmp.dds" , "28" , "28" , "dds" +"ui\common\dialogs\score-aeon\icon-energy_bmp.dds" , "20" , "20" , "dds" +"ui\common\dialogs\score-aeon\icon-kill-death_bmp.dds" , "24" , "24" , "dds" +"ui\common\dialogs\score-aeon\icon-kills_bmp.dds" , "20" , "20" , "dds" +"ui\common\dialogs\score-aeon\icon-land_bmp.dds" , "28" , "28" , "dds" +"ui\common\dialogs\score-aeon\icon-loss_bmp.dds" , "20" , "20" , "dds" +"ui\common\dialogs\score-aeon\icon-mass_bmp.dds" , "20" , "20" , "dds" +"ui\common\dialogs\score-aeon\icon-naval_bmp.dds" , "28" , "28" , "dds" +"ui\common\dialogs\score-aeon\icon-structure_bmp.dds" , "28" , "28" , "dds" +"ui\common\dialogs\score-aeon\panels_bmp.dds" , "964" , "628" , "dds" +"ui\common\dialogs\score-aeon\text-box_bmp_b.dds" , "628" , "16" , "dds" +"ui\common\dialogs\score-aeon\text-box_bmp_m.dds" , "628" , "8" , "dds" +"ui\common\dialogs\score-aeon\text-box_bmp_T.dds" , "628" , "36" , "dds" +"ui\common\dialogs\score-aeon\text-box_bmp.dds" , "636" , "240" , "dds" +"ui\common\dialogs\score-cybran\back_brd_horz_lml.dds" , "8" , "76" , "dds" +"ui\common\dialogs\score-cybran\back_brd_horz_um.dds" , "880" , "104" , "dds" +"ui\common\dialogs\score-cybran\back_brd_horz_uml.dds" , "8" , "32" , "dds" +"ui\common\dialogs\score-cybran\back_brd_horz_umr.dds" , "8" , "32" , "dds" +"ui\common\dialogs\score-cybran\back_brd_ll.dds" , "312" , "116" , "dds" +"ui\common\dialogs\score-cybran\back_brd_lr.dds" , "704" , "116" , "dds" +"ui\common\dialogs\score-cybran\back_brd_ul.dds" , "64" , "104" , "dds" +"ui\common\dialogs\score-cybran\back_brd_ur.dds" , "64" , "104" , "dds" +"ui\common\dialogs\score-cybran\background_bmp.dds" , "1024" , "768" , "dds" +"ui\common\dialogs\score-cybran\icon_experimental.dds" , "28" , "28" , "dds" +"ui\common\dialogs\score-cybran\icon-air_bmp.dds" , "28" , "28" , "dds" +"ui\common\dialogs\score-cybran\icon-cdr_bmp.dds" , "28" , "28" , "dds" +"ui\common\dialogs\score-cybran\icon-energy_bmp.dds" , "20" , "20" , "dds" +"ui\common\dialogs\score-cybran\icon-kill-death_bmp.dds" , "24" , "24" , "dds" +"ui\common\dialogs\score-cybran\icon-kills_bmp.dds" , "20" , "20" , "dds" +"ui\common\dialogs\score-cybran\icon-land_bmp.dds" , "28" , "28" , "dds" +"ui\common\dialogs\score-cybran\icon-loss_bmp.dds" , "20" , "20" , "dds" +"ui\common\dialogs\score-cybran\icon-mass_bmp.dds" , "20" , "20" , "dds" +"ui\common\dialogs\score-cybran\icon-naval_bmp.dds" , "28" , "28" , "dds" +"ui\common\dialogs\score-cybran\icon-structure_bmp.dds" , "28" , "28" , "dds" +"ui\common\dialogs\score-cybran\panel_bmp.dds" , "956" , "620" , "dds" +"ui\common\dialogs\score-cybran\panels_bmp.dds" , "964" , "628" , "dds" +"ui\common\dialogs\score-cybran\text-box_bmp_b.dds" , "628" , "16" , "dds" +"ui\common\dialogs\score-cybran\text-box_bmp_m.dds" , "628" , "8" , "dds" +"ui\common\dialogs\score-cybran\text-box_bmp_T.dds" , "628" , "36" , "dds" +"ui\common\dialogs\score-cybran\text-box_bmp.dds" , "636" , "240" , "dds" +"ui\common\dialogs\score-overlay\clock_bmp.dds" , "28" , "28" , "dds" +"ui\common\dialogs\score-overlay\factional-logo-aeon_bmp.dds" , "16" , "20" , "dds" +"ui\common\dialogs\score-overlay\factional-logo-cybran_bmp.dds" , "20" , "20" , "dds" +"ui\common\dialogs\score-overlay\factional-logo-uef_bmp.dds" , "20" , "20" , "dds" +"ui\common\dialogs\score-overlay\panel_bmp.dds" , "428" , "260" , "dds" +"ui\common\dialogs\score-overlay\score_brd_horz_um.dds" , "8" , "44" , "dds" +"ui\common\dialogs\score-overlay\score_brd_ll.dds" , "48" , "44" , "dds" +"ui\common\dialogs\score-overlay\score_brd_lm.dds" , "8" , "44" , "dds" +"ui\common\dialogs\score-overlay\score_brd_lr.dds" , "48" , "44" , "dds" +"ui\common\dialogs\score-overlay\score_brd_m.dds" , "8" , "24" , "dds" +"ui\common\dialogs\score-overlay\score_brd_ul.dds" , "48" , "44" , "dds" +"ui\common\dialogs\score-overlay\score_brd_ur.dds" , "48" , "44" , "dds" +"ui\common\dialogs\score-overlay\score_brd_vert_l.dds" , "48" , "24" , "dds" +"ui\common\dialogs\score-overlay\score_brd_vert_r.dds" , "24" , "24" , "dds" +"ui\common\dialogs\score-overlay\tank_bmp.dds" , "40" , "20" , "dds" +"ui\common\dialogs\score-uef\back_brd_horz_lm.dds" , "880" , "120" , "dds" +"ui\common\dialogs\score-uef\back_brd_horz_lml.dds" , "8" , "88" , "dds" +"ui\common\dialogs\score-uef\back_brd_horz_lmr.dds" , "8" , "112" , "dds" +"ui\common\dialogs\score-uef\back_brd_horz_um.dds" , "880" , "76" , "dds" +"ui\common\dialogs\score-uef\back_brd_horz_uml.dds" , "8" , "60" , "dds" +"ui\common\dialogs\score-uef\back_brd_horz_umr.dds" , "8" , "60" , "dds" +"ui\common\dialogs\score-uef\back_brd_ll.dds" , "312" , "120" , "dds" +"ui\common\dialogs\score-uef\back_brd_lr.dds" , "704" , "120" , "dds" +"ui\common\dialogs\score-uef\back_brd_ul.dds" , "64" , "92" , "dds" +"ui\common\dialogs\score-uef\back_brd_ur.dds" , "64" , "92" , "dds" +"ui\common\dialogs\score-uef\background_bmp.dds" , "1024" , "768" , "dds" +"ui\common\dialogs\score-uef\icon_experimental.dds" , "28" , "28" , "dds" +"ui\common\dialogs\score-uef\icon-air_bmp.dds" , "28" , "28" , "dds" +"ui\common\dialogs\score-uef\icon-cdr_bmp.dds" , "28" , "28" , "dds" +"ui\common\dialogs\score-uef\icon-energy_bmp.dds" , "20" , "20" , "dds" +"ui\common\dialogs\score-uef\icon-kill-death_bmp.dds" , "24" , "24" , "dds" +"ui\common\dialogs\score-uef\icon-kills_bmp.dds" , "20" , "20" , "dds" +"ui\common\dialogs\score-uef\icon-land_bmp.dds" , "28" , "28" , "dds" +"ui\common\dialogs\score-uef\icon-loss_bmp.dds" , "20" , "20" , "dds" +"ui\common\dialogs\score-uef\icon-mass_bmp.dds" , "20" , "20" , "dds" +"ui\common\dialogs\score-uef\icon-naval_bmp.dds" , "28" , "28" , "dds" +"ui\common\dialogs\score-uef\icon-structure_bmp.dds" , "28" , "28" , "dds" +"ui\common\dialogs\score-uef\panels_bmp.dds" , "964" , "628" , "dds" +"ui\common\dialogs\score-uef\text-box_bmp_b.dds" , "628" , "16" , "dds" +"ui\common\dialogs\score-uef\text-box_bmp_m.dds" , "628" , "8" , "dds" +"ui\common\dialogs\score-uef\text-box_bmp_T.dds" , "628" , "36" , "dds" +"ui\common\dialogs\score-uef\text-box_bmp.dds" , "636" , "240" , "dds" +"ui\common\dialogs\score-victory-defeat-border\back_brd_horz_lml.dds" , "8" , "84" , "dds" +"ui\common\dialogs\score-victory-defeat-border\back_brd_horz_um.dds" , "928" , "68" , "dds" +"ui\common\dialogs\score-victory-defeat-border\back_brd_horz_uml.dds" , "8" , "56" , "dds" +"ui\common\dialogs\score-victory-defeat-border\back_brd_horz_umr.dds" , "8" , "56" , "dds" +"ui\common\dialogs\score-victory-defeat-border\back_brd_ll.dds" , "372" , "140" , "dds" +"ui\common\dialogs\score-victory-defeat-border\back_brd_lr.dds" , "644" , "140" , "dds" +"ui\common\dialogs\score-victory-defeat-border\back_brd_ul.dds" , "40" , "120" , "dds" +"ui\common\dialogs\score-victory-defeat-border\back_brd_ur.dds" , "40" , "120" , "dds" +"ui\common\dialogs\score-victory-defeat-border\background_bmp.dds" , "1024" , "768" , "dds" +"ui\common\dialogs\score-victory-defeat\histograph-back_bmp.dds" , "976" , "336" , "dds" +"ui\common\dialogs\score-victory-defeat\panel_bmp.dds" , "1024" , "608" , "dds" +"ui\common\dialogs\score-victory-defeat\player-back_bmp.dds" , "956" , "36" , "dds" +"ui\common\dialogs\score-victory-defeat\player-back02_bmp.dds" , "264" , "220" , "dds" +"ui\common\dialogs\score-victory-defeat\rect_bmp.dds" , "84" , "36" , "dds" +"ui\common\dialogs\score-victory-defeat\totals-back_bmp.dds" , "392" , "48" , "dds" +"ui\common\dialogs\slider_btn\energy-bar-edge_btn_dis.dds" , "64" , "84" , "dds" +"ui\common\dialogs\slider_btn\energy-bar-edge_btn_down.dds" , "64" , "84" , "dds" +"ui\common\dialogs\slider_btn\energy-bar-edge_btn_over.dds" , "64" , "84" , "dds" +"ui\common\dialogs\slider_btn\energy-bar-edge_btn_up.dds" , "16" , "56" , "dds" +"ui\common\dialogs\slider_btn\mass-bar-edge_btn_dis.dds" , "64" , "84" , "dds" +"ui\common\dialogs\slider_btn\mass-bar-edge_btn_down.dds" , "64" , "84" , "dds" +"ui\common\dialogs\slider_btn\mass-bar-edge_btn_over.dds" , "64" , "84" , "dds" +"ui\common\dialogs\slider_btn\mass-bar-edge_btn_up.dds" , "16" , "56" , "dds" +"ui\common\dialogs\sort_btn\sort_btn_dis_l.dds" , "12" , "24" , "dds" +"ui\common\dialogs\sort_btn\sort_btn_dis_m.dds" , "12" , "24" , "dds" +"ui\common\dialogs\sort_btn\sort_btn_dis_r.dds" , "12" , "24" , "dds" +"ui\common\dialogs\sort_btn\sort_btn_down_l.dds" , "12" , "24" , "dds" +"ui\common\dialogs\sort_btn\sort_btn_down_m.dds" , "12" , "24" , "dds" +"ui\common\dialogs\sort_btn\sort_btn_down_r.dds" , "12" , "24" , "dds" +"ui\common\dialogs\sort_btn\sort_btn_over_l.dds" , "12" , "24" , "dds" +"ui\common\dialogs\sort_btn\sort_btn_over_m.dds" , "12" , "24" , "dds" +"ui\common\dialogs\sort_btn\sort_btn_over_r.dds" , "12" , "24" , "dds" +"ui\common\dialogs\sort_btn\sort_btn_up_l.dds" , "12" , "24" , "dds" +"ui\common\dialogs\sort_btn\sort_btn_up_m.dds" , "12" , "24" , "dds" +"ui\common\dialogs\sort_btn\sort_btn_up_r.dds" , "12" , "24" , "dds" +"ui\common\dialogs\sort_btn\sort-arrow-down_bmp.dds" , "24" , "16" , "dds" +"ui\common\dialogs\sort_btn\sort-arrow-up_bmp.dds" , "24" , "16" , "dds" +"ui\common\dialogs\standard_btn\standard_btn_dis.dds" , "112" , "48" , "dds" +"ui\common\dialogs\standard_btn\standard_btn_down.dds" , "112" , "48" , "dds" +"ui\common\dialogs\standard_btn\standard_btn_over.dds" , "112" , "48" , "dds" +"ui\common\dialogs\standard_btn\standard_btn_up.dds" , "112" , "48" , "dds" +"ui\common\dialogs\standard-small_btn\standard-small_btn_dis.dds" , "96" , "32" , "dds" +"ui\common\dialogs\standard-small_btn\standard-small_btn_down.dds" , "96" , "32" , "dds" +"ui\common\dialogs\standard-small_btn\standard-small_btn_over.dds" , "96" , "32" , "dds" +"ui\common\dialogs\standard-small_btn\standard-small_btn_up.dds" , "96" , "32" , "dds" +"ui\common\dialogs\sub-tab_btn\sub-tab_btn_dis.dds" , "120" , "28" , "dds" +"ui\common\dialogs\sub-tab_btn\sub-tab_btn_down.dds" , "120" , "28" , "dds" +"ui\common\dialogs\sub-tab_btn\sub-tab_btn_over.dds" , "120" , "28" , "dds" +"ui\common\dialogs\sub-tab_btn\sub-tab_btn_selected.dds" , "120" , "28" , "dds" +"ui\common\dialogs\sub-tab_btn\sub-tab_btn_up.dds" , "120" , "28" , "dds" +"ui\common\dialogs\tab_btn\campaign_btn_dis.dds" , "120" , "48" , "dds" +"ui\common\dialogs\tab_btn\campaign_btn_down.dds" , "120" , "48" , "dds" +"ui\common\dialogs\tab_btn\campaign_btn_over.dds" , "120" , "48" , "dds" +"ui\common\dialogs\tab_btn\campaign_btn_selected.dds" , "120" , "48" , "dds" +"ui\common\dialogs\tab_btn\campaign_btn_up.dds" , "120" , "48" , "dds" +"ui\common\dialogs\tab_btn\general_btn_dis.dds" , "148" , "40" , "dds" +"ui\common\dialogs\tab_btn\general_btn_down.dds" , "148" , "40" , "dds" +"ui\common\dialogs\tab_btn\general_btn_over.dds" , "148" , "40" , "dds" +"ui\common\dialogs\tab_btn\general_btn_selected.dds" , "148" , "40" , "dds" +"ui\common\dialogs\tab_btn\general_btn_up.dds" , "148" , "40" , "dds" +"ui\common\dialogs\tab_btn\resources_btn_dis.dds" , "148" , "32" , "dds" +"ui\common\dialogs\tab_btn\resources_btn_down.dds" , "148" , "32" , "dds" +"ui\common\dialogs\tab_btn\resources_btn_over.dds" , "148" , "32" , "dds" +"ui\common\dialogs\tab_btn\resources_btn_selected.dds" , "148" , "32" , "dds" +"ui\common\dialogs\tab_btn\resources_btn_up.dds" , "148" , "32" , "dds" +"ui\common\dialogs\tab_btn\tab_btn_dis.dds" , "148" , "40" , "dds" +"ui\common\dialogs\tab_btn\tab_btn_down.dds" , "148" , "40" , "dds" +"ui\common\dialogs\tab_btn\tab_btn_over.dds" , "148" , "40" , "dds" +"ui\common\dialogs\tab_btn\tab_btn_selected.dds" , "148" , "40" , "dds" +"ui\common\dialogs\tab_btn\tab_btn_up.dds" , "148" , "40" , "dds" +"ui\common\dialogs\tab_btn\units_btn_dis.dds" , "120" , "48" , "dds" +"ui\common\dialogs\tab_btn\units_btn_down.dds" , "120" , "48" , "dds" +"ui\common\dialogs\tab_btn\units_btn_over.dds" , "120" , "48" , "dds" +"ui\common\dialogs\tab_btn\units_btn_selected.dds" , "120" , "48" , "dds" +"ui\common\dialogs\tab_btn\units_btn_up.dds" , "120" , "48" , "dds" +"ui\common\dialogs\time-units-tabs\energy_bmp.dds" , "12" , "24" , "dds" +"ui\common\dialogs\time-units-tabs\panel-time_bmp.dds" , "208" , "48" , "dds" +"ui\common\dialogs\time-units-tabs\panel-tracking_bmp_d.dds" , "12" , "44" , "dds" +"ui\common\dialogs\time-units-tabs\panel-tracking_bmp_l.dds" , "24" , "44" , "dds" +"ui\common\dialogs\time-units-tabs\panel-tracking_bmp_m.dds" , "4" , "44" , "dds" +"ui\common\dialogs\time-units-tabs\panel-tracking_bmp_r.dds" , "24" , "44" , "dds" +"ui\common\dialogs\time-units-tabs\panel-tracking_bmp.dds" , "120" , "48" , "dds" +"ui\common\dialogs\time-units-tabs\panel-units_bmp.dds" , "208" , "48" , "dds" +"ui\common\dialogs\time-units-tabs\tracking-icon_bmp.dds" , "24" , "20" , "dds" +"ui\common\dialogs\toggle_btn\toggle-d_btn_dis.dds" , "84" , "28" , "dds" +"ui\common\dialogs\toggle_btn\toggle-d_btn_down.dds" , "84" , "28" , "dds" +"ui\common\dialogs\toggle_btn\toggle-d_btn_over.dds" , "84" , "28" , "dds" +"ui\common\dialogs\toggle_btn\toggle-d_btn_up.dds" , "84" , "28" , "dds" +"ui\common\dialogs\toggle_btn\toggle-s_btn_dis.dds" , "84" , "28" , "dds" +"ui\common\dialogs\toggle_btn\toggle-s_btn_down.dds" , "84" , "28" , "dds" +"ui\common\dialogs\toggle_btn\toggle-s_btn_over.dds" , "84" , "28" , "dds" +"ui\common\dialogs\toggle_btn\toggle-s_btn_up.dds" , "84" , "28" , "dds" +"ui\common\dialogs\transmision-log\panel_bmp.dds" , "888" , "532" , "dds" +"ui\common\dialogs\transmission-incoming\panel_bmp_b.dds" , "404" , "24" , "dds" +"ui\common\dialogs\transmission-incoming\panel_bmp_l.dds" , "136" , "136" , "dds" +"ui\common\dialogs\transmission-incoming\panel_bmp_m.dds" , "404" , "20" , "dds" +"ui\common\dialogs\transmission-incoming\panel_bmp_t.dds" , "404" , "40" , "dds" +"ui\common\dialogs\transmission-incoming\panel_bmp.dds" , "356" , "120" , "dds" +"ui\common\dialogs\transmission-incoming\panel-white_bmp.dds" , "356" , "120" , "dds" +"ui\common\dialogs\unit-naming\unit-name_bmp.dds" , "340" , "88" , "dds" +"ui\common\dialogs\zoom_btn\zoom_btn_dis.dds" , "24" , "24" , "dds" +"ui\common\dialogs\zoom_btn\zoom_btn_down.dds" , "24" , "24" , "dds" +"ui\common\dialogs\zoom_btn\zoom_btn_over.dds" , "24" , "24" , "dds" +"ui\common\dialogs\zoom_btn\zoom_btn_up.dds" , "24" , "24" , "dds" +"ui\common\faction_icon-lg\aeon_ico.dds" , "60" , "60" , "dds" +"ui\common\faction_icon-lg\cybran_ico.dds" , "60" , "60" , "dds" +"ui\common\faction_icon-lg\seraphim_ico.dds" , "60" , "60" , "dds" +"ui\common\faction_icon-lg\uef_ico.dds" , "60" , "60" , "dds" +"ui\common\faction_icon-sm\aeon_ico.dds" , "24" , "24" , "dds" +"ui\common\faction_icon-sm\cybran_ico.dds" , "24" , "24" , "dds" +"ui\common\faction_icon-sm\random_ico.dds" , "24" , "24" , "dds" +"ui\common\faction_icon-sm\seraphim_ico.dds" , "24" , "24" , "dds" +"ui\common\faction_icon-sm\uef_ico.dds" , "24" , "24" , "dds" +"ui\common\filters\strategic-filters\filter_defense-dashed.dds" , "16" , "16" , "dds" +"ui\common\filters\strategic-filters\filter_defense.dds" , "16" , "16" , "dds" +"ui\common\filters\strategic-filters\filter_economy-dashed.dds" , "16" , "16" , "dds" +"ui\common\filters\strategic-filters\filter_economy.dds" , "16" , "16" , "dds" +"ui\common\filters\strategic-filters\filter_intel-dashed.dds" , "16" , "16" , "dds" +"ui\common\filters\strategic-filters\filter_intel.dds" , "16" , "16" , "dds" +"ui\common\filters\strategic-filters\filter_military-dashed.dds" , "16" , "16" , "dds" +"ui\common\filters\strategic-filters\filter_military.dds" , "16" , "16" , "dds" +"ui\common\filters\strategic-filters\filter-dashed -black.dds" , "16" , "16" , "dds" +"ui\common\filters\strategic-filters\filter-dashed.dds" , "8" , "8" , "dds" +"ui\common\filters\strategic-filters\filter-solid-black.dds" , "16" , "16" , "dds" +"ui\common\filters\strategic-filters\filter-solid.dds" , "16" , "8" , "dds" +"ui\common\game\aeon-enhancements\aes_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\aes_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\aes_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\aes_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\cba_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\cba_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\cba_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\cba_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\cd_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\cd_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\cd_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\cd_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\ees_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\ees_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\ees_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\ees_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\efm_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\efm_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\efm_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\efm_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\eras_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\eras_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\eras_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\eras_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\ess_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\ess_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\ess_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\ess_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\hsa_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\hsa_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\hsa_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\hsa_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\htsg_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\htsg_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\htsg_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\htsg_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\phtsg_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\phtsg_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\phtsg_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\phtsg_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\pqt_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\pqt_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\pqt_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\pqt_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\ptsg_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\ptsg_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\ptsg_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\ptsg_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\ras_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\ras_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\ras_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\ras_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\sic_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\sic_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\sic_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\sic_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\sp_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\sp_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\sp_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\sp_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\ss_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\ss_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\ss_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\ss_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\tsg_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\tsg_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\tsg_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\tsg_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\AreaTargetDecal\nuke_icon_small.dds" , "400" , "400" , "dds" +"ui\common\game\AreaTargetDecal\weapon_icon_small.dds" , "128" , "128" , "dds" +"ui\common\game\avatar-tab-btn\tab-close_btn_dis.dds" , "24" , "44" , "dds" +"ui\common\game\avatar-tab-btn\tab-close_btn_down.dds" , "24" , "44" , "dds" +"ui\common\game\avatar-tab-btn\tab-close_btn_over.dds" , "24" , "44" , "dds" +"ui\common\game\avatar-tab-btn\tab-close_btn_up.dds" , "24" , "44" , "dds" +"ui\common\game\avatar-tab-btn\tab-open_btn_dis.dds" , "24" , "44" , "dds" +"ui\common\game\avatar-tab-btn\tab-open_btn_down.dds" , "24" , "44" , "dds" +"ui\common\game\avatar-tab-btn\tab-open_btn_over.dds" , "24" , "44" , "dds" +"ui\common\game\avatar-tab-btn\tab-open_btn_up.dds" , "24" , "44" , "dds" +"ui\common\game\avatar\avatar_bmp.dds" , "76" , "88" , "dds" +"ui\common\game\avatar\health-bar-back_bmp.dds" , "64" , "16" , "dds" +"ui\common\game\avatar\health-bar-green.dds" , "56" , "12" , "dds" +"ui\common\game\avatar\health-bar-red.dds" , "56" , "8" , "dds" +"ui\common\game\avatar\health-bar-yellow.dds" , "56" , "8" , "dds" +"ui\common\game\avatar\health-bar.dds" , "56" , "12" , "dds" +"ui\common\game\avatar\pulse-bars_bmp.dds" , "80" , "80" , "dds" +"ui\common\game\avatar\tech-tab_bmp.dds" , "32" , "28" , "dds" +"ui\common\game\beacons\beacon-aeon-transport_btn_up.dds" , "128" , "128" , "dds" +"ui\common\game\beacons\beacon-quantum-gate_btn_up.dds" , "128" , "128" , "dds" +"ui\common\game\border_bottom\back_brd_b-os.dds" , "4" , "12" , "dds" +"ui\common\game\border_bottom\back_brd_b.dds" , "4" , "216" , "dds" +"ui\common\game\border_bottom\back_brd_bl.dds" , "48" , "48" , "dds" +"ui\common\game\border_bottom\back_brd_bl02.dds" , "492" , "96" , "dds" +"ui\common\game\border_bottom\back_brd_br.dds" , "48" , "48" , "dds" +"ui\common\game\border_bottom\back_brd_l-os.dds" , "8" , "4" , "dds" +"ui\common\game\border_bottom\back_brd_l.dds" , "4" , "4" , "dds" +"ui\common\game\border_bottom\back_brd_r-os.dds" , "8" , "4" , "dds" +"ui\common\game\border_bottom\back_brd_r.dds" , "4" , "4" , "dds" +"ui\common\game\border_bottom\back_brd_t-os.dds" , "4" , "8" , "dds" +"ui\common\game\border_bottom\back_brd_t.dds" , "4" , "44" , "dds" +"ui\common\game\border_bottom\back_brd_tl.dds" , "48" , "48" , "dds" +"ui\common\game\border_bottom\back_brd_tr.dds" , "48" , "48" , "dds" +"ui\common\game\border_left\back_brd_b-os.dds" , "4" , "8" , "dds" +"ui\common\game\border_left\back_brd_b.dds" , "4" , "4" , "dds" +"ui\common\game\border_left\back_brd_bl.dds" , "48" , "48" , "dds" +"ui\common\game\border_left\back_brd_br.dds" , "48" , "48" , "dds" +"ui\common\game\border_left\back_brd_l-os.dds" , "8" , "4" , "dds" +"ui\common\game\border_left\back_brd_l.dds" , "244" , "4" , "dds" +"ui\common\game\border_left\back_brd_r-os.dds" , "8" , "4" , "dds" +"ui\common\game\border_left\back_brd_r.dds" , "4" , "4" , "dds" +"ui\common\game\border_left\back_brd_t-os.dds" , "4" , "8" , "dds" +"ui\common\game\border_left\back_brd_t.dds" , "4" , "44" , "dds" +"ui\common\game\border_left\back_brd_tl.dds" , "48" , "48" , "dds" +"ui\common\game\border_left\back_brd_tr.dds" , "48" , "48" , "dds" +"ui\common\game\border_right\back_brd_b-os.dds" , "4" , "8" , "dds" +"ui\common\game\border_right\back_brd_b.dds" , "4" , "4" , "dds" +"ui\common\game\border_right\back_brd_bl.dds" , "48" , "48" , "dds" +"ui\common\game\border_right\back_brd_br.dds" , "48" , "48" , "dds" +"ui\common\game\border_right\back_brd_l-os.dds" , "8" , "4" , "dds" +"ui\common\game\border_right\back_brd_l.dds" , "4" , "4" , "dds" +"ui\common\game\border_right\back_brd_r-os.dds" , "8" , "4" , "dds" +"ui\common\game\border_right\back_brd_r.dds" , "244" , "4" , "dds" +"ui\common\game\border_right\back_brd_t-os.dds" , "4" , "8" , "dds" +"ui\common\game\border_right\back_brd_t.dds" , "4" , "44" , "dds" +"ui\common\game\border_right\back_brd_tl.dds" , "48" , "48" , "dds" +"ui\common\game\border_right\back_brd_tr.dds" , "48" , "48" , "dds" +"ui\common\game\build-ui-02\abilities_bmp_b.dds" , "136" , "12" , "dds" +"ui\common\game\build-ui-02\abilities_bmp_m.dds" , "136" , "12" , "dds" +"ui\common\game\build-ui-02\abilities_bmp_t.dds" , "136" , "36" , "dds" +"ui\common\game\build-ui-02\abilities-panel_bmp.dds" , "144" , "92" , "dds" +"ui\common\game\build-ui-02\bar01_bmp.dds" , "76" , "8" , "dds" +"ui\common\game\build-ui-02\build-over_bmp_l.dds" , "284" , "96" , "dds" +"ui\common\game\build-ui-02\build-over_bmp_m.dds" , "12" , "96" , "dds" +"ui\common\game\build-ui-02\build-over_bmp_r.dds" , "200" , "96" , "dds" +"ui\common\game\build-ui-02\build-over_bmp.dds" , "492" , "100" , "dds" +"ui\common\game\build-ui-02\build-over-abilities_bmp _m.dds" , "408" , "8" , "dds" +"ui\common\game\build-ui-02\build-over-abilities_bmp _t.dds" , "408" , "24" , "dds" +"ui\common\game\build-ui-02\build-over-abilities_bmp_b.dds" , "408" , "12" , "dds" +"ui\common\game\build-ui-02\build-over-abilities_bmp.dds" , "416" , "64" , "dds" +"ui\common\game\build-ui-02\description_bmp_b.dds" , "344" , "40" , "dds" +"ui\common\game\build-ui-02\description_bmp_m.dds" , "344" , "12" , "dds" +"ui\common\game\build-ui-02\description_bmp_t.dds" , "344" , "36" , "dds" +"ui\common\game\build-ui-02\description-panel_bmp.dds" , "352" , "92" , "dds" +"ui\common\game\build-ui-02\health-bars-back-1_bmp.dds" , "76" , "8" , "dds" +"ui\common\game\build-ui-02\icon-clock_bmp.dds" , "16" , "16" , "dds" +"ui\common\game\build-ui-02\icon-energy_bmp.dds" , "20" , "20" , "dds" +"ui\common\game\build-ui-02\icon-health_bmp.dds" , "16" , "16" , "dds" +"ui\common\game\build-ui-02\icon-mass_bmp.dds" , "20" , "20" , "dds" +"ui\common\game\build-ui\abilities_bmp_b.dds" , "136" , "12" , "dds" +"ui\common\game\build-ui\abilities_bmp_l.dds" , "44" , "88" , "dds" +"ui\common\game\build-ui\abilities_bmp_m.dds" , "136" , "12" , "dds" +"ui\common\game\build-ui\abilities_bmp_r.dds" , "80" , "88" , "dds" +"ui\common\game\build-ui\abilities_bmp_t.dds" , "136" , "36" , "dds" +"ui\common\game\build-ui\abilities-panel_bmp.dds" , "144" , "92" , "dds" +"ui\common\game\build-ui\bar01_bmp.dds" , "76" , "8" , "dds" +"ui\common\game\build-ui\build-over_bmp_l.dds" , "280" , "88" , "dds" +"ui\common\game\build-ui\build-over_bmp_m.dds" , "12" , "88" , "dds" +"ui\common\game\build-ui\build-over_bmp_r.dds" , "132" , "88" , "dds" +"ui\common\game\build-ui\build-over_bmp.dds" , "428" , "96" , "dds" +"ui\common\game\build-ui\description_bmp_b.dds" , "344" , "12" , "dds" +"ui\common\game\build-ui\description_bmp_m.dds" , "344" , "12" , "dds" +"ui\common\game\build-ui\description_bmp_t.dds" , "344" , "36" , "dds" +"ui\common\game\build-ui\description-panel_bmp.dds" , "352" , "92" , "dds" +"ui\common\game\build-ui\health-bars-back-1_bmp.dds" , "76" , "8" , "dds" +"ui\common\game\build-ui\icon-clock_bmp.dds" , "16" , "16" , "dds" +"ui\common\game\build-ui\icon-energy_bmp.dds" , "20" , "20" , "dds" +"ui\common\game\build-ui\icon-health_bmp.dds" , "16" , "16" , "dds" +"ui\common\game\build-ui\icon-mass_bmp.dds" , "20" , "20" , "dds" +"ui\common\game\chat_brd\chat_brd_horz_um.dds" , "20" , "20" , "dds" +"ui\common\game\chat_brd\chat_brd_ll.dds" , "20" , "20" , "dds" +"ui\common\game\chat_brd\chat_brd_lm.dds" , "20" , "20" , "dds" +"ui\common\game\chat_brd\chat_brd_lr.dds" , "20" , "20" , "dds" +"ui\common\game\chat_brd\chat_brd_m.dds" , "20" , "20" , "dds" +"ui\common\game\chat_brd\chat_brd_ul.dds" , "20" , "20" , "dds" +"ui\common\game\chat_brd\chat_brd_ur.dds" , "20" , "20" , "dds" +"ui\common\game\chat_brd\chat_brd_vert_l.dds" , "20" , "20" , "dds" +"ui\common\game\chat_brd\chat_brd_vert_r.dds" , "20" , "20" , "dds" +"ui\common\game\chat_brd05\chat_brd_horz_um.dds" , "12" , "44" , "dds" +"ui\common\game\chat_brd05\chat_brd_ll.dds" , "120" , "68" , "dds" +"ui\common\game\chat_brd05\chat_brd_lm.dds" , "12" , "68" , "dds" +"ui\common\game\chat_brd05\chat_brd_lr.dds" , "44" , "68" , "dds" +"ui\common\game\chat_brd05\chat_brd_m.dds" , "12" , "12" , "dds" +"ui\common\game\chat_brd05\chat_brd_ul.dds" , "44" , "44" , "dds" +"ui\common\game\chat_brd05\chat_brd_ur.dds" , "44" , "44" , "dds" +"ui\common\game\chat_brd05\chat_brd_vert_l.dds" , "44" , "12" , "dds" +"ui\common\game\chat_brd05\chat_brd_vert_r.dds" , "44" , "12" , "dds" +"ui\common\game\cursors\attack_coordinated.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\attack-01.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\attack-02.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\attack-03.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\attack-04.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\attack-05.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\attack-06.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\attack-07.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\attack-08.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\attack-09.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\attack-10.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\attack-11.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\attack-coordinated-invalid.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\attack-coordinated.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\attack-invalid.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\attack.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\attack01.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\attack02.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\attack03.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\attack04.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\attack05.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\attack06.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\attack07.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\attack08.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\capture-01.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\capture-02.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\capture-03.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\capture-04.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\capture-05.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\capture-06.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\capture-07.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\capture-08.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\capture-09.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\capture-invalid.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\capture.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\construct-invalid.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\construct.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\construct02-01.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\construct02-02.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\construct02-03.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\construct02-04.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\construct02-05.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\construct02-06.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\construct02-07.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\construct02-08.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\construct02-09.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\construct02-10.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\construct02-11.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\construct02-12.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\construct02-13.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\construct02-14.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\ferry-invalid.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\ferry.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\guard-01.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\guard-02.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\guard-03.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\guard-04.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\guard-05.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\guard-06.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\guard-07.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\guard-08.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\guard-09.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\guard-10.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\guard-invalid.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\guard.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\launch-invalid.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\launch.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\load-01.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\load-02.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\load-03.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\load-04.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\load-05.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\load-06.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\load-07.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\load-08.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\load-09.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\load-10.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\load-11.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\load-12.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\load-13.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\load-14.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\load-invalid.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\load.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\message-01.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\message-02.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\message-03.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\message-04.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\message-05.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\message-06.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\message-07.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\message-08.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\message-09.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\message-10.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\message-11.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\move_window.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\move-01.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\move-02.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\move-03.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\move-04.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\move-05.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\move-06.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\move-07.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\move-08.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\move-09.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\move-10.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\move-11.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\move-12.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\move-invalid.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\move.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\n_s.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\ne_sw.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\nw_se.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\overcharge-01.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\overcharge-02.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\overcharge-03.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\overcharge-04.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\overcharge-05.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\overcharge-06.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\overcharge-07.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\overcharge-08.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\patrol-01.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\patrol-02.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\patrol-03.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\patrol-04.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\patrol-05.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\patrol-invalid.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\patrol.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\reclaim-invalid.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\reclaim.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\reclaim02-01.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\reclaim02-02.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\reclaim02-03.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\reclaim02-04.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\reclaim02-05.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\reclaim02-06.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\reclaim02-07.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\reclaim02-08.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\reclaim02-09.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\reclaim02-10.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\reclaim02-11.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\reclaim02-12.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\reclaim02-13.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\reclaim02-14.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\reclaim02-15.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\reclaim02-16.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\reclaim02-17.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\reclaim02-18.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\reclaim02-19.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\reclaim02-20.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\reclaim02-21.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\reclaim02-22.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\reclaim02-23.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\repair-01.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\repair-02.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\repair-03.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\repair-04.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\repair-05.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\repair-06.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\repair-07.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\repair-invalid.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\repair.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\sacrifice-01.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\sacrifice-02.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\sacrifice-03.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\sacrifice-04.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\sacrifice-05.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\sacrifice-06.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\sacrifice-07.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\sacrifice-08.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\sacrifice-09.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\sacrifice-10.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\sacrifice-11.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\sacrifice-12.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\sacrifice-13.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\sacrifice.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\selectable-01.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\selectable-02.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\selectable-03.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\selectable-04.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\selectable-05.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\selectable-06.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\selectable-07.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\selectable-invalid.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\selectable.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\transport-invalid.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\transport.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\unload-01.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\unload-02.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\unload-03.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\unload-04.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\unload-05.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\unload-06.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\unload-07.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\unload-08.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\unload-09.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\unload-10.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\unload-11.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\unload-12.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\unload-13.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\unload-14.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\unload-invalid.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\unload.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\w_e.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\waypoint-drag.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\waypoint-hover.dds" , "32" , "32" , "dds" +"ui\common\game\cybran-enhancements\acu_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\acu_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\acu_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\acu_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\aes_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\aes_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\aes_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\aes_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\cfs_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\cfs_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\cfs_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\cfs_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\ees_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\ees_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\ees_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\ees_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\emp_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\emp_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\emp_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\emp_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\eras_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\eras_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\eras_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\eras_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\fc_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\fc_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\fc_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\fc_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\mlg_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\mlg_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\mlg_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\mlg_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\nms_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\nms_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\nms_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\nms_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\ntt_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\ntt_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\ntt_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\ntt_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\pcg_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\pcg_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\pcg_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\pcg_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\pqt_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\pqt_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\pqt_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\pqt_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\psg_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\psg_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\psg_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\psg_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\ras_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\ras_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\ras_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\ras_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\ses_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\ses_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\ses_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\ses_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\sfs_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\sfs_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\sfs_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\sfs_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\srs_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\srs_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\srs_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\srs_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\economic-overlay\econ_bmp_l.dds" , "24" , "32" , "dds" +"ui\common\game\economic-overlay\econ_bmp_m.dds" , "4" , "32" , "dds" +"ui\common\game\economic-overlay\econ_bmp_r.dds" , "12" , "32" , "dds" +"ui\common\game\generic_brd\generic_brd_horz_lm.dds" , "8" , "8" , "dds" +"ui\common\game\generic_brd\generic_brd_horz_um.dds" , "8" , "8" , "dds" +"ui\common\game\generic_brd\generic_brd_ll.dds" , "8" , "8" , "dds" +"ui\common\game\generic_brd\generic_brd_lr.dds" , "8" , "8" , "dds" +"ui\common\game\generic_brd\generic_brd_m.dds" , "8" , "8" , "dds" +"ui\common\game\generic_brd\generic_brd_ul.dds" , "8" , "8" , "dds" +"ui\common\game\generic_brd\generic_brd_ur.dds" , "8" , "8" , "dds" +"ui\common\game\generic_brd\generic_brd_vert_l.dds" , "8" , "8" , "dds" +"ui\common\game\generic_brd\generic_brd_vert_r.dds" , "8" , "8" , "dds" +"ui\common\game\icon_tech-level\tech-level_open.dds" , "8" , "8" , "dds" +"ui\common\game\icon_tech-level\tech-level_solid.dds" , "8" , "8" , "dds" +"ui\common\game\icons\icon-trash_btn_dis.dds" , "12" , "12" , "dds" +"ui\common\game\icons\icon-trash_btn_dis.png" , "12" , "12" , "png" +"ui\common\game\icons\icon-trash_btn_down.dds" , "16" , "16" , "dds" +"ui\common\game\icons\icon-trash_btn_down.png" , "16" , "16" , "png" +"ui\common\game\icons\icon-trash_btn_over.dds" , "20" , "24" , "dds" +"ui\common\game\icons\icon-trash_btn_over.png" , "20" , "24" , "png" +"ui\common\game\icons\icon-trash_btn_up.dds" , "20" , "20" , "dds" +"ui\common\game\icons\icon-trash_btn_up.png" , "20" , "20" , "png" +"ui\common\game\icons\icon-trash-lg_btn_dis.dds" , "44" , "56" , "dds" +"ui\common\game\icons\icon-trash-lg_btn_dis.png" , "44" , "56" , "png" +"ui\common\game\icons\icon-trash-lg_btn_down.dds" , "56" , "64" , "dds" +"ui\common\game\icons\icon-trash-lg_btn_down.png" , "56" , "64" , "png" +"ui\common\game\icons\icon-trash-lg_btn_over.dds" , "56" , "64" , "dds" +"ui\common\game\icons\icon-trash-lg_btn_over.png" , "56" , "64" , "png" +"ui\common\game\icons\icon-trash-lg_btn_up.dds" , "52" , "64" , "dds" +"ui\common\game\icons\icon-trash-lg_btn_up.png" , "52" , "64" , "png" +"ui\common\game\idle_mini_icon\idle_icon.dds" , "28" , "24" , "dds" +"ui\common\game\infinite_btn\infinite_btn_dis.dds" , "32" , "36" , "dds" +"ui\common\game\infinite_btn\infinite_btn_down.dds" , "32" , "36" , "dds" +"ui\common\game\infinite_btn\infinite_btn_over.dds" , "32" , "36" , "dds" +"ui\common\game\infinite_btn\infinite_btn_up.dds" , "32" , "36" , "dds" +"ui\common\game\large-side_scr\arrow-left_scr_dis.dds" , "64" , "64" , "dds" +"ui\common\game\large-side_scr\arrow-left_scr_down.dds" , "64" , "64" , "dds" +"ui\common\game\large-side_scr\arrow-left_scr_over.dds" , "64" , "64" , "dds" +"ui\common\game\large-side_scr\arrow-left_scr_up.dds" , "64" , "64" , "dds" +"ui\common\game\large-side_scr\arrow-right_scr_dis.dds" , "64" , "64" , "dds" +"ui\common\game\large-side_scr\arrow-right_scr_down.dds" , "64" , "64" , "dds" +"ui\common\game\large-side_scr\arrow-right_scr_over.dds" , "64" , "64" , "dds" +"ui\common\game\large-side_scr\arrow-right_scr_up.dds" , "64" , "64" , "dds" +"ui\common\game\lg-vert_scr\arrow-down_scr_dis.dds" , "36" , "36" , "dds" +"ui\common\game\lg-vert_scr\arrow-down_scr_down.dds" , "36" , "36" , "dds" +"ui\common\game\lg-vert_scr\arrow-down_scr_over.dds" , "36" , "36" , "dds" +"ui\common\game\lg-vert_scr\arrow-down_scr_up.dds" , "36" , "36" , "dds" +"ui\common\game\lg-vert_scr\arrow-up_scr_dis.dds" , "36" , "36" , "dds" +"ui\common\game\lg-vert_scr\arrow-up_scr_down.dds" , "36" , "36" , "dds" +"ui\common\game\lg-vert_scr\arrow-up_scr_over.dds" , "36" , "36" , "dds" +"ui\common\game\lg-vert_scr\arrow-up_scr_up.dds" , "36" , "36" , "dds" +"ui\common\game\marker\ring_blue02-blur.dds" , "200" , "200" , "dds" +"ui\common\game\marker\ring_blue02.dds" , "200" , "200" , "dds" +"ui\common\game\marker\ring_red02-blur.dds" , "200" , "200" , "dds" +"ui\common\game\marker\ring_red02.dds" , "200" , "200" , "dds" +"ui\common\game\marker\ring_yellow02-blur.dds" , "200" , "200" , "dds" +"ui\common\game\marker\ring_yellow02.dds" , "200" , "200" , "dds" +"ui\common\game\mfd_bmp\mfd_bmp.dds" , "220" , "220" , "dds" +"ui\common\game\mfd_bmp\mfd_button_bmp.dds" , "68" , "220" , "dds" +"ui\common\game\mfd_bmp\mfd_glow_bmp.dds" , "160" , "164" , "dds" +"ui\common\game\mfd_bmp\mfd_glow.dds" , "224" , "220" , "dds" +"ui\common\game\mfd_btn\all-airforce_btn_dis.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\all-airforce_btn_down.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\all-airforce_btn_over.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\all-airforce_btn_up.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\all-army_btn_dis.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\all-army_btn_down.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\all-army_btn_over.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\all-army_btn_up.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\all-navy_btn_dis.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\all-navy_btn_down.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\all-navy_btn_over.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\all-navy_btn_up.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\control_btn_dis.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\control_btn_down.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\control_btn_over.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\control_btn_up.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\defenses_btn_dis.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\defenses_btn_down.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\defenses_btn_over.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\defenses_btn_up.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\economy_btn_dis.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\economy_btn_down.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\economy_btn_over.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\economy_btn_up.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\idle-engineer_btn_dis.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\idle-engineer_btn_down.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\idle-engineer_btn_over.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\idle-engineer_btn_up.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\idle-factory_btn_dis.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\idle-factory_btn_down.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\idle-factory_btn_over.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\idle-factory_btn_up.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\intelligence_btn_dis.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\intelligence_btn_down.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\intelligence_btn_over.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\intelligence_btn_up.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\mfd_btn_dis.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\mfd_btn_down.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\mfd_btn_over.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\mfd_btn_up.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\military_btn_dis.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\military_btn_down.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\military_btn_over.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\military_btn_up.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\ping_btn_dis.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\ping_btn_down.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\ping_btn_over.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\ping_btn_up.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\ping-alert_btn_dis.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\ping-alert_btn_down.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\ping-alert_btn_over.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\ping-alert_btn_up.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\ping-attack_btn_dis.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\ping-attack_btn_down.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\ping-attack_btn_over.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\ping-attack_btn_up.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\ping-marker_btn_dis.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\ping-marker_btn_down.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\ping-marker_btn_over.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\ping-marker_btn_up.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\ping-move_btn_dis.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\ping-move_btn_down.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\ping-move_btn_over.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\ping-move_btn_up.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\team-color_btn_dis.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\team-color_btn_down.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\team-color_btn_over.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\team-color_btn_up.dds" , "76" , "48" , "dds" +"ui\common\game\mfd-vertical_bmp\mfd_bmp.dds" , "244" , "220" , "dds" +"ui\common\game\mfd-vertical_bmp\mfd_button_bmp.dds" , "244" , "56" , "dds" +"ui\common\game\mfd-vertical_bmp\mfd_glow.dds" , "224" , "224" , "dds" +"ui\common\game\min-control-tab-btn\tab-close_btn_dis.dds" , "44" , "24" , "dds" +"ui\common\game\min-control-tab-btn\tab-close_btn_down.dds" , "44" , "24" , "dds" +"ui\common\game\min-control-tab-btn\tab-close_btn_over.dds" , "44" , "24" , "dds" +"ui\common\game\min-control-tab-btn\tab-close_btn_up.dds" , "44" , "24" , "dds" +"ui\common\game\min-control-tab-btn\tab-open_btn_dis.dds" , "44" , "24" , "dds" +"ui\common\game\min-control-tab-btn\tab-open_btn_down.dds" , "44" , "24" , "dds" +"ui\common\game\min-control-tab-btn\tab-open_btn_over.dds" , "44" , "24" , "dds" +"ui\common\game\min-control-tab-btn\tab-open_btn_up.dds" , "44" , "24" , "dds" +"ui\common\game\mini-filter-back_bmp\min-filter-back_bmp.dds" , "188" , "32" , "dds" +"ui\common\game\mini-map-brd01\mini-map_brd_horz_um.dds" , "12" , "36" , "dds" +"ui\common\game\mini-map-brd01\mini-map_brd_horz_um02.dds" , "12" , "40" , "dds" +"ui\common\game\mini-map-brd01\mini-map_brd_ll.dds" , "16" , "16" , "dds" +"ui\common\game\mini-map-brd01\mini-map_brd_lm.dds" , "12" , "16" , "dds" +"ui\common\game\mini-map-brd01\mini-map_brd_lr.dds" , "16" , "16" , "dds" +"ui\common\game\mini-map-brd01\mini-map_brd_m.dds" , "12" , "12" , "dds" +"ui\common\game\mini-map-brd01\mini-map_brd_ul.dds" , "36" , "36" , "dds" +"ui\common\game\mini-map-brd01\mini-map_brd_ul02.dds" , "40" , "40" , "dds" +"ui\common\game\mini-map-brd01\mini-map_brd_ur.dds" , "36" , "36" , "dds" +"ui\common\game\mini-map-brd01\mini-map_brd_ur02.dds" , "80" , "40" , "dds" +"ui\common\game\mini-map-brd01\mini-map_brd_vert_l.dds" , "16" , "12" , "dds" +"ui\common\game\mini-map-brd01\mini-map_brd_vert_r.dds" , "16" , "12" , "dds" +"ui\common\game\mini-map-brd01\mini-map-glow_bmp.dds" , "208" , "208" , "dds" +"ui\common\game\mini-map-glow-brd\mini-map-glow_brd_horz_um.dds" , "8" , "24" , "dds" +"ui\common\game\mini-map-glow-brd\mini-map-glow_brd_ll.dds" , "40" , "40" , "dds" +"ui\common\game\mini-map-glow-brd\mini-map-glow_brd_lm.dds" , "8" , "24" , "dds" +"ui\common\game\mini-map-glow-brd\mini-map-glow_brd_lr.dds" , "40" , "40" , "dds" +"ui\common\game\mini-map-glow-brd\mini-map-glow_brd_ul.dds" , "40" , "40" , "dds" +"ui\common\game\mini-map-glow-brd\mini-map-glow_brd_ur.dds" , "40" , "40" , "dds" +"ui\common\game\mini-map-glow-brd\mini-map-glow_brd_vert_l.dds" , "24" , "8" , "dds" +"ui\common\game\mini-map-glow-brd\mini-map-glow_brd_vert_r.dds" , "24" , "8" , "dds" +"ui\common\game\mini-options-control-bar\mini-options-control-bar_bmp.dds" , "288" , "24" , "dds" +"ui\common\game\mini-resource-back_bmp\center_bmp_l.dds" , "156" , "52" , "dds" +"ui\common\game\mini-resource-back_bmp\center_bmp_m.dds" , "8" , "52" , "dds" +"ui\common\game\mini-resource-back_bmp\center_bmp_r.dds" , "156" , "52" , "dds" +"ui\common\game\mini-scroll\arrow-left_scr_dis.dds" , "24" , "48" , "dds" +"ui\common\game\mini-scroll\arrow-left_scr_down.dds" , "24" , "48" , "dds" +"ui\common\game\mini-scroll\arrow-left_scr_over.dds" , "24" , "48" , "dds" +"ui\common\game\mini-scroll\arrow-left_scr_up.dds" , "24" , "48" , "dds" +"ui\common\game\mini-scroll\arrow-right_scr_dis.dds" , "24" , "48" , "dds" +"ui\common\game\mini-scroll\arrow-right_scr_down.dds" , "24" , "48" , "dds" +"ui\common\game\mini-scroll\arrow-right_scr_over.dds" , "24" , "48" , "dds" +"ui\common\game\mini-scroll\arrow-right_scr_up.dds" , "24" , "48" , "dds" +"ui\common\game\mini-ui-construct-que-back\back_bmp_l.dds" , "72" , "56" , "dds" +"ui\common\game\mini-ui-construct-que-back\back_bmp_m.dds" , "8" , "56" , "dds" +"ui\common\game\mini-ui-construct-que-back\back_bmp_r.dds" , "20" , "56" , "dds" +"ui\common\game\mini-ui-construct-que-back\unit-construct-que-back_bmp.dds" , "340" , "56" , "dds" +"ui\common\game\mini-ui-orders-back\unit-over-back_bmp.dds" , "328" , "120" , "dds" +"ui\common\game\mini-ui-unit-over\build-over-back_bmp.dds" , "324" , "120" , "dds" +"ui\common\game\mini-ui-unit-over\unit-over-back_bmp.dds" , "324" , "104" , "dds" +"ui\common\game\options_brd\line-long_bmp.dds" , "200" , "4" , "dds" +"ui\common\game\options_brd\line-short_bmp.dds" , "228" , "4" , "dds" +"ui\common\game\options_brd\options_brd_horz_um.dds" , "12" , "44" , "dds" +"ui\common\game\options_brd\options_brd_ll.dds" , "44" , "44" , "dds" +"ui\common\game\options_brd\options_brd_lm.dds" , "12" , "44" , "dds" +"ui\common\game\options_brd\options_brd_lr.dds" , "44" , "44" , "dds" +"ui\common\game\options_brd\options_brd_m.dds" , "12" , "12" , "dds" +"ui\common\game\options_brd\options_brd_ul.dds" , "44" , "44" , "dds" +"ui\common\game\options_brd\options_brd_ur.dds" , "44" , "44" , "dds" +"ui\common\game\options_brd\options_brd_vert_l.dds" , "44" , "12" , "dds" +"ui\common\game\options_brd\options_brd_vert_r.dds" , "44" , "12" , "dds" +"ui\common\game\options_btn\diplomacy_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\options_btn\diplomacy_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\options_btn\diplomacy_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\options_btn\diplomacy_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\options_btn\glow_bmp.dds" , "48" , "48" , "dds" +"ui\common\game\options_btn\inbox_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\options_btn\inbox_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\options_btn\inbox_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\options_btn\inbox_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\options_btn\menu_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\options_btn\menu_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\options_btn\menu_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\options_btn\menu_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\options_btn\objectives_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\options_btn\objectives_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\options_btn\objectives_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\options_btn\objectives_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\options_btn\pause_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\options_btn\pause_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\options_btn\pause_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\options_btn\pause_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\options_btn\play_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\options_btn\play_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\options_btn\play_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\options_btn\play_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\options_btn\score_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\options_btn\score_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\options_btn\score_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\options_btn\score_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orderline\orderline_arrow-big.dds" , "64" , "64" , "dds" +"ui\common\game\orderline\orderline_arrow.dds" , "61" , "59" , "dds" +"ui\common\game\orderline\orderline_arrow02.dds" , "64" , "64" , "dds" +"ui\common\game\orderline\orderline_arrow03.dds" , "32" , "32" , "dds" +"ui\common\game\orderline\orderline_arrow04.dds" , "64" , "64" , "dds" +"ui\common\game\orderline\orderline_attack.dds" , "64" , "64" , "dds" +"ui\common\game\orderline\orderline_attack02.dds" , "64" , "64" , "dds" +"ui\common\game\orderline\orderline_generic.dds" , "64" , "64" , "dds" +"ui\common\game\orderline\orderline_move.dds" , "64" , "64" , "dds" +"ui\common\game\orderline\orderline_patrol-ferry.dds" , "60" , "40" , "dds" +"ui\common\game\orderline\orderline_patrol.dds" , "64" , "64" , "dds" +"ui\common\game\orderline\orderline_standard.dds" , "60" , "64" , "dds" +"ui\common\game\orderline\orderline_teleport.dds" , "64" , "48" , "dds" +"ui\common\game\orderline\orderline.dds" , "64" , "64" , "dds" +"ui\common\game\orders_bmp\orders_bmp.dds" , "160" , "220" , "dds" +"ui\common\game\orders-vertical_bmp\orders-vertical_bmp.dds" , "244" , "168" , "dds" +"ui\common\game\orders\activate-weapon_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\activate-weapon_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\activate-weapon_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\activate-weapon_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\activate-weapon_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\activate-weapon_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\activate-weapon_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\advanced-empty_bmp.dds" , "48" , "48" , "dds" +"ui\common\game\orders\advanced-empty_btn_slot.dds" , "48" , "48" , "dds" +"ui\common\game\orders\area-assist_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\area-assist_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\area-assist_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\area-assist_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\area-assist_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\area-assist_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\attack_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\attack_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\attack_btn_down .dds" , "48" , "48" , "dds" +"ui\common\game\orders\attack_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\attack_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\attack_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\attack_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\attack_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\autocast_bmp.dds" , "48" , "48" , "dds" +"ui\common\game\orders\basic-empty_bmp.dds" , "48" , "48" , "dds" +"ui\common\game\orders\basic-empty_btn_slot.dds" , "48" , "48" , "dds" +"ui\common\game\orders\convert_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\convert_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\convert_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\convert_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\convert_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\convert_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\convert_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\deploy_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\deploy_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\deploy_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\deploy_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\deploy_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\deploy_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\dive_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\dive_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\dive_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\dive_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\dive_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\dive_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\dive_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\dock_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\dock_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\dock_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\dock_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\dock_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\dock_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\drone_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\drone_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\drone_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\drone_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\drone_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\drone_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\enhancement_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\enhancement_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\enhancement_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\enhancement_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\enhancement_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\enhancement_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\enhancement_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\ferry_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\ferry_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\ferry_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\ferry_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\ferry_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\ferry_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\ferry_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\glow-02_bmp.dds" , "48" , "48" , "dds" +"ui\common\game\orders\guard_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\guard_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\guard_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\guard_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\guard_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\guard_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\guard_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\hold-fire_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\hold-fire_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\hold-fire_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\hold-fire_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\hold-fire_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\hold-fire_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\hold-fire_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\intel_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\intel_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\intel_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\intel_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\intel_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\intel_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\intel_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\intel-counter_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\intel-counter_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\intel-counter_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\intel-counter_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\intel-counter_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\intel-counter_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\intel-counter_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\intel-counter-disabled_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\intel-counter-disabled_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\intel-counter-disabled_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\intel-counter-disabled_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\intel-counter-disabled_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\intel-counter-disabled_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\intel-counter-disabled_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\intel-disabled_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\intel-disabled_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\intel-disabled_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\intel-disabled_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\intel-disabled_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\intel-disabled_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\intel-disabled_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\jamming_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\jamming_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\jamming_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\jamming_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\jamming_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\jamming_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\jamming_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\launch-nuke_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\launch-nuke_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\launch-nuke_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\launch-nuke_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\launch-nuke_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\launch-nuke_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\launch-nuke_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\launch-tactical_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\launch-tactical_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\launch-tactical_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\launch-tactical_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\launch-tactical_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\launch-tactical_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\launch-tactical_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\load_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\load_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\load_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\load_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\load_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\load_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\load_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\move_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\move_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\move_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\move_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\move_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\move_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\move_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\nuke_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\nuke_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\nuke_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\nuke_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\nuke_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\nuke_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\nuke_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\omni_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\omni_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\omni_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\omni_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\omni_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\omni_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\omni_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\overcharge_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\overcharge_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\overcharge_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\overcharge_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\overcharge_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\overcharge_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\overcharge_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\patrol_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\patrol_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\patrol_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\patrol_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\patrol_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\patrol_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\patrol_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\pause_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\pause_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\pause_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\pause_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\pause_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\pause_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\pause_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\production_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\production_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\production_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\production_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\production_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\production_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\production_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\production-disabled_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\production-disabled_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\production-disabled_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\production-disabled_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\production-disabled_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\production-disabled_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\production-disabled_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\radar_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\radar_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\radar_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\radar_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\radar_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\radar_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\radar_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\reclaim_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\reclaim_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\reclaim_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\reclaim_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\reclaim_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\reclaim_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\reclaim_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\repair_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\repair_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\repair_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\repair_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\repair_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\repair_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\repair_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\retaliate_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\retaliate_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\retaliate_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\retaliate_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\retaliate_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\retaliate_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\retaliate_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\return-fire_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\return-fire_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\return-fire_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\return-fire_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\return-fire_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\return-fire_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\return-fire_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\sacrifice_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\sacrifice_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\sacrifice_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\sacrifice_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\sacrifice_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\sacrifice_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\sacrifice_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\shield_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\shield_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\shield_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\shield_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\shield_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\shield_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\shield_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\shield-disable_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\shield-disable_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\shield-disable_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\shield-disable_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\shield-disable_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\shield-disable_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\shield-disable_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\shield-dome_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\shield-dome_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\shield-dome_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\shield-dome_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\shield-dome_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\shield-dome_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\shield-personal_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\shield-personal_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\shield-personal_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\shield-personal_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\shield-personal_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\shield-personal_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\silo-build-nuke_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\silo-build-nuke_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\silo-build-nuke_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\silo-build-nuke_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\silo-build-nuke_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\silo-build-nuke_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\silo-build-nuke_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\silo-build-tactical_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\silo-build-tactical_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\silo-build-tactical_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\silo-build-tactical_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\silo-build-tactical_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\silo-build-tactical_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\silo-build-tactical_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\skry-target_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\skry-target_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\skry-target_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\skry-target_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\skry-target_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\skry-target_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\skry-toggle_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\skry-toggle_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\skry-toggle_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\skry-toggle_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\skry-toggle_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\skry-toggle_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\sonar_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\sonar_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\sonar_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\sonar_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\sonar_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\sonar_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\sonar_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stand-ground_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stand-ground_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stand-ground_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stand-ground_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stand-ground_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stand-ground_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stand-ground_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stealth_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stealth_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stealth_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stealth_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stealth_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stealth_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stealth_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stealth-field_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stealth-field_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stealth-field_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stealth-field_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stealth-field_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stealth-field_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stealth-personal_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stealth-personal_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stealth-personal_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stealth-personal_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stealth-personal_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stealth-personal_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stop_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stop_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stop_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stop_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stop_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stop_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stop_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\tactical_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\tactical_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\tactical_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\tactical_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\tactical_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\tactical_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\tactical_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\teleport_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\teleport_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\teleport_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\teleport_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\teleport_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\teleport_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\teleport_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\toggle-weapon_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\toggle-weapon_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\toggle-weapon_btn_down_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\toggle-weapon_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\toggle-weapon_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\toggle-weapon_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\toggle-weapon_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\toggle-weapon_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\toggle-weapon-aa_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\toggle-weapon-aa_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\toggle-weapon-aa_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\toggle-weapon-aa_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\toggle-weapon-aa_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\toggle-weapon-aa_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\toggle-weapon-aa_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\toggle-weapon-air_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\toggle-weapon-air_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\toggle-weapon-air_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\toggle-weapon-air_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\toggle-weapon-air_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\toggle-weapon-air_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\toggle-weapon-direct_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\toggle-weapon-direct_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\toggle-weapon-direct_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\toggle-weapon-direct_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\toggle-weapon-direct_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\toggle-weapon-direct_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\toggle-weapon-direct_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\unload_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\unload_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\unload_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\unload_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\unload_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\unload_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\unload_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\unload02_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\unload02_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\unload02_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\unload02_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\unload02_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\unload02_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\unload02_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\vision_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\vision_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\vision_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\vision_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\vision_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\vision_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\pause_btn\pause_btn_dis.dds" , "32" , "36" , "dds" +"ui\common\game\pause_btn\pause_btn_down.dds" , "32" , "36" , "dds" +"ui\common\game\pause_btn\pause_btn_over.dds" , "32" , "36" , "dds" +"ui\common\game\pause_btn\pause_btn_up.dds" , "32" , "36" , "dds" +"ui\common\game\ping_edge\ping_edge_blue_b_down.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_blue_b_over.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_blue_b_up.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_blue_bl_down.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_blue_bl_over.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_blue_bl_up.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_blue_br_down.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_blue_br_over.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_blue_br_up.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_blue_glow.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_blue_l_down.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_blue_l_over.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_blue_l_up.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_blue_r_down.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_blue_r_over.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_blue_r_up.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_blue_t_down.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_blue_t_over.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_blue_t_up.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_blue_tl_down.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_blue_tl_over.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_blue_tl_up.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_blue_tr_down.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_blue_tr_over.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_blue_tr_up.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_red_b_down.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_red_b_over.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_red_b_up.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_red_bl_down.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_red_bl_over.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_red_bl_up.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_red_br_down.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_red_br_over.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_red_br_up.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_red_glow.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_red_l_down.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_red_l_over.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_red_l_up.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_red_r_down.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_red_r_over.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_red_r_up.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_red_t_down.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_red_t_over.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_red_t_up.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_red_tl_down.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_red_tl_over.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_red_tl_up.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_red_tr_down.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_red_tr_over.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_red_tr_up.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_yellow_b_down.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_yellow_b_over.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_yellow_b_up.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_yellow_bl_down.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_yellow_bl_over.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_yellow_bl_up.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_yellow_br_down.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_yellow_br_over.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_yellow_br_up.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_yellow_glow.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_yellow_l_down.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_yellow_l_over.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_yellow_l_up.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_yellow_r_down.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_yellow_r_over.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_yellow_r_up.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_yellow_t_down.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_yellow_t_over.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_yellow_t_up.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_yellow_tl_down.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_yellow_tl_over.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_yellow_tl_up.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_yellow_tr_down.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_yellow_tr_over.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_yellow_tr_up.dds" , "52" , "52" , "dds" +"ui\common\game\ping_marker\ping_marker-01.dds" , "20" , "20" , "dds" +"ui\common\game\ping_marker\ping_marker-02.dds" , "20" , "20" , "dds" +"ui\common\game\ping-info-panel\bg-left.dds" , "24" , "36" , "dds" +"ui\common\game\ping-info-panel\bg-mid.dds" , "32" , "40" , "dds" +"ui\common\game\ping-info-panel\bg-right.dds" , "24" , "36" , "dds" +"ui\common\game\ping-info-panel\bg-stretch.dds" , "8" , "36" , "dds" +"ui\common\game\pinggroup\border-group.dds" , "100" , "100" , "dds" +"ui\common\game\que_bmp\que_bmp_l.dds" , "80" , "80" , "dds" +"ui\common\game\que_bmp\que_bmp_m.dds" , "80" , "80" , "dds" +"ui\common\game\que_bmp\que_bmp_r.dds" , "80" , "80" , "dds" +"ui\common\game\que_bmp\que-02_bmp_l.dds" , "80" , "80" , "dds" +"ui\common\game\que_bmp\que-02_bmp_m.dds" , "80" , "80" , "dds" +"ui\common\game\que_bmp\que-02_bmp_r.dds" , "80" , "80" , "dds" +"ui\common\game\que_bmp\scroll-back_bmp.dds" , "64" , "32" , "dds" +"ui\common\game\que-vertical_bmp\que_bmp_b.dds" , "236" , "16" , "dds" +"ui\common\game\que-vertical_bmp\que_bmp_m.dds" , "236" , "16" , "dds" +"ui\common\game\que-vertical_bmp\que_bmp_t.dds" , "236" , "16" , "dds" +"ui\common\game\que-vertical_bmp\que-02_bmp_b.dds" , "236" , "16" , "dds" +"ui\common\game\que-vertical_bmp\que-02_bmp_m.dds" , "236" , "16" , "dds" +"ui\common\game\que-vertical_bmp\que-02_bmp_t.dds" , "236" , "16" , "dds" +"ui\common\game\que-vertical_bmp\scroll-back_bmp.dds" , "60" , "28" , "dds" +"ui\common\game\replay-vertical\panel_bmp.dds" , "220" , "160" , "dds" +"ui\common\game\replay\game-speed-bmp.dds" , "116" , "20" , "dds" +"ui\common\game\replay\panel_bmp.dds" , "160" , "220" , "dds" +"ui\common\game\replay\slider-ticks_bmp.dds" , "104" , "8" , "dds" +"ui\common\game\resource-mini-bars\mini-energy-bar_bmp.dds" , "76" , "16" , "dds" +"ui\common\game\resource-mini-bars\mini-energy-bar-back_bmp.dds" , "76" , "16" , "dds" +"ui\common\game\resource-mini-bars\mini-mass-bar_bmp.dds" , "76" , "16" , "dds" +"ui\common\game\resource-mini-bars\mini-mass-bar-back_bmp.dds" , "76" , "16" , "dds" +"ui\common\game\resource-tutorial\resource_tutorial_bmp.dds" , "664" , "140" , "dds" +"ui\common\game\resources\center_bmp_l.dds" , "8" , "36" , "dds" +"ui\common\game\resources\center_bmp_m.dds" , "268" , "44" , "dds" +"ui\common\game\resources\center_bmp_r.dds" , "8" , "36" , "dds" +"ui\common\game\resources\center02_bmp_l.dds" , "4" , "36" , "dds" +"ui\common\game\resources\center02_bmp_m.dds" , "276" , "48" , "dds" +"ui\common\game\resources\center02_bmp_r.dds" , "4" , "36" , "dds" +"ui\common\game\resources\energy_btn_dis.dds" , "60" , "60" , "dds" +"ui\common\game\resources\energy_btn_down.dds" , "72" , "64" , "dds" +"ui\common\game\resources\energy_btn_over.dds" , "68" , "60" , "dds" +"ui\common\game\resources\energy_btn_up.dds" , "56" , "56" , "dds" +"ui\common\game\resources\energy-advanced_bmp.dds" , "48" , "36" , "dds" +"ui\common\game\resources\energy-back_bmp.dds" , "392" , "60" , "dds" +"ui\common\game\resources\energy-bar_bmp.dds" , "192" , "12" , "dds" +"ui\common\game\resources\energy-bar-back_bmp.dds" , "192" , "12" , "dds" +"ui\common\game\resources\energy-bar-edge_bmp.dds" , "140" , "40" , "dds" +"ui\common\game\resources\mass_btn_dis.dds" , "64" , "56" , "dds" +"ui\common\game\resources\mass_btn_down.dds" , "68" , "60" , "dds" +"ui\common\game\resources\mass_btn_over.dds" , "68" , "60" , "dds" +"ui\common\game\resources\mass_btn_up.dds" , "72" , "60" , "dds" +"ui\common\game\resources\mass-advanced_bmp.dds" , "48" , "36" , "dds" +"ui\common\game\resources\mass-advanced_over_bmp.dds" , "52" , "36" , "dds" +"ui\common\game\resources\mass-back_bmp.dds" , "396" , "48" , "dds" +"ui\common\game\resources\mass-bar_bmp.dds" , "192" , "12" , "dds" +"ui\common\game\resources\mass-bar-back_bmp.dds" , "192" , "12" , "dds" +"ui\common\game\resources\mass-bar-edge_bmp.dds" , "52" , "40" , "dds" +"ui\common\game\resources\resource_rate_bmp.dds" , "72" , "40" , "dds" +"ui\common\game\resources\resource_rate_over_bmp.dds" , "72" , "40" , "dds" +"ui\common\game\selection-bracket\selection-bracket_bmp.dds" , "48" , "44" , "dds" +"ui\common\game\selection-bracket\selection-bracket-glow_bmp.dds" , "64" , "64" , "dds" +"ui\common\game\selection-bracket\selection-bracket-white_bmp.dds" , "48" , "44" , "dds" +"ui\common\game\selection\selection_brackets_enemy_highlighted.dds" , "128" , "128" , "dds" +"ui\common\game\selection\selection_brackets_enemy.dds" , "128" , "128" , "dds" +"ui\common\game\selection\selection_brackets_neutral_highlighted.dds" , "128" , "128" , "dds" +"ui\common\game\selection\selection_brackets_neutral.dds" , "128" , "128" , "dds" +"ui\common\game\selection\selection_brackets_player_highlighted.dds" , "128" , "128" , "dds" +"ui\common\game\selection\selection_brackets_player.dds" , "128" , "128" , "dds" +"ui\common\game\selection\selection.dds" , "100" , "100" , "dds" +"ui\common\game\seraphim-enhancements\adss_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\adss_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\adss_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\adss_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\aes_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\aes_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\aes_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\aes_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\anrf_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\anrf_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\anrf_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\anrf_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\cba_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\cba_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\cba_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\cba_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\dss_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\dss_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\dss_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\dss_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\ees_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\ees_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\ees_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\ees_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\efm_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\efm_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\efm_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\efm_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\eras_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\eras_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\eras_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\eras_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\hsa_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\hsa_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\hsa_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\hsa_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\nrf_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\nrf_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\nrf_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\nrf_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\oc_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\oc_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\oc_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\oc_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\pqt_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\pqt_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\pqt_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\pqt_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\ras_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\ras_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\ras_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\ras_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\sp_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\sp_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\sp_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\sp_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\sre_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\sre_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\sre_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\sre_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\ss_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\ss_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\ss_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\ss_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\tml_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\tml_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\tml_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\tml_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\tmu_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\tmu_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\tmu_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\tmu_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\small-side_scr\arrow-left_scr_dis.dds" , "36" , "36" , "dds" +"ui\common\game\small-side_scr\arrow-left_scr_down.dds" , "36" , "36" , "dds" +"ui\common\game\small-side_scr\arrow-left_scr_over.dds" , "36" , "36" , "dds" +"ui\common\game\small-side_scr\arrow-left_scr_up.dds" , "36" , "36" , "dds" +"ui\common\game\small-side_scr\arrow-right_scr_dis.dds" , "36" , "36" , "dds" +"ui\common\game\small-side_scr\arrow-right_scr_down.dds" , "36" , "36" , "dds" +"ui\common\game\small-side_scr\arrow-right_scr_over.dds" , "36" , "36" , "dds" +"ui\common\game\small-side_scr\arrow-right_scr_up.dds" , "36" , "36" , "dds" +"ui\common\game\small-vert_scr\arrow-down_scr_dis.dds" , "36" , "36" , "dds" +"ui\common\game\small-vert_scr\arrow-down_scr_down.dds" , "36" , "36" , "dds" +"ui\common\game\small-vert_scr\arrow-down_scr_over.dds" , "36" , "36" , "dds" +"ui\common\game\small-vert_scr\arrow-down_scr_up.dds" , "36" , "36" , "dds" +"ui\common\game\small-vert_scr\arrow-up_scr_dis.dds" , "36" , "36" , "dds" +"ui\common\game\small-vert_scr\arrow-up_scr_down.dds" , "36" , "36" , "dds" +"ui\common\game\small-vert_scr\arrow-up_scr_over.dds" , "36" , "36" , "dds" +"ui\common\game\small-vert_scr\arrow-up_scr_up.dds" , "36" , "36" , "dds" +"ui\common\game\strategicicons\icon_bomber_antinavy_over.dds" , "20" , "12" , "dds" +"ui\common\game\strategicicons\icon_bomber_antinavy_rest.dds" , "20" , "12" , "dds" +"ui\common\game\strategicicons\icon_bomber_antinavy_selected.dds" , "24" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber_antinavy_selectedover.dds" , "24" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber_directfire_over.dds" , "20" , "12" , "dds" +"ui\common\game\strategicicons\icon_bomber_directfire_rest.dds" , "20" , "12" , "dds" +"ui\common\game\strategicicons\icon_bomber_directfire_selected.dds" , "24" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber_directfire_selectedover.dds" , "24" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber_generic_over.dds" , "20" , "12" , "dds" +"ui\common\game\strategicicons\icon_bomber_generic_rest.dds" , "20" , "12" , "dds" +"ui\common\game\strategicicons\icon_bomber_generic_selected.dds" , "24" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber_generic_selectedover.dds" , "24" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber1_antinavy_over.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber1_antinavy_rest.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber1_antinavy_selected.dds" , "24" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber1_antinavy_selectedover.dds" , "24" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber1_directfire_over.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber1_directfire_rest.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber1_directfire_selected.dds" , "24" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber1_directfire_selectedover.dds" , "24" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber1_generic_over.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber1_generic_rest.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber1_generic_selected.dds" , "24" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber1_generic_selectedover.dds" , "24" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber2_antinavy_over.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber2_antinavy_rest.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber2_antinavy_selected.dds" , "24" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber2_antinavy_selectedover.dds" , "24" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber2_directfire_over.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber2_directfire_rest.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber2_directfire_selected.dds" , "24" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber2_directfire_selectedover.dds" , "24" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber2_generic_over.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber2_generic_rest.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber2_generic_selected.dds" , "24" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber2_generic_selectedover.dds" , "24" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber3_antinavy_over.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber3_antinavy_rest.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber3_antinavy_selected.dds" , "24" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber3_antinavy_selectedover.dds" , "24" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber3_directfire_over.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber3_directfire_rest.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber3_directfire_selected.dds" , "24" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber3_directfire_selectedover.dds" , "24" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber3_generic_over.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber3_generic_rest.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber3_generic_selected.dds" , "24" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber3_generic_selectedover.dds" , "24" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot_antiair_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot_antiair_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot_antiair_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot_antiair_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot_artillery_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot_artillery_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot_artillery_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot_artillery_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot_directfire_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot_directfire_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot_directfire_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot_directfire_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot_engineer_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot_engineer_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot_engineer_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot_engineer_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot_generic_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot_generic_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot_generic_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot_generic_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot_intel_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot_intel_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot_intel_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot_intel_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot1_antiair_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot1_antiair_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot1_antiair_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot1_antiair_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot1_artillery_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot1_artillery_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot1_artillery_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot1_artillery_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot1_directfire_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot1_directfire_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot1_directfire_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot1_directfire_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot1_engineer_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot1_engineer_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot1_engineer_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot1_engineer_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot1_generic_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot1_generic_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot1_generic_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot1_generic_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot1_intel_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot1_intel_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot1_intel_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot1_intel_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot2_antiair_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot2_antiair_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot2_antiair_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot2_antiair_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot2_artillery_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot2_artillery_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot2_artillery_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot2_artillery_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot2_directfire_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot2_directfire_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot2_directfire_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot2_directfire_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot2_engineer_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot2_engineer_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot2_engineer_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot2_engineer_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot2_generic_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot2_generic_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot2_generic_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot2_generic_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot2_intel_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot2_intel_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot2_intel_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot2_intel_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot3_antiair_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot3_antiair_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot3_antiair_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot3_antiair_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot3_artillery_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot3_artillery_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot3_artillery_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot3_artillery_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot3_directfire_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot3_directfire_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot3_directfire_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot3_directfire_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot3_engineer_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot3_engineer_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot3_engineer_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot3_engineer_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot3_generic_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot3_generic_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot3_generic_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot3_generic_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot3_intel_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot3_intel_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot3_intel_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot3_intel_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_commander_generic_over.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_commander_generic_rest.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_commander_generic_selected.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_commander_generic_selectedover.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_experimental_generic_over.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_experimental_generic_rest.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_experimental_generic_selected.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_experimental_generic_selectedover.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory_air_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory_air_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory_air_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory_air_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory_generic_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory_generic_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory_generic_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory_generic_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory_land_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory_land_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory_land_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory_land_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory_naval_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory_naval_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory_naval_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory_naval_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory1_air_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory1_air_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory1_air_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory1_air_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory1_generic_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory1_generic_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory1_generic_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory1_generic_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory1_land_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory1_land_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory1_land_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory1_land_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory1_naval_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory1_naval_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory1_naval_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory1_naval_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory2_air_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory2_air_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory2_air_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory2_air_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory2_generic_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory2_generic_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory2_generic_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory2_generic_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory2_land_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory2_land_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory2_land_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory2_land_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory2_naval_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory2_naval_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory2_naval_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory2_naval_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory3_air_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory3_air_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory3_air_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory3_air_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory3_generic_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory3_generic_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory3_generic_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory3_generic_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory3_land_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory3_land_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory3_land_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory3_land_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory3_naval_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory3_naval_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory3_naval_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory3_naval_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_fighter_antiair_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_fighter_antiair_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_fighter_antiair_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter_antiair_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter_bomb_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_fighter_bomb_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_fighter_bomb_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter_bomb_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter_directfire_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_fighter_directfire_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_fighter_directfire_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter_directfire_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter_generic_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_fighter_generic_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_fighter_generic_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter_generic_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter_intel_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_fighter_intel_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_fighter_intel_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter_intel_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter_missile_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_fighter_missile_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_fighter_missile_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter_missile_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter1_antiair_over.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter1_antiair_rest.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter1_antiair_selected.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter1_antiair_selectedover.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter1_bomb_over.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter1_bomb_rest.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter1_bomb_selected.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter1_bomb_selectedover.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter1_directfire_over.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter1_directfire_rest.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter1_directfire_selected.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter1_directfire_selectedover.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter1_generic_over.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter1_generic_rest.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter1_generic_selected.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter1_generic_selectedover.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter1_intel_over.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter1_intel_rest.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter1_intel_selected.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter1_intel_selectedover.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter1_missile_over.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter1_missile_rest.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter1_missile_selected.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter1_missile_selectedover.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter2_antiair_over.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter2_antiair_rest.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter2_antiair_selected.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter2_antiair_selectedover.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter2_bomb_over.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter2_bomb_rest.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter2_bomb_selected.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter2_bomb_selectedover.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter2_directfire_over.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter2_directfire_rest.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter2_directfire_selected.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter2_directfire_selectedover.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter2_generic_over.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter2_generic_rest.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter2_generic_selected.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter2_generic_selectedover.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter2_intel_over.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter2_intel_rest.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter2_intel_selected.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter2_intel_selectedover.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter2_missile_over.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter2_missile_rest.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter2_missile_selected.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter2_missile_selectedover.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter3_antiair_over.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter3_antiair_rest.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter3_antiair_selected.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter3_antiair_selectedover.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter3_bomb_over.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter3_bomb_rest.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter3_bomb_selected.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter3_bomb_selectedover.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter3_directfire_over.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter3_directfire_rest.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter3_directfire_selected.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter3_directfire_selectedover.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter3_generic_over.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter3_generic_rest.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter3_generic_selected.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter3_generic_selectedover.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter3_intel_over.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter3_intel_rest.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter3_intel_selected.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter3_intel_selectedover.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter3_missile_over.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter3_missile_rest.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter3_missile_selected.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter3_missile_selectedover.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_gunship_antiair_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship_antiair_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship_antiair_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship_antiair_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship_directfire_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship_directfire_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship_directfire_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship_directfire_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship_generic_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship_generic_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship_generic_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship_generic_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship_transport_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship_transport_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship_transport_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship_transport_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship1_antiair_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship1_antiair_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship1_antiair_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship1_antiair_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship1_directfire_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship1_directfire_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship1_directfire_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship1_directfire_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship1_generic_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship1_generic_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship1_generic_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship1_generic_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship1_transport_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship1_transport_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship1_transport_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship1_transport_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship2_antiair_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship2_antiair_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship2_antiair_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship2_antiair_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship2_directfire_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship2_directfire_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship2_directfire_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship2_directfire_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship2_generic_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship2_generic_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship2_generic_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship2_generic_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship2_transport_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship2_transport_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship2_transport_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship2_transport_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship3_antiair_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship3_antiair_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship3_antiair_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship3_antiair_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship3_directfire_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship3_directfire_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship3_directfire_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship3_directfire_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship3_generic_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship3_generic_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship3_generic_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship3_generic_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship3_transport_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship3_transport_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship3_transport_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship3_transport_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_land_antiair_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land_antiair_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land_antiair_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land_antiair_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land_antishield_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land_antishield_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land_antishield_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land_antishield_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land_artillery_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land_artillery_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land_artillery_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land_artillery_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land_bomb_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land_bomb_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land_bomb_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land_bomb_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land_counterintel_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land_counterintel_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land_counterintel_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land_counterintel_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land_directfire_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land_directfire_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land_directfire_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land_directfire_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land_engineer_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land_engineer_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land_engineer_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land_engineer_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land_generic_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land_generic_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land_generic_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land_generic_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land_intel_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land_intel_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land_intel_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land_intel_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land_missile_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land_missile_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land_missile_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land_missile_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land_shield_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land_shield_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land_shield_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land_shield_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land1_antiair_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land1_antiair_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land1_antiair_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land1_antiair_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land1_antishield_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land1_antishield_rest.dds" , "16" , "24" , "dds" +"ui\common\game\strategicicons\icon_land1_antishield_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land1_antishield_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land1_artillery_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land1_artillery_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land1_artillery_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land1_artillery_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land1_bomb_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land1_bomb_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land1_bomb_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land1_bomb_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land1_counterintel_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land1_counterintel_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land1_counterintel_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land1_counterintel_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land1_directfire_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land1_directfire_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land1_directfire_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land1_directfire_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land1_engineer_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land1_engineer_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land1_engineer_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land1_engineer_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land1_generic_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land1_generic_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land1_generic_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land1_generic_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land1_intel_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land1_intel_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land1_intel_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land1_intel_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land1_missile_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land1_missile_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land1_missile_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land1_missile_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land1_shield_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land1_shield_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land1_shield_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land1_shield_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land2_antiair_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land2_antiair_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land2_antiair_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land2_antiair_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land2_antishield_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land2_antishield_rest.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_land2_antishield_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land2_antishield_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land2_artillery_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land2_artillery_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land2_artillery_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land2_artillery_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land2_bomb_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land2_bomb_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land2_bomb_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land2_bomb_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land2_counterintel_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land2_counterintel_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land2_counterintel_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land2_counterintel_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land2_directfire_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land2_directfire_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land2_directfire_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land2_directfire_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land2_engineer_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land2_engineer_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land2_engineer_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land2_engineer_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land2_generic_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land2_generic_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land2_generic_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land2_generic_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land2_intel_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land2_intel_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land2_intel_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land2_intel_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land2_missile_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land2_missile_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land2_missile_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land2_missile_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land2_shield_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land2_shield_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land2_shield_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land2_shield_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land3_antiair_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land3_antiair_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land3_antiair_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land3_antiair_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land3_antishield_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land3_antishield_rest.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_land3_antishield_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land3_antishield_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land3_artillery_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land3_artillery_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land3_artillery_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land3_artillery_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land3_bomb_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land3_bomb_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land3_bomb_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land3_bomb_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land3_counterintel_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land3_counterintel_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land3_counterintel_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land3_counterintel_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land3_directfire_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land3_directfire_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land3_directfire_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land3_directfire_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land3_engineer_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land3_engineer_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land3_engineer_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land3_engineer_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land3_generic_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land3_generic_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land3_generic_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land3_generic_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land3_intel_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land3_intel_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land3_intel_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land3_intel_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land3_missile_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land3_missile_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land3_missile_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land3_missile_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land3_shield_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land3_shield_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land3_shield_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land3_shield_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_objective_over.dds" , "32" , "32" , "dds" +"ui\common\game\strategicicons\icon_objective_primary_over.dds" , "28" , "28" , "dds" +"ui\common\game\strategicicons\icon_objective_primary_rest.dds" , "28" , "28" , "dds" +"ui\common\game\strategicicons\icon_objective_primary_selected.dds" , "28" , "28" , "dds" +"ui\common\game\strategicicons\icon_objective_primary_selectedover.dds" , "28" , "28" , "dds" +"ui\common\game\strategicicons\icon_objective_rest.dds" , "32" , "32" , "dds" +"ui\common\game\strategicicons\icon_objective_secondary_over.dds" , "28" , "28" , "dds" +"ui\common\game\strategicicons\icon_objective_secondary_rest.dds" , "28" , "28" , "dds" +"ui\common\game\strategicicons\icon_objective_secondary_selected.dds" , "28" , "28" , "dds" +"ui\common\game\strategicicons\icon_objective_secondary_selectedover.dds" , "28" , "28" , "dds" +"ui\common\game\strategicicons\icon_objective_selected.dds" , "32" , "32" , "dds" +"ui\common\game\strategicicons\icon_objective_selectedover.dds" , "32" , "32" , "dds" +"ui\common\game\strategicicons\icon_ship_air_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_ship_air_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_ship_air_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship_air_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship_antiair_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_ship_antiair_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_ship_antiair_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship_antiair_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship_antinavy_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_ship_antinavy_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_ship_antinavy_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship_antinavy_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship_counterintel_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_ship_counterintel_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_ship_counterintel_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship_counterintel_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship_directfire_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_ship_directfire_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_ship_directfire_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship_directfire_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship_generic_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_ship_generic_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_ship_generic_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship_generic_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship_intel_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_ship_intel_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_ship_intel_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship_intel_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship_missile_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_ship_missile_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_ship_missile_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship_missile_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship_shield_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_ship_shield_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_ship_shield_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship_shield_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_air_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_air_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_air_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_air_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_antiair_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_antiair_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_antiair_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_antiair_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_antinavy_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_antinavy_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_antinavy_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_antinavy_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_counterintel_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_counterintel_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_counterintel_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_counterintel_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_directfire_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_directfire_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_directfire_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_directfire_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_generic_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_generic_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_generic_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_generic_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_intel_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_intel_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_intel_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_intel_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_missile_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_missile_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_missile_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_missile_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_shield_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_shield_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_shield_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_shield_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_air_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_air_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_air_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_air_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_antiair_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_antiair_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_antiair_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_antiair_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_antinavy_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_antinavy_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_antinavy_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_antinavy_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_counterintel_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_counterintel_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_counterintel_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_counterintel_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_directfire_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_directfire_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_directfire_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_directfire_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_generic_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_generic_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_generic_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_generic_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_intel_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_intel_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_intel_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_intel_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_missile_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_missile_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_missile_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_missile_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_shield_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_shield_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_shield_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_shield_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_air_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_air_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_air_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_air_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_antiair_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_antiair_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_antiair_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_antiair_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_antinavy_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_antinavy_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_antinavy_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_antinavy_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_counterintel_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_counterintel_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_counterintel_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_counterintel_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_directfire_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_directfire_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_directfire_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_directfire_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_generic_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_generic_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_generic_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_generic_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_intel_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_intel_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_intel_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_intel_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_missile_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_missile_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_missile_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_missile_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_shield_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_shield_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_shield_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_shield_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_strategic_antinuke.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_strategic_artillery.dds" , "4" , "4" , "dds" +"ui\common\game\strategicicons\icon_strategic_ferrypoint_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_strategic_ferrypoint_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_strategic_ferrypoint_selected.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_strategic_ferrypoint_selectedover.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_strategic_nuclear.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_strategic_nuke.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure_air_over.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_air_rest.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_air_selected.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_air_selectedover.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_antiair_over.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_antiair_rest.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_antiair_selected.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_antiair_selectedover.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_antiartillery_over.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_antiartillery_rest.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_antiartillery_selected.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_antiartillery_selectedover.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_antimissile_over.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_antimissile_rest.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_antimissile_selected.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_antimissile_selectedover.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_antinavy_over.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_antinavy_rest.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_antinavy_selected.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_antinavy_selectedover.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_artillery_over.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_artillery_rest.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_artillery_selected.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_artillery_selectedover.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_counterintel_over.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_counterintel_rest.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_counterintel_selected.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_counterintel_selectedover.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_directfire_over.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_directfire_rest.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_directfire_selected.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_directfire_selectedover.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_energy_over.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_energy_rest.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_energy_selected.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_energy_selectedover.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_engineer_over.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_engineer_rest.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_engineer_selected.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_engineer_selectedover.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_generic_over.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_generic_rest.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_generic_selected.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_generic_selectedover.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_intel_over.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_intel_rest.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_intel_selected.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_intel_selectedover.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_land_over.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_land_rest.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_land_selected.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_land_selectedover.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_mass_over.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_mass_rest.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_mass_selected.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_mass_selectedover.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_missile_over.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_missile_rest.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_missile_selected.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_missile_selectedover.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_naval_over.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_naval_rest.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_naval_selected.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_naval_selectedover.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_shield_over.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_shield_rest.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_shield_selected.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_shield_selectedover.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_transport_over.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_transport_rest.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_transport_selected.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_transport_selectedover.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_wall_over.dds" , "8" , "8" , "dds" +"ui\common\game\strategicicons\icon_structure_wall_rest.dds" , "8" , "8" , "dds" +"ui\common\game\strategicicons\icon_structure_wall_selected.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_wall_selectedover.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure1_air_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_air_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_air_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_air_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_antiair_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_antiair_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_antiair_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_antiair_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_antiartillery_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_antiartillery_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_antiartillery_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_antiartillery_selectedover.dds", "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_antimissile_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_antimissile_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_antimissile_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_antimissile_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_antinavy_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_antinavy_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_antinavy_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_antinavy_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_artillery_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_artillery_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_artillery_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_artillery_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_counterintel_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_counterintel_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_counterintel_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_counterintel_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_directfire_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_directfire_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_directfire_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_directfire_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_energy_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_energy_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_energy_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_energy_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_engineer_over.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_engineer_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_engineer_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_engineer_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_generic_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_generic_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_generic_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_generic_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_intel_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_intel_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_intel_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_intel_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_land_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_land_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_land_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_land_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_mass_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_mass_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_mass_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_mass_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_missile_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_missile_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_missile_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_missile_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_naval_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_naval_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_naval_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_naval_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_shield_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_shield_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_shield_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_shield_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_transport_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_transport_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_transport_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_transport_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_air_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_air_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_air_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_air_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_antiair_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_antiair_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_antiair_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_antiair_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_antiartillery_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_antiartillery_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_antiartillery_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_antiartillery_selectedover.dds", "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_antimissile_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_antimissile_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_antimissile_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_antimissile_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_antinavy_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_antinavy_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_antinavy_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_antinavy_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_artillery_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_artillery_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_artillery_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_artillery_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_counterintel_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_counterintel_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_counterintel_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_counterintel_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_directfire_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_directfire_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_directfire_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_directfire_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_energy_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_energy_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_energy_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_energy_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_engineer_over.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_engineer_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_engineer_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_engineer_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_generic_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_generic_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_generic_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_generic_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_intel_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_intel_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_intel_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_intel_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_land_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_land_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_land_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_land_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_mass_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_mass_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_mass_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_mass_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_missile_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_missile_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_missile_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_missile_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_naval_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_naval_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_naval_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_naval_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_shield_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_shield_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_shield_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_shield_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_transport_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_transport_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_transport_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_transport_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_air_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_air_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_air_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_air_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_antiair_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_antiair_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_antiair_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_antiair_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_antiartillery_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_antiartillery_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_antiartillery_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_antiartillery_selectedover.dds", "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_antimissile_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_antimissile_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_antimissile_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_antimissile_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_antinavy_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_antinavy_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_antinavy_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_antinavy_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_artillery_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_artillery_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_artillery_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_artillery_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_counterintel_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_counterintel_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_counterintel_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_counterintel_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_directfire_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_directfire_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_directfire_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_directfire_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_energy_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_energy_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_energy_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_energy_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_engineer_over.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_engineer_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_engineer_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_engineer_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_generic_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_generic_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_generic_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_generic_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_intel_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_intel_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_intel_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_intel_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_land_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_land_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_land_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_land_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_mass_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_mass_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_mass_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_mass_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_missile_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_missile_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_missile_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_missile_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_naval_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_naval_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_naval_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_naval_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_shield_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_shield_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_shield_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_shield_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_transport_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_transport_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_transport_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_transport_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_sub_antinavy_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_sub_antinavy_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_sub_antinavy_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub_antinavy_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub_directfire_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_sub_directfire_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_sub_directfire_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub_directfire_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub_generic_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_sub_generic_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_sub_generic_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub_generic_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub_intel_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_sub_intel_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_sub_intel_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub_intel_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub_missile_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_sub_missile_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_sub_missile_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub_missile_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub1_antinavy_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub1_antinavy_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub1_antinavy_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub1_antinavy_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub1_directfire_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub1_directfire_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub1_directfire_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub1_directfire_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub1_generic_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub1_generic_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub1_generic_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub1_generic_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub1_intel_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub1_intel_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub1_intel_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub1_intel_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub1_missile_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub1_missile_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub1_missile_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub1_missile_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub2_antinavy_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub2_antinavy_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub2_antinavy_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub2_antinavy_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub2_directfire_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub2_directfire_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub2_directfire_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub2_directfire_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub2_generic_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub2_generic_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub2_generic_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub2_generic_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub2_intel_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub2_intel_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub2_intel_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub2_intel_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub2_missile_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub2_missile_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub2_missile_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub2_missile_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub3_antinavy_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub3_antinavy_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub3_antinavy_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub3_antinavy_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub3_directfire_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub3_directfire_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub3_directfire_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub3_directfire_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub3_generic_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub3_generic_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub3_generic_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub3_generic_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub3_intel_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub3_intel_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub3_intel_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub3_intel_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub3_missile_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub3_missile_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub3_missile_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub3_missile_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\pause_rest.dds" , "32" , "32" , "dds" +"ui\common\game\strategicicons\strat_alert_ping_over.dds" , "12" , "20" , "dds" +"ui\common\game\strategicicons\strat_alert_ping_rest.dds" , "12" , "20" , "dds" +"ui\common\game\strategicicons\strat_alert_ping_selected.dds" , "12" , "20" , "dds" +"ui\common\game\strategicicons\strat_alert_ping_selectedover.dds" , "12" , "20" , "dds" +"ui\common\game\strategicicons\strat_attack_ping_over.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\strat_attack_ping_rest.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\strat_attack_ping_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\strat_attack_ping_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\strat_move_ping_over.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\strat_move_ping_rest.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\strat_move_ping_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\strat_move_ping_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\stunned_rest.dds" , "32" , "32" , "dds" +"ui\common\game\target-area\target-area_bmp.dds" , "64" , "64" , "dds" +"ui\common\game\tech-icons-sm_btn\experimental_icon_btn_dis.dds" , "32" , "32" , "dds" +"ui\common\game\tech-icons-sm_btn\experimental_icon_btn_down.dds" , "32" , "32" , "dds" +"ui\common\game\tech-icons-sm_btn\experimental_icon_btn_over.dds" , "32" , "32" , "dds" +"ui\common\game\tech-icons-sm_btn\experimental_icon_btn_up.dds" , "32" , "32" , "dds" +"ui\common\game\tech-icons-sm_btn\tech-level-1_icon_btn_dis.dds" , "32" , "32" , "dds" +"ui\common\game\tech-icons-sm_btn\tech-level-1_icon_btn_down.dds" , "32" , "32" , "dds" +"ui\common\game\tech-icons-sm_btn\tech-level-1_icon_btn_over.dds" , "32" , "32" , "dds" +"ui\common\game\tech-icons-sm_btn\tech-level-1_icon_btn_up.dds" , "32" , "32" , "dds" +"ui\common\game\tech-icons-sm_btn\tech-level-2_icon_btn_dis.dds" , "32" , "32" , "dds" +"ui\common\game\tech-icons-sm_btn\tech-level-2_icon_btn_down.dds" , "32" , "32" , "dds" +"ui\common\game\tech-icons-sm_btn\tech-level-2_icon_btn_over.dds" , "32" , "32" , "dds" +"ui\common\game\tech-icons-sm_btn\tech-level-2_icon_btn_up.dds" , "32" , "32" , "dds" +"ui\common\game\tech-icons-sm_btn\tech-level-3_icon_btn_dis.dds" , "32" , "32" , "dds" +"ui\common\game\tech-icons-sm_btn\tech-level-3_icon_btn_down.dds" , "32" , "32" , "dds" +"ui\common\game\tech-icons-sm_btn\tech-level-3_icon_btn_over.dds" , "32" , "32" , "dds" +"ui\common\game\tech-icons-sm_btn\tech-level-3_icon_btn_up.dds" , "32" , "32" , "dds" +"ui\common\game\tech-icons-sm_btn\transport-icon_btn_dis.dds" , "32" , "32" , "dds" +"ui\common\game\tech-icons-sm_btn\transport-icon_btn_down.dds" , "32" , "32" , "dds" +"ui\common\game\tech-icons-sm_btn\transport-icon_btn_over.dds" , "32" , "32" , "dds" +"ui\common\game\tech-icons-sm_btn\transport-icon_btn_up.dds" , "32" , "32" , "dds" +"ui\common\game\tech-icons-sm_btn\upgrade-icon_btn_dis.dds" , "32" , "32" , "dds" +"ui\common\game\tech-icons-sm_btn\upgrade-icon_btn_down.dds" , "32" , "32" , "dds" +"ui\common\game\tech-icons-sm_btn\upgrade-icon_btn_over.dds" , "32" , "32" , "dds" +"ui\common\game\tech-icons-sm_btn\upgrade-icon_btn_up.dds" , "32" , "32" , "dds" +"ui\common\game\tech-tabs_btn\experimental_icon_bmp.dds" , "72" , "44" , "dds" +"ui\common\game\tech-tabs_btn\tech-level-1_icon_bmp.dds" , "72" , "44" , "dds" +"ui\common\game\tech-tabs_btn\tech-level-2_icon_bmp.dds" , "72" , "44" , "dds" +"ui\common\game\tech-tabs_btn\tech-level-3_icon_bmp.dds" , "72" , "44" , "dds" +"ui\common\game\tech-tabs_btn\tech-tab_btn_dis.dds" , "72" , "44" , "dds" +"ui\common\game\tech-tabs_btn\tech-tab_btn_down.dds" , "72" , "44" , "dds" +"ui\common\game\tech-tabs_btn\tech-tab_btn_over.dds" , "72" , "44" , "dds" +"ui\common\game\tech-tabs_btn\tech-tab_btn_selected.dds" , "72" , "44" , "dds" +"ui\common\game\tech-tabs_btn\tech-tab_btn_up.dds" , "72" , "44" , "dds" +"ui\common\game\tech-tabs_btn\tech-tab02_btn_dis.dds" , "72" , "44" , "dds" +"ui\common\game\tech-tabs_btn\tech-tab02_btn_down.dds" , "72" , "44" , "dds" +"ui\common\game\tech-tabs_btn\tech-tab02_btn_over.dds" , "72" , "44" , "dds" +"ui\common\game\tech-tabs_btn\tech-tab02_btn_selected.dds" , "72" , "44" , "dds" +"ui\common\game\tech-tabs_btn\tech-tab02_btn_up.dds" , "72" , "44" , "dds" +"ui\common\game\tech-tabs_btn\transport-icon_bmp.dds" , "72" , "44" , "dds" +"ui\common\game\tech-tabs_btn\upgrade-icon_bmp.dds" , "72" , "44" , "dds" +"ui\common\game\timer\clock_bmp.dds" , "32" , "32" , "dds" +"ui\common\game\timer\glow-02_bmp.dds" , "36" , "36" , "dds" +"ui\common\game\timer\timer-panel_bmp.dds" , "148" , "44" , "dds" +"ui\common\game\uef-enhancements\acu_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\acu_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\acu_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\acu_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\aes_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\aes_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\aes_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\aes_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\dsu_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\dsu_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\dsu_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\dsu_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\ed_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\ed_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\ed_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\ed_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\ees_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\ees_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\ees_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\ees_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\hamc_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\hamc_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\hamc_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\hamc_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\heo_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\heo_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\heo_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\heo_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\isb_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\isb_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\isb_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\isb_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\led_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\led_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\led_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\led_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\pqt_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\pqt_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\pqt_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\pqt_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\psg_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\psg_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\psg_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\psg_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\red_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\red_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\red_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\red_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\rj_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\rj_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\rj_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\rj_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\sgf_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\sgf_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\sgf_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\sgf_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\sre_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\sre_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\sre_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\sre_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\srtn_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\srtn_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\srtn_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\srtn_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\tm_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\tm_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\tm_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\tm_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\unit_bmp\bar-01_bmp.dds" , "148" , "8" , "dds" +"ui\common\game\unit_bmp\bar-02_bmp.dds" , "148" , "8" , "dds" +"ui\common\game\unit_bmp\bar-03_bmp.dds" , "148" , "8" , "dds" +"ui\common\game\unit_bmp\bar-back_bmp.dds" , "148" , "8" , "dds" +"ui\common\game\unit_bmp\bar-info-back_bmp.dds" , "152" , "12" , "dds" +"ui\common\game\unit_bmp\bar02_bmp.dds" , "148" , "8" , "dds" +"ui\common\game\unit_bmp\focus_arrow.dds" , "20" , "48" , "dds" +"ui\common\game\unit_bmp\unit-over_bmp.dds" , "160" , "220" , "dds" +"ui\common\game\unit_bmp\veteran-logo_bmp.dds" , "16" , "16" , "dds" +"ui\common\game\unit_unselected\panel_bmp_l.dds" , "304" , "212" , "dds" +"ui\common\game\unit_unselected\panel_bmp_m.dds" , "12" , "212" , "dds" +"ui\common\game\unit_unselected\panel_bmp_r.dds" , "264" , "212" , "dds" +"ui\common\game\unit_view_icons\attached.dds" , "20" , "20" , "dds" +"ui\common\game\unit_view_icons\build.dds" , "24" , "20" , "dds" +"ui\common\game\unit_view_icons\energy.dds" , "20" , "20" , "dds" +"ui\common\game\unit_view_icons\fuel.dds" , "20" , "20" , "dds" +"ui\common\game\unit_view_icons\idle.dds" , "48" , "48" , "dds" +"ui\common\game\unit_view_icons\kills.dds" , "20" , "20" , "dds" +"ui\common\game\unit_view_icons\mass.dds" , "20" , "20" , "dds" +"ui\common\game\unit_view_icons\missiles.dds" , "20" , "20" , "dds" +"ui\common\game\unit_view_icons\redcross.dds" , "16" , "16" , "dds" +"ui\common\game\unit_view_icons\shield.dds" , "20" , "20" , "dds" +"ui\common\game\unit_view_icons\tactical.dds" , "20" , "20" , "dds" +"ui\common\game\unit_view_icons\time.dds" , "20" , "20" , "dds" +"ui\common\game\unit_view_icons\unidentified.dds" , "48" , "48" , "dds" +"ui\common\game\unit-over-abilites_bmp\build-over-abilities_bmp _m.dds" , "396" , "12" , "dds" +"ui\common\game\unit-over-abilites_bmp\build-over-abilities_bmp _t.dds" , "396" , "20" , "dds" +"ui\common\game\unit-over-abilites_bmp\build-over-abilities_bmp_b.dds" , "396" , "24" , "dds" +"ui\common\game\unit-over-abilites_bmp\unit-over-abilities_bmp _m.dds" , "404" , "12" , "dds" +"ui\common\game\unit-over-abilites_bmp\unit-over-abilities_bmp _t.dds" , "404" , "60" , "dds" +"ui\common\game\unit-over-abilites_bmp\unit-over-abilities_bmp_b.dds" , "412" , "24" , "dds" +"ui\common\game\unit-over\_icon-mass-text.dds" , "28" , "8" , "dds" +"ui\common\game\unit-over\bar01_bmp.dds" , "76" , "8" , "dds" +"ui\common\game\unit-over\bar02_bmp.dds" , "76" , "8" , "dds" +"ui\common\game\unit-over\health-bars-back_bmp.dds" , "76" , "16" , "dds" +"ui\common\game\unit-over\health-bars-back-1_bmp.dds" , "76" , "8" , "dds" +"ui\common\game\unit-over\icon-clock_bmp.dds" , "16" , "16" , "dds" +"ui\common\game\unit-over\icon-clock_large_bmp.dds" , "40" , "40" , "dds" +"ui\common\game\unit-over\icon-energy_bmp.dds" , "20" , "20" , "dds" +"ui\common\game\unit-over\icon-mass_bmp.dds" , "20" , "20" , "dds" +"ui\common\game\unit-over\icon-nuke_bmp.dds" , "20" , "20" , "dds" +"ui\common\game\unit-over\icon-skull_bmp.dds" , "24" , "24" , "dds" +"ui\common\game\unit-over\unit-bar_bmp.dds" , "192" , "16" , "dds" +"ui\common\game\unit-over\unit-over_bmp.dds" , "460" , "64" , "dds" +"ui\common\game\unit-over\unit-over-build_bmp.dds" , "344" , "52" , "dds" +"ui\common\game\unit-over\unit-over02_bmp.dds" , "492" , "100" , "dds" +"ui\common\game\unit-unselected-vertical\panel_bmp_b.dds" , "236" , "76" , "dds" +"ui\common\game\unit-unselected-vertical\panel_bmp_m.dds" , "236" , "8" , "dds" +"ui\common\game\unit-unselected-vertical\panel_bmp_t.dds" , "236" , "128" , "dds" +"ui\common\game\unit-vertical_bmp\bar-01_bmp.dds" , "148" , "8" , "dds" +"ui\common\game\unit-vertical_bmp\bar-back_bmp.dds" , "148" , "8" , "dds" +"ui\common\game\unit-vertical_bmp\bar-info-back01_bmp.dds" , "72" , "24" , "dds" +"ui\common\game\unit-vertical_bmp\bar-info-back02_bmp.dds" , "72" , "36" , "dds" +"ui\common\game\unit-vertical_bmp\bar02_bmp.dds" , "148" , "8" , "dds" +"ui\common\game\unit-vertical_bmp\unit-over-vertical_bmp.dds" , "244" , "168" , "dds" +"ui\common\game\unit-vertical_bmp\veteran-logo_bmp.dds" , "16" , "16" , "dds" +"ui\common\game\units_bmp\avatar-glow.dds" , "100" , "100" , "dds" +"ui\common\game\units_bmp\glow.dds" , "64" , "64" , "dds" +"ui\common\game\units_bmp\scroll-back_bmp.dds" , "64" , "112" , "dds" +"ui\common\game\units_bmp\unit_bmp_l.dds" , "80" , "128" , "dds" +"ui\common\game\units_bmp\unit_bmp_m.dds" , "80" , "128" , "dds" +"ui\common\game\units_bmp\unit_bmp_r.dds" , "80" , "128" , "dds" +"ui\common\game\units_bmp\unit-02_bmp_l.dds" , "80" , "132" , "dds" +"ui\common\game\units_bmp\unit-02_bmp_m.dds" , "80" , "132" , "dds" +"ui\common\game\units_bmp\unit-02_bmp_r.dds" , "80" , "132" , "dds" +"ui\common\game\units-vertical_bmp\scroll-back_bmp.dds" , "28" , "60" , "dds" +"ui\common\game\units-vertical_bmp\unit_bmp_b.dds" , "236" , "16" , "dds" +"ui\common\game\units-vertical_bmp\unit_bmp_m.dds" , "236" , "16" , "dds" +"ui\common\game\units-vertical_bmp\unit_bmp_t.dds" , "236" , "16" , "dds" +"ui\common\game\units-vertical_bmp\unit-02_bmp_b.dds" , "236" , "16" , "dds" +"ui\common\game\units-vertical_bmp\unit-02_bmp_m.dds" , "236" , "16" , "dds" +"ui\common\game\units-vertical_bmp\unit-02_bmp_t.dds" , "236" , "16" , "dds" +"ui\common\game\veteran-logo_bmp\aeon-veteran_bmp.dds" , "16" , "16" , "dds" +"ui\common\game\veteran-logo_bmp\cybran-veteran_bmp.dds" , "16" , "16" , "dds" +"ui\common\game\veteran-logo_bmp\seraphim-veteran_bmp.dds" , "16" , "16" , "dds" +"ui\common\game\veteran-logo_bmp\uef-veteran_bmp.dds" , "16" , "16" , "dds" +"ui\common\game\waypoints\attack_btn_up.dds" , "128" , "128" , "dds" +"ui\common\game\waypoints\attack_move_btn_up.dds" , "128" , "128" , "dds" +"ui\common\game\waypoints\convert_btn_up.dds" , "128" , "128" , "dds" +"ui\common\game\waypoints\ferry_btn_up.dds" , "128" , "128" , "dds" +"ui\common\game\waypoints\guard_btn_up.dds" , "128" , "128" , "dds" +"ui\common\game\waypoints\load_btn_up.dds" , "128" , "128" , "dds" +"ui\common\game\waypoints\move_btn_up.dds" , "128" , "128" , "dds" +"ui\common\game\waypoints\nuke_btn_up.dds" , "128" , "128" , "dds" +"ui\common\game\waypoints\patrol_btn_up.dds" , "128" , "128" , "dds" +"ui\common\game\waypoints\production_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\waypoints\reclaim_btn_up.dds" , "128" , "128" , "dds" +"ui\common\game\waypoints\repair_btn_up.dds" , "128" , "128" , "dds" +"ui\common\game\waypoints\return-fire_btn_up.dds" , "128" , "128" , "dds" +"ui\common\game\waypoints\sacrifice_btn_up.dds" , "126" , "123" , "dds" +"ui\common\game\waypoints\stop_btn_up.dds" , "128" , "128" , "dds" +"ui\common\game\waypoints\teleport_btn_up.dds" , "128" , "128" , "dds" +"ui\common\game\waypoints\unload_btn_up.dds" , "128" , "128" , "dds" +"ui\common\gen-tab_btn\gen-tab_btn_dis.dds" , "64" , "32" , "dds" +"ui\common\gen-tab_btn\gen-tab_btn_down.dds" , "64" , "32" , "dds" +"ui\common\gen-tab_btn\gen-tab_btn_over.dds" , "64" , "32" , "dds" +"ui\common\gen-tab_btn\gen-tab_btn_up.dds" , "64" , "32" , "dds" +"ui\common\icons\comm_aeon.dds" , "112" , "112" , "dds" +"ui\common\icons\comm_allied.dds" , "112" , "112" , "dds" +"ui\common\icons\comm_cybran.dds" , "112" , "112" , "dds" +"ui\common\icons\comm_seraphim.dds" , "112" , "112" , "dds" +"ui\common\icons\comm_uef.dds" , "112" , "112" , "dds" +"ui\common\icons\units\air_down.dds" , "64" , "64" , "dds" +"ui\common\icons\units\air_over.dds" , "64" , "64" , "dds" +"ui\common\icons\units\air_up.dds" , "64" , "64" , "dds" +"ui\common\icons\units\amph_down.dds" , "64" , "64" , "dds" +"ui\common\icons\units\amph_over.dds" , "64" , "64" , "dds" +"ui\common\icons\units\amph_up.dds" , "64" , "64" , "dds" +"ui\common\icons\units\cons_bar.dds" , "48" , "48" , "dds" +"ui\common\icons\units\daa0206_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\dab2102_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\dal0310_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\dea0202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\deb4303_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\default_icon.dds" , "48" , "48" , "dds" +"ui\common\icons\units\del0204_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\dra0202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\drl0204_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\drs0102_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\land_down.dds" , "64" , "64" , "dds" +"ui\common\icons\units\land_over.dds" , "64" , "64" , "dds" +"ui\common\icons\units\land_up.dds" , "64" , "64" , "dds" +"ui\common\icons\units\opc2002_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\ope2003_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\ope3001_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\ope6001_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\ope6003_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\sea_down.dds" , "64" , "64" , "dds" +"ui\common\icons\units\sea_over.dds" , "64" , "64" , "dds" +"ui\common\icons\units\sea_up.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAA0101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAA0102_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAA0103_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAA0104_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAA0107_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAA0203_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAA0204_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAA0302_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAA0303_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAA0304_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAA0310_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB0101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB0102_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB0103_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB0201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB0202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB0203_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB0301_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB0302_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB0303_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB0304_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB1101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\uab1101_over.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB1102_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB1103_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB1104_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB1105_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB1106_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB1201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB1202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB1301_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB1302_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB1303_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB2101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB2104_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB2108_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB2109_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB2204_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB2205_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB2301_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB2302_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB2303_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB2304_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB2305_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB3101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB3102_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB3104_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB3201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB3202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB4201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB4202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB4203_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB4301_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB4302_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB5101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB5102_icon.dds" , "32" , "32" , "dds" +"ui\common\icons\units\UAB5103_icon.dds" , "72" , "72" , "dds" +"ui\common\icons\units\UAB5202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\uac1101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\uac1201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\uac1301_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\uac1401_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\uac1501_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\uac1901_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAL0001_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAL0101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAL0103_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAL0104_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAL0105_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAL0106_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAL0111_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAL0201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAL0202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAL0205_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAL0208_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAL0301_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAL0303_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAL0304_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAL0307_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAL0309_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAL0401_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAS0102_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAS0103_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAS0201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAS0202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\uas0203_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAS0302_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAS0303_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAS0304_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAS0305_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAS0401_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\uea0001_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\uea0003_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEA0101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEA0102_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEA0103_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEA0104_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEA0107_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEA0203_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEA0204_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEA0302_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEA0303_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEA0304_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEA0305_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB0101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB0102_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB0103_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB0201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB0202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB0203_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB0301_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB0302_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB0303_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB0304_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB1101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB1102_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB1103_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB1104_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB1105_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB1106_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB1201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB1202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB1301_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB1302_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB1303_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB2101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB2104_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB2108_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB2109_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB2204_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB2205_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB2301_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB2302_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB2303_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB2304_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB2305_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB2401_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB3101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB3102_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB3103_icon.dds" , "72" , "72" , "dds" +"ui\common\icons\units\UEB3104_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB3201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB3202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB4201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB4202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB4203_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB4301_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB4302_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB5101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB5102_icon.dds" , "32" , "32" , "dds" +"ui\common\icons\units\UEB5103_icon.dds" , "72" , "72" , "dds" +"ui\common\icons\units\UEB5202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\uec0001_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\uec1101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\uec1201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\uec1301_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\uec1401_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\uec1501_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\uec1901_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\uec1902_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\uec1903_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\uec1904_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\uec1905_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\uec1906_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\uec1907_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEL0001_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEL0101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEL0103_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEL0104_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEL0105_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEL0106_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEL0111_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEL0201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEL0202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEL0203_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEL0205_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEL0208_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEL0301_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEL0303_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEL0304_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEL0307_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEL0309_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEL0401_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UES0103_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UES0201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UES0202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UES0203_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UES0302_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UES0304_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UES0305_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UES0401_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URA0001_icon.dds" , "72" , "72" , "dds" +"ui\common\icons\units\URA0101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URA0102_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URA0103_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URA0104_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URA0107_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URA0203_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URA0204_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URA0302_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URA0303_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URA0304_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URA0401_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB0101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB0102_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB0103_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB0201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB0202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB0203_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB0301_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB0302_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB0303_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB0304_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB1101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB1102_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB1103_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB1104_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB1105_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB1106_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB1201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB1202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB1301_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB1302_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB1303_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB2101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB2104_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB2108_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB2109_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB2204_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB2205_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB2301_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB2302_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB2303_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB2304_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB2305_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB3101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB3102_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB3104_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB3201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB3202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB3302_icon.dds" , "72" , "72" , "dds" +"ui\common\icons\units\URB4201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB4202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB4203_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB4204_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB4205_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB4206_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB4207_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB4302_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB5101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB5102_icon.dds" , "32" , "32" , "dds" +"ui\common\icons\units\URB5103_icon.dds" , "72" , "72" , "dds" +"ui\common\icons\units\URB5202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\urc0001_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\urc1101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\urc1201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\urc1301_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\urc1302_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\urc1401_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\urc1501_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\urc1901_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\urc1902_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URL0001_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URL0101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URL0103_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URL0104_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URL0105_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URL0106_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URL0107_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URL0111_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URL0202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URL0203_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URL0204_Icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URL0205_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URL0208_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URL0301_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URL0303_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URL0304_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URL0306_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URL0309_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URL0401_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URL0402_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URS0103_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URS0201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URS0202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URS0203_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URS0302_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URS0303_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URS0304_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URS0305_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xaa0202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xaa0305_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xaa0306_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xab1401_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xab2307_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xab3301_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xac0101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xac1101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xac1401_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xac2101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xac2201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xal0203_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xal0305_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xas0204_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xas0306_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xea0002_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xea0306_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xea3204_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xeb0104_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xeb0204_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xeb2306_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xeb2402_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xec1301_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xec1401_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xec1501_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xec8001_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xec8002_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xec8003_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xec8004_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xec8005_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xec8006_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xec8007_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xec8008_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xec8009_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xec8010_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xec8011_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xec8012_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xec8013_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xec8014_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xec8015_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xec8016_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xec8017_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xec8018_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xec8019_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xec8020_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xel0209_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xel0305_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xel0306_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xes0102_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xes0205_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xes0307_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xra0105_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xra0305_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrb0104_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrb0204_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrb0304_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrb2308_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrb3301_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc1101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc1502_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc2201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8001_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8002_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8003_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8004_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8005_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8006_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8007_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8008_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8009_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8010_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8011_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8012_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8013_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8014_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8015_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8016_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8017_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8018_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8019_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8020_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8102_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8103_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8104_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8105_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8106_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8107_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8108_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8109_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8110_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8111_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8112_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8113_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8114_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8115_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8116_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8117_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8118_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8119_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8120_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XRL0002_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrl0003_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XRL0004_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XRL0005_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrl0302_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrl0305_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrl0403_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrs0204_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrs0205_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSA0101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSA0102_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSA0103_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSA0104_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSA0107_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSA0202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSA0203_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSA0204_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSA0302_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSA0303_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSA0304_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSA0402_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB0101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB0102_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB0103_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB0201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB0202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB0203_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB0301_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB0302_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB0303_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB0304_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB1101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB1102_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB1103_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB1104_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB1105_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xsb1106_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB1201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB1202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB1301_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB1302_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB1303_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB2101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB2104_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB2108_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB2109_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB2204_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB2205_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB2301_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB2302_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB2303_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB2304_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB2305_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xsb2401_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB3101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB3102_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB3104_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB3201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB3202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB4201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB4202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB4203_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB4301_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xsb4302_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB5101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB5102_icon.dds" , "32" , "32" , "dds" +"ui\common\icons\units\XSB5202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSC0001_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSC1101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSC1201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSC1301_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSC1401_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSC1501_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSC1901_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSC1902_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xsc2201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xsc8001_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xsc8002_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xsc8003_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xsc8004_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xsc8005_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xsc8006_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xsc8007_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xsc8008_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xsc8009_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xsc8010_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xsc8011_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xsc8012_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSC9001_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSC9002_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSC9003_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSL0001_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSL0101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSL0103_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSL0104_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSL0105_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSL0111_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSL0201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSL0202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSL0203_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSL0205_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSL0208_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSL0301_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSL0303_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSL0304_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSL0305_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSL0307_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSL0309_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSL0401_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSL0402_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSL0403_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSL0404_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSS0103_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSS0201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSS0202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSS0203_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSS0302_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSS0303_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSS0304_icon.dds" , "64" , "64" , "dds" +"ui\common\load\background-portal_bmp.dds" , "1024" , "768" , "dds" +"ui\common\load\load-bar_bmp.dds" , "264" , "24" , "dds" +"ui\common\load\load-bar-bg_bmp.dds" , "264" , "24" , "dds" +"ui\common\load\panel_bmp.dds" , "404" , "352" , "dds" +"ui\common\lobby\aeon_ico.dds" , "24" , "24" , "dds" +"ui\common\lobby\cybran_ico.dds" , "24" , "24" , "dds" +"ui\common\lobby\direct-ip-connect\panel_bmp.dds" , "456" , "320" , "dds" +"ui\common\lobby\gamecreate\panel_bmp.dds" , "456" , "320" , "dds" +"ui\common\lobby\gameselect\panel_bmp.dds" , "944" , "608" , "dds" +"ui\common\lobby\gameselect02\panel_bmp.dds" , "888" , "548" , "dds" +"ui\common\lobby\gameselect02\text-over_bmp.dds" , "412" , "20" , "dds" +"ui\common\lobby\gameselect03\gameselect03_ip_cover.dds" , "396" , "28" , "dds" +"ui\common\lobby\gameselect03\gameselect03_ip_hlite.dds" , "396" , "28" , "dds" +"ui\common\lobby\gameselect03\gameselect03_lan_cover.dds" , "784" , "256" , "dds" +"ui\common\lobby\gameselect03\gameselect03_lan_hlite.dds" , "396" , "256" , "dds" +"ui\common\lobby\gameselect03\gameselect03_panel.dds" , "848" , "504" , "dds" +"ui\common\lobby\gameselect03\map-pane-border_bmp.dds" , "332" , "332" , "dds" +"ui\common\lobby\gameselect03\name_btn_dis.dds" , "320" , "40" , "dds" +"ui\common\lobby\gameselect03\name_btn_down.dds" , "320" , "40" , "dds" +"ui\common\lobby\gameselect03\name_btn_over.dds" , "320" , "40" , "dds" +"ui\common\lobby\gameselect03\name_btn_up.dds" , "320" , "40" , "dds" +"ui\common\lobby\gameselect03\panel_bmp.dds" , "1016" , "768" , "dds" +"ui\common\lobby\gameselect03\temp-game-sel-bg.dds" , "1008" , "768" , "dds" +"ui\common\lobby\lan-game-lobby\border-l_bmp.dds" , "304" , "396" , "dds" +"ui\common\lobby\lan-game-lobby\border-r_bmp.dds" , "304" , "396" , "dds" +"ui\common\lobby\lan-game-lobby\combo_btn_dis.dds" , "24" , "16" , "dds" +"ui\common\lobby\lan-game-lobby\combo_btn_down.dds" , "24" , "16" , "dds" +"ui\common\lobby\lan-game-lobby\combo_btn_over.dds" , "24" , "16" , "dds" +"ui\common\lobby\lan-game-lobby\combo_btn_up.dds" , "24" , "16" , "dds" +"ui\common\lobby\lan-game-lobby\drop-down_bmp_b.dds" , "52" , "16" , "dds" +"ui\common\lobby\lan-game-lobby\drop-down_bmp_m.dds" , "52" , "8" , "dds" +"ui\common\lobby\lan-game-lobby\drop-down_bmp_t.dds" , "52" , "16" , "dds" +"ui\common\lobby\lan-game-lobby\drop-down_bmp.dds" , "60" , "44" , "dds" +"ui\common\lobby\lan-game-lobby\drop-down-w_bmp_b.dds" , "116" , "16" , "dds" +"ui\common\lobby\lan-game-lobby\drop-down-w_bmp_m.dds" , "116" , "8" , "dds" +"ui\common\lobby\lan-game-lobby\drop-down-w_bmp_t.dds" , "116" , "16" , "dds" +"ui\common\lobby\lan-game-lobby\drop-down-w_bmp.dds" , "124" , "44" , "dds" +"ui\common\lobby\lan-game-lobby\highlight_bmp.dds" , "48" , "20" , "dds" +"ui\common\lobby\lan-game-lobby\large_btn_dis.dds" , "240" , "72" , "dds" +"ui\common\lobby\lan-game-lobby\large_btn_down.dds" , "240" , "72" , "dds" +"ui\common\lobby\lan-game-lobby\large_btn_over.dds" , "240" , "72" , "dds" +"ui\common\lobby\lan-game-lobby\large_btn_up.dds" , "240" , "72" , "dds" +"ui\common\lobby\lan-game-lobby\map-pane-border_bmp.dds" , "240" , "244" , "dds" +"ui\common\lobby\lan-game-lobby\panel_bmp.dds" , "1016" , "768" , "dds" +"ui\common\lobby\lan-game-lobby\panel-skirmish_bmp.dds" , "1016" , "768" , "dds" +"ui\common\lobby\lan-game-lobby\player_btn_dis.dds" , "368" , "32" , "dds" +"ui\common\lobby\lan-game-lobby\player_btn_down.dds" , "368" , "32" , "dds" +"ui\common\lobby\lan-game-lobby\player_btn_over.dds" , "368" , "32" , "dds" +"ui\common\lobby\lan-game-lobby\player_btn_up.dds" , "368" , "32" , "dds" +"ui\common\lobby\lan-game-lobby\player-drop_b.dds" , "364" , "28" , "dds" +"ui\common\lobby\lan-game-lobby\player-drop_m.dds" , "360" , "20" , "dds" +"ui\common\lobby\lan-game-lobby\player-drop_t.dds" , "360" , "20" , "dds" +"ui\common\lobby\lan-game-lobby\player-drop.dds" , "368" , "96" , "dds" +"ui\common\lobby\lan-game-lobby\player-text-highlight_bmp.dds" , "348" , "20" , "dds" +"ui\common\lobby\lan-game-lobby\small_btn_dis.dds" , "152" , "40" , "dds" +"ui\common\lobby\lan-game-lobby\small_btn_down.dds" , "152" , "40" , "dds" +"ui\common\lobby\lan-game-lobby\small_btn_over.dds" , "152" , "40" , "dds" +"ui\common\lobby\lan-game-lobby\small_btn_up.dds" , "152" , "40" , "dds" +"ui\common\lobby\lan-game-lobby\small-back_btn_dis.dds" , "168" , "40" , "dds" +"ui\common\lobby\lan-game-lobby\small-back_btn_down.dds" , "168" , "40" , "dds" +"ui\common\lobby\lan-game-lobby\small-back_btn_over.dds" , "168" , "40" , "dds" +"ui\common\lobby\lan-game-lobby\small-back_btn_up.dds" , "168" , "40" , "dds" +"ui\common\lobby\lan-game-lobby\square_btn_dis.dds" , "60" , "32" , "dds" +"ui\common\lobby\lan-game-lobby\square_btn_down.dds" , "60" , "32" , "dds" +"ui\common\lobby\lan-game-lobby\square_btn_over.dds" , "60" , "32" , "dds" +"ui\common\lobby\lan-game-lobby\square_btn_up.dds" , "60" , "32" , "dds" +"ui\common\lobby\lan-game-lobby\square-w_btn_dis.dds" , "124" , "32" , "dds" +"ui\common\lobby\lan-game-lobby\square-w_btn_down.dds" , "124" , "32" , "dds" +"ui\common\lobby\lan-game-lobby\square-w_btn_over.dds" , "124" , "32" , "dds" +"ui\common\lobby\lan-game-lobby\square-w_btn_up.dds" , "124" , "32" , "dds" +"ui\common\lobby\lan-game-lobby\toggle_btn_dis.dds" , "120" , "32" , "dds" +"ui\common\lobby\lan-game-lobby\toggle_btn_down.dds" , "120" , "32" , "dds" +"ui\common\lobby\lan-game-lobby\toggle_btn_over.dds" , "120" , "32" , "dds" +"ui\common\lobby\lan-game-lobby\toggle_btn_up.dds" , "120" , "32" , "dds" +"ui\common\lobby\multiplayer-select\panel_bmp.dds" , "476" , "360" , "dds" +"ui\common\lobby\multiplayer-select\panel02_bmp.dds" , "456" , "488" , "dds" +"ui\common\lobby\not-ready_ico.dds" , "24" , "24" , "dds" +"ui\common\lobby\observer_ico.dds" , "24" , "24" , "dds" +"ui\common\lobby\ready_ico.dds" , "24" , "24" , "dds" +"ui\common\lobby\team_icons\team_1_icon.dds" , "16" , "16" , "dds" +"ui\common\lobby\team_icons\team_2_icon.dds" , "16" , "16" , "dds" +"ui\common\lobby\team_icons\team_3_icon.dds" , "16" , "16" , "dds" +"ui\common\lobby\team_icons\team_4_icon.dds" , "16" , "16" , "dds" +"ui\common\lobby\team_icons\team_no_icon.dds" , "16" , "16" , "dds" +"ui\common\lobby\uef_ico.dds" , "24" , "24" , "dds" +"ui\common\logos\gpgnet_logo.dds" , "156" , "40" , "dds" +"ui\common\marketing\end_demo_1.dds" , "1920" , "1080" , "dds" +"ui\common\marketing\splash.dds" , "1024" , "768" , "dds" +"ui\common\medium-aeon-btn\medium02_btn_dis.dds" , "276" , "72" , "dds" +"ui\common\medium-aeon-btn\medium02_btn_down.dds" , "276" , "72" , "dds" +"ui\common\medium-aeon-btn\medium02_btn_glow.dds" , "276" , "72" , "dds" +"ui\common\medium-aeon-btn\medium02_btn_over.dds" , "276" , "72" , "dds" +"ui\common\medium-aeon-btn\medium02_btn_up.dds" , "276" , "72" , "dds" +"ui\common\medium-cybran-btn\medium02_btn_dis.dds" , "276" , "72" , "dds" +"ui\common\medium-cybran-btn\medium02_btn_down.dds" , "276" , "72" , "dds" +"ui\common\medium-cybran-btn\medium02_btn_glow.dds" , "276" , "72" , "dds" +"ui\common\medium-cybran-btn\medium02_btn_over.dds" , "276" , "72" , "dds" +"ui\common\medium-cybran-btn\medium02_btn_up.dds" , "276" , "72" , "dds" +"ui\common\medium-uef-btn\medium02_btn_dis.dds" , "276" , "72" , "dds" +"ui\common\medium-uef-btn\medium02_btn_down.dds" , "276" , "72" , "dds" +"ui\common\medium-uef-btn\medium02_btn_glow.dds" , "276" , "72" , "dds" +"ui\common\medium-uef-btn\medium02_btn_over.dds" , "276" , "72" , "dds" +"ui\common\medium-uef-btn\medium02_btn_up.dds" , "276" , "72" , "dds" +"ui\common\menus\background_bmp.dds" , "1024" , "768" , "dds" +"ui\common\menus\background-colossus_bmp.dds" , "1024" , "768" , "dds" +"ui\common\menus\background-colossus.dds" , "1024" , "768" , "dds" +"ui\common\menus\background-colossus02.dds" , "1024" , "768" , "dds" +"ui\common\menus\background-paint01_bmp.dds" , "1024" , "768" , "dds" +"ui\common\menus\background-paint02_bmp.dds" , "1024" , "768" , "dds" +"ui\common\menus\background-paint02-640x480_bmp.dds" , "640" , "480" , "dds" +"ui\common\menus\background-paint02-640x480_bmp.png" , "640" , "480" , "png" +"ui\common\menus\background-paint03_bmp.dds" , "1024" , "768" , "dds" +"ui\common\menus\background-paint04_bmp.dds" , "1024" , "768" , "dds" +"ui\common\menus\background-paint05_bmp.dds" , "1024" , "768" , "dds" +"ui\common\menus\background02_bmp.dds" , "1024" , "768" , "dds" +"ui\common\menus\background03_bmp.dds" , "1024" , "768" , "dds" +"ui\common\menus\background04_bmp.dds" , "1024" , "768" , "dds" +"ui\common\menus\background05_bmp.dds" , "1024" , "768" , "dds" +"ui\common\menus\blast .dds" , "640" , "680" , "dds" +"ui\common\menus\borde02r-l_bmp.dds" , "348" , "412" , "dds" +"ui\common\menus\border_bmp.dds" , "1024" , "768" , "dds" +"ui\common\menus\border-l_bmp.dds" , "308" , "400" , "dds" +"ui\common\menus\border-r_bmp.dds" , "308" , "400" , "dds" +"ui\common\menus\border02_bmp.dds" , "1024" , "768" , "dds" +"ui\common\menus\border02-r_bmp.dds" , "340" , "180" , "dds" +"ui\common\menus\border03_bmp.dds" , "1024" , "768" , "dds" +"ui\common\menus\gpgnet-logo\gpgnet-logo_bmp.dds" , "160" , "28" , "dds" +"ui\common\menus\lights_left.dds" , "36" , "324" , "dds" +"ui\common\menus\lights_right.dds" , "40" , "324" , "dds" +"ui\common\menus\main02-1600-1200\sc-logo_bmp.dds" , "728" , "136" , "dds" +"ui\common\menus\main02-1600-1200\scan-lines_bmp.dds" , "1028" , "776" , "dds" +"ui\common\menus\main02-1600-1200\texture-grime_bmp.dds" , "1600" , "1200" , "dds" +"ui\common\menus\main02\borde02-l_bmp.dds" , "624" , "136" , "dds" +"ui\common\menus\main02\border02-r_bmp.dds" , "296" , "420" , "dds" +"ui\common\menus\main02\border02-t_bmp.dds" , "340" , "176" , "dds" +"ui\common\menus\main02\large_btn_dis.dds" , "296" , "64" , "dds" +"ui\common\menus\main02\large_btn_down.dds" , "296" , "64" , "dds" +"ui\common\menus\main02\large_btn_over.dds" , "296" , "64" , "dds" +"ui\common\menus\main02\large_btn_up.dds" , "296" , "64" , "dds" +"ui\common\menus\main02\multiplayer beta.dds" , "604" , "224" , "dds" +"ui\common\menus\main02\multiplayer_beta.dds" , "604" , "224" , "dds" +"ui\common\menus\main02\panel-bottom_bmp.dds" , "336" , "72" , "dds" +"ui\common\menus\main02\panel-profile02_bmp.dds" , "336" , "76" , "dds" +"ui\common\menus\main02\panel-top_bmp.dds" , "336" , "84" , "dds" +"ui\common\menus\main02\profile-edit_btn_dis.dds" , "304" , "44" , "dds" +"ui\common\menus\main02\profile-edit_btn_down.dds" , "304" , "44" , "dds" +"ui\common\menus\main02\profile-edit_btn_over.dds" , "304" , "44" , "dds" +"ui\common\menus\main02\profile-edit_btn_up.dds" , "304" , "44" , "dds" +"ui\common\menus\main02\quick-panel_t.dds" , "292" , "20" , "dds" +"ui\common\menus\main02\quick-panel-b.dds" , "292" , "16" , "dds" +"ui\common\menus\main02\quick-panel-m.dds" , "292" , "8" , "dds" +"ui\common\menus\main02\sc-logo-lg_bmp.dds" , "508" , "160" , "dds" +"ui\common\menus\main02\texture-grime_bmp.dds" , "1024" , "768" , "dds" +"ui\common\menus\main02\thin_btn_dis.dds" , "260" , "32" , "dds" +"ui\common\menus\main02\thin_btn_down.dds" , "260" , "32" , "dds" +"ui\common\menus\main02\thin_btn_over.dds" , "260" , "32" , "dds" +"ui\common\menus\main02\thin_btn_up.dds" , "260" , "32" , "dds" +"ui\common\menus\main03\back_brd_horz_lm.dds" , "452" , "76" , "dds" +"ui\common\menus\main03\back_brd_horz_lml.dds" , "20" , "60" , "dds" +"ui\common\menus\main03\back_brd_horz_lmr.dds" , "20" , "60" , "dds" +"ui\common\menus\main03\back_brd_horz_um.dds" , "452" , "52" , "dds" +"ui\common\menus\main03\back_brd_horz_uml.dds" , "20" , "56" , "dds" +"ui\common\menus\main03\back_brd_horz_umr.dds" , "20" , "56" , "dds" +"ui\common\menus\main03\back_brd_ll.dds" , "40" , "132" , "dds" +"ui\common\menus\main03\back_brd_lr.dds" , "40" , "132" , "dds" +"ui\common\menus\main03\back_brd_ul.dds" , "40" , "128" , "dds" +"ui\common\menus\main03\back_brd_ur.dds" , "40" , "128" , "dds" +"ui\common\menus\main03\large_btn_dis.dds" , "296" , "64" , "dds" +"ui\common\menus\main03\large_btn_down.dds" , "296" , "64" , "dds" +"ui\common\menus\main03\large_btn_glow.dds" , "316" , "84" , "dds" +"ui\common\menus\main03\large_btn_over.dds" , "296" , "64" , "dds" +"ui\common\menus\main03\large_btn_up.dds" , "296" , "64" , "dds" +"ui\common\menus\main03\panel-bottom_bmp.dds" , "336" , "72" , "dds" +"ui\common\menus\main03\panel-top_bmp.dds" , "336" , "84" , "dds" +"ui\common\menus\main03\press_build.dds" , "604" , "224" , "dds" +"ui\common\menus\main03\profile-edit_btn_dis.dds" , "336" , "76" , "dds" +"ui\common\menus\main03\profile-edit_btn_down.dds" , "304" , "44" , "dds" +"ui\common\menus\main03\profile-edit_btn_over.dds" , "336" , "76" , "dds" +"ui\common\menus\main03\profile-edit_btn_up.dds" , "304" , "44" , "dds" +"ui\common\menus\multiplayer\panel_bmp.dds" , "484" , "460" , "dds" +"ui\common\menus\panel-profile_bmp.dds" , "188" , "36" , "dds" +"ui\common\menus\profile-select_btn_dis.dds" , "136" , "28" , "dds" +"ui\common\menus\profile-select_btn_down.dds" , "136" , "28" , "dds" +"ui\common\menus\profile-select_btn_over.dds" , "136" , "28" , "dds" +"ui\common\menus\profile-select_btn_up.dds" , "136" , "28" , "dds" +"ui\common\menus\pulse-ray_bmp.dds" , "684" , "676" , "dds" +"ui\common\menus\smoke_bmp.dds" , "936" , "704" , "dds" +"ui\common\menus\smoke-02_bmp.dds" , "936" , "704" , "dds" +"ui\common\menus\smoke.dds" , "900" , "684" , "dds" +"ui\common\menus\smoke02 .dds" , "900" , "684" , "dds" +"ui\common\menus02\background_bmp.dds" , "1024" , "768" , "dds" +"ui\common\menus02\background-colossus_bmp.dds" , "1024" , "768" , "dds" +"ui\common\menus02\background-paint01_bmp.dds" , "1024" , "768" , "dds" +"ui\common\menus02\background-paint02_bmp.dds" , "1024" , "768" , "dds" +"ui\common\menus02\background-paint03_bmp.dds" , "1024" , "768" , "dds" +"ui\common\menus02\background-paint04_bmp.dds" , "1024" , "768" , "dds" +"ui\common\menus02\background-paint05_bmp.dds" , "1024" , "768" , "dds" +"ui\common\menus02\background02_bmp.dds" , "1024" , "768" , "dds" +"ui\common\menus02\background03_bmp.dds" , "1024" , "768" , "dds" +"ui\common\menus02\background04_bmp.dds" , "1024" , "768" , "dds" +"ui\common\menus02\background05_bmp.dds" , "1024" , "768" , "dds" +"ui\common\menus02\borde02-l_bmp.dds" , "676" , "160" , "dds" +"ui\common\menus02\borde02b-l_bmp.dds" , "132" , "160" , "dds" +"ui\common\menus02\border_bmp.dds" , "1024" , "768" , "dds" +"ui\common\menus02\border-l_bmp.dds" , "364" , "400" , "dds" +"ui\common\menus02\border-r_bmp.dds" , "364" , "400" , "dds" +"ui\common\menus02\border02-r_bmp.dds" , "296" , "420" , "dds" +"ui\common\menus02\border02-t_bmp.dds" , "340" , "176" , "dds" +"ui\common\menus02\panel-profile_bmp.dds" , "336" , "76" , "dds" +"ui\common\menus02\profile-edit_btn_dis.dds" , "304" , "44" , "dds" +"ui\common\menus02\profile-edit_btn_down.dds" , "304" , "44" , "dds" +"ui\common\menus02\profile-edit_btn_over.dds" , "296" , "36" , "dds" +"ui\common\menus02\profile-edit_btn_up.dds" , "304" , "44" , "dds" +"ui\common\menus02\profile-select_btn_dis.dds" , "72" , "32" , "dds" +"ui\common\menus02\profile-select_btn_down.dds" , "72" , "32" , "dds" +"ui\common\menus02\profile-select_btn_over.dds" , "72" , "32" , "dds" +"ui\common\menus02\profile-select_btn_up.dds" , "72" , "32" , "dds" +"ui\common\menus02\pulse-ray_bmp.dds" , "684" , "676" , "dds" +"ui\common\menus02\smoke_bmp.dds" , "936" , "704" , "dds" +"ui\common\menus02\smoke-02_bmp.dds" , "936" , "704" , "dds" +"ui\common\portal02.dds" , "1024" , "768" , "dds" +"ui\common\scx_menu\bracket-left\bracket_bmp_b.dds" , "84" , "288" , "dds" +"ui\common\scx_menu\bracket-left\bracket_bmp_m.dds" , "28" , "12" , "dds" +"ui\common\scx_menu\bracket-left\bracket_bmp_t.dds" , "64" , "192" , "dds" +"ui\common\scx_menu\bracket-right\bracket_bmp_b.dds" , "84" , "288" , "dds" +"ui\common\scx_menu\bracket-right\bracket_bmp_m.dds" , "28" , "12" , "dds" +"ui\common\scx_menu\bracket-right\bracket_bmp_t.dds" , "64" , "192" , "dds" +"ui\common\scx_menu\bracket-tube-vertical\bracket-tube-v_bmp_b.dds" , "44" , "84" , "dds" +"ui\common\scx_menu\bracket-tube-vertical\bracket-tube-v_bmp_m.dds" , "40" , "4" , "dds" +"ui\common\scx_menu\bracket-tube-vertical\bracket-tube-v_bmp_t.dds" , "104" , "36" , "dds" +"ui\common\scx_menu\campaign-select\bg.dds" , "1824" , "1024" , "dds" +"ui\common\scx_menu\campaign-select\border-console-bot_bmp.dds" , "1020" , "96" , "dds" +"ui\common\scx_menu\campaign-select\border-console-bot-center_bmp.dds" , "952" , "140" , "dds" +"ui\common\scx_menu\campaign-select\border-console-top_bmp.dds" , "596" , "72" , "dds" +"ui\common\scx_menu\campaign-select\icon-video_bmp.dds" , "28" , "24" , "dds" +"ui\common\scx_menu\campaign-select\icon-video-dis_bmp.dds" , "28" , "24" , "dds" +"ui\common\scx_menu\campaign-select\large-emitter_bmp.dds" , "36" , "68" , "dds" +"ui\common\scx_menu\campaign-select\panel_bmp.dds" , "428" , "332" , "dds" +"ui\common\scx_menu\campaign-select\panel-02_bmp.dds" , "252" , "228" , "dds" +"ui\common\scx_menu\campaign-select\panel-list_bmp.dds" , "424" , "568" , "dds" +"ui\common\scx_menu\campaign-select\select_bmp.dds" , "20" , "32" , "dds" +"ui\common\scx_menu\campaign-select\small-emitter_bmp.dds" , "28" , "56" , "dds" +"ui\common\scx_menu\campaign-select\small-emitter_left_bmp.dds" , "28" , "56" , "dds" +"ui\common\scx_menu\collapse_btn\collapse_btn_dis.dds" , "24" , "24" , "dds" +"ui\common\scx_menu\collapse_btn\collapse_btn_down.dds" , "24" , "24" , "dds" +"ui\common\scx_menu\collapse_btn\collapse_btn_over.dds" , "24" , "24" , "dds" +"ui\common\scx_menu\collapse_btn\collapse_btn_up.dds" , "24" , "24" , "dds" +"ui\common\scx_menu\eula\eula.dds" , "720" , "600" , "dds" +"ui\common\scx_menu\game-select-faction-panel\panel_bmp.dds" , "332" , "184" , "dds" +"ui\common\scx_menu\game-settings\map-panel-glow_bmp.dds" , "328" , "328" , "dds" +"ui\common\scx_menu\game-settings\options-panel-bar_bmp.dds" , "260" , "48" , "dds" +"ui\common\scx_menu\game-settings\panel_bmp.dds" , "980" , "732" , "dds" +"ui\common\scx_menu\gamecreate\panel_bmp.dds" , "420" , "264" , "dds" +"ui\common\scx_menu\gamecreate\panel-brackets_bmp.dds" , "480" , "320" , "dds" +"ui\common\scx_menu\gameselect\map-panel_bmp.dds" , "596" , "292" , "dds" +"ui\common\scx_menu\gameselect\map-panel-glow_bmp.dds" , "260" , "260" , "dds" +"ui\common\scx_menu\gameselect\map-slot_bmp.dds" , "76" , "76" , "dds" +"ui\common\scx_menu\gameselect\panel_bmp.dds" , "976" , "736" , "dds" +"ui\common\scx_menu\gameselect\slot_bmp.dds" , "652" , "76" , "dds" +"ui\common\scx_menu\lan-game-lobby\panel_bmp.dds" , "1024" , "768" , "dds" +"ui\common\scx_menu\lan-game-lobby\panel-skirmish_bmp.dds" , "1024" , "768" , "dds" +"ui\common\scx_menu\large-btn-brackets\bracket-left.dds" , "36" , "68" , "dds" +"ui\common\scx_menu\large-btn-brackets\bracket-right.dds" , "40" , "68" , "dds" +"ui\common\scx_menu\large-btn-brackets\energy-spike-left_bmp.dds" , "20" , "16" , "dds" +"ui\common\scx_menu\large-btn-brackets\energy-spike-right_bmp.dds" , "20" , "16" , "dds" +"ui\common\scx_menu\large-btn\energy-spikes_bmp.dds" , "360" , "72" , "dds" +"ui\common\scx_menu\large-btn\large_btn_dis.dds" , "360" , "72" , "dds" +"ui\common\scx_menu\large-btn\large_btn_down.dds" , "360" , "72" , "dds" +"ui\common\scx_menu\large-btn\large_btn_glow.dds" , "360" , "72" , "dds" +"ui\common\scx_menu\large-btn\large_btn_over.dds" , "360" , "72" , "dds" +"ui\common\scx_menu\large-btn\large_btn_up.dds" , "360" , "72" , "dds" +"ui\common\scx_menu\large-no-bracket-btn\large_btn_dis.dds" , "360" , "72" , "dds" +"ui\common\scx_menu\large-no-bracket-btn\large_btn_down.dds" , "360" , "72" , "dds" +"ui\common\scx_menu\large-no-bracket-btn\large_btn_glow.dds" , "360" , "72" , "dds" +"ui\common\scx_menu\large-no-bracket-btn\large_btn_over.dds" , "360" , "72" , "dds" +"ui\common\scx_menu\large-no-bracket-btn\large_btn_up.dds" , "360" , "72" , "dds" +"ui\common\scx_menu\load\panel_bmp.dds" , "704" , "588" , "dds" +"ui\common\scx_menu\logo-btn\logo-aeon_btn_dis.dds" , "64" , "64" , "dds" +"ui\common\scx_menu\logo-btn\logo-aeon_btn_over_sel.dds" , "64" , "64" , "dds" +"ui\common\scx_menu\logo-btn\logo-aeon_btn_over.dds" , "64" , "64" , "dds" +"ui\common\scx_menu\logo-btn\logo-aeon_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\scx_menu\logo-btn\logo-aeon_btn_up.dds" , "64" , "64" , "dds" +"ui\common\scx_menu\logo-btn\logo-cybran_btn_dis.dds" , "64" , "64" , "dds" +"ui\common\scx_menu\logo-btn\logo-cybran_btn_over.dds" , "64" , "64" , "dds" +"ui\common\scx_menu\logo\logo.dds" , "500" , "148" , "dds" +"ui\common\scx_menu\main-menu\border-bot-left.dds" , "56" , "40" , "dds" +"ui\common\scx_menu\main-menu\border-bot-mid.dds" , "16" , "28" , "dds" +"ui\common\scx_menu\main-menu\border-bot-right.dds" , "56" , "40" , "dds" +"ui\common\scx_menu\main-menu\border-console-botton_bmp.dds" , "1024" , "100" , "dds" +"ui\common\scx_menu\main-menu\border-console-top_bmp.dds" , "528" , "156" , "dds" +"ui\common\scx_menu\main-menu\bracket_bmp_left.dds" , "48" , "76" , "dds" +"ui\common\scx_menu\main-menu\bracket_bmp_right.dds" , "48" , "76" , "dds" +"ui\common\scx_menu\main-menu\bracket-left_bmp.dds" , "84" , "592" , "dds" +"ui\common\scx_menu\main-menu\bracket-left-energy_bmp.dds" , "60" , "580" , "dds" +"ui\common\scx_menu\main-menu\bracket-lg_bmp_left.dds" , "36" , "96" , "dds" +"ui\common\scx_menu\main-menu\bracket-lg_bmp_right.dds" , "36" , "96" , "dds" +"ui\common\scx_menu\main-menu\bracket-right_bmp.dds" , "84" , "592" , "dds" +"ui\common\scx_menu\main-menu\bracket-right-energy_bmp.dds" , "60" , "580" , "dds" +"ui\common\scx_menu\main-menu\bracket-tube-h_bmp.dds" , "368" , "32" , "dds" +"ui\common\scx_menu\main-menu\bracket-tube-v_bmp.dds" , "108" , "152" , "dds" +"ui\common\scx_menu\main-menu\panel-top_bmp.dds" , "304" , "52" , "dds" +"ui\common\scx_menu\main-menu\profile-edit_btn_up.dds" , "304" , "44" , "dds" +"ui\common\scx_menu\main-menu\tickerborder.dds" , "896" , "40" , "dds" +"ui\common\scx_menu\medium-no-br-btn\medium-uef_btn_dis.dds" , "296" , "72" , "dds" +"ui\common\scx_menu\medium-no-br-btn\medium-uef_btn_down.dds" , "296" , "72" , "dds" +"ui\common\scx_menu\medium-no-br-btn\medium-uef_btn_glow.dds" , "296" , "72" , "dds" +"ui\common\scx_menu\medium-no-br-btn\medium-uef_btn_over.dds" , "296" , "72" , "dds" +"ui\common\scx_menu\medium-no-br-btn\medium-uef_btn_up.dds" , "296" , "72" , "dds" +"ui\common\scx_menu\mod-manager\panel_bmp.dds" , "716" , "588" , "dds" +"ui\common\scx_menu\movie-control\nav-back_btn_dis.dds" , "36" , "36" , "dds" +"ui\common\scx_menu\movie-control\nav-back_btn_down.dds" , "36" , "36" , "dds" +"ui\common\scx_menu\movie-control\nav-back_btn_over.dds" , "36" , "36" , "dds" +"ui\common\scx_menu\movie-control\nav-back_btn_up.dds" , "36" , "36" , "dds" +"ui\common\scx_menu\movie-control\nav-end_btn_dis.dds" , "36" , "36" , "dds" +"ui\common\scx_menu\movie-control\nav-end_btn_down.dds" , "36" , "36" , "dds" +"ui\common\scx_menu\movie-control\nav-end_btn_over.dds" , "36" , "36" , "dds" +"ui\common\scx_menu\movie-control\nav-end_btn_up.dds" , "36" , "36" , "dds" +"ui\common\scx_menu\movie-control\nav-ff_btn_dis.dds" , "36" , "36" , "dds" +"ui\common\scx_menu\movie-control\nav-ff_btn_down.dds" , "36" , "36" , "dds" +"ui\common\scx_menu\movie-control\nav-ff_btn_over.dds" , "36" , "36" , "dds" +"ui\common\scx_menu\movie-control\nav-ff_btn_up.dds" , "36" , "36" , "dds" +"ui\common\scx_menu\movie-control\nav-pause_btn_dis.dds" , "36" , "36" , "dds" +"ui\common\scx_menu\movie-control\nav-pause_btn_down.dds" , "36" , "36" , "dds" +"ui\common\scx_menu\movie-control\nav-pause_btn_over.dds" , "36" , "36" , "dds" +"ui\common\scx_menu\movie-control\nav-pause_btn_up.dds" , "36" , "36" , "dds" +"ui\common\scx_menu\movie-control\nav-play_btn_dis.dds" , "36" , "36" , "dds" +"ui\common\scx_menu\movie-control\nav-play_btn_down.dds" , "36" , "36" , "dds" +"ui\common\scx_menu\movie-control\nav-play_btn_over.dds" , "36" , "36" , "dds" +"ui\common\scx_menu\movie-control\nav-play_btn_up.dds" , "36" , "36" , "dds" +"ui\common\scx_menu\operation-briefing\border-console-bot_bmp.dds" , "1020" , "96" , "dds" +"ui\common\scx_menu\operation-briefing\border-console-top_bmp.dds" , "596" , "72" , "dds" +"ui\common\scx_menu\operation-briefing\emiter-bar_bmp.dds" , "872" , "96" , "dds" +"ui\common\scx_menu\operation-briefing\emiter-bar-aeon_bmp.dds" , "920" , "56" , "dds" +"ui\common\scx_menu\operation-briefing\emiter-bar-cybran_bmp.dds" , "872" , "116" , "dds" +"ui\common\scx_menu\operation-briefing\emiter-bar-uef_bmp.dds" , "872" , "64" , "dds" +"ui\common\scx_menu\operation-briefing\large-emitter_bmp.dds" , "36" , "68" , "dds" +"ui\common\scx_menu\operation-briefing\popup_btn_dis.dds" , "100" , "36" , "dds" +"ui\common\scx_menu\operation-briefing\popup_btn_down.dds" , "100" , "36" , "dds" +"ui\common\scx_menu\operation-briefing\popup_btn_over.dds" , "100" , "36" , "dds" +"ui\common\scx_menu\operation-briefing\popup_btn_up.dds" , "100" , "36" , "dds" +"ui\common\scx_menu\operation-briefing\small-emitter_bmp.dds" , "28" , "56" , "dds" +"ui\common\scx_menu\operation-briefing\status-bar_bmp.dds" , "360" , "16" , "dds" +"ui\common\scx_menu\operation-briefing\status-bar-back_bmp.dds" , "348" , "12" , "dds" +"ui\common\scx_menu\operation-briefing\subtitle_btn_off_over.dds" , "176" , "32" , "dds" +"ui\common\scx_menu\operation-briefing\subtitle_btn_off.dds" , "176" , "32" , "dds" +"ui\common\scx_menu\operation-briefing\subtitle_btn_on_over.dds" , "176" , "32" , "dds" +"ui\common\scx_menu\operation-briefing\subtitle_btn_on.dds" , "176" , "32" , "dds" +"ui\common\scx_menu\operation-briefing\text-panel_bmp.dds" , "844" , "192" , "dds" +"ui\common\scx_menu\operation-briefing\text-panel-aeon_bmp.dds" , "852" , "204" , "dds" +"ui\common\scx_menu\operation-briefing\text-panel-cybran_bmp.dds" , "844" , "192" , "dds" +"ui\common\scx_menu\operation-briefing\text-panel-uef_bmp.dds" , "844" , "192" , "dds" +"ui\common\scx_menu\options\content-box_bmp.dds" , "604" , "36" , "dds" +"ui\common\scx_menu\options\content-btn-line_bmp.dds" , "232" , "4" , "dds" +"ui\common\scx_menu\options\panel_bmp.dds" , "700" , "600" , "dds" +"ui\common\scx_menu\options\slots_bmp.dds" , "640" , "380" , "dds" +"ui\common\scx_menu\panel-brackets-small\bracket-glow-ll_bmp.dds" , "180" , "120" , "dds" +"ui\common\scx_menu\panel-brackets-small\bracket-glow-lr_bmp.dds" , "180" , "120" , "dds" +"ui\common\scx_menu\panel-brackets-small\bracket-glow-ul_bmp.dds" , "180" , "120" , "dds" +"ui\common\scx_menu\panel-brackets-small\bracket-glow-ur_bmp.dds" , "180" , "120" , "dds" +"ui\common\scx_menu\panel-brackets-small\bracket-ll_bmp.dds" , "180" , "120" , "dds" +"ui\common\scx_menu\panel-brackets-small\bracket-lr_bmp.dds" , "180" , "120" , "dds" +"ui\common\scx_menu\panel-brackets-small\bracket-ul_bmp.dds" , "180" , "120" , "dds" +"ui\common\scx_menu\panel-brackets-small\bracket-ur_bmp.dds" , "180" , "120" , "dds" +"ui\common\scx_menu\panel-brackets\bracket-glow-ll_bmp.dds" , "120" , "180" , "dds" +"ui\common\scx_menu\panel-brackets\bracket-glow-lr_bmp.dds" , "120" , "180" , "dds" +"ui\common\scx_menu\panel-brackets\bracket-glow-ul_bmp.dds" , "120" , "180" , "dds" +"ui\common\scx_menu\panel-brackets\bracket-glow-ur_bmp.dds" , "120" , "180" , "dds" +"ui\common\scx_menu\panel-brackets\bracket-ll_bmp.dds" , "120" , "180" , "dds" +"ui\common\scx_menu\panel-brackets\bracket-lr_bmp.dds" , "120" , "180" , "dds" +"ui\common\scx_menu\panel-brackets\bracket-ul_bmp.dds" , "120" , "180" , "dds" +"ui\common\scx_menu\panel-brackets\bracket-ur_bmp.dds" , "120" , "180" , "dds" +"ui\common\scx_menu\panel-brd\conn-bg.dds" , "396" , "60" , "dds" +"ui\common\scx_menu\panel-brd\panel_brd_horz_um.dds" , "8" , "68" , "dds" +"ui\common\scx_menu\panel-brd\panel_brd_ll.dds" , "76" , "72" , "dds" +"ui\common\scx_menu\panel-brd\panel_brd_lm.dds" , "8" , "68" , "dds" +"ui\common\scx_menu\panel-brd\panel_brd_lr.dds" , "76" , "72" , "dds" +"ui\common\scx_menu\panel-brd\panel_brd_m.dds" , "8" , "8" , "dds" +"ui\common\scx_menu\panel-brd\panel_brd_ul.dds" , "76" , "72" , "dds" +"ui\common\scx_menu\panel-brd\panel_brd_ur.dds" , "76" , "72" , "dds" +"ui\common\scx_menu\panel-brd\panel_brd_vert_l.dds" , "76" , "8" , "dds" +"ui\common\scx_menu\panel-brd\panel_brd_vert_r.dds" , "76" , "8" , "dds" +"ui\common\scx_menu\popup_btn\popup_btn_dis.dds" , "100" , "32" , "dds" +"ui\common\scx_menu\popup_btn\popup_btn_down.dds" , "100" , "32" , "dds" +"ui\common\scx_menu\popup_btn\popup_btn_over.dds" , "100" , "32" , "dds" +"ui\common\scx_menu\popup_btn\popup_btn_up.dds" , "100" , "32" , "dds" +"ui\common\scx_menu\profile-brackets\bracket-lg_bmp_left.dds" , "36" , "96" , "dds" +"ui\common\scx_menu\profile-brackets\bracket-lg_bmp_right.dds" , "36" , "96" , "dds" +"ui\common\scx_menu\profile-edit_btn\profile-edit_btn_dis.dds" , "304" , "44" , "dds" +"ui\common\scx_menu\profile-edit_btn\profile-edit_btn_down.dds" , "304" , "44" , "dds" +"ui\common\scx_menu\profile-edit_btn\profile-edit_btn_glow.dds" , "304" , "44" , "dds" +"ui\common\scx_menu\profile-edit_btn\profile-edit_btn_over.dds" , "304" , "44" , "dds" +"ui\common\scx_menu\profile-edit_btn\profile-edit_btn_up.dds" , "304" , "44" , "dds" +"ui\common\scx_menu\profile\panel_bmp.dds" , "580" , "448" , "dds" +"ui\common\scx_menu\replay\panel_bmp.dds" , "704" , "588" , "dds" +"ui\common\scx_menu\restrict_units\bg_over.dds" , "368" , "32" , "dds" +"ui\common\scx_menu\restrict_units\bg_sel_over.dds" , "368" , "32" , "dds" +"ui\common\scx_menu\restrict_units\bg_sel_up.dds" , "368" , "32" , "dds" +"ui\common\scx_menu\restrict_units\bg_up.dds" , "368" , "32" , "dds" +"ui\common\scx_menu\score-victory-defeat\panel_bmp.dds" , "972" , "588" , "dds" +"ui\common\scx_menu\score-victory-defeat\panel-campaign_bmp.dds" , "904" , "300" , "dds" +"ui\common\scx_menu\score-victory-defeat\panel-campaign_brd_ll.dds" , "8" , "8" , "dds" +"ui\common\scx_menu\score-victory-defeat\panel-campaign_brd_lm.dds" , "8" , "8" , "dds" +"ui\common\scx_menu\score-victory-defeat\panel-campaign_brd_lr.dds" , "8" , "8" , "dds" +"ui\common\scx_menu\score-victory-defeat\panel-campaign_brd_ul.dds" , "8" , "8" , "dds" +"ui\common\scx_menu\score-victory-defeat\panel-campaign_brd_um.dds" , "8" , "8" , "dds" +"ui\common\scx_menu\score-victory-defeat\panel-campaign_brd_ur.dds" , "8" , "8" , "dds" +"ui\common\scx_menu\score-victory-defeat\panel-campaign_brd_vert_l.dds" , "8" , "8" , "dds" +"ui\common\scx_menu\score-victory-defeat\panel-campaign_brd_vert_r.dds" , "8" , "8" , "dds" +"ui\common\scx_menu\score-victory-defeat\panel-line_bmp.dds" , "920" , "4" , "dds" +"ui\common\scx_menu\score-victory-defeat\player-back_bmp.dds" , "912" , "36" , "dds" +"ui\common\scx_menu\score-victory-defeat\totals-back_bmp.dds" , "284" , "40" , "dds" +"ui\common\scx_menu\score-victory-defeat\video-frame_bmp.dds" , "336" , "200" , "dds" +"ui\common\scx_menu\small-btn\small_btn_dis.dds" , "200" , "72" , "dds" +"ui\common\scx_menu\small-btn\small_btn_down.dds" , "200" , "72" , "dds" +"ui\common\scx_menu\small-btn\small_btn_glow.dds" , "200" , "72" , "dds" +"ui\common\scx_menu\small-btn\small_btn_over.dds" , "200" , "72" , "dds" +"ui\common\scx_menu\small-btn\small_btn_up.dds" , "200" , "72" , "dds" +"ui\common\scx_menu\small-short-btn\small-btn_dis.dds" , "172" , "52" , "dds" +"ui\common\scx_menu\small-short-btn\small-btn_down.dds" , "172" , "52" , "dds" +"ui\common\scx_menu\small-short-btn\small-btn_glow.dds" , "172" , "52" , "dds" +"ui\common\scx_menu\small-short-btn\small-btn_over.dds" , "172" , "52" , "dds" +"ui\common\scx_menu\small-short-btn\small-btn_up.dds" , "172" , "52" , "dds" +"ui\common\scx_menu\small-wide-btn\small-btn_dis.dds" , "260" , "72" , "dds" +"ui\common\scx_menu\small-wide-btn\small-btn_down.dds" , "260" , "72" , "dds" +"ui\common\scx_menu\small-wide-btn\small-btn_glow.dds" , "260" , "72" , "dds" +"ui\common\scx_menu\small-wide-btn\small-btn_over.dds" , "260" , "72" , "dds" +"ui\common\scx_menu\small-wide-btn\small-btn_up.dds" , "260" , "72" , "dds" +"ui\common\scx_menu\subtitle_btn\subtitles_btn_off_over.dds" , "176" , "36" , "dds" +"ui\common\scx_menu\subtitle_btn\subtitles_btn_off.dds" , "176" , "36" , "dds" +"ui\common\scx_menu\subtitle_btn\subtitles_btn_on_over.dds" , "176" , "36" , "dds" +"ui\common\scx_menu\subtitle_btn\subtitles_btn_on.dds" , "176" , "36" , "dds" +"ui\common\scx_menu\tab_btn\tab_btn_dis.dds" , "180" , "60" , "dds" +"ui\common\scx_menu\tab_btn\tab_btn_down.dds" , "180" , "60" , "dds" +"ui\common\scx_menu\tab_btn\tab_btn_over.dds" , "180" , "60" , "dds" +"ui\common\scx_menu\tab_btn\tab_btn_selected.dds" , "180" , "60" , "dds" +"ui\common\scx_menu\tab_btn\tab_btn_selected02.dds" , "180" , "60" , "dds" +"ui\common\scx_menu\tab_btn\tab_btn_up.dds" , "180" , "60" , "dds" +"ui\common\scx_menu\toggle-lg_btn\toggle-d_btn_dis.dds" , "112" , "48" , "dds" +"ui\common\scx_menu\toggle-lg_btn\toggle-d_btn_down.dds" , "112" , "48" , "dds" +"ui\common\scx_menu\toggle-lg_btn\toggle-d_btn_over.dds" , "112" , "48" , "dds" +"ui\common\scx_menu\toggle-lg_btn\toggle-d_btn_up.dds" , "112" , "48" , "dds" +"ui\common\scx_menu\toggle-lg_btn\toggle-s_btn_dis.dds" , "112" , "48" , "dds" +"ui\common\scx_menu\toggle-lg_btn\toggle-s_btn_down.dds" , "112" , "48" , "dds" +"ui\common\scx_menu\toggle-lg_btn\toggle-s_btn_over.dds" , "112" , "48" , "dds" +"ui\common\scx_menu\toggle-lg_btn\toggle-s_btn_up.dds" , "112" , "48" , "dds" +"ui\common\seraphim_load.dds" , "1280" , "1024" , "dds" +"ui\common\slider\slider_btn_dis.dds" , "32" , "24" , "dds" +"ui\common\slider\slider_btn_down.dds" , "32" , "24" , "dds" +"ui\common\slider\slider_btn_over.dds" , "32" , "24" , "dds" +"ui\common\slider\slider_btn_up.dds" , "32" , "24" , "dds" +"ui\common\slider\slider-back_bmp.dds" , "200" , "20" , "dds" +"ui\common\slider\slider-bar-green_bmp.dds" , "196" , "16" , "dds" +"ui\common\slider\slider-bar-orange_bmp.dds" , "196" , "16" , "dds" +"ui\common\slider02\slider_btn_dis.dds" , "32" , "24" , "dds" +"ui\common\slider02\slider_btn_down.dds" , "32" , "28" , "dds" +"ui\common\slider02\slider_btn_over.dds" , "32" , "24" , "dds" +"ui\common\slider02\slider_btn_up.dds" , "32" , "24" , "dds" +"ui\common\slider02\slider-back_bmp.dds" , "200" , "24" , "dds" +"ui\common\slider02\slider-bar-green_bmp.dds" , "196" , "16" , "dds" +"ui\common\slider02\slider-bar-orange_bmp.dds" , "196" , "16" , "dds" +"ui\common\small_wide_btn\small_wide_btn_dis.dds" , "204" , "48" , "dds" +"ui\common\small_wide_btn\small_wide_btn_dis.png" , "204" , "48" , "png" +"ui\common\small_wide_btn\small_wide_btn_down.dds" , "204" , "48" , "dds" +"ui\common\small_wide_btn\small_wide_btn_down.png" , "204" , "48" , "png" +"ui\common\small_wide_btn\small_wide_btn_over.dds" , "204" , "48" , "dds" +"ui\common\small_wide_btn\small_wide_btn_over.png" , "204" , "48" , "png" +"ui\common\small_wide_btn\small_wide_btn_up.dds" , "204" , "48" , "dds" +"ui\common\small_wide_btn\small_wide_btn_up.png" , "204" , "48" , "png" +"ui\common\small-stretch_btn\small_btn_dis_l.dds" , "32" , "36" , "dds" +"ui\common\small-stretch_btn\small_btn_dis_m.dds" , "8" , "36" , "dds" +"ui\common\small-stretch_btn\small_btn_dis_r.dds" , "32" , "36" , "dds" +"ui\common\small-stretch_btn\small_btn_down_l.dds" , "32" , "36" , "dds" +"ui\common\small-stretch_btn\small_btn_down_m.dds" , "8" , "36" , "dds" +"ui\common\small-stretch_btn\small_btn_down_r.dds" , "32" , "36" , "dds" +"ui\common\small-stretch_btn\small_btn_over_l.dds" , "32" , "36" , "dds" +"ui\common\small-stretch_btn\small_btn_over_m.dds" , "8" , "36" , "dds" +"ui\common\small-stretch_btn\small_btn_over_r.dds" , "32" , "36" , "dds" +"ui\common\small-stretch_btn\small_btn_up_l.dds" , "32" , "36" , "dds" +"ui\common\small-stretch_btn\small_btn_up_m.dds" , "8" , "36" , "dds" +"ui\common\small-stretch_btn\small_btn_up_r.dds" , "32" , "36" , "dds" +"ui\common\small-vert_scroll-aeon\arrow-down_scr_dis.dds" , "36" , "28" , "dds" +"ui\common\small-vert_scroll-aeon\arrow-down_scr_down.dds" , "36" , "28" , "dds" +"ui\common\small-vert_scroll-aeon\arrow-down_scr_over.dds" , "36" , "28" , "dds" +"ui\common\small-vert_scroll-aeon\arrow-down_scr_up.dds" , "36" , "28" , "dds" +"ui\common\small-vert_scroll-aeon\arrow-up_scr_dis.dds" , "36" , "28" , "dds" +"ui\common\small-vert_scroll-aeon\arrow-up_scr_down.dds" , "36" , "28" , "dds" +"ui\common\small-vert_scroll-aeon\arrow-up_scr_over.dds" , "36" , "28" , "dds" +"ui\common\small-vert_scroll-aeon\arrow-up_scr_up.dds" , "36" , "28" , "dds" +"ui\common\small-vert_scroll-aeon\back_scr_bot.dds" , "28" , "12" , "dds" +"ui\common\small-vert_scroll-aeon\back_scr_mid.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll-aeon\back_scr_top.dds" , "28" , "12" , "dds" +"ui\common\small-vert_scroll-aeon\bar-bot_scr_dis.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll-aeon\bar-bot_scr_down.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll-aeon\bar-bot_scr_over.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll-aeon\bar-bot_scr_up.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll-aeon\bar-mid_scr_dis.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll-aeon\bar-mid_scr_down.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll-aeon\bar-mid_scr_over.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll-aeon\bar-mid_scr_up.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll-aeon\bar-top_scr_dis.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll-aeon\bar-top_scr_down.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll-aeon\bar-top_scr_over.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll-aeon\bar-top_scr_up.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll-cybran\arrow-down_scr_dis.dds" , "36" , "32" , "dds" +"ui\common\small-vert_scroll-cybran\arrow-down_scr_down.dds" , "36" , "32" , "dds" +"ui\common\small-vert_scroll-cybran\arrow-down_scr_over.dds" , "36" , "32" , "dds" +"ui\common\small-vert_scroll-cybran\arrow-down_scr_up.dds" , "36" , "32" , "dds" +"ui\common\small-vert_scroll-cybran\arrow-up_scr_dis.dds" , "36" , "32" , "dds" +"ui\common\small-vert_scroll-cybran\arrow-up_scr_down.dds" , "36" , "32" , "dds" +"ui\common\small-vert_scroll-cybran\arrow-up_scr_over.dds" , "36" , "32" , "dds" +"ui\common\small-vert_scroll-cybran\arrow-up_scr_up.dds" , "36" , "32" , "dds" +"ui\common\small-vert_scroll-cybran\back_scr_mid.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll-cybran\bar-bot_scr_up.dds" , "36" , "12" , "dds" +"ui\common\small-vert_scroll-cybran\bar-mid_scr_up.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll-cybran\bar-top_scr_up.dds" , "36" , "12" , "dds" +"ui\common\small-vert_scroll-uef\arrow-down_scr_dis.dds" , "36" , "28" , "dds" +"ui\common\small-vert_scroll-uef\arrow-down_scr_down.dds" , "36" , "28" , "dds" +"ui\common\small-vert_scroll-uef\arrow-down_scr_over.dds" , "36" , "28" , "dds" +"ui\common\small-vert_scroll-uef\arrow-down_scr_up.dds" , "36" , "28" , "dds" +"ui\common\small-vert_scroll-uef\arrow-up_scr_dis.dds" , "36" , "28" , "dds" +"ui\common\small-vert_scroll-uef\arrow-up_scr_down.dds" , "36" , "28" , "dds" +"ui\common\small-vert_scroll-uef\arrow-up_scr_over.dds" , "36" , "28" , "dds" +"ui\common\small-vert_scroll-uef\arrow-up_scr_up.dds" , "36" , "28" , "dds" +"ui\common\small-vert_scroll-uef\back_scr_mid.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll-uef\bar-bot_scr_up.dds" , "36" , "12" , "dds" +"ui\common\small-vert_scroll-uef\bar-mid_scr_up.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll-uef\bar-top_scr_up.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll\arrow-down_scr_dis.dds" , "36" , "28" , "dds" +"ui\common\small-vert_scroll\arrow-down_scr_down.dds" , "36" , "28" , "dds" +"ui\common\small-vert_scroll\arrow-down_scr_over.dds" , "36" , "28" , "dds" +"ui\common\small-vert_scroll\arrow-down_scr_up.dds" , "36" , "28" , "dds" +"ui\common\small-vert_scroll\arrow-up_scr_dis.dds" , "36" , "28" , "dds" +"ui\common\small-vert_scroll\arrow-up_scr_down.dds" , "36" , "28" , "dds" +"ui\common\small-vert_scroll\arrow-up_scr_over.dds" , "36" , "28" , "dds" +"ui\common\small-vert_scroll\arrow-up_scr_up.dds" , "36" , "28" , "dds" +"ui\common\small-vert_scroll\back_scr_bot.dds" , "36" , "36" , "dds" +"ui\common\small-vert_scroll\back_scr_mid.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll\back_scr_top.dds" , "28" , "12" , "dds" +"ui\common\small-vert_scroll\bar-bot_scr_dis.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll\bar-bot_scr_down.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll\bar-bot_scr_over.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll\bar-bot_scr_up.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll\bar-mid_scr_dis.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll\bar-mid_scr_down.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll\bar-mid_scr_over.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll\bar-mid_scr_up.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll\bar-top_scr_dis.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll\bar-top_scr_down.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll\bar-top_scr_over.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll\bar-top_scr_up.dds" , "36" , "8" , "dds" +"ui\common\text_hotspot\text-edge_bmp.dds" , "16" , "84" , "dds" +"ui\common\text_hotspot\text-edge-glow_bmp.dds" , "16" , "84" , "dds" +"ui\common\UEF_load.dds" , "1280" , "1024" , "dds" +"ui\common\uef-btn-small\small_btn_dis.dds" , "196" , "76" , "dds" +"ui\common\uef-btn-small\small_btn_down.dds" , "196" , "76" , "dds" +"ui\common\uef-btn-small\small_btn_over.dds" , "196" , "76" , "dds" +"ui\common\uef-btn-small\small_btn_up.dds" , "196" , "76" , "dds" +"ui\common\widgets-cybran\large_btn_dis.dds" , "392" , "92" , "dds" +"ui\common\widgets-cybran\large_btn_down.dds" , "392" , "92" , "dds" +"ui\common\widgets-cybran\large_btn_over.dds" , "392" , "92" , "dds" +"ui\common\widgets-cybran\large_btn_up.dds" , "392" , "92" , "dds" +"ui\common\widgets-cybran\medium_btn_dis.dds" , "244" , "48" , "dds" +"ui\common\widgets-cybran\medium_btn_down.dds" , "244" , "48" , "dds" +"ui\common\widgets-cybran\medium_btn_over.dds" , "244" , "48" , "dds" +"ui\common\widgets-cybran\medium_btn_up.dds" , "244" , "48" , "dds" +"ui\common\widgets-cybran\panel-profile_bmp.dds" , "188" , "40" , "dds" +"ui\common\widgets-cybran\profile-select_btn_dis.dds" , "136" , "28" , "dds" +"ui\common\widgets-cybran\profile-select_btn_down.dds" , "136" , "28" , "dds" +"ui\common\widgets-cybran\profile-select_btn_over.dds" , "136" , "28" , "dds" +"ui\common\widgets-cybran\profile-select_btn_up.dds" , "136" , "28" , "dds" +"ui\common\widgets-cybran\small_btn_dis.dds" , "152" , "44" , "dds" +"ui\common\widgets-cybran\small_btn_down.dds" , "152" , "44" , "dds" +"ui\common\widgets-cybran\small_btn_over.dds" , "152" , "44" , "dds" +"ui\common\widgets-cybran\small_btn_up.dds" , "152" , "44" , "dds" +"ui\common\widgets\64x32_btn_dis.dds" , "64" , "32" , "dds" +"ui\common\widgets\64x32_btn_down.dds" , "64" , "32" , "dds" +"ui\common\widgets\64x32_btn_over.dds" , "64" , "32" , "dds" +"ui\common\widgets\64x32_btn_up.dds" , "64" , "32" , "dds" +"ui\common\widgets\64x64_btn_dis.dds" , "64" , "64" , "dds" +"ui\common\widgets\64x64_btn_down.dds" , "64" , "64" , "dds" +"ui\common\widgets\64x64_btn_over.dds" , "64" , "64" , "dds" +"ui\common\widgets\64x64_btn_up.dds" , "64" , "64" , "dds" +"ui\common\widgets\back_scr\back_scr_bot.dds" , "32" , "8" , "dds" +"ui\common\widgets\back_scr\back_scr_mid.dds" , "32" , "8" , "dds" +"ui\common\widgets\back_scr\back_scr_top.dds" , "32" , "8" , "dds" +"ui\common\widgets\back-small_scr\back-small_scr_bot.dds" , "24" , "8" , "dds" +"ui\common\widgets\back-small_scr\back-small_scr_mid.dds" , "24" , "8" , "dds" +"ui\common\widgets\back-small_scr\back-small_scr_top.dds" , "24" , "8" , "dds" +"ui\common\widgets\bar_sbr.dds" , "32" , "32" , "dds" +"ui\common\widgets\bg_sbr.dds" , "32" , "32" , "dds" +"ui\common\widgets\drop-down\drop_btn_dis_l.dds" , "20" , "20" , "dds" +"ui\common\widgets\drop-down\drop_btn_dis_m.dds" , "8" , "20" , "dds" +"ui\common\widgets\drop-down\drop_btn_dis_r.dds" , "40" , "20" , "dds" +"ui\common\widgets\drop-down\drop_btn_down_l.dds" , "20" , "20" , "dds" +"ui\common\widgets\drop-down\drop_btn_down_m.dds" , "8" , "20" , "dds" +"ui\common\widgets\drop-down\drop_btn_down_r.dds" , "40" , "20" , "dds" +"ui\common\widgets\drop-down\drop_btn_over_l.dds" , "20" , "20" , "dds" +"ui\common\widgets\drop-down\drop_btn_over_m.dds" , "8" , "20" , "dds" +"ui\common\widgets\drop-down\drop_btn_over_r.dds" , "40" , "20" , "dds" +"ui\common\widgets\drop-down\drop_btn_up_l.dds" , "20" , "20" , "dds" +"ui\common\widgets\drop-down\drop_btn_up_m.dds" , "8" , "20" , "dds" +"ui\common\widgets\drop-down\drop_btn_up_r.dds" , "40" , "20" , "dds" +"ui\common\widgets\drop-down\drop-box_brd_horz_um.dds" , "12" , "12" , "dds" +"ui\common\widgets\drop-down\drop-box_brd_ll.dds" , "12" , "12" , "dds" +"ui\common\widgets\drop-down\drop-box_brd_lm.dds" , "12" , "12" , "dds" +"ui\common\widgets\drop-down\drop-box_brd_lr.dds" , "12" , "12" , "dds" +"ui\common\widgets\drop-down\drop-box_brd_m.dds" , "12" , "12" , "dds" +"ui\common\widgets\drop-down\drop-box_brd_ul.dds" , "12" , "12" , "dds" +"ui\common\widgets\drop-down\drop-box_brd_ur.dds" , "12" , "12" , "dds" +"ui\common\widgets\drop-down\drop-box_brd_vert_l.dds" , "12" , "12" , "dds" +"ui\common\widgets\drop-down\drop-box_brd_vert_r.dds" , "12" , "12" , "dds" +"ui\common\widgets\drop-down\player-text-highlight_bmp.dds" , "12" , "16" , "dds" +"ui\common\widgets\faction-icons-alpha_bmp\aeon_ico.dds" , "24" , "24" , "dds" +"ui\common\widgets\faction-icons-alpha_bmp\cybran_ico.dds" , "24" , "24" , "dds" +"ui\common\widgets\faction-icons-alpha_bmp\observer_ico.dds" , "24" , "24" , "dds" +"ui\common\widgets\faction-icons-alpha_bmp\seraphim_ico.dds" , "24" , "24" , "dds" +"ui\common\widgets\faction-icons-alpha_bmp\uef_ico.dds" , "24" , "24" , "dds" +"ui\common\widgets\gen_brd_horz.dds" , "4" , "4" , "dds" +"ui\common\widgets\gen_brd_ll.dds" , "16" , "16" , "dds" +"ui\common\widgets\gen_brd_lr.dds" , "16" , "16" , "dds" +"ui\common\widgets\gen_brd_ul.dds" , "16" , "16" , "dds" +"ui\common\widgets\gen_brd_ur.dds" , "16" , "16" , "dds" +"ui\common\widgets\gen_brd_vert.dds" , "4" , "4" , "dds" +"ui\common\widgets\gen-tab_btn_dis.dds" , "44" , "16" , "dds" +"ui\common\widgets\gen-tab_btn_down.dds" , "44" , "16" , "dds" +"ui\common\widgets\gen-tab_btn_over.dds" , "44" , "16" , "dds" +"ui\common\widgets\gen-tab_btn_up.dds" , "44" , "16" , "dds" +"ui\common\widgets\generic03_brd\generic_brd_horz_lm.dds" , "12" , "12" , "dds" +"ui\common\widgets\generic03_brd\generic_brd_horz_um.dds" , "12" , "12" , "dds" +"ui\common\widgets\generic03_brd\generic_brd_ll.dds" , "12" , "12" , "dds" +"ui\common\widgets\generic03_brd\generic_brd_lr.dds" , "12" , "12" , "dds" +"ui\common\widgets\generic03_brd\generic_brd_m.dds" , "12" , "12" , "dds" +"ui\common\widgets\generic03_brd\generic_brd_ul.dds" , "12" , "12" , "dds" +"ui\common\widgets\generic03_brd\generic_brd_ur.dds" , "12" , "12" , "dds" +"ui\common\widgets\generic03_brd\generic_brd_vert_l.dds" , "12" , "12" , "dds" +"ui\common\widgets\generic03_brd\generic_brd_vert_r.dds" , "12" , "12" , "dds" +"ui\common\widgets\help_btn\help-sm_btn_dis.dds" , "40" , "40" , "dds" +"ui\common\widgets\help_btn\help-sm_btn_down.dds" , "40" , "40" , "dds" +"ui\common\widgets\help_btn\help-sm_btn_over.dds" , "40" , "40" , "dds" +"ui\common\widgets\help_btn\help-sm_btn_up.dds" , "40" , "40" , "dds" +"ui\common\widgets\large_btn_dis.dds" , "392" , "92" , "dds" +"ui\common\widgets\large_btn_down.dds" , "392" , "92" , "dds" +"ui\common\widgets\large_btn_over.dds" , "392" , "92" , "dds" +"ui\common\widgets\large_btn_up.dds" , "392" , "92" , "dds" +"ui\common\widgets\large_scr\arrow-down_scr_dis.dds" , "32" , "24" , "dds" +"ui\common\widgets\large_scr\arrow-down_scr_down.dds" , "40" , "28" , "dds" +"ui\common\widgets\large_scr\arrow-down_scr_over.dds" , "40" , "28" , "dds" +"ui\common\widgets\large_scr\arrow-down_scr_up.dds" , "40" , "28" , "dds" +"ui\common\widgets\large_scr\arrow-up_scr_dis.dds" , "32" , "24" , "dds" +"ui\common\widgets\large_scr\arrow-up_scr_down.dds" , "40" , "28" , "dds" +"ui\common\widgets\large_scr\arrow-up_scr_over.dds" , "40" , "28" , "dds" +"ui\common\widgets\large_scr\arrow-up_scr_up.dds" , "40" , "28" , "dds" +"ui\common\widgets\large_scr\bar-bot_scr_dis.dds" , "28" , "12" , "dds" +"ui\common\widgets\large_scr\bar-bot_scr_down.dds" , "40" , "12" , "dds" +"ui\common\widgets\large_scr\bar-bot_scr_over.dds" , "40" , "12" , "dds" +"ui\common\widgets\large_scr\bar-bot_scr_up.dds" , "28" , "12" , "dds" +"ui\common\widgets\large_scr\bar-mid_scr_dis.dds" , "28" , "28" , "dds" +"ui\common\widgets\large_scr\bar-mid_scr_down.dds" , "40" , "28" , "dds" +"ui\common\widgets\large_scr\bar-mid_scr_over.dds" , "40" , "28" , "dds" +"ui\common\widgets\large_scr\bar-mid_scr_up.dds" , "28" , "28" , "dds" +"ui\common\widgets\large_scr\bar-top_scr_dis.dds" , "28" , "12" , "dds" +"ui\common\widgets\large_scr\bar-top_scr_down.dds" , "40" , "12" , "dds" +"ui\common\widgets\large_scr\bar-top_scr_over.dds" , "40" , "12" , "dds" +"ui\common\widgets\large_scr\bar-top_scr_up.dds" , "28" , "12" , "dds" +"ui\common\widgets\large-h_scr\arrow-left_scr_dis.dds" , "24" , "32" , "dds" +"ui\common\widgets\large-h_scr\arrow-left_scr_down.dds" , "28" , "40" , "dds" +"ui\common\widgets\large-h_scr\arrow-left_scr_over.dds" , "28" , "40" , "dds" +"ui\common\widgets\large-h_scr\arrow-left_scr_up.dds" , "28" , "40" , "dds" +"ui\common\widgets\large-h_scr\arrow-right_scr_dis.dds" , "24" , "32" , "dds" +"ui\common\widgets\large-h_scr\arrow-right_scr_down.dds" , "28" , "40" , "dds" +"ui\common\widgets\large-h_scr\arrow-right_scr_over.dds" , "28" , "40" , "dds" +"ui\common\widgets\large-h_scr\arrow-right_scr_up.dds" , "28" , "40" , "dds" +"ui\common\widgets\large-h_scr\bar-left_scr_dis.dds" , "12" , "28" , "dds" +"ui\common\widgets\large-h_scr\bar-left_scr_down.dds" , "12" , "40" , "dds" +"ui\common\widgets\large-h_scr\bar-left_scr_over.dds" , "12" , "40" , "dds" +"ui\common\widgets\large-h_scr\bar-left_scr_up.dds" , "12" , "28" , "dds" +"ui\common\widgets\large-h_scr\bar-mid_scr_dis.dds" , "28" , "28" , "dds" +"ui\common\widgets\large-h_scr\bar-mid_scr_down.dds" , "28" , "40" , "dds" +"ui\common\widgets\large-h_scr\bar-mid_scr_over.dds" , "28" , "40" , "dds" +"ui\common\widgets\large-h_scr\bar-mid_scr_up.dds" , "28" , "28" , "dds" +"ui\common\widgets\large-h_scr\bar-right_scr_dis.dds" , "12" , "28" , "dds" +"ui\common\widgets\large-h_scr\bar-right_scr_down.dds" , "12" , "40" , "dds" +"ui\common\widgets\large-h_scr\bar-right_scr_over.dds" , "12" , "40" , "dds" +"ui\common\widgets\large-h_scr\bar-right_scr_up.dds" , "12" , "28" , "dds" +"ui\common\widgets\large02_btn_dis.dds" , "240" , "72" , "dds" +"ui\common\widgets\large02_btn_down.dds" , "240" , "72" , "dds" +"ui\common\widgets\large02_btn_over.dds" , "240" , "72" , "dds" +"ui\common\widgets\large02_btn_up.dds" , "240" , "72" , "dds" +"ui\common\widgets\medium_btn_dis.dds" , "276" , "72" , "dds" +"ui\common\widgets\medium_btn_down.dds" , "280" , "72" , "dds" +"ui\common\widgets\medium_btn_over.dds" , "276" , "72" , "dds" +"ui\common\widgets\medium_btn_up.dds" , "276" , "72" , "dds" +"ui\common\widgets\rad_down.dds" , "32" , "32" , "dds" +"ui\common\widgets\rad_over.dds" , "32" , "32" , "dds" +"ui\common\widgets\rad_sel.dds" , "32" , "32" , "dds" +"ui\common\widgets\rad_un.dds" , "32" , "32" , "dds" +"ui\common\widgets\radio02\rad_btn_disabled.dds" , "24" , "24" , "dds" +"ui\common\widgets\radio02\rad_btn_enabled.dds" , "24" , "24" , "dds" +"ui\common\widgets\small_btn_dis.dds" , "152" , "40" , "dds" +"ui\common\widgets\small_btn_down.dds" , "152" , "40" , "dds" +"ui\common\widgets\small_btn_over.dds" , "152" , "40" , "dds" +"ui\common\widgets\small_btn_up.dds" , "152" , "40" , "dds" +"ui\common\widgets\small_scr\arrow-down_scr_dis.dds" , "28" , "20" , "dds" +"ui\common\widgets\small_scr\arrow-down_scr_down.dds" , "28" , "20" , "dds" +"ui\common\widgets\small_scr\arrow-down_scr_over.dds" , "28" , "20" , "dds" +"ui\common\widgets\small_scr\arrow-down_scr_up.dds" , "28" , "20" , "dds" +"ui\common\widgets\small_scr\arrow-up_scr_dis.dds" , "28" , "20" , "dds" +"ui\common\widgets\small_scr\arrow-up_scr_down.dds" , "28" , "20" , "dds" +"ui\common\widgets\small_scr\arrow-up_scr_over.dds" , "28" , "20" , "dds" +"ui\common\widgets\small_scr\arrow-up_scr_up.dds" , "28" , "20" , "dds" +"ui\common\widgets\small_scr\bar-bot_scr_dis.dds" , "28" , "20" , "dds" +"ui\common\widgets\small_scr\bar-bot_scr_down.dds" , "28" , "20" , "dds" +"ui\common\widgets\small_scr\bar-bot_scr_over.dds" , "28" , "20" , "dds" +"ui\common\widgets\small_scr\bar-bot_scr_up.dds" , "28" , "20" , "dds" +"ui\common\widgets\small_scr\bar-mid_scr_dis.dds" , "28" , "20" , "dds" +"ui\common\widgets\small_scr\bar-mid_scr_down.dds" , "28" , "20" , "dds" +"ui\common\widgets\small_scr\bar-mid_scr_over.dds" , "28" , "20" , "dds" +"ui\common\widgets\small_scr\bar-mid_scr_up.dds" , "28" , "20" , "dds" +"ui\common\widgets\small_scr\bar-top_scr_dis.dds" , "28" , "20" , "dds" +"ui\common\widgets\small_scr\bar-top_scr_down.dds" , "28" , "20" , "dds" +"ui\common\widgets\small_scr\bar-top_scr_over.dds" , "28" , "20" , "dds" +"ui\common\widgets\small_scr\bar-top_scr_up.dds" , "28" , "20" , "dds" +"ui\common\widgets\small02_btn_dis.dds" , "152" , "40" , "dds" +"ui\common\widgets\small02_btn_down.dds" , "152" , "40" , "dds" +"ui\common\widgets\small02_btn_over.dds" , "152" , "40" , "dds" +"ui\common\widgets\small02_btn_up.dds" , "152" , "40" , "dds" +"ui\common\widgets\text_box.dds" , "624" , "60" , "dds" +"ui\common\widgets\text_over.dds" , "340" , "16" , "dds" +"ui\common\widgets\text-entry-brd\text-entry_brd_left.dds" , "8" , "20" , "dds" +"ui\common\widgets\text-entry-brd\text-entry_brd_mid.dds" , "4" , "20" , "dds" +"ui\common\widgets\text-entry-brd\text-entry_brd_right.dds" , "8" , "20" , "dds" +"ui\common\widgets\text-entry-brd02\text-entry_brd_left.dds" , "12" , "24" , "dds" +"ui\common\widgets\text-entry-brd02\text-entry_brd_mid.dds" , "12" , "24" , "dds" +"ui\common\widgets\text-entry-brd02\text-entry_brd_right.dds" , "12" , "24" , "dds" +"ui\common\widgets\toggle_btn_dis.dds" , "120" , "32" , "dds" +"ui\common\widgets\toggle_btn_down.dds" , "120" , "32" , "dds" +"ui\common\widgets\toggle_btn_over.dds" , "120" , "32" , "dds" +"ui\common\widgets\toggle_btn_up.dds" , "120" , "32" , "dds" +"ui\common\widgets02\large_btn_dis.dds" , "296" , "64" , "dds" +"ui\common\widgets02\large_btn_down.dds" , "296" , "64" , "dds" +"ui\common\widgets02\large_btn_over.dds" , "296" , "64" , "dds" +"ui\common\widgets02\large_btn_up.dds" , "296" , "64" , "dds" +"ui\common\widgets02\large02_btn_dis.dds" , "240" , "72" , "dds" +"ui\common\widgets02\large02_btn_down.dds" , "240" , "72" , "dds" +"ui\common\widgets02\large02_btn_over.dds" , "240" , "72" , "dds" +"ui\common\widgets02\large02_btn_up.dds" , "240" , "72" , "dds" +"ui\common\widgets02\medium_btn_dis.dds" , "276" , "72" , "dds" +"ui\common\widgets02\medium_btn_down.dds" , "280" , "72" , "dds" +"ui\common\widgets02\medium_btn_over.dds" , "276" , "72" , "dds" +"ui\common\widgets02\medium_btn_up.dds" , "276" , "72" , "dds" +"ui\common\widgets02\panel-profile02_bmp.dds" , "336" , "76" , "dds" +"ui\common\widgets02\profile-select_btn_dis.dds" , "72" , "32" , "dds" +"ui\common\widgets02\profile-select_btn_down.dds" , "72" , "32" , "dds" +"ui\common\widgets02\profile-select_btn_over.dds" , "72" , "32" , "dds" +"ui\common\widgets02\profile-select_btn_up.dds" , "72" , "32" , "dds" +"ui\common\widgets02\rad_sel.dds" , "32" , "32" , "dds" +"ui\common\widgets02\rad_un.dds" , "32" , "32" , "dds" +"ui\common\widgets02\small_btn_dis.dds" , "148" , "36" , "dds" +"ui\common\widgets02\small_btn_down.dds" , "148" , "36" , "dds" +"ui\common\widgets02\small_btn_over.dds" , "148" , "36" , "dds" +"ui\common\widgets02\small_btn_up.dds" , "148" , "36" , "dds" +"ui\common\widgets02\small-back_btn_dis.dds" , "168" , "40" , "dds" +"ui\common\widgets02\small-back_btn_down.dds" , "168" , "40" , "dds" +"ui\common\widgets02\small-back_btn_over.dds" , "168" , "40" , "dds" +"ui\common\widgets02\small-back_btn_up.dds" , "168" , "40" , "dds" +"ui\common\widgets02\small-back-trans_btn_dis.dds" , "164" , "36" , "dds" +"ui\common\widgets02\small-back-trans_btn_down.dds" , "164" , "36" , "dds" +"ui\common\widgets02\small-back-trans_btn_over.dds" , "164" , "36" , "dds" +"ui\common\widgets02\small-back-trans_btn_up.dds" , "164" , "36" , "dds" +"ui\common\widgets02\thin_btn_dis.dds" , "260" , "32" , "dds" +"ui\common\widgets02\thin_btn_down.dds" , "260" , "32" , "dds" +"ui\common\widgets02\thin_btn_over.dds" , "260" , "32" , "dds" +"ui\common\widgets02\thin_btn_up.dds" , "260" , "32" , "dds" +"ui\common\widgets02\toggle_btn_dis.dds" , "120" , "32" , "dds" +"ui\common\widgets02\toggle_btn_down.dds" , "120" , "32" , "dds" +"ui\common\widgets02\toggle_btn_over.dds" , "120" , "32" , "dds" +"ui\common\widgets02\toggle_btn_up.dds" , "120" , "32" , "dds" +"ui\cybran\dialogs\menu-btns\close_btn_dis.dds" , "24" , "24" , "dds" +"ui\cybran\dialogs\menu-btns\close_btn_down.dds" , "24" , "24" , "dds" +"ui\cybran\dialogs\menu-btns\close_btn_over.dds" , "24" , "24" , "dds" +"ui\cybran\dialogs\menu-btns\close_btn_up.dds" , "24" , "24" , "dds" +"ui\cybran\dialogs\menu-btns\config_btn_dis.dds" , "24" , "24" , "dds" +"ui\cybran\dialogs\menu-btns\config_btn_down.dds" , "24" , "24" , "dds" +"ui\cybran\dialogs\menu-btns\config_btn_over.dds" , "24" , "24" , "dds" +"ui\cybran\dialogs\menu-btns\config_btn_up.dds" , "24" , "24" , "dds" +"ui\cybran\dialogs\menu-btns\default_btn_dis.dds" , "24" , "24" , "dds" +"ui\cybran\dialogs\menu-btns\default_btn_down.dds" , "24" , "24" , "dds" +"ui\cybran\dialogs\menu-btns\default_btn_over.dds" , "24" , "24" , "dds" +"ui\cybran\dialogs\menu-btns\default_btn_up.dds" , "24" , "24" , "dds" +"ui\cybran\dialogs\menu-btns\pin_btn_dis.dds" , "24" , "24" , "dds" +"ui\cybran\dialogs\menu-btns\pin_btn_down.dds" , "24" , "24" , "dds" +"ui\cybran\dialogs\menu-btns\pin_btn_over.dds" , "24" , "24" , "dds" +"ui\cybran\dialogs\menu-btns\pin_btn_up.dds" , "24" , "24" , "dds" +"ui\cybran\dialogs\menu-btns\pinned_btn_dis.dds" , "24" , "24" , "dds" +"ui\cybran\dialogs\menu-btns\pinned_btn_down.dds" , "24" , "24" , "dds" +"ui\cybran\dialogs\menu-btns\pinned_btn_over.dds" , "24" , "24" , "dds" +"ui\cybran\dialogs\menu-btns\pinned_btn_up.dds" , "24" , "24" , "dds" +"ui\cybran\dialogs\time-units-tabs\energy_bmp.dds" , "16" , "28" , "dds" +"ui\cybran\dialogs\time-units-tabs\panel-tracking_bmp_d.dds" , "12" , "44" , "dds" +"ui\cybran\dialogs\time-units-tabs\panel-tracking_bmp_l.dds" , "24" , "44" , "dds" +"ui\cybran\dialogs\time-units-tabs\panel-tracking_bmp_m.dds" , "4" , "44" , "dds" +"ui\cybran\dialogs\time-units-tabs\panel-tracking_bmp_r.dds" , "24" , "44" , "dds" +"ui\cybran\dialogs\time-units-tabs\tracking-icon_bmp.dds" , "24" , "20" , "dds" +"ui\cybran\game\avatar-arrow_btn\tab-close_btn_dis.dds" , "12" , "32" , "dds" +"ui\cybran\game\avatar-arrow_btn\tab-close_btn_down.dds" , "12" , "32" , "dds" +"ui\cybran\game\avatar-arrow_btn\tab-close_btn_over.dds" , "12" , "32" , "dds" +"ui\cybran\game\avatar-arrow_btn\tab-close_btn_up.dds" , "12" , "32" , "dds" +"ui\cybran\game\avatar-arrow_btn\tab-open_btn_dis.dds" , "12" , "32" , "dds" +"ui\cybran\game\avatar-arrow_btn\tab-open_btn_down.dds" , "12" , "32" , "dds" +"ui\cybran\game\avatar-arrow_btn\tab-open_btn_over.dds" , "12" , "32" , "dds" +"ui\cybran\game\avatar-arrow_btn\tab-open_btn_up.dds" , "12" , "32" , "dds" +"ui\cybran\game\avatar-engineers-panel\bracket_bmp.dds" , "28" , "48" , "dds" +"ui\cybran\game\avatar-engineers-panel\panel-eng_bmp_b.dds" , "84" , "8" , "dds" +"ui\cybran\game\avatar-engineers-panel\panel-eng_bmp_m.dds" , "84" , "44" , "dds" +"ui\cybran\game\avatar-engineers-panel\panel-eng_bmp_t.dds" , "84" , "8" , "dds" +"ui\cybran\game\avatar-engineers-panel\tech-1_bmp.dds" , "28" , "28" , "dds" +"ui\cybran\game\avatar-engineers-panel\tech-2_bmp.dds" , "28" , "28" , "dds" +"ui\cybran\game\avatar-engineers-panel\tech-3_bmp.dds" , "28" , "28" , "dds" +"ui\cybran\game\avatar-factory-panel\bracket_bmp.dds" , "28" , "48" , "dds" +"ui\cybran\game\avatar-factory-panel\factory-panel_bmp.dds" , "184" , "160" , "dds" +"ui\cybran\game\avatar\avatar_bmp.dds" , "64" , "68" , "dds" +"ui\cybran\game\avatar\avatar-control-group_bmp.dds" , "48" , "36" , "dds" +"ui\cybran\game\avatar\avatar-s-e-f_bmp.dds" , "56" , "48" , "dds" +"ui\cybran\game\avatar\health-bar-back_bmp.dds" , "52" , "16" , "dds" +"ui\cybran\game\avatar\health-bar.dds" , "40" , "8" , "dds" +"ui\cybran\game\avatar\pulse-bars_bmp.dds" , "64" , "64" , "dds" +"ui\cybran\game\bracket-left-energy\bracket_bmp_b.dds" , "20" , "16" , "dds" +"ui\cybran\game\bracket-left-energy\bracket_bmp_m.dds" , "12" , "4" , "dds" +"ui\cybran\game\bracket-left-energy\bracket_bmp_t.dds" , "20" , "20" , "dds" +"ui\cybran\game\bracket-left-energy\bracket_bmp.dds" , "20" , "68" , "dds" +"ui\cybran\game\bracket-left\bracket_bmp_b.dds" , "32" , "28" , "dds" +"ui\cybran\game\bracket-left\bracket_bmp_m.dds" , "16" , "4" , "dds" +"ui\cybran\game\bracket-left\bracket_bmp_t.dds" , "32" , "68" , "dds" +"ui\cybran\game\bracket-left\bracket_bmp.dds" , "32" , "128" , "dds" +"ui\cybran\game\bracket-right-energy\bracket_bmp_b.dds" , "20" , "16" , "dds" +"ui\cybran\game\bracket-right-energy\bracket_bmp_m.dds" , "12" , "4" , "dds" +"ui\cybran\game\bracket-right-energy\bracket_bmp_t.dds" , "20" , "20" , "dds" +"ui\cybran\game\bracket-right-energy\bracket_bmp.dds" , "20" , "68" , "dds" +"ui\cybran\game\bracket-right\bracket_bmp_b.dds" , "32" , "28" , "dds" +"ui\cybran\game\bracket-right\bracket_bmp_m.dds" , "16" , "4" , "dds" +"ui\cybran\game\bracket-right\bracket_bmp_t.dds" , "32" , "68" , "dds" +"ui\cybran\game\bracket-right\bracket_bmp.dds" , "32" , "128" , "dds" +"ui\cybran\game\c-q-e-panel\arrow_bmp.dds" , "28" , "16" , "dds" +"ui\cybran\game\c-q-e-panel\arrow_vert_bmp.dds" , "16" , "24" , "dds" +"ui\cybran\game\c-q-e-panel\construct-panel_bmp_l.dds" , "84" , "60" , "dds" +"ui\cybran\game\c-q-e-panel\divider_bmp.dds" , "12" , "56" , "dds" +"ui\cybran\game\c-q-e-panel\divider_horizontal_bmp.dds" , "64" , "12" , "dds" +"ui\cybran\game\camera-btn\pinned_btn_dis.dds" , "24" , "24" , "dds" +"ui\cybran\game\camera-btn\pinned_btn_down.dds" , "24" , "24" , "dds" +"ui\cybran\game\camera-btn\pinned_btn_over.dds" , "24" , "24" , "dds" +"ui\cybran\game\camera-btn\pinned_btn_up.dds" , "24" , "24" , "dds" +"ui\cybran\game\chat_brd\chat_brd_horz_um.dds" , "8" , "48" , "dds" +"ui\cybran\game\chat_brd\chat_brd_ll.dds" , "44" , "48" , "dds" +"ui\cybran\game\chat_brd\chat_brd_lm.dds" , "8" , "48" , "dds" +"ui\cybran\game\chat_brd\chat_brd_lr.dds" , "44" , "48" , "dds" +"ui\cybran\game\chat_brd\chat_brd_m.dds" , "8" , "8" , "dds" +"ui\cybran\game\chat_brd\chat_brd_ul.dds" , "44" , "48" , "dds" +"ui\cybran\game\chat_brd\chat_brd_ur.dds" , "44" , "48" , "dds" +"ui\cybran\game\chat_brd\chat_brd_vert_l.dds" , "44" , "8" , "dds" +"ui\cybran\game\chat_brd\chat_brd_vert_r.dds" , "44" , "8" , "dds" +"ui\cybran\game\chat_brd\drop-box_brd_horz_um.dds" , "8" , "16" , "dds" +"ui\cybran\game\chat_brd\drop-box_brd_ll.dds" , "16" , "12" , "dds" +"ui\cybran\game\chat_brd\drop-box_brd_lm.dds" , "8" , "12" , "dds" +"ui\cybran\game\chat_brd\drop-box_brd_lr.dds" , "16" , "12" , "dds" +"ui\cybran\game\chat_brd\drop-box_brd_m.dds" , "8" , "8" , "dds" +"ui\cybran\game\chat_brd\drop-box_brd_ul.dds" , "16" , "16" , "dds" +"ui\cybran\game\chat_brd\drop-box_brd_ur.dds" , "16" , "16" , "dds" +"ui\cybran\game\chat_brd\drop-box_brd_vert_l.dds" , "16" , "8" , "dds" +"ui\cybran\game\chat_brd\drop-box_brd_vert_r.dds" , "16" , "8" , "dds" +"ui\cybran\game\chat-box_btn\radio_btn_dis.dds" , "28" , "28" , "dds" +"ui\cybran\game\chat-box_btn\radio_btn_down.dds" , "28" , "28" , "dds" +"ui\cybran\game\chat-box_btn\radio_btn_over.dds" , "28" , "28" , "dds" +"ui\cybran\game\chat-box_btn\radio_btn_up.dds" , "28" , "28" , "dds" +"ui\cybran\game\construct-panel_vert\construct-panel_bmp_b.dds" , "172" , "12" , "dds" +"ui\cybran\game\construct-panel_vert\construct-panel_bmp_m.dds" , "172" , "8" , "dds" +"ui\cybran\game\construct-panel_vert\construct-panel_bmp_t.dds" , "172" , "64" , "dds" +"ui\cybran\game\construct-panel_vert\que-panel_bmp_b.dds" , "60" , "8" , "dds" +"ui\cybran\game\construct-panel_vert\que-panel_bmp_m.dds" , "60" , "8" , "dds" +"ui\cybran\game\construct-panel_vert\que-panel_bmp_t.dds" , "60" , "8" , "dds" +"ui\cybran\game\construct-panel\construct-panel_bmp_l.dds" , "84" , "140" , "dds" +"ui\cybran\game\construct-panel\construct-panel_bmp_m1.dds" , "8" , "140" , "dds" +"ui\cybran\game\construct-panel\construct-panel_bmp_m2.dds" , "16" , "140" , "dds" +"ui\cybran\game\construct-panel\construct-panel_bmp_m3.dds" , "8" , "112" , "dds" +"ui\cybran\game\construct-panel\construct-panel_bmp_r.dds" , "16" , "112" , "dds" +"ui\cybran\game\construct-panel\construct-panel_s_bmp_l.dds" , "16" , "112" , "dds" +"ui\cybran\game\construct-panel\construct-panel_s_bmp_m.dds" , "8" , "112" , "dds" +"ui\cybran\game\construct-panel\construct-panel_s_bmp_r.dds" , "16" , "112" , "dds" +"ui\cybran\game\construct-panel\que-panel_bmp_l.dds" , "12" , "52" , "dds" +"ui\cybran\game\construct-panel\que-panel_bmp_m.dds" , "8" , "52" , "dds" +"ui\cybran\game\construct-panel\que-panel_bmp_r.dds" , "12" , "52" , "dds" +"ui\cybran\game\construct-sm_btn\back_off.dds" , "24" , "52" , "dds" +"ui\cybran\game\construct-sm_btn\back_on.dds" , "24" , "52" , "dds" +"ui\cybran\game\construct-sm_btn\fforward_off.dds" , "24" , "52" , "dds" +"ui\cybran\game\construct-sm_btn\fforward_on.dds" , "24" , "52" , "dds" +"ui\cybran\game\construct-sm_btn\forward_off.dds" , "24" , "52" , "dds" +"ui\cybran\game\construct-sm_btn\forward_on.dds" , "24" , "52" , "dds" +"ui\cybran\game\construct-sm_btn\infinite_off.dds" , "24" , "52" , "dds" +"ui\cybran\game\construct-sm_btn\infinite_on.dds" , "24" , "52" , "dds" +"ui\cybran\game\construct-sm_btn\left_btn_dis.dds" , "24" , "52" , "dds" +"ui\cybran\game\construct-sm_btn\left_btn_over.dds" , "24" , "52" , "dds" +"ui\cybran\game\construct-sm_btn\left_btn_up.dds" , "24" , "52" , "dds" +"ui\cybran\game\construct-sm_btn\mid_btn_dis.dds" , "24" , "52" , "dds" +"ui\cybran\game\construct-sm_btn\mid_btn_over.dds" , "24" , "52" , "dds" +"ui\cybran\game\construct-sm_btn\mid_btn_selected.dds" , "24" , "52" , "dds" +"ui\cybran\game\construct-sm_btn\mid_btn_up.dds" , "24" , "52" , "dds" +"ui\cybran\game\construct-sm_btn\pause_off.dds" , "24" , "52" , "dds" +"ui\cybran\game\construct-sm_btn\pause_on.dds" , "24" , "52" , "dds" +"ui\cybran\game\construct-sm_btn\rewind_off.dds" , "24" , "52" , "dds" +"ui\cybran\game\construct-sm_btn\rewind_on.dds" , "24" , "52" , "dds" +"ui\cybran\game\construct-sm_btn\right_btn_dis.dds" , "24" , "52" , "dds" +"ui\cybran\game\construct-sm_btn\right_btn_over.dds" , "24" , "52" , "dds" +"ui\cybran\game\construct-sm_btn\right_btn_up.dds" , "24" , "52" , "dds" +"ui\cybran\game\construct-sm_btn\template_off.dds" , "24" , "52" , "dds" +"ui\cybran\game\construct-sm_btn\template_on.dds" , "24" , "52" , "dds" +"ui\cybran\game\construct-sm_horiz_btn\infinite_off.dds" , "60" , "28" , "dds" +"ui\cybran\game\construct-sm_horiz_btn\infinite_on.dds" , "60" , "28" , "dds" +"ui\cybran\game\construct-sm_horiz_btn\pause_off.dds" , "60" , "28" , "dds" +"ui\cybran\game\construct-sm_horiz_btn\pause_on.dds" , "60" , "28" , "dds" +"ui\cybran\game\construct-sm_horiz_btn\que_btn_dis.dds" , "60" , "28" , "dds" +"ui\cybran\game\construct-sm_horiz_btn\que_btn_over.dds" , "60" , "28" , "dds" +"ui\cybran\game\construct-sm_horiz_btn\que_btn_selected.dds" , "60" , "28" , "dds" +"ui\cybran\game\construct-sm_horiz_btn\que_btn_up.dds" , "60" , "28" , "dds" +"ui\cybran\game\construct-sm_horiz_btn\template_off.dds" , "60" , "28" , "dds" +"ui\cybran\game\construct-sm_horiz_btn\template_on.dds" , "60" , "28" , "dds" +"ui\cybran\game\construct-sm_nav_horiz_btn\bottom_btn_dis.dds" , "60" , "16" , "dds" +"ui\cybran\game\construct-sm_nav_horiz_btn\bottom_btn_over.dds" , "60" , "16" , "dds" +"ui\cybran\game\construct-sm_nav_horiz_btn\bottom_btn_up.dds" , "60" , "16" , "dds" +"ui\cybran\game\construct-sm_nav_horiz_btn\down_off.dds" , "60" , "16" , "dds" +"ui\cybran\game\construct-sm_nav_horiz_btn\down_on.dds" , "60" , "16" , "dds" +"ui\cybran\game\construct-sm_nav_horiz_btn\end_off.dds" , "60" , "16" , "dds" +"ui\cybran\game\construct-sm_nav_horiz_btn\end_on.dds" , "60" , "16" , "dds" +"ui\cybran\game\construct-sm_nav_horiz_btn\home_off.dds" , "60" , "16" , "dds" +"ui\cybran\game\construct-sm_nav_horiz_btn\home_on.dds" , "60" , "16" , "dds" +"ui\cybran\game\construct-sm_nav_horiz_btn\mid_btn_dis.dds" , "60" , "16" , "dds" +"ui\cybran\game\construct-sm_nav_horiz_btn\mid_btn_over.dds" , "60" , "16" , "dds" +"ui\cybran\game\construct-sm_nav_horiz_btn\mid_btn_up.dds" , "60" , "16" , "dds" +"ui\cybran\game\construct-sm_nav_horiz_btn\top_btn_dis.dds" , "60" , "16" , "dds" +"ui\cybran\game\construct-sm_nav_horiz_btn\top_btn_over.dds" , "60" , "16" , "dds" +"ui\cybran\game\construct-sm_nav_horiz_btn\top_btn_up.dds" , "60" , "16" , "dds" +"ui\cybran\game\construct-sm_nav_horiz_btn\up_off.dds" , "60" , "16" , "dds" +"ui\cybran\game\construct-sm_nav_horiz_btn\up_on.dds" , "60" , "16" , "dds" +"ui\cybran\game\construct-tab_btn\bot_tab_btn_dis_bmp.dds" , "76" , "56" , "dds" +"ui\cybran\game\construct-tab_btn\bot_tab_btn_down_bmp.dds" , "76" , "56" , "dds" +"ui\cybran\game\construct-tab_btn\bot_tab_btn_over_bmp.dds" , "76" , "56" , "dds" +"ui\cybran\game\construct-tab_btn\bot_tab_btn_sel_bmp.dds" , "76" , "56" , "dds" +"ui\cybran\game\construct-tab_btn\bot_tab_btn_up_bmp.dds" , "76" , "56" , "dds" +"ui\cybran\game\construct-tab_btn\mid_tab_btn_dis_bmp.dds" , "76" , "56" , "dds" +"ui\cybran\game\construct-tab_btn\mid_tab_btn_down_bmp.dds" , "76" , "56" , "dds" +"ui\cybran\game\construct-tab_btn\mid_tab_btn_over_bmp.dds" , "76" , "56" , "dds" +"ui\cybran\game\construct-tab_btn\mid_tab_btn_sel_bmp.dds" , "76" , "56" , "dds" +"ui\cybran\game\construct-tab_btn\mid_tab_btn_up_bmp.dds" , "76" , "56" , "dds" +"ui\cybran\game\construct-tab_btn\top_tab_btn_dis_bmp.dds" , "76" , "56" , "dds" +"ui\cybran\game\construct-tab_btn\top_tab_btn_down_bmp.dds" , "76" , "56" , "dds" +"ui\cybran\game\construct-tab_btn\top_tab_btn_over_bmp.dds" , "76" , "56" , "dds" +"ui\cybran\game\construct-tab_btn\top_tab_btn_sel_bmp.dds" , "76" , "56" , "dds" +"ui\cybran\game\construct-tab_btn\top_tab_btn_up_bmp.dds" , "76" , "56" , "dds" +"ui\cybran\game\construct-tab_top_btn\bot_tab_btn_dis_bmp.dds" , "60" , "32" , "dds" +"ui\cybran\game\construct-tab_top_btn\bot_tab_btn_down_bmp.dds" , "60" , "32" , "dds" +"ui\cybran\game\construct-tab_top_btn\bot_tab_btn_over_bmp.dds" , "60" , "32" , "dds" +"ui\cybran\game\construct-tab_top_btn\bot_tab_btn_sel_bmp.dds" , "60" , "32" , "dds" +"ui\cybran\game\construct-tab_top_btn\bot_tab_btn_up_bmp.dds" , "60" , "32" , "dds" +"ui\cybran\game\construct-tab_top_btn\mid_tab_btn_dis_bmp.dds" , "60" , "32" , "dds" +"ui\cybran\game\construct-tab_top_btn\mid_tab_btn_down_bmp.dds" , "60" , "32" , "dds" +"ui\cybran\game\construct-tab_top_btn\mid_tab_btn_over_bmp.dds" , "60" , "32" , "dds" +"ui\cybran\game\construct-tab_top_btn\mid_tab_btn_sel_bmp.dds" , "60" , "32" , "dds" +"ui\cybran\game\construct-tab_top_btn\mid_tab_btn_up_bmp.dds" , "60" , "32" , "dds" +"ui\cybran\game\construct-tab_top_btn\top_tab_btn_dis_bmp.dds" , "60" , "32" , "dds" +"ui\cybran\game\construct-tab_top_btn\top_tab_btn_down_bmp.dds" , "60" , "32" , "dds" +"ui\cybran\game\construct-tab_top_btn\top_tab_btn_over_bmp.dds" , "60" , "32" , "dds" +"ui\cybran\game\construct-tab_top_btn\top_tab_btn_sel_bmp.dds" , "60" , "32" , "dds" +"ui\cybran\game\construct-tab_top_btn\top_tab_btn_up_bmp.dds" , "60" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\left_upgrade_btn_dis.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\left_upgrade_btn_down.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\left_upgrade_btn_over.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\left_upgrade_btn_selected.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\left_upgrade_btn_up.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\m_upgrade_btn_dis.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\m_upgrade_btn_down.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\m_upgrade_btn_over.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\m_upgrade_btn_selected.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\m_upgrade_btn_up.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\r_upgrade_btn_dis.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\r_upgrade_btn_down.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\r_upgrade_btn_over.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\r_upgrade_btn_selected.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\r_upgrade_btn_up.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\t1_btn_dis.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\t1_btn_down.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\t1_btn_over.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\t1_btn_selected.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\t1_btn_up.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\t2_btn_dis.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\t2_btn_down.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\t2_btn_over.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\t2_btn_selected.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\t2_btn_up.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\t3_btn_dis.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\t3_btn_down.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\t3_btn_over.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\t3_btn_selected.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\t3_btn_up.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\t4_btn_dis.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\t4_btn_down.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\t4_btn_over.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\t4_btn_selected.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\t4_btn_up.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\template_btn_dis.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\template_btn_down.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\template_btn_over.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\template_btn_selected.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\template_btn_up.dds" , "32" , "32" , "dds" +"ui\cybran\game\control-group-bracket\panel-control_bmp_b.dds" , "64" , "28" , "dds" +"ui\cybran\game\control-group-bracket\panel-control_bmp_m.dds" , "60" , "4" , "dds" +"ui\cybran\game\control-group-bracket\panel-control_bmp_t.dds" , "68" , "52" , "dds" +"ui\cybran\game\drag-handle\drag-handle-ll_btn_dis.dds" , "80" , "96" , "dds" +"ui\cybran\game\drag-handle\drag-handle-ll_btn_down.dds" , "80" , "96" , "dds" +"ui\cybran\game\drag-handle\drag-handle-ll_btn_over.dds" , "80" , "96" , "dds" +"ui\cybran\game\drag-handle\drag-handle-ll_btn_up.dds" , "80" , "96" , "dds" +"ui\cybran\game\drag-handle\drag-handle-lr_btn_dis.dds" , "80" , "96" , "dds" +"ui\cybran\game\drag-handle\drag-handle-lr_btn_down.dds" , "80" , "96" , "dds" +"ui\cybran\game\drag-handle\drag-handle-lr_btn_over.dds" , "80" , "96" , "dds" +"ui\cybran\game\drag-handle\drag-handle-lr_btn_up.dds" , "80" , "96" , "dds" +"ui\cybran\game\drag-handle\drag-handle-ul_btn_dis.dds" , "80" , "96" , "dds" +"ui\cybran\game\drag-handle\drag-handle-ul_btn_down.dds" , "80" , "96" , "dds" +"ui\cybran\game\drag-handle\drag-handle-ul_btn_over.dds" , "80" , "96" , "dds" +"ui\cybran\game\drag-handle\drag-handle-ul_btn_up.dds" , "80" , "96" , "dds" +"ui\cybran\game\drag-handle\drag-handle-ur_btn_dis.dds" , "80" , "96" , "dds" +"ui\cybran\game\drag-handle\drag-handle-ur_btn_down.dds" , "80" , "96" , "dds" +"ui\cybran\game\drag-handle\drag-handle-ur_btn_over.dds" , "80" , "96" , "dds" +"ui\cybran\game\drag-handle\drag-handle-ur_btn_up.dds" , "80" , "96" , "dds" +"ui\cybran\game\economic-overlay\econ_bmp_l.dds" , "24" , "32" , "dds" +"ui\cybran\game\economic-overlay\econ_bmp_m.dds" , "4" , "32" , "dds" +"ui\cybran\game\economic-overlay\econ_bmp_r.dds" , "12" , "32" , "dds" +"ui\cybran\game\filter-arrow_btn\tab-close_btn_dis.dds" , "44" , "32" , "dds" +"ui\cybran\game\filter-arrow_btn\tab-close_btn_down.dds" , "44" , "32" , "dds" +"ui\cybran\game\filter-arrow_btn\tab-close_btn_over.dds" , "44" , "32" , "dds" +"ui\cybran\game\filter-arrow_btn\tab-close_btn_up.dds" , "44" , "32" , "dds" +"ui\cybran\game\filter-arrow_btn\tab-open_btn_dis.dds" , "44" , "32" , "dds" +"ui\cybran\game\filter-arrow_btn\tab-open_btn_down.dds" , "44" , "32" , "dds" +"ui\cybran\game\filter-arrow_btn\tab-open_btn_over.dds" , "44" , "32" , "dds" +"ui\cybran\game\filter-arrow_btn\tab-open_btn_up.dds" , "44" , "32" , "dds" +"ui\cybran\game\filter-ping-list-panel\energy-bar_bmp.dds" , "28" , "16" , "dds" +"ui\cybran\game\filter-ping-list-panel\panel_bmp_b.dds" , "128" , "24" , "dds" +"ui\cybran\game\filter-ping-list-panel\panel_bmp_m.dds" , "128" , "8" , "dds" +"ui\cybran\game\filter-ping-list-panel\panel_bmp_t.dds" , "128" , "24" , "dds" +"ui\cybran\game\filter-ping-list-panel\panel_brd_horz_um.dds" , "8" , "20" , "dds" +"ui\cybran\game\filter-ping-list-panel\panel_brd_ll.dds" , "24" , "24" , "dds" +"ui\cybran\game\filter-ping-list-panel\panel_brd_lm.dds" , "8" , "20" , "dds" +"ui\cybran\game\filter-ping-list-panel\panel_brd_lr.dds" , "24" , "24" , "dds" +"ui\cybran\game\filter-ping-list-panel\panel_brd_m.dds" , "8" , "8" , "dds" +"ui\cybran\game\filter-ping-list-panel\panel_brd_ul.dds" , "24" , "24" , "dds" +"ui\cybran\game\filter-ping-list-panel\panel_brd_ur.dds" , "24" , "24" , "dds" +"ui\cybran\game\filter-ping-list-panel\panel_brd_vert_l.dds" , "24" , "8" , "dds" +"ui\cybran\game\filter-ping-list-panel\panel_brd_vert_r.dds" , "24" , "8" , "dds" +"ui\cybran\game\filter-ping-panel\bracket-energy-l_bmp.dds" , "20" , "72" , "dds" +"ui\cybran\game\filter-ping-panel\bracket-energy-r_bmp.dds" , "20" , "72" , "dds" +"ui\cybran\game\filter-ping-panel\bracket-left_bmp.dds" , "28" , "68" , "dds" +"ui\cybran\game\filter-ping-panel\filter-ping-panel02_bmp.dds" , "180" , "72" , "dds" +"ui\cybran\game\medium-btn\medium_btn_dis.dds" , "296" , "72" , "dds" +"ui\cybran\game\medium-btn\medium_btn_down.dds" , "296" , "72" , "dds" +"ui\cybran\game\medium-btn\medium_btn_glow.dds" , "296" , "72" , "dds" +"ui\cybran\game\medium-btn\medium_btn_over.dds" , "296" , "72" , "dds" +"ui\cybran\game\medium-btn\medium_btn_up.dds" , "296" , "72" , "dds" +"ui\cybran\game\menu-btns\close_btn_dis.dds" , "24" , "24" , "dds" +"ui\cybran\game\menu-btns\close_btn_down.dds" , "24" , "24" , "dds" +"ui\cybran\game\menu-btns\close_btn_over.dds" , "24" , "24" , "dds" +"ui\cybran\game\menu-btns\close_btn_up.dds" , "24" , "24" , "dds" +"ui\cybran\game\menu-btns\config_btn_dis.dds" , "24" , "24" , "dds" +"ui\cybran\game\menu-btns\config_btn_down.dds" , "24" , "24" , "dds" +"ui\cybran\game\menu-btns\config_btn_over.dds" , "24" , "24" , "dds" +"ui\cybran\game\menu-btns\config_btn_up.dds" , "24" , "24" , "dds" +"ui\cybran\game\menu-btns\default_btn_dis.dds" , "24" , "24" , "dds" +"ui\cybran\game\menu-btns\default_btn_down.dds" , "24" , "24" , "dds" +"ui\cybran\game\menu-btns\default_btn_over.dds" , "24" , "24" , "dds" +"ui\cybran\game\menu-btns\default_btn_up.dds" , "24" , "24" , "dds" +"ui\cybran\game\menu-btns\pin_btn_dis.dds" , "24" , "24" , "dds" +"ui\cybran\game\menu-btns\pin_btn_down.dds" , "24" , "24" , "dds" +"ui\cybran\game\menu-btns\pin_btn_over.dds" , "24" , "24" , "dds" +"ui\cybran\game\menu-btns\pin_btn_up.dds" , "24" , "24" , "dds" +"ui\cybran\game\menu-btns\pinned_btn_dis.dds" , "24" , "24" , "dds" +"ui\cybran\game\menu-btns\pinned_btn_down.dds" , "24" , "24" , "dds" +"ui\cybran\game\menu-btns\pinned_btn_over.dds" , "24" , "24" , "dds" +"ui\cybran\game\menu-btns\pinned_btn_up.dds" , "24" , "24" , "dds" +"ui\cybran\game\mini-map-brd\mini-map_brd_horz_um.dds" , "12" , "36" , "dds" +"ui\cybran\game\mini-map-brd\mini-map_brd_ll.dds" , "12" , "16" , "dds" +"ui\cybran\game\mini-map-brd\mini-map_brd_lm.dds" , "12" , "16" , "dds" +"ui\cybran\game\mini-map-brd\mini-map_brd_lr.dds" , "16" , "16" , "dds" +"ui\cybran\game\mini-map-brd\mini-map_brd_m.dds" , "12" , "12" , "dds" +"ui\cybran\game\mini-map-brd\mini-map_brd_ul.dds" , "32" , "36" , "dds" +"ui\cybran\game\mini-map-brd\mini-map_brd_ur.dds" , "36" , "36" , "dds" +"ui\cybran\game\mini-map-brd\mini-map_brd_vert_l.dds" , "12" , "12" , "dds" +"ui\cybran\game\mini-map-brd\mini-map_brd_vert_r.dds" , "16" , "12" , "dds" +"ui\cybran\game\mini-map-brd\mini-map-glow_bmp.dds" , "204" , "204" , "dds" +"ui\cybran\game\mini-map-glow-brd\mini-map-glow_brd_horz_um.dds" , "8" , "24" , "dds" +"ui\cybran\game\mini-map-glow-brd\mini-map-glow_brd_ll.dds" , "40" , "40" , "dds" +"ui\cybran\game\mini-map-glow-brd\mini-map-glow_brd_lm.dds" , "8" , "24" , "dds" +"ui\cybran\game\mini-map-glow-brd\mini-map-glow_brd_lr.dds" , "40" , "40" , "dds" +"ui\cybran\game\mini-map-glow-brd\mini-map-glow_brd_ul.dds" , "40" , "40" , "dds" +"ui\cybran\game\mini-map-glow-brd\mini-map-glow_brd_ur.dds" , "40" , "40" , "dds" +"ui\cybran\game\mini-map-glow-brd\mini-map-glow_brd_vert_l.dds" , "24" , "8" , "dds" +"ui\cybran\game\mini-map-glow-brd\mini-map-glow_brd_vert_r.dds" , "24" , "8" , "dds" +"ui\cybran\game\objective-icons\panel-icon_bmp.dds" , "60" , "68" , "dds" +"ui\cybran\game\objective-icons\primary-ring_bmp.dds" , "52" , "48" , "dds" +"ui\cybran\game\objective-icons\secondary-ring_bmp.dds" , "52" , "48" , "dds" +"ui\cybran\game\options_tab\diplomacy_btn_dis.dds" , "56" , "48" , "dds" +"ui\cybran\game\options_tab\diplomacy_btn_down.dds" , "56" , "48" , "dds" +"ui\cybran\game\options_tab\diplomacy_btn_over.dds" , "56" , "48" , "dds" +"ui\cybran\game\options_tab\diplomacy_btn_selected.dds" , "56" , "48" , "dds" +"ui\cybran\game\options_tab\diplomacy_btn_up.dds" , "56" , "48" , "dds" +"ui\cybran\game\options_tab\glow_bmp.dds" , "56" , "48" , "dds" +"ui\cybran\game\options_tab\menu_btn_dis.dds" , "56" , "48" , "dds" +"ui\cybran\game\options_tab\menu_btn_down.dds" , "56" , "48" , "dds" +"ui\cybran\game\options_tab\menu_btn_over.dds" , "56" , "48" , "dds" +"ui\cybran\game\options_tab\menu_btn_selected.dds" , "56" , "48" , "dds" +"ui\cybran\game\options_tab\menu_btn_up.dds" , "56" , "48" , "dds" +"ui\cybran\game\options_tab\objectives_btn_dis.dds" , "56" , "48" , "dds" +"ui\cybran\game\options_tab\objectives_btn_down.dds" , "56" , "48" , "dds" +"ui\cybran\game\options_tab\objectives_btn_over.dds" , "56" , "48" , "dds" +"ui\cybran\game\options_tab\objectives_btn_selected.dds" , "56" , "48" , "dds" +"ui\cybran\game\options_tab\objectives_btn_up.dds" , "56" , "48" , "dds" +"ui\cybran\game\options_tab\pause_btn_dis.dds" , "56" , "48" , "dds" +"ui\cybran\game\options_tab\pause_btn_down.dds" , "56" , "48" , "dds" +"ui\cybran\game\options_tab\pause_btn_over.dds" , "56" , "48" , "dds" +"ui\cybran\game\options_tab\pause_btn_selected.dds" , "56" , "48" , "dds" +"ui\cybran\game\options_tab\pause_btn_up.dds" , "56" , "48" , "dds" +"ui\cybran\game\options_tab\play_btn_dis.dds" , "56" , "48" , "dds" +"ui\cybran\game\options_tab\play_btn_down.dds" , "56" , "48" , "dds" +"ui\cybran\game\options_tab\play_btn_over.dds" , "56" , "48" , "dds" +"ui\cybran\game\options_tab\play_btn_selected.dds" , "56" , "48" , "dds" +"ui\cybran\game\options_tab\play_btn_up.dds" , "56" , "48" , "dds" +"ui\cybran\game\options-panel\options_brd_horz_lm.dds" , "4" , "28" , "dds" +"ui\cybran\game\options-panel\options_brd_horz_um.dds" , "80" , "36" , "dds" +"ui\cybran\game\options-panel\options_brd_horz_uml.dds" , "4" , "28" , "dds" +"ui\cybran\game\options-panel\options_brd_horz_umr.dds" , "4" , "28" , "dds" +"ui\cybran\game\options-panel\options_brd_ll.dds" , "44" , "36" , "dds" +"ui\cybran\game\options-panel\options_brd_lr.dds" , "44" , "36" , "dds" +"ui\cybran\game\options-panel\options_brd_m.dds" , "4" , "8" , "dds" +"ui\cybran\game\options-panel\options_brd_ul.dds" , "44" , "36" , "dds" +"ui\cybran\game\options-panel\options_brd_ur.dds" , "44" , "36" , "dds" +"ui\cybran\game\options-panel\options_brd_vert_l.dds" , "36" , "8" , "dds" +"ui\cybran\game\options-panel\options_brd_vert_ll.dds" , "40" , "24" , "dds" +"ui\cybran\game\options-panel\options_brd_vert_lr.dds" , "40" , "24" , "dds" +"ui\cybran\game\options-panel\options_brd_vert_r.dds" , "36" , "8" , "dds" +"ui\cybran\game\options-panel\options_brd_vert_ul.dds" , "40" , "24" , "dds" +"ui\cybran\game\options-panel\options_brd_vert_ur.dds" , "40" , "24" , "dds" +"ui\cybran\game\orders-panel_vert\order-panel_bmp.dds" , "188" , "224" , "dds" +"ui\cybran\game\orders-panel\bracket_bmp.dds" , "32" , "128" , "dds" +"ui\cybran\game\orders-panel\no-parking_bmp.dds" , "56" , "56" , "dds" +"ui\cybran\game\orders-panel\order-panel_bmp.dds" , "332" , "120" , "dds" +"ui\cybran\game\orders-panel\question-mark_bmp.dds" , "16" , "24" , "dds" +"ui\cybran\game\panel\panel_brd_horz_um.dds" , "8" , "24" , "dds" +"ui\cybran\game\panel\panel_brd_ll.dds" , "24" , "24" , "dds" +"ui\cybran\game\panel\panel_brd_lm.dds" , "8" , "24" , "dds" +"ui\cybran\game\panel\panel_brd_lr.dds" , "24" , "24" , "dds" +"ui\cybran\game\panel\panel_brd_m.dds" , "8" , "8" , "dds" +"ui\cybran\game\panel\panel_brd_ul.dds" , "24" , "24" , "dds" +"ui\cybran\game\panel\panel_brd_ur.dds" , "24" , "24" , "dds" +"ui\cybran\game\panel\panel_brd_vert_l.dds" , "24" , "8" , "dds" +"ui\cybran\game\panel\panel_brd_vert_r.dds" , "24" , "8" , "dds" +"ui\cybran\game\pause_btn\glow_bmp.dds" , "56" , "48" , "dds" +"ui\cybran\game\pause_btn\pause_btn_dis.dds" , "56" , "48" , "dds" +"ui\cybran\game\pause_btn\pause_btn_down.dds" , "56" , "48" , "dds" +"ui\cybran\game\pause_btn\pause_btn_over.dds" , "56" , "48" , "dds" +"ui\cybran\game\pause_btn\pause_btn_up.dds" , "56" , "48" , "dds" +"ui\cybran\game\pause_btn\play_btn_dis.dds" , "56" , "48" , "dds" +"ui\cybran\game\pause_btn\play_btn_down.dds" , "56" , "48" , "dds" +"ui\cybran\game\pause_btn\play_btn_over.dds" , "56" , "48" , "dds" +"ui\cybran\game\pause_btn\play_btn_up.dds" , "56" , "48" , "dds" +"ui\cybran\game\pause-indicator\bottom.dds" , "20" , "20" , "dds" +"ui\cybran\game\pause-indicator\left.dds" , "20" , "20" , "dds" +"ui\cybran\game\pause-indicator\right.dds" , "20" , "20" , "dds" +"ui\cybran\game\pause-indicator\top.dds" , "20" , "20" , "dds" +"ui\cybran\game\pda-panel\bracket-right_bmp.dds" , "32" , "156" , "dds" +"ui\cybran\game\pda-panel\panel-objectives_bmp_l.dds" , "20" , "76" , "dds" +"ui\cybran\game\pda-panel\panel-objectives_bmp_m.dds" , "4" , "72" , "dds" +"ui\cybran\game\pda-panel\panel-objectives_bmp_r.dds" , "24" , "72" , "dds" +"ui\cybran\game\pda-panel\panel-ping_bmp_l.dds" , "20" , "60" , "dds" +"ui\cybran\game\pda-panel\panel-ping_bmp_m.dds" , "4" , "56" , "dds" +"ui\cybran\game\pda-panel\panel-ping_bmp_r.dds" , "24" , "56" , "dds" +"ui\cybran\game\pda-panel\panel-time-units_bmp_l.dds" , "20" , "28" , "dds" +"ui\cybran\game\pda-panel\panel-time-units_bmp_m.dds" , "4" , "24" , "dds" +"ui\cybran\game\pda-panel\panel-time-units_bmp_r.dds" , "24" , "24" , "dds" +"ui\cybran\game\pda-panel\title-bar_bmp.dds" , "136" , "20" , "dds" +"ui\cybran\game\pda-panel\video-panel_bmp.dds" , "168" , "148" , "dds" +"ui\cybran\game\ping-icons\panel-icon_bmp.dds" , "56" , "56" , "dds" +"ui\cybran\game\ping-icons\panel-icon-ring_bmp.dds" , "56" , "56" , "dds" +"ui\cybran\game\resource-bars\mini-energy-bar_bmp.dds" , "160" , "16" , "dds" +"ui\cybran\game\resource-bars\mini-energy-bar-back_bmp.dds" , "160" , "16" , "dds" +"ui\cybran\game\resource-bars\mini-mass-bar_bmp.dds" , "160" , "16" , "dds" +"ui\cybran\game\resource-bars\mini-mass-bar-back_bmp.dds" , "160" , "16" , "dds" +"ui\cybran\game\resource-panel\alert-caution_bmp.dds" , "48" , "44" , "dds" +"ui\cybran\game\resource-panel\alert-energy-panel_bmp.dds" , "316" , "32" , "dds" +"ui\cybran\game\resource-panel\alert-icon_bmp.dds" , "28" , "36" , "dds" +"ui\cybran\game\resource-panel\alert-mass-panel_bmp.dds" , "316" , "32" , "dds" +"ui\cybran\game\resource-panel\alert-no-parking_bmp.dds" , "52" , "52" , "dds" +"ui\cybran\game\resource-panel\bracket-energy-l_bmp.dds" , "20" , "72" , "dds" +"ui\cybran\game\resource-panel\bracket-energy-r_bmp.dds" , "20" , "72" , "dds" +"ui\cybran\game\resource-panel\bracket-left_bmp.dds" , "28" , "68" , "dds" +"ui\cybran\game\resource-panel\bracket-right_bmp.dds" , "28" , "68" , "dds" +"ui\cybran\game\resource-panel\caution-energy-panel_bmp.dds" , "316" , "32" , "dds" +"ui\cybran\game\resource-panel\caution-mass-panel_bmp.dds" , "316" , "32" , "dds" +"ui\cybran\game\resource-panel\resources_panel_bmp.dds" , "324" , "72" , "dds" +"ui\cybran\game\score-panel\icon-square_bmp.dds" , "24" , "24" , "dds" +"ui\cybran\game\score-panel\panel-score_bmp_b.dds" , "236" , "20" , "dds" +"ui\cybran\game\score-panel\panel-score_bmp_m.dds" , "236" , "4" , "dds" +"ui\cybran\game\score-panel\panel-score_bmp_t.dds" , "236" , "44" , "dds" +"ui\cybran\game\tab-l-btn\tab-close_btn_dis.dds" , "20" , "28" , "dds" +"ui\cybran\game\tab-l-btn\tab-close_btn_down.dds" , "20" , "28" , "dds" +"ui\cybran\game\tab-l-btn\tab-close_btn_over.dds" , "20" , "28" , "dds" +"ui\cybran\game\tab-l-btn\tab-close_btn_up.dds" , "20" , "28" , "dds" +"ui\cybran\game\tab-l-btn\tab-open_btn_dis.dds" , "20" , "28" , "dds" +"ui\cybran\game\tab-l-btn\tab-open_btn_down.dds" , "20" , "28" , "dds" +"ui\cybran\game\tab-l-btn\tab-open_btn_over.dds" , "20" , "28" , "dds" +"ui\cybran\game\tab-l-btn\tab-open_btn_up.dds" , "20" , "28" , "dds" +"ui\cybran\game\tab-r-btn\tab-close_btn_dis.dds" , "20" , "28" , "dds" +"ui\cybran\game\tab-r-btn\tab-close_btn_down.dds" , "20" , "28" , "dds" +"ui\cybran\game\tab-r-btn\tab-close_btn_over.dds" , "20" , "28" , "dds" +"ui\cybran\game\tab-r-btn\tab-close_btn_up.dds" , "20" , "28" , "dds" +"ui\cybran\game\tab-r-btn\tab-open_btn_dis.dds" , "20" , "28" , "dds" +"ui\cybran\game\tab-r-btn\tab-open_btn_down.dds" , "20" , "28" , "dds" +"ui\cybran\game\tab-r-btn\tab-open_btn_over.dds" , "20" , "28" , "dds" +"ui\cybran\game\tab-r-btn\tab-open_btn_up.dds" , "20" , "28" , "dds" +"ui\cybran\game\tab-t-btn\tab-close_btn_dis.dds" , "28" , "20" , "dds" +"ui\cybran\game\tab-t-btn\tab-close_btn_down.dds" , "28" , "20" , "dds" +"ui\cybran\game\tab-t-btn\tab-close_btn_over.dds" , "28" , "20" , "dds" +"ui\cybran\game\tab-t-btn\tab-close_btn_up.dds" , "28" , "20" , "dds" +"ui\cybran\game\tab-t-btn\tab-open_btn_dis.dds" , "28" , "20" , "dds" +"ui\cybran\game\tab-t-btn\tab-open_btn_down.dds" , "28" , "20" , "dds" +"ui\cybran\game\tab-t-btn\tab-open_btn_over.dds" , "28" , "20" , "dds" +"ui\cybran\game\tab-t-btn\tab-open_btn_up.dds" , "28" , "20" , "dds" +"ui\cybran\game\temp_textures\checkmark_dark.dds" , "16" , "16" , "dds" +"ui\cybran\game\temp_textures\checkmark.dds" , "16" , "16" , "dds" +"ui\cybran\game\temp_textures\combo_sel.dds" , "20" , "20" , "dds" +"ui\cybran\game\temp_textures\combo_up.dds" , "20" , "20" , "dds" +"ui\cybran\game\transmission\title-bar_bmp.dds" , "188" , "20" , "dds" +"ui\cybran\game\transmission\video-brackets.dds" , "220" , "212" , "dds" +"ui\cybran\game\transmission\video-panel_bmp.dds" , "248" , "240" , "dds" +"ui\cybran\game\transmission\video-panel.dds" , "208" , "208" , "dds" +"ui\cybran\game\unit-build-over-panel\bracket-build_bmp.dds" , "32" , "120" , "dds" +"ui\cybran\game\unit-build-over-panel\bracket-unit_bmp.dds" , "32" , "120" , "dds" +"ui\cybran\game\unit-build-over-panel\build-over-back_bmp.dds" , "332" , "116" , "dds" +"ui\cybran\game\unit-build-over-panel\fuelbar.dds" , "188" , "4" , "dds" +"ui\cybran\game\unit-build-over-panel\healthbar_bg.dds" , "188" , "20" , "dds" +"ui\cybran\game\unit-build-over-panel\healthbar_green.dds" , "192" , "24" , "dds" +"ui\cybran\game\unit-build-over-panel\healthbar_red.dds" , "192" , "24" , "dds" +"ui\cybran\game\unit-build-over-panel\healthbar_yellow.dds" , "192" , "24" , "dds" +"ui\cybran\game\unit-build-over-panel\shieldbar.dds" , "188" , "4" , "dds" +"ui\cybran\game\unit-build-over-panel\unit-over-back_bmp.dds" , "332" , "116" , "dds" +"ui\cybran\small-vert_scroll\arrow-down_scr_dis.dds" , "36" , "32" , "dds" +"ui\cybran\small-vert_scroll\arrow-down_scr_down.dds" , "36" , "32" , "dds" +"ui\cybran\small-vert_scroll\arrow-down_scr_over.dds" , "36" , "32" , "dds" +"ui\cybran\small-vert_scroll\arrow-down_scr_up.dds" , "36" , "32" , "dds" +"ui\cybran\small-vert_scroll\arrow-up_scr_dis.dds" , "36" , "32" , "dds" +"ui\cybran\small-vert_scroll\arrow-up_scr_down.dds" , "36" , "32" , "dds" +"ui\cybran\small-vert_scroll\arrow-up_scr_over.dds" , "36" , "32" , "dds" +"ui\cybran\small-vert_scroll\arrow-up_scr_up.dds" , "36" , "32" , "dds" +"ui\cybran\small-vert_scroll\back_scr_mid.dds" , "36" , "8" , "dds" +"ui\cybran\small-vert_scroll\bar-bot_scr_up.dds" , "36" , "8" , "dds" +"ui\cybran\small-vert_scroll\bar-mid_scr_over.dds" , "36" , "8" , "dds" +"ui\cybran\small-vert_scroll\bar-mid_scr_up.dds" , "36" , "8" , "dds" +"ui\cybran\small-vert_scroll\bar-top_scr_up.dds" , "36" , "12" , "dds" +"ui\cybran\widgets02\small_btn_dis.dds" , "148" , "36" , "dds" +"ui\cybran\widgets02\small_btn_down.dds" , "148" , "36" , "dds" +"ui\cybran\widgets02\small_btn_over.dds" , "148" , "36" , "dds" +"ui\cybran\widgets02\small_btn_up.dds" , "148" , "36" , "dds" +"ui\frontend\panel-campaign_brd\panel-campaign_brd_ll.dds" , "4" , "4" , "dds" +"ui\frontend\panel-campaign_brd\panel-campaign_brd_lm.dds" , "4" , "4" , "dds" +"ui\frontend\panel-campaign_brd\panel-campaign_brd_lr.dds" , "4" , "4" , "dds" +"ui\frontend\panel-campaign_brd\panel-campaign_brd_ul.dds" , "4" , "4" , "dds" +"ui\frontend\panel-campaign_brd\panel-campaign_brd_um.dds" , "4" , "4" , "dds" +"ui\frontend\panel-campaign_brd\panel-campaign_brd_ur.dds" , "4" , "4" , "dds" +"ui\frontend\panel-campaign_brd\panel-campaign_brd_vert_l.dds" , "4" , "4" , "dds" +"ui\frontend\panel-campaign_brd\panel-campaign_brd_vert_r.dds" , "4" , "4" , "dds" +"ui\frontend\score-victory-defeat\panel_bmp.dds" , "972" , "588" , "dds" +"ui\frontend\score-victory-defeat\panel-line_bmp.dds" , "920" , "4" , "dds" +"ui\frontend\score-victory-defeat\player-back_bmp.dds" , "912" , "36" , "dds" +"ui\frontend\score-victory-defeat\totals-back_bmp.dds" , "284" , "40" , "dds" +"ui\seraphim\dialogs\menu-btns\close_btn_dis.dds" , "24" , "24" , "dds" +"ui\seraphim\dialogs\menu-btns\close_btn_down.dds" , "24" , "24" , "dds" +"ui\seraphim\dialogs\menu-btns\close_btn_over.dds" , "24" , "24" , "dds" +"ui\seraphim\dialogs\menu-btns\close_btn_up.dds" , "24" , "24" , "dds" +"ui\seraphim\dialogs\menu-btns\config_btn_dis.dds" , "24" , "24" , "dds" +"ui\seraphim\dialogs\menu-btns\config_btn_down.dds" , "24" , "24" , "dds" +"ui\seraphim\dialogs\menu-btns\config_btn_over.dds" , "24" , "24" , "dds" +"ui\seraphim\dialogs\menu-btns\config_btn_up.dds" , "24" , "24" , "dds" +"ui\seraphim\dialogs\menu-btns\default_btn_dis.dds" , "24" , "24" , "dds" +"ui\seraphim\dialogs\menu-btns\default_btn_down.dds" , "24" , "24" , "dds" +"ui\seraphim\dialogs\menu-btns\default_btn_over.dds" , "24" , "24" , "dds" +"ui\seraphim\dialogs\menu-btns\default_btn_up.dds" , "24" , "24" , "dds" +"ui\seraphim\dialogs\menu-btns\pin_btn_dis.dds" , "24" , "24" , "dds" +"ui\seraphim\dialogs\menu-btns\pin_btn_down.dds" , "24" , "24" , "dds" +"ui\seraphim\dialogs\menu-btns\pin_btn_over.dds" , "24" , "24" , "dds" +"ui\seraphim\dialogs\menu-btns\pin_btn_up.dds" , "24" , "24" , "dds" +"ui\seraphim\dialogs\menu-btns\pinned_btn_dis.dds" , "24" , "24" , "dds" +"ui\seraphim\dialogs\menu-btns\pinned_btn_down.dds" , "24" , "24" , "dds" +"ui\seraphim\dialogs\menu-btns\pinned_btn_over.dds" , "24" , "24" , "dds" +"ui\seraphim\dialogs\menu-btns\pinned_btn_up.dds" , "24" , "24" , "dds" +"ui\seraphim\dialogs\time-units-tabs\energy_bmp.dds" , "16" , "28" , "dds" +"ui\seraphim\dialogs\time-units-tabs\panel-tracking_bmp_d.dds" , "12" , "44" , "dds" +"ui\seraphim\dialogs\time-units-tabs\panel-tracking_bmp_l.dds" , "24" , "44" , "dds" +"ui\seraphim\dialogs\time-units-tabs\panel-tracking_bmp_m.dds" , "4" , "44" , "dds" +"ui\seraphim\dialogs\time-units-tabs\panel-tracking_bmp_r.dds" , "24" , "44" , "dds" +"ui\seraphim\dialogs\time-units-tabs\tracking-icon_bmp.dds" , "24" , "20" , "dds" +"ui\seraphim\game\avatar-arrow_btn\tab-close_btn_dis.dds" , "12" , "32" , "dds" +"ui\seraphim\game\avatar-arrow_btn\tab-close_btn_down.dds" , "12" , "32" , "dds" +"ui\seraphim\game\avatar-arrow_btn\tab-close_btn_over.dds" , "12" , "32" , "dds" +"ui\seraphim\game\avatar-arrow_btn\tab-close_btn_up.dds" , "12" , "32" , "dds" +"ui\seraphim\game\avatar-arrow_btn\tab-open_btn_dis.dds" , "12" , "32" , "dds" +"ui\seraphim\game\avatar-arrow_btn\tab-open_btn_down.dds" , "12" , "32" , "dds" +"ui\seraphim\game\avatar-arrow_btn\tab-open_btn_over.dds" , "12" , "32" , "dds" +"ui\seraphim\game\avatar-arrow_btn\tab-open_btn_up.dds" , "12" , "32" , "dds" +"ui\seraphim\game\avatar-engineers-panel\bracket_bmp.dds" , "28" , "48" , "dds" +"ui\seraphim\game\avatar-engineers-panel\panel-eng_bmp_b.dds" , "88" , "12" , "dds" +"ui\seraphim\game\avatar-engineers-panel\panel-eng_bmp_m.dds" , "88" , "44" , "dds" +"ui\seraphim\game\avatar-engineers-panel\panel-eng_bmp_t.dds" , "88" , "12" , "dds" +"ui\seraphim\game\avatar-engineers-panel\tech-1_bmp.dds" , "28" , "28" , "dds" +"ui\seraphim\game\avatar-engineers-panel\tech-2_bmp.dds" , "28" , "28" , "dds" +"ui\seraphim\game\avatar-engineers-panel\tech-3_bmp.dds" , "28" , "28" , "dds" +"ui\seraphim\game\avatar-factory-panel\bracket_bmp.dds" , "20" , "40" , "dds" +"ui\seraphim\game\avatar-factory-panel\factory-panel_bmp.dds" , "188" , "164" , "dds" +"ui\seraphim\game\avatar\avatar_bmp.dds" , "60" , "68" , "dds" +"ui\seraphim\game\avatar\avatar-control-group_bmp.dds" , "48" , "36" , "dds" +"ui\seraphim\game\avatar\avatar-s-e-f_bmp.dds" , "56" , "52" , "dds" +"ui\seraphim\game\avatar\health-bar-back_bmp.dds" , "52" , "16" , "dds" +"ui\seraphim\game\avatar\health-bar.dds" , "44" , "12" , "dds" +"ui\seraphim\game\avatar\pulse-bars_bmp.dds" , "64" , "64" , "dds" +"ui\seraphim\game\bracket-left-energy\bracket_bmp_b.dds" , "20" , "16" , "dds" +"ui\seraphim\game\bracket-left-energy\bracket_bmp_m.dds" , "12" , "4" , "dds" +"ui\seraphim\game\bracket-left-energy\bracket_bmp_t.dds" , "20" , "20" , "dds" +"ui\seraphim\game\bracket-left-energy\bracket_bmp.dds" , "20" , "68" , "dds" +"ui\seraphim\game\bracket-left\bracket_bmp_b.dds" , "32" , "28" , "dds" +"ui\seraphim\game\bracket-left\bracket_bmp_m.dds" , "16" , "4" , "dds" +"ui\seraphim\game\bracket-left\bracket_bmp_t.dds" , "32" , "68" , "dds" +"ui\seraphim\game\bracket-left\bracket_bmp.dds" , "32" , "120" , "dds" +"ui\seraphim\game\bracket-right-energy\bracket_bmp_b.dds" , "20" , "16" , "dds" +"ui\seraphim\game\bracket-right-energy\bracket_bmp_m.dds" , "12" , "4" , "dds" +"ui\seraphim\game\bracket-right-energy\bracket_bmp_t.dds" , "20" , "20" , "dds" +"ui\seraphim\game\bracket-right-energy\bracket_bmp.dds" , "20" , "68" , "dds" +"ui\seraphim\game\bracket-right\bracket_bmp_b.dds" , "32" , "28" , "dds" +"ui\seraphim\game\bracket-right\bracket_bmp_m.dds" , "16" , "4" , "dds" +"ui\seraphim\game\bracket-right\bracket_bmp_t.dds" , "32" , "68" , "dds" +"ui\seraphim\game\bracket-right\bracket_bmp.dds" , "32" , "120" , "dds" +"ui\seraphim\game\c-q-e-panel\construct-panel_bmp_l.dds" , "84" , "60" , "dds" +"ui\seraphim\game\camera-btn\pinned_btn_dis.dds" , "24" , "24" , "dds" +"ui\seraphim\game\camera-btn\pinned_btn_down.dds" , "24" , "24" , "dds" +"ui\seraphim\game\camera-btn\pinned_btn_over.dds" , "24" , "24" , "dds" +"ui\seraphim\game\camera-btn\pinned_btn_up.dds" , "24" , "24" , "dds" +"ui\seraphim\game\chat_brd\chat_brd_horz_um.dds" , "8" , "48" , "dds" +"ui\seraphim\game\chat_brd\chat_brd_ll.dds" , "44" , "48" , "dds" +"ui\seraphim\game\chat_brd\chat_brd_lm.dds" , "8" , "48" , "dds" +"ui\seraphim\game\chat_brd\chat_brd_lr.dds" , "44" , "48" , "dds" +"ui\seraphim\game\chat_brd\chat_brd_m.dds" , "8" , "8" , "dds" +"ui\seraphim\game\chat_brd\chat_brd_ul.dds" , "44" , "48" , "dds" +"ui\seraphim\game\chat_brd\chat_brd_ur.dds" , "44" , "48" , "dds" +"ui\seraphim\game\chat_brd\chat_brd_vert_l.dds" , "44" , "8" , "dds" +"ui\seraphim\game\chat_brd\chat_brd_vert_r.dds" , "44" , "8" , "dds" +"ui\seraphim\game\chat_brd\drop-box_brd_horz_um.dds" , "8" , "16" , "dds" +"ui\seraphim\game\chat_brd\drop-box_brd_ll.dds" , "16" , "12" , "dds" +"ui\seraphim\game\chat_brd\drop-box_brd_lm.dds" , "8" , "12" , "dds" +"ui\seraphim\game\chat_brd\drop-box_brd_lr.dds" , "16" , "12" , "dds" +"ui\seraphim\game\chat_brd\drop-box_brd_m.dds" , "8" , "8" , "dds" +"ui\seraphim\game\chat_brd\drop-box_brd_ul.dds" , "16" , "16" , "dds" +"ui\seraphim\game\chat_brd\drop-box_brd_ur.dds" , "16" , "16" , "dds" +"ui\seraphim\game\chat_brd\drop-box_brd_vert_l.dds" , "16" , "8" , "dds" +"ui\seraphim\game\chat_brd\drop-box_brd_vert_r.dds" , "16" , "8" , "dds" +"ui\seraphim\game\chat-box_btn\radio_btn_dis.dds" , "28" , "28" , "dds" +"ui\seraphim\game\chat-box_btn\radio_btn_down.dds" , "28" , "28" , "dds" +"ui\seraphim\game\chat-box_btn\radio_btn_over.dds" , "28" , "28" , "dds" +"ui\seraphim\game\chat-box_btn\radio_btn_up.dds" , "28" , "28" , "dds" +"ui\seraphim\game\construct-panel_vert\construct-panel_bmp_b.dds" , "172" , "12" , "dds" +"ui\seraphim\game\construct-panel_vert\construct-panel_bmp_m.dds" , "172" , "8" , "dds" +"ui\seraphim\game\construct-panel_vert\construct-panel_bmp_t.dds" , "172" , "64" , "dds" +"ui\seraphim\game\construct-panel_vert\que-panel_bmp_b.dds" , "60" , "8" , "dds" +"ui\seraphim\game\construct-panel_vert\que-panel_bmp_m.dds" , "60" , "8" , "dds" +"ui\seraphim\game\construct-panel_vert\que-panel_bmp_t.dds" , "60" , "8" , "dds" +"ui\seraphim\game\construct-panel\construct-panel_bmp_l.dds" , "84" , "140" , "dds" +"ui\seraphim\game\construct-panel\construct-panel_bmp_m1.dds" , "8" , "140" , "dds" +"ui\seraphim\game\construct-panel\construct-panel_bmp_m2.dds" , "16" , "140" , "dds" +"ui\seraphim\game\construct-panel\construct-panel_bmp_m3.dds" , "8" , "112" , "dds" +"ui\seraphim\game\construct-panel\construct-panel_bmp_r.dds" , "16" , "112" , "dds" +"ui\seraphim\game\construct-panel\construct-panel_s_bmp_l.dds" , "16" , "112" , "dds" +"ui\seraphim\game\construct-panel\construct-panel_s_bmp_m.dds" , "8" , "112" , "dds" +"ui\seraphim\game\construct-panel\construct-panel_s_bmp_r.dds" , "16" , "112" , "dds" +"ui\seraphim\game\construct-panel\que-panel_bmp_l.dds" , "12" , "52" , "dds" +"ui\seraphim\game\construct-panel\que-panel_bmp_m.dds" , "8" , "52" , "dds" +"ui\seraphim\game\construct-panel\que-panel_bmp_r.dds" , "12" , "52" , "dds" +"ui\seraphim\game\construct-sm_btn\back_off.dds" , "24" , "52" , "dds" +"ui\seraphim\game\construct-sm_btn\back_on.dds" , "24" , "52" , "dds" +"ui\seraphim\game\construct-sm_btn\fforward_off.dds" , "24" , "52" , "dds" +"ui\seraphim\game\construct-sm_btn\fforward_on.dds" , "24" , "52" , "dds" +"ui\seraphim\game\construct-sm_btn\forward_off.dds" , "24" , "52" , "dds" +"ui\seraphim\game\construct-sm_btn\forward_on.dds" , "24" , "52" , "dds" +"ui\seraphim\game\construct-sm_btn\infinite_off.dds" , "24" , "52" , "dds" +"ui\seraphim\game\construct-sm_btn\infinite_on.dds" , "24" , "52" , "dds" +"ui\seraphim\game\construct-sm_btn\left_btn_dis.dds" , "24" , "52" , "dds" +"ui\seraphim\game\construct-sm_btn\left_btn_over.dds" , "24" , "52" , "dds" +"ui\seraphim\game\construct-sm_btn\left_btn_up.dds" , "24" , "52" , "dds" +"ui\seraphim\game\construct-sm_btn\mid_btn_dis.dds" , "24" , "52" , "dds" +"ui\seraphim\game\construct-sm_btn\mid_btn_over.dds" , "24" , "52" , "dds" +"ui\seraphim\game\construct-sm_btn\mid_btn_selected.dds" , "24" , "52" , "dds" +"ui\seraphim\game\construct-sm_btn\mid_btn_up.dds" , "24" , "52" , "dds" +"ui\seraphim\game\construct-sm_btn\pause_off.dds" , "24" , "52" , "dds" +"ui\seraphim\game\construct-sm_btn\pause_on.dds" , "24" , "52" , "dds" +"ui\seraphim\game\construct-sm_btn\rewind_off.dds" , "24" , "52" , "dds" +"ui\seraphim\game\construct-sm_btn\rewind_on.dds" , "24" , "52" , "dds" +"ui\seraphim\game\construct-sm_btn\right_btn_dis.dds" , "24" , "52" , "dds" +"ui\seraphim\game\construct-sm_btn\right_btn_over.dds" , "24" , "52" , "dds" +"ui\seraphim\game\construct-sm_btn\right_btn_up.dds" , "24" , "52" , "dds" +"ui\seraphim\game\construct-sm_btn\template_off.dds" , "24" , "52" , "dds" +"ui\seraphim\game\construct-sm_btn\template_on.dds" , "24" , "52" , "dds" +"ui\seraphim\game\construct-sm_horiz_btn\infinite_off.dds" , "60" , "28" , "dds" +"ui\seraphim\game\construct-sm_horiz_btn\infinite_on.dds" , "60" , "28" , "dds" +"ui\seraphim\game\construct-sm_horiz_btn\pause_off.dds" , "60" , "28" , "dds" +"ui\seraphim\game\construct-sm_horiz_btn\pause_on.dds" , "60" , "28" , "dds" +"ui\seraphim\game\construct-sm_horiz_btn\que_btn_dis.dds" , "60" , "28" , "dds" +"ui\seraphim\game\construct-sm_horiz_btn\que_btn_over.dds" , "60" , "28" , "dds" +"ui\seraphim\game\construct-sm_horiz_btn\que_btn_selected.dds" , "60" , "28" , "dds" +"ui\seraphim\game\construct-sm_horiz_btn\que_btn_up.dds" , "60" , "28" , "dds" +"ui\seraphim\game\construct-sm_horiz_btn\template_off.dds" , "60" , "28" , "dds" +"ui\seraphim\game\construct-sm_horiz_btn\template_on.dds" , "60" , "28" , "dds" +"ui\seraphim\game\construct-sm_nav_horiz_btn\bottom_btn_dis.dds" , "60" , "16" , "dds" +"ui\seraphim\game\construct-sm_nav_horiz_btn\bottom_btn_over.dds" , "60" , "16" , "dds" +"ui\seraphim\game\construct-sm_nav_horiz_btn\bottom_btn_up.dds" , "60" , "16" , "dds" +"ui\seraphim\game\construct-sm_nav_horiz_btn\down_off.dds" , "60" , "16" , "dds" +"ui\seraphim\game\construct-sm_nav_horiz_btn\down_on.dds" , "60" , "16" , "dds" +"ui\seraphim\game\construct-sm_nav_horiz_btn\end_off.dds" , "60" , "16" , "dds" +"ui\seraphim\game\construct-sm_nav_horiz_btn\end_on.dds" , "60" , "16" , "dds" +"ui\seraphim\game\construct-sm_nav_horiz_btn\home_off.dds" , "60" , "16" , "dds" +"ui\seraphim\game\construct-sm_nav_horiz_btn\home_on.dds" , "60" , "16" , "dds" +"ui\seraphim\game\construct-sm_nav_horiz_btn\mid_btn_dis.dds" , "60" , "16" , "dds" +"ui\seraphim\game\construct-sm_nav_horiz_btn\mid_btn_over.dds" , "60" , "16" , "dds" +"ui\seraphim\game\construct-sm_nav_horiz_btn\mid_btn_up.dds" , "60" , "16" , "dds" +"ui\seraphim\game\construct-sm_nav_horiz_btn\top_btn_dis.dds" , "60" , "16" , "dds" +"ui\seraphim\game\construct-sm_nav_horiz_btn\top_btn_over.dds" , "60" , "16" , "dds" +"ui\seraphim\game\construct-sm_nav_horiz_btn\top_btn_up.dds" , "60" , "16" , "dds" +"ui\seraphim\game\construct-sm_nav_horiz_btn\up_off.dds" , "60" , "16" , "dds" +"ui\seraphim\game\construct-sm_nav_horiz_btn\up_on.dds" , "60" , "16" , "dds" +"ui\seraphim\game\construct-tab_btn\bot_tab_btn_dis_bmp.dds" , "76" , "56" , "dds" +"ui\seraphim\game\construct-tab_btn\bot_tab_btn_down_bmp.dds" , "76" , "56" , "dds" +"ui\seraphim\game\construct-tab_btn\bot_tab_btn_over_bmp.dds" , "76" , "56" , "dds" +"ui\seraphim\game\construct-tab_btn\bot_tab_btn_sel_bmp.dds" , "76" , "56" , "dds" +"ui\seraphim\game\construct-tab_btn\bot_tab_btn_up_bmp.dds" , "76" , "56" , "dds" +"ui\seraphim\game\construct-tab_btn\mid_tab_btn_dis_bmp.dds" , "76" , "56" , "dds" +"ui\seraphim\game\construct-tab_btn\mid_tab_btn_down_bmp.dds" , "76" , "56" , "dds" +"ui\seraphim\game\construct-tab_btn\mid_tab_btn_over_bmp.dds" , "76" , "56" , "dds" +"ui\seraphim\game\construct-tab_btn\mid_tab_btn_sel_bmp.dds" , "76" , "56" , "dds" +"ui\seraphim\game\construct-tab_btn\mid_tab_btn_up_bmp.dds" , "76" , "56" , "dds" +"ui\seraphim\game\construct-tab_btn\top_tab_btn_dis_bmp.dds" , "76" , "56" , "dds" +"ui\seraphim\game\construct-tab_btn\top_tab_btn_down_bmp.dds" , "76" , "56" , "dds" +"ui\seraphim\game\construct-tab_btn\top_tab_btn_over_bmp.dds" , "76" , "56" , "dds" +"ui\seraphim\game\construct-tab_btn\top_tab_btn_sel_bmp.dds" , "76" , "56" , "dds" +"ui\seraphim\game\construct-tab_btn\top_tab_btn_up_bmp.dds" , "76" , "56" , "dds" +"ui\seraphim\game\construct-tab_top_btn\bot_tab_btn_dis_bmp.dds" , "60" , "32" , "dds" +"ui\seraphim\game\construct-tab_top_btn\bot_tab_btn_down_bmp.dds" , "60" , "32" , "dds" +"ui\seraphim\game\construct-tab_top_btn\bot_tab_btn_over_bmp.dds" , "60" , "32" , "dds" +"ui\seraphim\game\construct-tab_top_btn\bot_tab_btn_sel_bmp.dds" , "60" , "32" , "dds" +"ui\seraphim\game\construct-tab_top_btn\bot_tab_btn_up_bmp.dds" , "60" , "32" , "dds" +"ui\seraphim\game\construct-tab_top_btn\mid_tab_btn_dis_bmp.dds" , "60" , "32" , "dds" +"ui\seraphim\game\construct-tab_top_btn\mid_tab_btn_down_bmp.dds" , "60" , "32" , "dds" +"ui\seraphim\game\construct-tab_top_btn\mid_tab_btn_over_bmp.dds" , "60" , "32" , "dds" +"ui\seraphim\game\construct-tab_top_btn\mid_tab_btn_sel_bmp.dds" , "60" , "32" , "dds" +"ui\seraphim\game\construct-tab_top_btn\mid_tab_btn_up_bmp.dds" , "60" , "32" , "dds" +"ui\seraphim\game\construct-tab_top_btn\top_tab_btn_dis_bmp.dds" , "60" , "32" , "dds" +"ui\seraphim\game\construct-tab_top_btn\top_tab_btn_down_bmp.dds" , "60" , "32" , "dds" +"ui\seraphim\game\construct-tab_top_btn\top_tab_btn_over_bmp.dds" , "60" , "32" , "dds" +"ui\seraphim\game\construct-tab_top_btn\top_tab_btn_sel_bmp.dds" , "60" , "32" , "dds" +"ui\seraphim\game\construct-tab_top_btn\top_tab_btn_up_bmp.dds" , "60" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\left_upgrade_btn_dis.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\left_upgrade_btn_down.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\left_upgrade_btn_over.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\left_upgrade_btn_selected.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\left_upgrade_btn_up.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\m_upgrade_btn_dis.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\m_upgrade_btn_down.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\m_upgrade_btn_over.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\m_upgrade_btn_selected.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\m_upgrade_btn_up.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\r_upgrade_btn_dis.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\r_upgrade_btn_down.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\r_upgrade_btn_over.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\r_upgrade_btn_selected.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\r_upgrade_btn_up.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\t1_btn_dis.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\t1_btn_down.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\t1_btn_over.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\t1_btn_selected.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\t1_btn_up.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\t2_btn_dis.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\t2_btn_down.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\t2_btn_over.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\t2_btn_selected.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\t2_btn_up.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\t3_btn_dis.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\t3_btn_down.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\t3_btn_over.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\t3_btn_selected.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\t3_btn_up.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\t4_btn_dis.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\t4_btn_down.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\t4_btn_over.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\t4_btn_selected.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\t4_btn_up.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\template_btn_dis.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\template_btn_down.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\template_btn_over.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\template_btn_selected.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\template_btn_up.dds" , "32" , "32" , "dds" +"ui\seraphim\game\control-group-bracket\panel-control_bmp_b.dds" , "68" , "24" , "dds" +"ui\seraphim\game\control-group-bracket\panel-control_bmp_m.dds" , "60" , "4" , "dds" +"ui\seraphim\game\control-group-bracket\panel-control_bmp_t.dds" , "68" , "48" , "dds" +"ui\seraphim\game\drag-handle\drag-handle-ll_btn_dis.dds" , "80" , "96" , "dds" +"ui\seraphim\game\drag-handle\drag-handle-ll_btn_down.dds" , "80" , "96" , "dds" +"ui\seraphim\game\drag-handle\drag-handle-ll_btn_over.dds" , "80" , "96" , "dds" +"ui\seraphim\game\drag-handle\drag-handle-ll_btn_up.dds" , "80" , "96" , "dds" +"ui\seraphim\game\drag-handle\drag-handle-lr_btn_dis.dds" , "80" , "96" , "dds" +"ui\seraphim\game\drag-handle\drag-handle-lr_btn_down.dds" , "80" , "96" , "dds" +"ui\seraphim\game\drag-handle\drag-handle-lr_btn_over.dds" , "80" , "96" , "dds" +"ui\seraphim\game\drag-handle\drag-handle-lr_btn_up.dds" , "80" , "96" , "dds" +"ui\seraphim\game\drag-handle\drag-handle-ul_btn_dis.dds" , "80" , "96" , "dds" +"ui\seraphim\game\drag-handle\drag-handle-ul_btn_down.dds" , "80" , "96" , "dds" +"ui\seraphim\game\drag-handle\drag-handle-ul_btn_over.dds" , "80" , "96" , "dds" +"ui\seraphim\game\drag-handle\drag-handle-ul_btn_up.dds" , "80" , "96" , "dds" +"ui\seraphim\game\drag-handle\drag-handle-ur_btn_dis.dds" , "80" , "96" , "dds" +"ui\seraphim\game\drag-handle\drag-handle-ur_btn_down.dds" , "80" , "96" , "dds" +"ui\seraphim\game\drag-handle\drag-handle-ur_btn_over.dds" , "80" , "96" , "dds" +"ui\seraphim\game\drag-handle\drag-handle-ur_btn_up.dds" , "80" , "96" , "dds" +"ui\seraphim\game\economic-overlay\econ_bmp_l.dds" , "24" , "32" , "dds" +"ui\seraphim\game\economic-overlay\econ_bmp_m.dds" , "4" , "32" , "dds" +"ui\seraphim\game\economic-overlay\econ_bmp_r.dds" , "12" , "32" , "dds" +"ui\seraphim\game\filter-ping-list-panel\energy-bar_bmp.dds" , "28" , "16" , "dds" +"ui\seraphim\game\filter-ping-list-panel\panel_bmp_b.dds" , "128" , "24" , "dds" +"ui\seraphim\game\filter-ping-list-panel\panel_bmp_m.dds" , "128" , "8" , "dds" +"ui\seraphim\game\filter-ping-list-panel\panel_bmp_t.dds" , "128" , "24" , "dds" +"ui\seraphim\game\filter-ping-list-panel\panel_brd_horz_um.dds" , "8" , "20" , "dds" +"ui\seraphim\game\filter-ping-list-panel\panel_brd_ll.dds" , "24" , "24" , "dds" +"ui\seraphim\game\filter-ping-list-panel\panel_brd_lm.dds" , "8" , "20" , "dds" +"ui\seraphim\game\filter-ping-list-panel\panel_brd_lr.dds" , "24" , "24" , "dds" +"ui\seraphim\game\filter-ping-list-panel\panel_brd_m.dds" , "8" , "8" , "dds" +"ui\seraphim\game\filter-ping-list-panel\panel_brd_ul.dds" , "24" , "24" , "dds" +"ui\seraphim\game\filter-ping-list-panel\panel_brd_ur.dds" , "24" , "24" , "dds" +"ui\seraphim\game\filter-ping-list-panel\panel_brd_vert_l.dds" , "24" , "8" , "dds" +"ui\seraphim\game\filter-ping-list-panel\panel_brd_vert_r.dds" , "24" , "8" , "dds" +"ui\seraphim\game\filter-ping-panel\bracket-energy-l_bmp.dds" , "20" , "72" , "dds" +"ui\seraphim\game\filter-ping-panel\bracket-energy-r_bmp.dds" , "20" , "72" , "dds" +"ui\seraphim\game\filter-ping-panel\bracket-left_bmp.dds" , "28" , "68" , "dds" +"ui\seraphim\game\filter-ping-panel\filter-ping-panel02_bmp.dds" , "180" , "72" , "dds" +"ui\seraphim\game\medium-btn\medium_btn_dis.dds" , "296" , "72" , "dds" +"ui\seraphim\game\medium-btn\medium_btn_down.dds" , "296" , "72" , "dds" +"ui\seraphim\game\medium-btn\medium_btn_glow.dds" , "296" , "72" , "dds" +"ui\seraphim\game\medium-btn\medium_btn_over.dds" , "296" , "72" , "dds" +"ui\seraphim\game\medium-btn\medium_btn_up.dds" , "296" , "72" , "dds" +"ui\seraphim\game\menu-btns\close_btn_dis.dds" , "24" , "24" , "dds" +"ui\seraphim\game\menu-btns\close_btn_down.dds" , "24" , "24" , "dds" +"ui\seraphim\game\menu-btns\close_btn_over.dds" , "24" , "24" , "dds" +"ui\seraphim\game\menu-btns\close_btn_up.dds" , "24" , "24" , "dds" +"ui\seraphim\game\menu-btns\config_btn_dis.dds" , "24" , "24" , "dds" +"ui\seraphim\game\menu-btns\config_btn_down.dds" , "24" , "24" , "dds" +"ui\seraphim\game\menu-btns\config_btn_over.dds" , "24" , "24" , "dds" +"ui\seraphim\game\menu-btns\config_btn_up.dds" , "24" , "24" , "dds" +"ui\seraphim\game\menu-btns\default_btn_dis.dds" , "24" , "24" , "dds" +"ui\seraphim\game\menu-btns\default_btn_down.dds" , "24" , "24" , "dds" +"ui\seraphim\game\menu-btns\default_btn_over.dds" , "24" , "24" , "dds" +"ui\seraphim\game\menu-btns\default_btn_up.dds" , "24" , "24" , "dds" +"ui\seraphim\game\menu-btns\pin_btn_dis.dds" , "24" , "24" , "dds" +"ui\seraphim\game\menu-btns\pin_btn_down.dds" , "24" , "24" , "dds" +"ui\seraphim\game\menu-btns\pin_btn_over.dds" , "24" , "24" , "dds" +"ui\seraphim\game\menu-btns\pin_btn_up.dds" , "24" , "24" , "dds" +"ui\seraphim\game\menu-btns\pinned_btn_dis.dds" , "24" , "24" , "dds" +"ui\seraphim\game\menu-btns\pinned_btn_down.dds" , "24" , "24" , "dds" +"ui\seraphim\game\menu-btns\pinned_btn_over.dds" , "24" , "24" , "dds" +"ui\seraphim\game\menu-btns\pinned_btn_up.dds" , "24" , "24" , "dds" +"ui\seraphim\game\mfd_btn\control_btn_dis.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\control_btn_down.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\control_btn_over.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\control_btn_up.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\defenses_btn_dis.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\defenses_btn_down.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\defenses_btn_over.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\defenses_btn_up.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\economy_btn_dis.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\economy_btn_down.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\economy_btn_over.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\economy_btn_up.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\intelligence_btn_dis.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\intelligence_btn_down.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\intelligence_btn_over.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\intelligence_btn_up.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\military_btn_dis.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\military_btn_down.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\military_btn_over.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\military_btn_up.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\military-radar_btn_dis.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\military-radar_btn_down.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\military-radar_btn_over.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\military-radar_btn_up.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\ping_btn_dis.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\ping_btn_down.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\ping_btn_over.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\ping_btn_up.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\ping-alert_btn_dis.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\ping-alert_btn_down.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\ping-alert_btn_over.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\ping-alert_btn_up.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\ping-attack_btn_dis.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\ping-attack_btn_down.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\ping-attack_btn_over.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\ping-attack_btn_up.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\ping-marker_btn_dis.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\ping-marker_btn_down.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\ping-marker_btn_over.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\ping-marker_btn_up.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\ping-move_btn_dis.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\ping-move_btn_down.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\ping-move_btn_over.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\ping-move_btn_up.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\team-color_btn_dis.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\team-color_btn_down.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\team-color_btn_over.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\team-color_btn_up.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mini-map-brd\mini-map_brd_horz_um.dds" , "12" , "36" , "dds" +"ui\seraphim\game\mini-map-brd\mini-map_brd_ll.dds" , "12" , "16" , "dds" +"ui\seraphim\game\mini-map-brd\mini-map_brd_lm.dds" , "12" , "16" , "dds" +"ui\seraphim\game\mini-map-brd\mini-map_brd_lr.dds" , "16" , "16" , "dds" +"ui\seraphim\game\mini-map-brd\mini-map_brd_m.dds" , "12" , "12" , "dds" +"ui\seraphim\game\mini-map-brd\mini-map_brd_ul.dds" , "32" , "36" , "dds" +"ui\seraphim\game\mini-map-brd\mini-map_brd_ur.dds" , "36" , "36" , "dds" +"ui\seraphim\game\mini-map-brd\mini-map_brd_vert_l.dds" , "12" , "12" , "dds" +"ui\seraphim\game\mini-map-brd\mini-map_brd_vert_r.dds" , "16" , "12" , "dds" +"ui\seraphim\game\mini-map-brd\mini-map-glow_bmp.dds" , "204" , "204" , "dds" +"ui\seraphim\game\mini-map-glow-brd\mini-map-glow_brd_horz_um.dds" , "8" , "24" , "dds" +"ui\seraphim\game\mini-map-glow-brd\mini-map-glow_brd_ll.dds" , "40" , "40" , "dds" +"ui\seraphim\game\mini-map-glow-brd\mini-map-glow_brd_lm.dds" , "8" , "24" , "dds" +"ui\seraphim\game\mini-map-glow-brd\mini-map-glow_brd_lr.dds" , "40" , "40" , "dds" +"ui\seraphim\game\mini-map-glow-brd\mini-map-glow_brd_ul.dds" , "40" , "40" , "dds" +"ui\seraphim\game\mini-map-glow-brd\mini-map-glow_brd_ur.dds" , "40" , "40" , "dds" +"ui\seraphim\game\mini-map-glow-brd\mini-map-glow_brd_vert_l.dds" , "24" , "8" , "dds" +"ui\seraphim\game\mini-map-glow-brd\mini-map-glow_brd_vert_r.dds" , "24" , "8" , "dds" +"ui\seraphim\game\objective-icons\panel-icon_bmp.dds" , "60" , "68" , "dds" +"ui\seraphim\game\options_tab\diplomacy_btn_dis.dds" , "56" , "48" , "dds" +"ui\seraphim\game\options_tab\diplomacy_btn_down.dds" , "56" , "48" , "dds" +"ui\seraphim\game\options_tab\diplomacy_btn_over.dds" , "56" , "48" , "dds" +"ui\seraphim\game\options_tab\diplomacy_btn_selected.dds" , "56" , "48" , "dds" +"ui\seraphim\game\options_tab\diplomacy_btn_up.dds" , "56" , "48" , "dds" +"ui\seraphim\game\options_tab\glow_bmp.dds" , "56" , "48" , "dds" +"ui\seraphim\game\options_tab\menu_btn_dis.dds" , "56" , "48" , "dds" +"ui\seraphim\game\options_tab\menu_btn_down.dds" , "56" , "48" , "dds" +"ui\seraphim\game\options_tab\menu_btn_over.dds" , "56" , "48" , "dds" +"ui\seraphim\game\options_tab\menu_btn_selected.dds" , "56" , "48" , "dds" +"ui\seraphim\game\options_tab\menu_btn_up.dds" , "56" , "48" , "dds" +"ui\seraphim\game\options_tab\objectives_btn_dis.dds" , "56" , "48" , "dds" +"ui\seraphim\game\options_tab\objectives_btn_down.dds" , "56" , "48" , "dds" +"ui\seraphim\game\options_tab\objectives_btn_over.dds" , "56" , "48" , "dds" +"ui\seraphim\game\options_tab\objectives_btn_selected.dds" , "56" , "48" , "dds" +"ui\seraphim\game\options_tab\objectives_btn_up.dds" , "56" , "48" , "dds" +"ui\seraphim\game\options_tab\pause_btn_dis.dds" , "56" , "48" , "dds" +"ui\seraphim\game\options_tab\pause_btn_down.dds" , "56" , "48" , "dds" +"ui\seraphim\game\options_tab\pause_btn_over.dds" , "56" , "48" , "dds" +"ui\seraphim\game\options_tab\pause_btn_selected.dds" , "56" , "48" , "dds" +"ui\seraphim\game\options_tab\pause_btn_up.dds" , "56" , "48" , "dds" +"ui\seraphim\game\options_tab\play_btn_dis.dds" , "56" , "48" , "dds" +"ui\seraphim\game\options_tab\play_btn_down.dds" , "56" , "48" , "dds" +"ui\seraphim\game\options_tab\play_btn_over.dds" , "56" , "48" , "dds" +"ui\seraphim\game\options_tab\play_btn_selected.dds" , "56" , "48" , "dds" +"ui\seraphim\game\options_tab\play_btn_up.dds" , "56" , "48" , "dds" +"ui\seraphim\game\options-panel\options_brd_horz_lm.dds" , "4" , "28" , "dds" +"ui\seraphim\game\options-panel\options_brd_horz_um.dds" , "80" , "36" , "dds" +"ui\seraphim\game\options-panel\options_brd_horz_uml.dds" , "4" , "28" , "dds" +"ui\seraphim\game\options-panel\options_brd_horz_umr.dds" , "4" , "28" , "dds" +"ui\seraphim\game\options-panel\options_brd_ll.dds" , "44" , "36" , "dds" +"ui\seraphim\game\options-panel\options_brd_lr.dds" , "44" , "36" , "dds" +"ui\seraphim\game\options-panel\options_brd_m.dds" , "4" , "8" , "dds" +"ui\seraphim\game\options-panel\options_brd_ul.dds" , "44" , "36" , "dds" +"ui\seraphim\game\options-panel\options_brd_ur.dds" , "44" , "36" , "dds" +"ui\seraphim\game\options-panel\options_brd_vert_l.dds" , "36" , "8" , "dds" +"ui\seraphim\game\options-panel\options_brd_vert_ll.dds" , "40" , "24" , "dds" +"ui\seraphim\game\options-panel\options_brd_vert_lr.dds" , "40" , "24" , "dds" +"ui\seraphim\game\options-panel\options_brd_vert_r.dds" , "36" , "8" , "dds" +"ui\seraphim\game\options-panel\options_brd_vert_ul.dds" , "40" , "24" , "dds" +"ui\seraphim\game\options-panel\options_brd_vert_ur.dds" , "40" , "24" , "dds" +"ui\seraphim\game\orders-panel_vert\bracket_bmp.dds" , "32" , "224" , "dds" +"ui\seraphim\game\orders-panel_vert\order-panel_bmp.dds" , "188" , "224" , "dds" +"ui\seraphim\game\orders-panel\bracket_bmp.dds" , "32" , "120" , "dds" +"ui\seraphim\game\orders-panel\no-parking_bmp.dds" , "56" , "56" , "dds" +"ui\seraphim\game\orders-panel\order-panel_bmp.dds" , "332" , "120" , "dds" +"ui\seraphim\game\orders-panel\question-mark_bmp.dds" , "16" , "24" , "dds" +"ui\seraphim\game\panel\panel_brd_horz_um.dds" , "8" , "28" , "dds" +"ui\seraphim\game\panel\panel_brd_ll.dds" , "28" , "28" , "dds" +"ui\seraphim\game\panel\panel_brd_lm.dds" , "8" , "28" , "dds" +"ui\seraphim\game\panel\panel_brd_lr.dds" , "28" , "28" , "dds" +"ui\seraphim\game\panel\panel_brd_m.dds" , "8" , "8" , "dds" +"ui\seraphim\game\panel\panel_brd_ul.dds" , "28" , "28" , "dds" +"ui\seraphim\game\panel\panel_brd_ur.dds" , "28" , "28" , "dds" +"ui\seraphim\game\panel\panel_brd_vert_l.dds" , "28" , "8" , "dds" +"ui\seraphim\game\panel\panel_brd_vert_r.dds" , "28" , "8" , "dds" +"ui\seraphim\game\pause_btn\glow_bmp.dds" , "56" , "48" , "dds" +"ui\seraphim\game\pause_btn\pause_btn_dis.dds" , "56" , "48" , "dds" +"ui\seraphim\game\pause_btn\pause_btn_down.dds" , "56" , "48" , "dds" +"ui\seraphim\game\pause_btn\pause_btn_over.dds" , "56" , "48" , "dds" +"ui\seraphim\game\pause_btn\pause_btn_up.dds" , "56" , "48" , "dds" +"ui\seraphim\game\pause_btn\play_btn_dis.dds" , "56" , "48" , "dds" +"ui\seraphim\game\pause_btn\play_btn_down.dds" , "56" , "48" , "dds" +"ui\seraphim\game\pause_btn\play_btn_over.dds" , "56" , "48" , "dds" +"ui\seraphim\game\pause_btn\play_btn_up.dds" , "56" , "48" , "dds" +"ui\seraphim\game\pause-indicator\bottom.dds" , "20" , "20" , "dds" +"ui\seraphim\game\pause-indicator\left.dds" , "20" , "20" , "dds" +"ui\seraphim\game\pause-indicator\right.dds" , "20" , "20" , "dds" +"ui\seraphim\game\pause-indicator\top.dds" , "20" , "20" , "dds" +"ui\seraphim\game\pda-panel\bracket-right_bmp.dds" , "32" , "156" , "dds" +"ui\seraphim\game\pda-panel\panel-objectives_bmp_l.dds" , "20" , "76" , "dds" +"ui\seraphim\game\pda-panel\panel-objectives_bmp_m.dds" , "4" , "72" , "dds" +"ui\seraphim\game\pda-panel\panel-objectives_bmp_r.dds" , "24" , "72" , "dds" +"ui\seraphim\game\pda-panel\panel-ping_bmp_l.dds" , "20" , "60" , "dds" +"ui\seraphim\game\pda-panel\panel-ping_bmp_m.dds" , "4" , "56" , "dds" +"ui\seraphim\game\pda-panel\panel-ping_bmp_r.dds" , "24" , "56" , "dds" +"ui\seraphim\game\pda-panel\panel-time-units_bmp_l.dds" , "20" , "28" , "dds" +"ui\seraphim\game\pda-panel\panel-time-units_bmp_m.dds" , "4" , "24" , "dds" +"ui\seraphim\game\pda-panel\panel-time-units_bmp_r.dds" , "24" , "24" , "dds" +"ui\seraphim\game\pda-panel\title-bar_bmp.dds" , "136" , "24" , "dds" +"ui\seraphim\game\pda-panel\video-panel_bmp.dds" , "168" , "164" , "dds" +"ui\seraphim\game\ping-icons\panel-icon_bmp.dds" , "56" , "56" , "dds" +"ui\seraphim\game\ping-icons\panel-icon-ring_bmp.dds" , "56" , "56" , "dds" +"ui\seraphim\game\resource-panel\alert-caution_bmp.dds" , "48" , "44" , "dds" +"ui\seraphim\game\resource-panel\alert-energy-panel_bmp.dds" , "316" , "32" , "dds" +"ui\seraphim\game\resource-panel\alert-icon_bmp.dds" , "28" , "36" , "dds" +"ui\seraphim\game\resource-panel\alert-mass-panel_bmp.dds" , "316" , "32" , "dds" +"ui\seraphim\game\resource-panel\alert-no-parking_bmp.dds" , "52" , "52" , "dds" +"ui\seraphim\game\resource-panel\bracket-energy-l_bmp.dds" , "20" , "72" , "dds" +"ui\seraphim\game\resource-panel\bracket-energy-r_bmp.dds" , "20" , "72" , "dds" +"ui\seraphim\game\resource-panel\bracket-left_bmp.dds" , "32" , "68" , "dds" +"ui\seraphim\game\resource-panel\bracket-right_bmp.dds" , "32" , "68" , "dds" +"ui\seraphim\game\resource-panel\caution-energy-panel_bmp.dds" , "316" , "32" , "dds" +"ui\seraphim\game\resource-panel\caution-mass-panel_bmp.dds" , "316" , "32" , "dds" +"ui\seraphim\game\resource-panel\resources_panel_bmp.dds" , "324" , "72" , "dds" +"ui\seraphim\game\score-panel\icon-square_bmp.dds" , "24" , "24" , "dds" +"ui\seraphim\game\score-panel\panel-score_bmp_b.dds" , "236" , "20" , "dds" +"ui\seraphim\game\score-panel\panel-score_bmp_m.dds" , "236" , "4" , "dds" +"ui\seraphim\game\score-panel\panel-score_bmp_t.dds" , "236" , "44" , "dds" +"ui\seraphim\game\tab-l-btn\tab-close_btn_dis.dds" , "20" , "28" , "dds" +"ui\seraphim\game\tab-l-btn\tab-close_btn_down.dds" , "20" , "28" , "dds" +"ui\seraphim\game\tab-l-btn\tab-close_btn_over.dds" , "20" , "28" , "dds" +"ui\seraphim\game\tab-l-btn\tab-close_btn_up.dds" , "20" , "28" , "dds" +"ui\seraphim\game\tab-l-btn\tab-open_btn_dis.dds" , "20" , "28" , "dds" +"ui\seraphim\game\tab-l-btn\tab-open_btn_down.dds" , "20" , "28" , "dds" +"ui\seraphim\game\tab-l-btn\tab-open_btn_over.dds" , "20" , "28" , "dds" +"ui\seraphim\game\tab-l-btn\tab-open_btn_up.dds" , "20" , "28" , "dds" +"ui\seraphim\game\tab-r-btn\tab-close_btn_dis.dds" , "20" , "28" , "dds" +"ui\seraphim\game\tab-r-btn\tab-close_btn_down.dds" , "20" , "28" , "dds" +"ui\seraphim\game\tab-r-btn\tab-close_btn_over.dds" , "20" , "28" , "dds" +"ui\seraphim\game\tab-r-btn\tab-close_btn_up.dds" , "20" , "28" , "dds" +"ui\seraphim\game\tab-r-btn\tab-open_btn_dis.dds" , "20" , "28" , "dds" +"ui\seraphim\game\tab-r-btn\tab-open_btn_down.dds" , "20" , "28" , "dds" +"ui\seraphim\game\tab-r-btn\tab-open_btn_over.dds" , "20" , "28" , "dds" +"ui\seraphim\game\tab-r-btn\tab-open_btn_up.dds" , "20" , "28" , "dds" +"ui\seraphim\game\tab-t-btn\tab-close_btn_dis.dds" , "28" , "20" , "dds" +"ui\seraphim\game\tab-t-btn\tab-close_btn_down.dds" , "28" , "20" , "dds" +"ui\seraphim\game\tab-t-btn\tab-close_btn_over.dds" , "28" , "20" , "dds" +"ui\seraphim\game\tab-t-btn\tab-close_btn_up.dds" , "28" , "20" , "dds" +"ui\seraphim\game\tab-t-btn\tab-open_btn_dis.dds" , "28" , "20" , "dds" +"ui\seraphim\game\tab-t-btn\tab-open_btn_down.dds" , "28" , "20" , "dds" +"ui\seraphim\game\tab-t-btn\tab-open_btn_over.dds" , "28" , "20" , "dds" +"ui\seraphim\game\tab-t-btn\tab-open_btn_up.dds" , "28" , "20" , "dds" +"ui\seraphim\game\temp_textures\checkmark_dark.dds" , "16" , "16" , "dds" +"ui\seraphim\game\temp_textures\checkmark.dds" , "16" , "16" , "dds" +"ui\seraphim\game\temp_textures\combo_sel.dds" , "20" , "20" , "dds" +"ui\seraphim\game\temp_textures\combo_up.dds" , "20" , "20" , "dds" +"ui\seraphim\game\transmission\title-bar_bmp.dds" , "192" , "20" , "dds" +"ui\seraphim\game\transmission\video-brackets.dds" , "224" , "216" , "dds" +"ui\seraphim\game\transmission\video-panel_bmp.dds" , "232" , "216" , "dds" +"ui\seraphim\game\transmission\video-panel.dds" , "208" , "208" , "dds" +"ui\seraphim\game\unit-build-over-panel\bracket-build_bmp.dds" , "28" , "116" , "dds" +"ui\seraphim\game\unit-build-over-panel\bracket-unit_bmp.dds" , "28" , "116" , "dds" +"ui\seraphim\game\unit-build-over-panel\build-over-back_bmp.dds" , "332" , "116" , "dds" +"ui\seraphim\game\unit-build-over-panel\fuelbar.dds" , "188" , "4" , "dds" +"ui\seraphim\game\unit-build-over-panel\healthbar_bg.dds" , "188" , "20" , "dds" +"ui\seraphim\game\unit-build-over-panel\healthbar_green.dds" , "192" , "24" , "dds" +"ui\seraphim\game\unit-build-over-panel\healthbar_red.dds" , "192" , "24" , "dds" +"ui\seraphim\game\unit-build-over-panel\healthbar_yellow.dds" , "192" , "24" , "dds" +"ui\seraphim\game\unit-build-over-panel\shieldbar.dds" , "188" , "4" , "dds" +"ui\seraphim\game\unit-build-over-panel\unit-over-back_bmp.dds" , "332" , "116" , "dds" +"ui\seraphim\small-vert_scroll\arrow-down_scr_dis.dds" , "36" , "28" , "dds" +"ui\seraphim\small-vert_scroll\arrow-down_scr_down.dds" , "36" , "28" , "dds" +"ui\seraphim\small-vert_scroll\arrow-down_scr_over.dds" , "36" , "28" , "dds" +"ui\seraphim\small-vert_scroll\arrow-down_scr_up.dds" , "36" , "28" , "dds" +"ui\seraphim\small-vert_scroll\arrow-up_scr_dis.dds" , "36" , "28" , "dds" +"ui\seraphim\small-vert_scroll\arrow-up_scr_down.dds" , "36" , "28" , "dds" +"ui\seraphim\small-vert_scroll\arrow-up_scr_over.dds" , "36" , "28" , "dds" +"ui\seraphim\small-vert_scroll\arrow-up_scr_up.dds" , "36" , "28" , "dds" +"ui\seraphim\small-vert_scroll\back_scr_bot.dds" , "28" , "12" , "dds" +"ui\seraphim\small-vert_scroll\back_scr_mid.dds" , "36" , "8" , "dds" +"ui\seraphim\small-vert_scroll\back_scr_top.dds" , "28" , "12" , "dds" +"ui\seraphim\small-vert_scroll\bar-bot_scr_dis.dds" , "36" , "8" , "dds" +"ui\seraphim\small-vert_scroll\bar-bot_scr_down.dds" , "20" , "8" , "dds" +"ui\seraphim\small-vert_scroll\bar-bot_scr_over.dds" , "36" , "8" , "dds" +"ui\seraphim\small-vert_scroll\bar-bot_scr_up.dds" , "36" , "8" , "dds" +"ui\seraphim\small-vert_scroll\bar-mid_scr_dis.dds" , "36" , "8" , "dds" +"ui\seraphim\small-vert_scroll\bar-mid_scr_down.dds" , "20" , "8" , "dds" +"ui\seraphim\small-vert_scroll\bar-mid_scr_over.dds" , "36" , "8" , "dds" +"ui\seraphim\small-vert_scroll\bar-mid_scr_up.dds" , "36" , "8" , "dds" +"ui\seraphim\small-vert_scroll\bar-top_scr_dis.dds" , "36" , "8" , "dds" +"ui\seraphim\small-vert_scroll\bar-top_scr_down.dds" , "20" , "8" , "dds" +"ui\seraphim\small-vert_scroll\bar-top_scr_over.dds" , "36" , "8" , "dds" +"ui\seraphim\small-vert_scroll\bar-top_scr_up.dds" , "36" , "8" , "dds" +"ui\seraphim\widgets02\small_btn_dis.dds" , "148" , "36" , "dds" +"ui\seraphim\widgets02\small_btn_down.dds" , "148" , "36" , "dds" +"ui\seraphim\widgets02\small_btn_over.dds" , "148" , "36" , "dds" +"ui\seraphim\widgets02\small_btn_up.dds" , "148" , "36" , "dds" +"ui\uef\dialogs\menu-btns\close_btn_dis.dds" , "24" , "24" , "dds" +"ui\uef\dialogs\menu-btns\close_btn_down.dds" , "24" , "24" , "dds" +"ui\uef\dialogs\menu-btns\close_btn_over.dds" , "24" , "24" , "dds" +"ui\uef\dialogs\menu-btns\close_btn_up.dds" , "24" , "24" , "dds" +"ui\uef\dialogs\menu-btns\config_btn_dis.dds" , "24" , "24" , "dds" +"ui\uef\dialogs\menu-btns\config_btn_down.dds" , "24" , "24" , "dds" +"ui\uef\dialogs\menu-btns\config_btn_over.dds" , "24" , "24" , "dds" +"ui\uef\dialogs\menu-btns\config_btn_up.dds" , "24" , "24" , "dds" +"ui\uef\dialogs\menu-btns\default_btn_dis.dds" , "24" , "24" , "dds" +"ui\uef\dialogs\menu-btns\default_btn_down.dds" , "24" , "24" , "dds" +"ui\uef\dialogs\menu-btns\default_btn_over.dds" , "24" , "24" , "dds" +"ui\uef\dialogs\menu-btns\default_btn_up.dds" , "24" , "24" , "dds" +"ui\uef\dialogs\menu-btns\pin_btn_dis.dds" , "24" , "24" , "dds" +"ui\uef\dialogs\menu-btns\pin_btn_down.dds" , "24" , "24" , "dds" +"ui\uef\dialogs\menu-btns\pin_btn_over.dds" , "24" , "24" , "dds" +"ui\uef\dialogs\menu-btns\pin_btn_up.dds" , "24" , "24" , "dds" +"ui\uef\dialogs\menu-btns\pinned_btn_dis.dds" , "24" , "24" , "dds" +"ui\uef\dialogs\menu-btns\pinned_btn_down.dds" , "24" , "24" , "dds" +"ui\uef\dialogs\menu-btns\pinned_btn_over.dds" , "24" , "24" , "dds" +"ui\uef\dialogs\menu-btns\pinned_btn_up.dds" , "24" , "24" , "dds" +"ui\uef\game\ability_brd\chat_brd_horz_um.dds" , "20" , "20" , "dds" +"ui\uef\game\ability_brd\chat_brd_ll.dds" , "20" , "20" , "dds" +"ui\uef\game\ability_brd\chat_brd_lm.dds" , "20" , "20" , "dds" +"ui\uef\game\ability_brd\chat_brd_lr.dds" , "20" , "20" , "dds" +"ui\uef\game\ability_brd\chat_brd_m.dds" , "20" , "20" , "dds" +"ui\uef\game\ability_brd\chat_brd_ul.dds" , "20" , "20" , "dds" +"ui\uef\game\ability_brd\chat_brd_ur.dds" , "20" , "20" , "dds" +"ui\uef\game\ability_brd\chat_brd_vert_l.dds" , "20" , "20" , "dds" +"ui\uef\game\ability_brd\chat_brd_vert_r.dds" , "20" , "20" , "dds" +"ui\uef\game\avatar-arrow_btn\tab-close_btn_dis.dds" , "12" , "32" , "dds" +"ui\uef\game\avatar-arrow_btn\tab-close_btn_down.dds" , "12" , "32" , "dds" +"ui\uef\game\avatar-arrow_btn\tab-close_btn_over.dds" , "12" , "32" , "dds" +"ui\uef\game\avatar-arrow_btn\tab-close_btn_up.dds" , "12" , "32" , "dds" +"ui\uef\game\avatar-arrow_btn\tab-open_btn_dis.dds" , "12" , "32" , "dds" +"ui\uef\game\avatar-arrow_btn\tab-open_btn_down.dds" , "12" , "32" , "dds" +"ui\uef\game\avatar-arrow_btn\tab-open_btn_over.dds" , "12" , "32" , "dds" +"ui\uef\game\avatar-arrow_btn\tab-open_btn_up.dds" , "12" , "32" , "dds" +"ui\uef\game\avatar-engineers-panel\bracket_bmp.dds" , "28" , "48" , "dds" +"ui\uef\game\avatar-engineers-panel\panel_bmp.dds" , "108" , "188" , "dds" +"ui\uef\game\avatar-engineers-panel\panel-eng_bmp_b.dds" , "88" , "12" , "dds" +"ui\uef\game\avatar-engineers-panel\panel-eng_bmp_m.dds" , "88" , "44" , "dds" +"ui\uef\game\avatar-engineers-panel\panel-eng_bmp_t.dds" , "88" , "12" , "dds" +"ui\uef\game\avatar-engineers-panel\tech-1_bmp.dds" , "28" , "28" , "dds" +"ui\uef\game\avatar-engineers-panel\tech-2_bmp.dds" , "28" , "28" , "dds" +"ui\uef\game\avatar-engineers-panel\tech-3_bmp.dds" , "28" , "28" , "dds" +"ui\uef\game\avatar-factory-panel\avatar-s-e-f_bmp.dds" , "52" , "52" , "dds" +"ui\uef\game\avatar-factory-panel\bracket_bmp.dds" , "28" , "48" , "dds" +"ui\uef\game\avatar-factory-panel\factory-panel_bmp.dds" , "184" , "160" , "dds" +"ui\uef\game\avatar-factory-panel\factory-panel_brd_horz_um.dds" , "44" , "12" , "dds" +"ui\uef\game\avatar-factory-panel\factory-panel_brd_ll.dds" , "36" , "52" , "dds" +"ui\uef\game\avatar-factory-panel\factory-panel_brd_lm.dds" , "44" , "48" , "dds" +"ui\uef\game\avatar-factory-panel\factory-panel_brd_lr.dds" , "60" , "52" , "dds" +"ui\uef\game\avatar-factory-panel\factory-panel_brd_ul copy.dds" , "36" , "16" , "dds" +"ui\uef\game\avatar-factory-panel\factory-panel_brd_ul.dds" , "36" , "16" , "dds" +"ui\uef\game\avatar-factory-panel\factory-panel_brd_ur.dds" , "60" , "16" , "dds" +"ui\uef\game\avatar-factory-panel\factory-panel_brd_vert_l.dds" , "36" , "44" , "dds" +"ui\uef\game\avatar-factory-panel\factory-panel_brd_vert_r.dds" , "60" , "44" , "dds" +"ui\uef\game\avatar-factory-panel\panel_bmp.dds" , "200" , "152" , "dds" +"ui\uef\game\avatar-factory-panel\strat-icon-air_bmp.dds" , "8" , "8" , "dds" +"ui\uef\game\avatar-factory-panel\strat-icon-land_bmp.dds" , "8" , "8" , "dds" +"ui\uef\game\avatar-factory-panel\strat-icon-sea_bmp.dds" , "8" , "8" , "dds" +"ui\uef\game\avatar-factory-panel\tech-1_bmp.dds" , "28" , "28" , "dds" +"ui\uef\game\avatar-factory-panel\tech-2_bmp.dds" , "28" , "28" , "dds" +"ui\uef\game\avatar-factory-panel\tech-3_bmp.dds" , "28" , "28" , "dds" +"ui\uef\game\avatar\avatar_bmp.dds" , "64" , "68" , "dds" +"ui\uef\game\avatar\avatar-control-group_bmp.dds" , "48" , "36" , "dds" +"ui\uef\game\avatar\avatar-s-e-f_bmp.dds" , "56" , "52" , "dds" +"ui\uef\game\avatar\health-bar-back_bmp.dds" , "52" , "16" , "dds" +"ui\uef\game\avatar\health-bar.dds" , "40" , "8" , "dds" +"ui\uef\game\avatar\pulse-bars_bmp.dds" , "64" , "64" , "dds" +"ui\uef\game\bracket-left-energy\bracket_bmp_b.dds" , "20" , "16" , "dds" +"ui\uef\game\bracket-left-energy\bracket_bmp_m.dds" , "12" , "4" , "dds" +"ui\uef\game\bracket-left-energy\bracket_bmp_t.dds" , "20" , "20" , "dds" +"ui\uef\game\bracket-left-energy\bracket_bmp.dds" , "20" , "68" , "dds" +"ui\uef\game\bracket-left\bracket_bmp_b.dds" , "32" , "28" , "dds" +"ui\uef\game\bracket-left\bracket_bmp_m.dds" , "16" , "4" , "dds" +"ui\uef\game\bracket-left\bracket_bmp_t.dds" , "32" , "68" , "dds" +"ui\uef\game\bracket-left\bracket_bmp.dds" , "32" , "120" , "dds" +"ui\uef\game\bracket-min-small\bracket-sm-left_bmp.dds" , "24" , "80" , "dds" +"ui\uef\game\bracket-min-small\bracket-sm-right_bmp.dds" , "24" , "80" , "dds" +"ui\uef\game\bracket-right-energy\bracket_bmp_b.dds" , "20" , "16" , "dds" +"ui\uef\game\bracket-right-energy\bracket_bmp_m.dds" , "12" , "4" , "dds" +"ui\uef\game\bracket-right-energy\bracket_bmp_t.dds" , "20" , "20" , "dds" +"ui\uef\game\bracket-right-energy\bracket_bmp.dds" , "20" , "68" , "dds" +"ui\uef\game\bracket-right\bracket_bmp_b.dds" , "32" , "28" , "dds" +"ui\uef\game\bracket-right\bracket_bmp_m.dds" , "16" , "4" , "dds" +"ui\uef\game\bracket-right\bracket_bmp_t.dds" , "32" , "68" , "dds" +"ui\uef\game\bracket-right\bracket_bmp.dds" , "32" , "120" , "dds" +"ui\uef\game\c-q-e-panel\arrow_bmp.dds" , "28" , "16" , "dds" +"ui\uef\game\c-q-e-panel\arrow_vert_bmp.dds" , "16" , "24" , "dds" +"ui\uef\game\c-q-e-panel\c-icon-group-b_bmp_l.dds" , "24" , "52" , "dds" +"ui\uef\game\c-q-e-panel\c-icon-group-b_bmp_m.dds" , "8" , "52" , "dds" +"ui\uef\game\c-q-e-panel\c-icon-group-b_bmp_r.dds" , "24" , "52" , "dds" +"ui\uef\game\c-q-e-panel\c-icon-group-e_bmp_l.dds" , "24" , "52" , "dds" +"ui\uef\game\c-q-e-panel\c-icon-group-e_bmp_m.dds" , "8" , "52" , "dds" +"ui\uef\game\c-q-e-panel\c-icon-group-e_bmp_r.dds" , "24" , "52" , "dds" +"ui\uef\game\c-q-e-panel\c-icon-group-g_bmp_l.dds" , "24" , "52" , "dds" +"ui\uef\game\c-q-e-panel\c-icon-group-g_bmp_m.dds" , "8" , "52" , "dds" +"ui\uef\game\c-q-e-panel\c-icon-group-g_bmp_r.dds" , "24" , "52" , "dds" +"ui\uef\game\c-q-e-panel\c-icon-group-o_bmp_l.dds" , "24" , "52" , "dds" +"ui\uef\game\c-q-e-panel\c-icon-group-o_bmp_m.dds" , "8" , "52" , "dds" +"ui\uef\game\c-q-e-panel\c-icon-group-o_bmp_r.dds" , "24" , "52" , "dds" +"ui\uef\game\c-q-e-panel\c-icon-group-p_bmp_l.dds" , "24" , "52" , "dds" +"ui\uef\game\c-q-e-panel\c-icon-group-p_bmp_m.dds" , "8" , "52" , "dds" +"ui\uef\game\c-q-e-panel\c-icon-group-p_bmp_r.dds" , "24" , "52" , "dds" +"ui\uef\game\c-q-e-panel\c-icon-group-r_bmp_l.dds" , "24" , "52" , "dds" +"ui\uef\game\c-q-e-panel\c-icon-group-r_bmp_m.dds" , "8" , "52" , "dds" +"ui\uef\game\c-q-e-panel\c-icon-group-r_bmp_r.dds" , "24" , "52" , "dds" +"ui\uef\game\c-q-e-panel\construct-panel_bmp_l.dds" , "84" , "60" , "dds" +"ui\uef\game\c-q-e-panel\construct-panel_bmp_m.dds" , "8" , "60" , "dds" +"ui\uef\game\c-q-e-panel\construct-panel_bmp_r.dds" , "16" , "60" , "dds" +"ui\uef\game\c-q-e-panel\divider_bmp.dds" , "12" , "56" , "dds" +"ui\uef\game\c-q-e-panel\divider_horizontal_bmp.dds" , "64" , "12" , "dds" +"ui\uef\game\c-q-e-panel\divider_horz_bmp.dds" , "52" , "8" , "dds" +"ui\uef\game\c-q-e-panel\e-icon-group_bmp_l.dds" , "24" , "44" , "dds" +"ui\uef\game\c-q-e-panel\e-icon-group_bmp_m.dds" , "8" , "44" , "dds" +"ui\uef\game\c-q-e-panel\e-icon-group_bmp_r.dds" , "24" , "44" , "dds" +"ui\uef\game\c-q-e-panel\enhance-panel_bmp_l.dds" , "60" , "60" , "dds" +"ui\uef\game\c-q-e-panel\enhance-panel_bmp_m.dds" , "8" , "60" , "dds" +"ui\uef\game\c-q-e-panel\enhance-panel_bmp_r.dds" , "16" , "60" , "dds" +"ui\uef\game\c-q-e-panel\icon-group_bmp.dds" , "52" , "52" , "dds" +"ui\uef\game\c-q-e-panel\que-panel_bmp_l.dds" , "104" , "60" , "dds" +"ui\uef\game\c-q-e-panel\que-panel_bmp_m.dds" , "8" , "60" , "dds" +"ui\uef\game\c-q-e-panel\que-panel_bmp_r.dds" , "16" , "60" , "dds" +"ui\uef\game\camera-btn\pinned_btn_dis.dds" , "24" , "24" , "dds" +"ui\uef\game\camera-btn\pinned_btn_down.dds" , "24" , "24" , "dds" +"ui\uef\game\camera-btn\pinned_btn_over.dds" , "24" , "24" , "dds" +"ui\uef\game\camera-btn\pinned_btn_up.dds" , "24" , "24" , "dds" +"ui\uef\game\chat_brd\chat_brd_horz_um.dds" , "8" , "48" , "dds" +"ui\uef\game\chat_brd\chat_brd_ll.dds" , "44" , "48" , "dds" +"ui\uef\game\chat_brd\chat_brd_lm.dds" , "8" , "48" , "dds" +"ui\uef\game\chat_brd\chat_brd_lr.dds" , "44" , "48" , "dds" +"ui\uef\game\chat_brd\chat_brd_m.dds" , "8" , "8" , "dds" +"ui\uef\game\chat_brd\chat_brd_ul.dds" , "44" , "48" , "dds" +"ui\uef\game\chat_brd\chat_brd_ur.dds" , "44" , "48" , "dds" +"ui\uef\game\chat_brd\chat_brd_vert_l.dds" , "44" , "8" , "dds" +"ui\uef\game\chat_brd\chat_brd_vert_r.dds" , "44" , "8" , "dds" +"ui\uef\game\chat_brd\drop-box_brd_horz_um.dds" , "8" , "16" , "dds" +"ui\uef\game\chat_brd\drop-box_brd_ll.dds" , "16" , "12" , "dds" +"ui\uef\game\chat_brd\drop-box_brd_lm.dds" , "8" , "12" , "dds" +"ui\uef\game\chat_brd\drop-box_brd_lr.dds" , "16" , "12" , "dds" +"ui\uef\game\chat_brd\drop-box_brd_m.dds" , "8" , "8" , "dds" +"ui\uef\game\chat_brd\drop-box_brd_ul.dds" , "16" , "16" , "dds" +"ui\uef\game\chat_brd\drop-box_brd_ur.dds" , "16" , "16" , "dds" +"ui\uef\game\chat_brd\drop-box_brd_vert_l.dds" , "16" , "8" , "dds" +"ui\uef\game\chat_brd\drop-box_brd_vert_r.dds" , "16" , "8" , "dds" +"ui\uef\game\chat-box_btn\radio_btn_dis.dds" , "28" , "28" , "dds" +"ui\uef\game\chat-box_btn\radio_btn_down.dds" , "28" , "28" , "dds" +"ui\uef\game\chat-box_btn\radio_btn_over.dds" , "28" , "28" , "dds" +"ui\uef\game\chat-box_btn\radio_btn_up.dds" , "28" , "28" , "dds" +"ui\uef\game\construct-panel_vert\construct-panel_bmp_b.dds" , "172" , "12" , "dds" +"ui\uef\game\construct-panel_vert\construct-panel_bmp_m.dds" , "172" , "8" , "dds" +"ui\uef\game\construct-panel_vert\construct-panel_bmp_t.dds" , "172" , "64" , "dds" +"ui\uef\game\construct-panel_vert\que-panel_bmp_b.dds" , "60" , "8" , "dds" +"ui\uef\game\construct-panel_vert\que-panel_bmp_m.dds" , "60" , "8" , "dds" +"ui\uef\game\construct-panel_vert\que-panel_bmp_t.dds" , "60" , "8" , "dds" +"ui\uef\game\construct-panel\construct-panel_bmp_l.dds" , "84" , "140" , "dds" +"ui\uef\game\construct-panel\construct-panel_bmp_m1.dds" , "8" , "140" , "dds" +"ui\uef\game\construct-panel\construct-panel_bmp_m2.dds" , "16" , "140" , "dds" +"ui\uef\game\construct-panel\construct-panel_bmp_m3.dds" , "8" , "112" , "dds" +"ui\uef\game\construct-panel\construct-panel_bmp_r.dds" , "16" , "112" , "dds" +"ui\uef\game\construct-panel\construct-panel_s_bmp_l.dds" , "16" , "112" , "dds" +"ui\uef\game\construct-panel\construct-panel_s_bmp_m.dds" , "8" , "112" , "dds" +"ui\uef\game\construct-panel\construct-panel_s_bmp_r.dds" , "16" , "112" , "dds" +"ui\uef\game\construct-panel\que-panel_bmp_l.dds" , "12" , "52" , "dds" +"ui\uef\game\construct-panel\que-panel_bmp_m.dds" , "8" , "52" , "dds" +"ui\uef\game\construct-panel\que-panel_bmp_r.dds" , "12" , "52" , "dds" +"ui\uef\game\construct-sm_btn\back_off.dds" , "24" , "52" , "dds" +"ui\uef\game\construct-sm_btn\back_on.dds" , "24" , "52" , "dds" +"ui\uef\game\construct-sm_btn\fforward_off.dds" , "24" , "52" , "dds" +"ui\uef\game\construct-sm_btn\fforward_on.dds" , "24" , "52" , "dds" +"ui\uef\game\construct-sm_btn\forward_off.dds" , "24" , "52" , "dds" +"ui\uef\game\construct-sm_btn\forward_on.dds" , "24" , "52" , "dds" +"ui\uef\game\construct-sm_btn\infinite_off.dds" , "24" , "52" , "dds" +"ui\uef\game\construct-sm_btn\infinite_on.dds" , "24" , "52" , "dds" +"ui\uef\game\construct-sm_btn\left_btn_dis.dds" , "24" , "52" , "dds" +"ui\uef\game\construct-sm_btn\left_btn_over.dds" , "24" , "52" , "dds" +"ui\uef\game\construct-sm_btn\left_btn_up.dds" , "24" , "52" , "dds" +"ui\uef\game\construct-sm_btn\mid_btn_dis.dds" , "24" , "52" , "dds" +"ui\uef\game\construct-sm_btn\mid_btn_over.dds" , "24" , "52" , "dds" +"ui\uef\game\construct-sm_btn\mid_btn_selected.dds" , "24" , "52" , "dds" +"ui\uef\game\construct-sm_btn\mid_btn_up.dds" , "24" , "52" , "dds" +"ui\uef\game\construct-sm_btn\pause_off.dds" , "24" , "52" , "dds" +"ui\uef\game\construct-sm_btn\pause_on.dds" , "24" , "52" , "dds" +"ui\uef\game\construct-sm_btn\rewind_off.dds" , "24" , "52" , "dds" +"ui\uef\game\construct-sm_btn\rewind_on.dds" , "24" , "52" , "dds" +"ui\uef\game\construct-sm_btn\right_btn_dis.dds" , "24" , "52" , "dds" +"ui\uef\game\construct-sm_btn\right_btn_over.dds" , "24" , "52" , "dds" +"ui\uef\game\construct-sm_btn\right_btn_up.dds" , "24" , "52" , "dds" +"ui\uef\game\construct-sm_btn\template_off.dds" , "24" , "52" , "dds" +"ui\uef\game\construct-sm_btn\template_on.dds" , "24" , "52" , "dds" +"ui\uef\game\construct-sm_horiz_btn\infinite_off.dds" , "60" , "28" , "dds" +"ui\uef\game\construct-sm_horiz_btn\infinite_on.dds" , "60" , "28" , "dds" +"ui\uef\game\construct-sm_horiz_btn\pause_off.dds" , "60" , "28" , "dds" +"ui\uef\game\construct-sm_horiz_btn\pause_on.dds" , "60" , "28" , "dds" +"ui\uef\game\construct-sm_horiz_btn\que_btn_dis.dds" , "60" , "28" , "dds" +"ui\uef\game\construct-sm_horiz_btn\que_btn_over.dds" , "60" , "28" , "dds" +"ui\uef\game\construct-sm_horiz_btn\que_btn_selected.dds" , "60" , "28" , "dds" +"ui\uef\game\construct-sm_horiz_btn\que_btn_up.dds" , "60" , "28" , "dds" +"ui\uef\game\construct-sm_horiz_btn\template_off.dds" , "60" , "28" , "dds" +"ui\uef\game\construct-sm_horiz_btn\template_on.dds" , "60" , "28" , "dds" +"ui\uef\game\construct-sm_nav_horiz_btn\bottom_btn_dis.dds" , "60" , "16" , "dds" +"ui\uef\game\construct-sm_nav_horiz_btn\bottom_btn_over.dds" , "60" , "16" , "dds" +"ui\uef\game\construct-sm_nav_horiz_btn\bottom_btn_up.dds" , "60" , "16" , "dds" +"ui\uef\game\construct-sm_nav_horiz_btn\down_off.dds" , "60" , "16" , "dds" +"ui\uef\game\construct-sm_nav_horiz_btn\down_on.dds" , "60" , "16" , "dds" +"ui\uef\game\construct-sm_nav_horiz_btn\end_off.dds" , "60" , "16" , "dds" +"ui\uef\game\construct-sm_nav_horiz_btn\end_on.dds" , "60" , "16" , "dds" +"ui\uef\game\construct-sm_nav_horiz_btn\home_off.dds" , "60" , "16" , "dds" +"ui\uef\game\construct-sm_nav_horiz_btn\home_on.dds" , "60" , "16" , "dds" +"ui\uef\game\construct-sm_nav_horiz_btn\mid_btn_dis.dds" , "60" , "16" , "dds" +"ui\uef\game\construct-sm_nav_horiz_btn\mid_btn_over.dds" , "60" , "16" , "dds" +"ui\uef\game\construct-sm_nav_horiz_btn\mid_btn_up.dds" , "60" , "16" , "dds" +"ui\uef\game\construct-sm_nav_horiz_btn\top_btn_dis.dds" , "60" , "16" , "dds" +"ui\uef\game\construct-sm_nav_horiz_btn\top_btn_over.dds" , "60" , "16" , "dds" +"ui\uef\game\construct-sm_nav_horiz_btn\top_btn_up.dds" , "60" , "16" , "dds" +"ui\uef\game\construct-sm_nav_horiz_btn\up_off.dds" , "60" , "16" , "dds" +"ui\uef\game\construct-sm_nav_horiz_btn\up_on.dds" , "60" , "16" , "dds" +"ui\uef\game\construct-tab_btn\bot_tab_btn_dis_bmp.dds" , "76" , "56" , "dds" +"ui\uef\game\construct-tab_btn\bot_tab_btn_down_bmp.dds" , "76" , "56" , "dds" +"ui\uef\game\construct-tab_btn\bot_tab_btn_over_bmp.dds" , "76" , "56" , "dds" +"ui\uef\game\construct-tab_btn\bot_tab_btn_sel_bmp.dds" , "76" , "56" , "dds" +"ui\uef\game\construct-tab_btn\bot_tab_btn_up_bmp.dds" , "76" , "56" , "dds" +"ui\uef\game\construct-tab_btn\mid_tab_btn_dis_bmp.dds" , "76" , "56" , "dds" +"ui\uef\game\construct-tab_btn\mid_tab_btn_down_bmp.dds" , "76" , "56" , "dds" +"ui\uef\game\construct-tab_btn\mid_tab_btn_over_bmp.dds" , "76" , "56" , "dds" +"ui\uef\game\construct-tab_btn\mid_tab_btn_sel_bmp.dds" , "76" , "56" , "dds" +"ui\uef\game\construct-tab_btn\mid_tab_btn_up_bmp.dds" , "76" , "56" , "dds" +"ui\uef\game\construct-tab_btn\top_tab_btn_dis_bmp.dds" , "76" , "56" , "dds" +"ui\uef\game\construct-tab_btn\top_tab_btn_down_bmp.dds" , "76" , "56" , "dds" +"ui\uef\game\construct-tab_btn\top_tab_btn_over_bmp.dds" , "76" , "56" , "dds" +"ui\uef\game\construct-tab_btn\top_tab_btn_sel_bmp.dds" , "76" , "56" , "dds" +"ui\uef\game\construct-tab_btn\top_tab_btn_up_bmp.dds" , "76" , "56" , "dds" +"ui\uef\game\construct-tab_top_btn\bot_tab_btn_dis_bmp.dds" , "60" , "32" , "dds" +"ui\uef\game\construct-tab_top_btn\bot_tab_btn_down_bmp.dds" , "60" , "32" , "dds" +"ui\uef\game\construct-tab_top_btn\bot_tab_btn_over_bmp.dds" , "60" , "32" , "dds" +"ui\uef\game\construct-tab_top_btn\bot_tab_btn_sel_bmp.dds" , "60" , "32" , "dds" +"ui\uef\game\construct-tab_top_btn\bot_tab_btn_up_bmp.dds" , "60" , "32" , "dds" +"ui\uef\game\construct-tab_top_btn\mid_tab_btn_dis_bmp.dds" , "60" , "32" , "dds" +"ui\uef\game\construct-tab_top_btn\mid_tab_btn_down_bmp.dds" , "60" , "32" , "dds" +"ui\uef\game\construct-tab_top_btn\mid_tab_btn_over_bmp.dds" , "60" , "32" , "dds" +"ui\uef\game\construct-tab_top_btn\mid_tab_btn_sel_bmp.dds" , "60" , "32" , "dds" +"ui\uef\game\construct-tab_top_btn\mid_tab_btn_up_bmp.dds" , "60" , "32" , "dds" +"ui\uef\game\construct-tab_top_btn\top_tab_btn_dis_bmp.dds" , "60" , "32" , "dds" +"ui\uef\game\construct-tab_top_btn\top_tab_btn_down_bmp.dds" , "60" , "32" , "dds" +"ui\uef\game\construct-tab_top_btn\top_tab_btn_over_bmp.dds" , "60" , "32" , "dds" +"ui\uef\game\construct-tab_top_btn\top_tab_btn_sel_bmp.dds" , "60" , "32" , "dds" +"ui\uef\game\construct-tab_top_btn\top_tab_btn_up_bmp.dds" , "60" , "32" , "dds" +"ui\uef\game\construct-tech_btn\left_upgrade_btn_dis.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\left_upgrade_btn_down.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\left_upgrade_btn_over.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\left_upgrade_btn_selected.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\left_upgrade_btn_up.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\m_upgrade_btn_dis.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\m_upgrade_btn_down.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\m_upgrade_btn_over.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\m_upgrade_btn_selected.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\m_upgrade_btn_up.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\r_upgrade_btn_dis.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\r_upgrade_btn_down.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\r_upgrade_btn_over.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\r_upgrade_btn_selected.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\r_upgrade_btn_up.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\t1_btn_dis.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\t1_btn_down.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\t1_btn_over.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\t1_btn_selected.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\t1_btn_up.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\t2_btn_dis.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\t2_btn_down.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\t2_btn_over.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\t2_btn_selected.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\t2_btn_up.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\t3_btn_dis.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\t3_btn_down.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\t3_btn_over.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\t3_btn_selected.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\t3_btn_up.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\t4_btn_dis.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\t4_btn_down.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\t4_btn_over.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\t4_btn_selected.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\t4_btn_up.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\template_btn_dis.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\template_btn_down.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\template_btn_over.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\template_btn_selected.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\template_btn_up.dds" , "32" , "32" , "dds" +"ui\uef\game\construction-pause_btn\pause_btn_dis.dds" , "32" , "36" , "dds" +"ui\uef\game\construction-pause_btn\pause_btn_down.dds" , "32" , "36" , "dds" +"ui\uef\game\construction-pause_btn\pause_btn_over.dds" , "32" , "36" , "dds" +"ui\uef\game\construction-pause_btn\pause_btn_up.dds" , "32" , "36" , "dds" +"ui\uef\game\construction-tab_btn\construction_dis.dds" , "28" , "20" , "dds" +"ui\uef\game\construction-tab_btn\construction.dds" , "28" , "20" , "dds" +"ui\uef\game\construction-tab_btn\enhance-back_icon_bmp_dis.dds" , "56" , "32" , "dds" +"ui\uef\game\construction-tab_btn\enhance-back_icon_bmp.dds" , "56" , "32" , "dds" +"ui\uef\game\construction-tab_btn\enhance-l-arm_icon_bmp_dis.dds" , "56" , "32" , "dds" +"ui\uef\game\construction-tab_btn\enhance-l-arm_icon_bmp.dds" , "56" , "32" , "dds" +"ui\uef\game\construction-tab_btn\enhance-r-arm_icon_bmp_dis.dds" , "56" , "32" , "dds" +"ui\uef\game\construction-tab_btn\enhance-r-arm_icon_bmp.dds" , "56" , "32" , "dds" +"ui\uef\game\construction-tab_btn\experimental_icon_bmp_dis.dds" , "56" , "32" , "dds" +"ui\uef\game\construction-tab_btn\experimental_icon_bmp.dds" , "56" , "32" , "dds" +"ui\uef\game\construction-tab_btn\tech-level-1_icon_bmp_dis.dds" , "56" , "32" , "dds" +"ui\uef\game\construction-tab_btn\tech-level-1_icon_bmp.dds" , "56" , "32" , "dds" +"ui\uef\game\construction-tab_btn\tech-level-2_icon_bmp_dis.dds" , "56" , "32" , "dds" +"ui\uef\game\construction-tab_btn\tech-level-2_icon_bmp.dds" , "56" , "32" , "dds" +"ui\uef\game\construction-tab_btn\tech-level-3_icon_bmp_dis.dds" , "56" , "32" , "dds" +"ui\uef\game\construction-tab_btn\tech-level-3_icon_bmp.dds" , "56" , "32" , "dds" +"ui\uef\game\construction-tab_btn\tech-tab_btn_dis.dds" , "56" , "32" , "dds" +"ui\uef\game\construction-tab_btn\tech-tab_btn_down.dds" , "56" , "32" , "dds" +"ui\uef\game\construction-tab_btn\tech-tab_btn_over_sel.dds" , "56" , "32" , "dds" +"ui\uef\game\construction-tab_btn\tech-tab_btn_over.dds" , "56" , "32" , "dds" +"ui\uef\game\construction-tab_btn\tech-tab_btn_selected.dds" , "56" , "32" , "dds" +"ui\uef\game\construction-tab_btn\tech-tab_btn_up_sel.dds" , "56" , "32" , "dds" +"ui\uef\game\construction-tab_btn\tech-tab_btn_up.dds" , "56" , "32" , "dds" +"ui\uef\game\construction-tab_btn\units-attached_icon_bmp_dis.dds" , "56" , "32" , "dds" +"ui\uef\game\construction-tab_btn\units-attached_icon_bmp.dds" , "56" , "32" , "dds" +"ui\uef\game\construction-tab_btn\units-select_icon_bmp_dis.dds" , "56" , "32" , "dds" +"ui\uef\game\construction-tab_btn\units-select_icon_bmp.dds" , "56" , "32" , "dds" +"ui\uef\game\control-group-bracket\panel-control_bmp_b.dds" , "68" , "24" , "dds" +"ui\uef\game\control-group-bracket\panel-control_bmp_m.dds" , "60" , "4" , "dds" +"ui\uef\game\control-group-bracket\panel-control_bmp_t.dds" , "68" , "48" , "dds" +"ui\uef\game\drag-handle\drag-handle-ll_btn_dis.dds" , "80" , "96" , "dds" +"ui\uef\game\drag-handle\drag-handle-ll_btn_down.dds" , "80" , "96" , "dds" +"ui\uef\game\drag-handle\drag-handle-ll_btn_over.dds" , "80" , "96" , "dds" +"ui\uef\game\drag-handle\drag-handle-ll_btn_up.dds" , "80" , "96" , "dds" +"ui\uef\game\drag-handle\drag-handle-lr_btn_dis.dds" , "80" , "96" , "dds" +"ui\uef\game\drag-handle\drag-handle-lr_btn_down.dds" , "80" , "96" , "dds" +"ui\uef\game\drag-handle\drag-handle-lr_btn_over.dds" , "80" , "96" , "dds" +"ui\uef\game\drag-handle\drag-handle-lr_btn_up.dds" , "80" , "96" , "dds" +"ui\uef\game\drag-handle\drag-handle-ul_btn_dis.dds" , "80" , "96" , "dds" +"ui\uef\game\drag-handle\drag-handle-ul_btn_down.dds" , "80" , "96" , "dds" +"ui\uef\game\drag-handle\drag-handle-ul_btn_over.dds" , "80" , "96" , "dds" +"ui\uef\game\drag-handle\drag-handle-ul_btn_up.dds" , "80" , "96" , "dds" +"ui\uef\game\drag-handle\drag-handle-ur_btn_dis.dds" , "80" , "96" , "dds" +"ui\uef\game\drag-handle\drag-handle-ur_btn_down.dds" , "80" , "96" , "dds" +"ui\uef\game\drag-handle\drag-handle-ur_btn_over.dds" , "80" , "96" , "dds" +"ui\uef\game\drag-handle\drag-handle-ur_btn_up.dds" , "80" , "96" , "dds" +"ui\uef\game\filter-arrow_btn\tab-close_btn_dis.dds" , "44" , "32" , "dds" +"ui\uef\game\filter-arrow_btn\tab-close_btn_down.dds" , "44" , "32" , "dds" +"ui\uef\game\filter-arrow_btn\tab-close_btn_over.dds" , "44" , "32" , "dds" +"ui\uef\game\filter-arrow_btn\tab-close_btn_up.dds" , "44" , "32" , "dds" +"ui\uef\game\filter-arrow_btn\tab-open_btn_dis.dds" , "44" , "32" , "dds" +"ui\uef\game\filter-arrow_btn\tab-open_btn_down.dds" , "44" , "32" , "dds" +"ui\uef\game\filter-arrow_btn\tab-open_btn_over.dds" , "44" , "32" , "dds" +"ui\uef\game\filter-arrow_btn\tab-open_btn_up.dds" , "44" , "32" , "dds" +"ui\uef\game\filter-ping-list-panel\energy-bar_bmp.dds" , "28" , "16" , "dds" +"ui\uef\game\filter-ping-list-panel\panel_bmp_b.dds" , "128" , "24" , "dds" +"ui\uef\game\filter-ping-list-panel\panel_bmp_m.dds" , "128" , "8" , "dds" +"ui\uef\game\filter-ping-list-panel\panel_bmp_t.dds" , "128" , "24" , "dds" +"ui\uef\game\filter-ping-list-panel\panel_brd_horz_um.dds" , "8" , "20" , "dds" +"ui\uef\game\filter-ping-list-panel\panel_brd_ll.dds" , "24" , "24" , "dds" +"ui\uef\game\filter-ping-list-panel\panel_brd_lm.dds" , "8" , "20" , "dds" +"ui\uef\game\filter-ping-list-panel\panel_brd_lr.dds" , "24" , "24" , "dds" +"ui\uef\game\filter-ping-list-panel\panel_brd_m.dds" , "8" , "8" , "dds" +"ui\uef\game\filter-ping-list-panel\panel_brd_ul.dds" , "24" , "24" , "dds" +"ui\uef\game\filter-ping-list-panel\panel_brd_ur.dds" , "24" , "24" , "dds" +"ui\uef\game\filter-ping-list-panel\panel_brd_vert_l.dds" , "24" , "8" , "dds" +"ui\uef\game\filter-ping-list-panel\panel_brd_vert_r.dds" , "24" , "8" , "dds" +"ui\uef\game\filter-ping-panel\bracket-energy-l_bmp.dds" , "20" , "72" , "dds" +"ui\uef\game\filter-ping-panel\bracket-energy-r_bmp.dds" , "20" , "72" , "dds" +"ui\uef\game\filter-ping-panel\bracket-left_bmp.dds" , "28" , "68" , "dds" +"ui\uef\game\filter-ping-panel\filter-ping-panel_bmp.dds" , "236" , "72" , "dds" +"ui\uef\game\filter-ping-panel\filter-ping-panel02_bmp.dds" , "180" , "72" , "dds" +"ui\uef\game\infinite_btn\infinite_btn_dis.dds" , "32" , "36" , "dds" +"ui\uef\game\infinite_btn\infinite_btn_down.dds" , "32" , "36" , "dds" +"ui\uef\game\infinite_btn\infinite_btn_over.dds" , "32" , "36" , "dds" +"ui\uef\game\infinite_btn\infinite_btn_up.dds" , "32" , "36" , "dds" +"ui\uef\game\map-options_brd\bracket_bmp.dds" , "48" , "28" , "dds" +"ui\uef\game\map-options_brd\energy-bar_bmp.dds" , "16" , "28" , "dds" +"ui\uef\game\map-options_brd\panel_brd_horz_um.dds" , "8" , "44" , "dds" +"ui\uef\game\map-options_brd\panel_brd_ll.dds" , "24" , "24" , "dds" +"ui\uef\game\map-options_brd\panel_brd_lm.dds" , "8" , "24" , "dds" +"ui\uef\game\map-options_brd\panel_brd_lr.dds" , "24" , "24" , "dds" +"ui\uef\game\map-options_brd\panel_brd_m.dds" , "8" , "8" , "dds" +"ui\uef\game\map-options_brd\panel_brd_ul.dds" , "24" , "44" , "dds" +"ui\uef\game\map-options_brd\panel_brd_ur.dds" , "24" , "44" , "dds" +"ui\uef\game\map-options_brd\panel_brd_vert_l.dds" , "20" , "8" , "dds" +"ui\uef\game\map-options_brd\panel_brd_vert_r.dds" , "20" , "8" , "dds" +"ui\uef\game\medium-btn\medium_btn_dis.dds" , "296" , "72" , "dds" +"ui\uef\game\medium-btn\medium_btn_down.dds" , "296" , "72" , "dds" +"ui\uef\game\medium-btn\medium_btn_glow.dds" , "296" , "72" , "dds" +"ui\uef\game\medium-btn\medium_btn_over.dds" , "296" , "72" , "dds" +"ui\uef\game\medium-btn\medium_btn_up.dds" , "296" , "72" , "dds" +"ui\uef\game\menu-btns\close_btn_dis.dds" , "24" , "24" , "dds" +"ui\uef\game\menu-btns\close_btn_down.dds" , "24" , "24" , "dds" +"ui\uef\game\menu-btns\close_btn_over.dds" , "24" , "24" , "dds" +"ui\uef\game\menu-btns\close_btn_up.dds" , "24" , "24" , "dds" +"ui\uef\game\menu-btns\config_btn_dis.dds" , "24" , "24" , "dds" +"ui\uef\game\menu-btns\config_btn_down.dds" , "24" , "24" , "dds" +"ui\uef\game\menu-btns\config_btn_over.dds" , "24" , "24" , "dds" +"ui\uef\game\menu-btns\config_btn_up.dds" , "24" , "24" , "dds" +"ui\uef\game\menu-btns\default_btn_dis.dds" , "24" , "24" , "dds" +"ui\uef\game\menu-btns\default_btn_down.dds" , "24" , "24" , "dds" +"ui\uef\game\menu-btns\default_btn_over.dds" , "24" , "24" , "dds" +"ui\uef\game\menu-btns\default_btn_up.dds" , "24" , "24" , "dds" +"ui\uef\game\menu-btns\pin_btn_dis.dds" , "24" , "24" , "dds" +"ui\uef\game\menu-btns\pin_btn_down.dds" , "24" , "24" , "dds" +"ui\uef\game\menu-btns\pin_btn_over.dds" , "24" , "24" , "dds" +"ui\uef\game\menu-btns\pin_btn_up.dds" , "24" , "24" , "dds" +"ui\uef\game\menu-btns\pinned_btn_dis.dds" , "24" , "24" , "dds" +"ui\uef\game\menu-btns\pinned_btn_down.dds" , "24" , "24" , "dds" +"ui\uef\game\menu-btns\pinned_btn_over.dds" , "24" , "24" , "dds" +"ui\uef\game\menu-btns\pinned_btn_up.dds" , "24" , "24" , "dds" +"ui\uef\game\mfd_btn\control_btn_dis.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\control_btn_down.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\control_btn_over.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\control_btn_up.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\defenses_btn_dis.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\defenses_btn_down.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\defenses_btn_over.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\defenses_btn_up.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\economy_btn_dis.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\economy_btn_down.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\economy_btn_over.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\economy_btn_up.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\intelligence_btn_dis.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\intelligence_btn_down.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\intelligence_btn_over.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\intelligence_btn_up.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\military_btn_dis.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\military_btn_down.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\military_btn_over.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\military_btn_up.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\military-radar_btn_dis.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\military-radar_btn_down.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\military-radar_btn_over.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\military-radar_btn_up.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\ping_btn_dis.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\ping_btn_down.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\ping_btn_over.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\ping_btn_up.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\ping-alert_btn_dis.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\ping-alert_btn_down.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\ping-alert_btn_over.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\ping-alert_btn_up.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\ping-attack_btn_dis.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\ping-attack_btn_down.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\ping-attack_btn_over.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\ping-attack_btn_up.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\ping-marker_btn_dis.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\ping-marker_btn_down.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\ping-marker_btn_over.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\ping-marker_btn_up.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\ping-move_btn_dis.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\ping-move_btn_down.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\ping-move_btn_over.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\ping-move_btn_up.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\team-color_btn_dis.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\team-color_btn_down.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\team-color_btn_over.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\team-color_btn_up.dds" , "44" , "32" , "dds" +"ui\uef\game\mini-map-brd\mini-map_brd_horz_um.dds" , "12" , "36" , "dds" +"ui\uef\game\mini-map-brd\mini-map_brd_ll.dds" , "12" , "16" , "dds" +"ui\uef\game\mini-map-brd\mini-map_brd_lm.dds" , "12" , "16" , "dds" +"ui\uef\game\mini-map-brd\mini-map_brd_lr.dds" , "16" , "16" , "dds" +"ui\uef\game\mini-map-brd\mini-map_brd_m.dds" , "12" , "12" , "dds" +"ui\uef\game\mini-map-brd\mini-map_brd_ul.dds" , "32" , "36" , "dds" +"ui\uef\game\mini-map-brd\mini-map_brd_ur.dds" , "36" , "36" , "dds" +"ui\uef\game\mini-map-brd\mini-map_brd_vert_l.dds" , "12" , "12" , "dds" +"ui\uef\game\mini-map-brd\mini-map_brd_vert_r.dds" , "16" , "12" , "dds" +"ui\uef\game\mini-map-brd\mini-map-glow_bmp.dds" , "204" , "204" , "dds" +"ui\uef\game\mini-map-glow-brd\mini-map-glow_brd_horz_um.dds" , "8" , "24" , "dds" +"ui\uef\game\mini-map-glow-brd\mini-map-glow_brd_ll.dds" , "40" , "40" , "dds" +"ui\uef\game\mini-map-glow-brd\mini-map-glow_brd_lm.dds" , "8" , "24" , "dds" +"ui\uef\game\mini-map-glow-brd\mini-map-glow_brd_lr.dds" , "40" , "40" , "dds" +"ui\uef\game\mini-map-glow-brd\mini-map-glow_brd_ul.dds" , "40" , "40" , "dds" +"ui\uef\game\mini-map-glow-brd\mini-map-glow_brd_ur.dds" , "40" , "40" , "dds" +"ui\uef\game\mini-map-glow-brd\mini-map-glow_brd_vert_l.dds" , "24" , "8" , "dds" +"ui\uef\game\mini-map-glow-brd\mini-map-glow_brd_vert_r.dds" , "24" , "8" , "dds" +"ui\uef\game\mini-scroll\arrow-left_scr_dis.dds" , "24" , "48" , "dds" +"ui\uef\game\mini-scroll\arrow-left_scr_down.dds" , "24" , "48" , "dds" +"ui\uef\game\mini-scroll\arrow-left_scr_over.dds" , "24" , "48" , "dds" +"ui\uef\game\mini-scroll\arrow-left_scr_up.dds" , "24" , "48" , "dds" +"ui\uef\game\mini-scroll\arrow-right_scr_dis.dds" , "24" , "48" , "dds" +"ui\uef\game\mini-scroll\arrow-right_scr_down.dds" , "24" , "48" , "dds" +"ui\uef\game\mini-scroll\arrow-right_scr_over.dds" , "24" , "48" , "dds" +"ui\uef\game\mini-scroll\arrow-right_scr_up.dds" , "24" , "48" , "dds" +"ui\uef\game\mini-vertical-scroll\arrow-down_scr_dis.dds" , "48" , "24" , "dds" +"ui\uef\game\mini-vertical-scroll\arrow-down_scr_down.dds" , "48" , "24" , "dds" +"ui\uef\game\mini-vertical-scroll\arrow-down_scr_over.dds" , "48" , "24" , "dds" +"ui\uef\game\mini-vertical-scroll\arrow-down_scr_up.dds" , "48" , "24" , "dds" +"ui\uef\game\mini-vertical-scroll\arrow-up_scr_dis.dds" , "48" , "24" , "dds" +"ui\uef\game\mini-vertical-scroll\arrow-up_scr_down.dds" , "48" , "24" , "dds" +"ui\uef\game\mini-vertical-scroll\arrow-up_scr_over.dds" , "48" , "24" , "dds" +"ui\uef\game\mini-vertical-scroll\arrow-up_scr_up.dds" , "48" , "24" , "dds" +"ui\uef\game\objective-icons\panel-icon_bmp.dds" , "60" , "68" , "dds" +"ui\uef\game\objective-icons\primary-ring_bmp.dds" , "52" , "48" , "dds" +"ui\uef\game\objective-icons\secondary-ring_bmp.dds" , "52" , "48" , "dds" +"ui\uef\game\options_brd\line-long_bmp.dds" , "200" , "4" , "dds" +"ui\uef\game\options_brd\line-short_bmp.dds" , "228" , "4" , "dds" +"ui\uef\game\options_brd\options_brd_horz_um.dds" , "8" , "48" , "dds" +"ui\uef\game\options_brd\options_brd_ll.dds" , "44" , "48" , "dds" +"ui\uef\game\options_brd\options_brd_lm.dds" , "8" , "48" , "dds" +"ui\uef\game\options_brd\options_brd_lr.dds" , "44" , "48" , "dds" +"ui\uef\game\options_brd\options_brd_m.dds" , "8" , "8" , "dds" +"ui\uef\game\options_brd\options_brd_ul.dds" , "44" , "48" , "dds" +"ui\uef\game\options_brd\options_brd_ur.dds" , "44" , "48" , "dds" +"ui\uef\game\options_brd\options_brd_vert_l.dds" , "44" , "8" , "dds" +"ui\uef\game\options_brd\options_brd_vert_r.dds" , "44" , "8" , "dds" +"ui\uef\game\options_tab\diplomacy_btn_dis.dds" , "56" , "48" , "dds" +"ui\uef\game\options_tab\diplomacy_btn_down.dds" , "56" , "48" , "dds" +"ui\uef\game\options_tab\diplomacy_btn_over.dds" , "56" , "48" , "dds" +"ui\uef\game\options_tab\diplomacy_btn_selected.dds" , "56" , "48" , "dds" +"ui\uef\game\options_tab\diplomacy_btn_up.dds" , "56" , "48" , "dds" +"ui\uef\game\options_tab\glow_bmp.dds" , "56" , "48" , "dds" +"ui\uef\game\options_tab\menu_btn_dis.dds" , "56" , "48" , "dds" +"ui\uef\game\options_tab\menu_btn_down.dds" , "56" , "48" , "dds" +"ui\uef\game\options_tab\menu_btn_over.dds" , "56" , "48" , "dds" +"ui\uef\game\options_tab\menu_btn_selected.dds" , "56" , "48" , "dds" +"ui\uef\game\options_tab\menu_btn_up.dds" , "56" , "48" , "dds" +"ui\uef\game\options_tab\objectives_btn_dis.dds" , "56" , "48" , "dds" +"ui\uef\game\options_tab\objectives_btn_down.dds" , "56" , "48" , "dds" +"ui\uef\game\options_tab\objectives_btn_over.dds" , "56" , "48" , "dds" +"ui\uef\game\options_tab\objectives_btn_selected.dds" , "56" , "48" , "dds" +"ui\uef\game\options_tab\objectives_btn_up.dds" , "56" , "48" , "dds" +"ui\uef\game\options_tab\pause_btn_dis.dds" , "56" , "48" , "dds" +"ui\uef\game\options_tab\pause_btn_down.dds" , "56" , "48" , "dds" +"ui\uef\game\options_tab\pause_btn_over.dds" , "56" , "48" , "dds" +"ui\uef\game\options_tab\pause_btn_selected.dds" , "56" , "48" , "dds" +"ui\uef\game\options_tab\pause_btn_up.dds" , "56" , "48" , "dds" +"ui\uef\game\options_tab\play_btn_dis.dds" , "56" , "48" , "dds" +"ui\uef\game\options_tab\play_btn_down.dds" , "56" , "48" , "dds" +"ui\uef\game\options_tab\play_btn_over.dds" , "56" , "48" , "dds" +"ui\uef\game\options_tab\play_btn_selected.dds" , "56" , "48" , "dds" +"ui\uef\game\options_tab\play_btn_up.dds" , "56" , "48" , "dds" +"ui\uef\game\options-diplomacy-panel\icon-ai_bmp.dds" , "20" , "16" , "dds" +"ui\uef\game\options-diplomacy-panel\icon-person_bmp.dds" , "16" , "20" , "dds" +"ui\uef\game\options-diplomacy-panel\line-allies_bmp.dds" , "260" , "4" , "dds" +"ui\uef\game\options-diplomacy-panel\line-enemies_bmp.dds" , "260" , "4" , "dds" +"ui\uef\game\options-diplomacy-panel\panel-allies_bmp_b.dds" , "268" , "20" , "dds" +"ui\uef\game\options-diplomacy-panel\panel-allies_bmp_m.dds" , "268" , "8" , "dds" +"ui\uef\game\options-diplomacy-panel\panel-allies_bmp_t.dds" , "268" , "40" , "dds" +"ui\uef\game\options-diplomacy-panel\panel-enemy_bmp_b.dds" , "268" , "20" , "dds" +"ui\uef\game\options-diplomacy-panel\panel-enemy_bmp_m.dds" , "268" , "8" , "dds" +"ui\uef\game\options-diplomacy-panel\panel-enemy_bmp_t.dds" , "268" , "40" , "dds" +"ui\uef\game\options-panel\option-panel-inner.dds" , "264" , "388" , "dds" +"ui\uef\game\options-panel\options_brd_horz_lm.dds" , "4" , "28" , "dds" +"ui\uef\game\options-panel\options_brd_horz_um.dds" , "80" , "36" , "dds" +"ui\uef\game\options-panel\options_brd_horz_uml.dds" , "4" , "28" , "dds" +"ui\uef\game\options-panel\options_brd_horz_umr.dds" , "4" , "28" , "dds" +"ui\uef\game\options-panel\options_brd_ll.dds" , "44" , "36" , "dds" +"ui\uef\game\options-panel\options_brd_lr.dds" , "44" , "36" , "dds" +"ui\uef\game\options-panel\options_brd_m.dds" , "4" , "8" , "dds" +"ui\uef\game\options-panel\options_brd_ul.dds" , "44" , "36" , "dds" +"ui\uef\game\options-panel\options_brd_ur.dds" , "44" , "36" , "dds" +"ui\uef\game\options-panel\options_brd_vert_l.dds" , "36" , "8" , "dds" +"ui\uef\game\options-panel\options_brd_vert_ll.dds" , "40" , "24" , "dds" +"ui\uef\game\options-panel\options_brd_vert_lr.dds" , "40" , "24" , "dds" +"ui\uef\game\options-panel\options_brd_vert_r.dds" , "36" , "8" , "dds" +"ui\uef\game\options-panel\options_brd_vert_ul.dds" , "40" , "24" , "dds" +"ui\uef\game\options-panel\options_brd_vert_ur.dds" , "40" , "24" , "dds" +"ui\uef\game\options-panel\options-inner_bmp_b.dds" , "256" , "20" , "dds" +"ui\uef\game\options-panel\options-inner_bmp_m.dds" , "256" , "8" , "dds" +"ui\uef\game\options-panel\options-inner_bmp_t.dds" , "256" , "8" , "dds" +"ui\uef\game\orders-panel_vert\bracket_bmp.dds" , "36" , "228" , "dds" +"ui\uef\game\orders-panel_vert\order-panel_bmp.dds" , "188" , "224" , "dds" +"ui\uef\game\orders-panel\bracket_bmp.dds" , "32" , "120" , "dds" +"ui\uef\game\orders-panel\no-parking_bmp.dds" , "56" , "56" , "dds" +"ui\uef\game\orders-panel\order-panel_bmp.dds" , "332" , "120" , "dds" +"ui\uef\game\orders-panel\question-mark_bmp.dds" , "16" , "24" , "dds" +"ui\uef\game\panel\panel_brd_horz_um.dds" , "8" , "24" , "dds" +"ui\uef\game\panel\panel_brd_ll.dds" , "24" , "24" , "dds" +"ui\uef\game\panel\panel_brd_lm.dds" , "8" , "24" , "dds" +"ui\uef\game\panel\panel_brd_lr.dds" , "24" , "24" , "dds" +"ui\uef\game\panel\panel_brd_m.dds" , "8" , "8" , "dds" +"ui\uef\game\panel\panel_brd_ul.dds" , "24" , "24" , "dds" +"ui\uef\game\panel\panel_brd_ur.dds" , "24" , "24" , "dds" +"ui\uef\game\panel\panel_brd_vert_l.dds" , "24" , "8" , "dds" +"ui\uef\game\panel\panel_brd_vert_r.dds" , "24" , "8" , "dds" +"ui\uef\game\pause_btn\glow_bmp.dds" , "56" , "48" , "dds" +"ui\uef\game\pause_btn\pause_btn_dis.dds" , "56" , "48" , "dds" +"ui\uef\game\pause_btn\pause_btn_down.dds" , "56" , "48" , "dds" +"ui\uef\game\pause_btn\pause_btn_over.dds" , "56" , "48" , "dds" +"ui\uef\game\pause_btn\pause_btn_up.dds" , "56" , "48" , "dds" +"ui\uef\game\pause_btn\play_btn_dis.dds" , "56" , "48" , "dds" +"ui\uef\game\pause_btn\play_btn_down.dds" , "56" , "48" , "dds" +"ui\uef\game\pause_btn\play_btn_over.dds" , "56" , "48" , "dds" +"ui\uef\game\pause_btn\play_btn_up.dds" , "56" , "48" , "dds" +"ui\uef\game\pause-indicator\bottom.dds" , "20" , "20" , "dds" +"ui\uef\game\pause-indicator\left.dds" , "20" , "20" , "dds" +"ui\uef\game\pause-indicator\right.dds" , "20" , "20" , "dds" +"ui\uef\game\pause-indicator\top.dds" , "20" , "20" , "dds" +"ui\uef\game\pda-panel\bracket-right_bmp.dds" , "32" , "156" , "dds" +"ui\uef\game\pda-panel\bracket-right.dds" , "32" , "156" , "dds" +"ui\uef\game\pda-panel\panel-objectives_bmp_l.dds" , "20" , "76" , "dds" +"ui\uef\game\pda-panel\panel-objectives_bmp_m.dds" , "4" , "72" , "dds" +"ui\uef\game\pda-panel\panel-objectives_bmp_r.dds" , "24" , "72" , "dds" +"ui\uef\game\pda-panel\panel-ping_bmp_l.dds" , "20" , "60" , "dds" +"ui\uef\game\pda-panel\panel-ping_bmp_m.dds" , "4" , "56" , "dds" +"ui\uef\game\pda-panel\panel-ping_bmp_r.dds" , "24" , "56" , "dds" +"ui\uef\game\pda-panel\panel-time-units_bmp_l.dds" , "20" , "28" , "dds" +"ui\uef\game\pda-panel\panel-time-units_bmp_m.dds" , "4" , "24" , "dds" +"ui\uef\game\pda-panel\panel-time-units_bmp_r.dds" , "24" , "24" , "dds" +"ui\uef\game\pda-panel\title-bar_bmp.dds" , "136" , "20" , "dds" +"ui\uef\game\pda-panel\video-panel_bmp.dds" , "168" , "148" , "dds" +"ui\uef\game\ping-icons\panel-icon_bmp.dds" , "60" , "60" , "dds" +"ui\uef\game\ping-icons\panel-icon-ring_bmp.dds" , "60" , "60" , "dds" +"ui\uef\game\resource-bars\mini-energy-bar_bmp.dds" , "160" , "16" , "dds" +"ui\uef\game\resource-bars\mini-energy-bar-back_bmp.dds" , "160" , "16" , "dds" +"ui\uef\game\resource-bars\mini-mass-bar_bmp.dds" , "160" , "16" , "dds" +"ui\uef\game\resource-bars\mini-mass-bar-back_bmp.dds" , "160" , "16" , "dds" +"ui\uef\game\resource-panel\alert-caution_bmp.dds" , "48" , "44" , "dds" +"ui\uef\game\resource-panel\alert-energy-panel_bmp.dds" , "316" , "32" , "dds" +"ui\uef\game\resource-panel\alert-icon_bmp.dds" , "28" , "36" , "dds" +"ui\uef\game\resource-panel\alert-mass-panel_bmp.dds" , "316" , "32" , "dds" +"ui\uef\game\resource-panel\alert-no-parking_bmp.dds" , "52" , "52" , "dds" +"ui\uef\game\resource-panel\bracket-energy-l_bmp.dds" , "20" , "72" , "dds" +"ui\uef\game\resource-panel\bracket-energy-r_bmp.dds" , "20" , "72" , "dds" +"ui\uef\game\resource-panel\bracket-left_bmp.dds" , "28" , "68" , "dds" +"ui\uef\game\resource-panel\bracket-right_bmp.dds" , "28" , "68" , "dds" +"ui\uef\game\resource-panel\caution-energy-panel_bmp.dds" , "316" , "32" , "dds" +"ui\uef\game\resource-panel\caution-mass-panel_bmp.dds" , "316" , "32" , "dds" +"ui\uef\game\resource-panel\resources_panel_bmp.dds" , "324" , "72" , "dds" +"ui\uef\game\score-panel\icon-square_bmp.dds" , "24" , "24" , "dds" +"ui\uef\game\score-panel\panel-score_bmp_b.dds" , "236" , "20" , "dds" +"ui\uef\game\score-panel\panel-score_bmp_m.dds" , "236" , "4" , "dds" +"ui\uef\game\score-panel\panel-score_bmp_t.dds" , "236" , "44" , "dds" +"ui\uef\game\slider-btn\slider_btn_dis.dds" , "28" , "28" , "dds" +"ui\uef\game\slider-btn\slider_btn_down.dds" , "28" , "28" , "dds" +"ui\uef\game\slider-btn\slider_btn_over.dds" , "28" , "28" , "dds" +"ui\uef\game\slider-btn\slider_btn_up.dds" , "28" , "28" , "dds" +"ui\uef\game\slider-btn\slider-energy_btn_dis.dds" , "28" , "28" , "dds" +"ui\uef\game\slider-btn\slider-energy_btn_down.dds" , "28" , "28" , "dds" +"ui\uef\game\slider-btn\slider-energy_btn_over.dds" , "28" , "28" , "dds" +"ui\uef\game\slider-btn\slider-energy_btn_up.dds" , "28" , "28" , "dds" +"ui\uef\game\slider-btn\slider-mass_btn_dis.dds" , "28" , "28" , "dds" +"ui\uef\game\slider-btn\slider-mass_btn_down.dds" , "28" , "28" , "dds" +"ui\uef\game\slider-btn\slider-mass_btn_over.dds" , "28" , "28" , "dds" +"ui\uef\game\slider-btn\slider-mass_btn_up.dds" , "28" , "28" , "dds" +"ui\uef\game\slider\slider-back_bmp.dds" , "200" , "20" , "dds" +"ui\uef\game\slider\slider-bar-green_bmp.dds" , "196" , "16" , "dds" +"ui\uef\game\slider\slider-bar-orange_bmp.dds" , "196" , "16" , "dds" +"ui\uef\game\tab-btn\tab-close_btn_dis.dds" , "20" , "28" , "dds" +"ui\uef\game\tab-btn\tab-close_btn_down.dds" , "20" , "28" , "dds" +"ui\uef\game\tab-btn\tab-close_btn_over.dds" , "20" , "28" , "dds" +"ui\uef\game\tab-btn\tab-close_btn_up.dds" , "20" , "28" , "dds" +"ui\uef\game\tab-btn\tab-open_btn_dis.dds" , "20" , "28" , "dds" +"ui\uef\game\tab-btn\tab-open_btn_down.dds" , "20" , "28" , "dds" +"ui\uef\game\tab-btn\tab-open_btn_over.dds" , "20" , "28" , "dds" +"ui\uef\game\tab-btn\tab-open_btn_up.dds" , "20" , "28" , "dds" +"ui\uef\game\tab-dock-left-btn\tab-dock-l_btn_dis.dds" , "24" , "44" , "dds" +"ui\uef\game\tab-dock-left-btn\tab-dock-l_btn_down.dds" , "24" , "44" , "dds" +"ui\uef\game\tab-dock-left-btn\tab-dock-l_btn_over.dds" , "24" , "44" , "dds" +"ui\uef\game\tab-dock-left-btn\tab-dock-l_btn_up.dds" , "24" , "44" , "dds" +"ui\uef\game\tab-dock-left-btn\tab-dock-r_btn_dis.dds" , "24" , "44" , "dds" +"ui\uef\game\tab-dock-left-btn\tab-dock-r_btn_down.dds" , "24" , "44" , "dds" +"ui\uef\game\tab-dock-left-btn\tab-dock-r_btn_over.dds" , "24" , "44" , "dds" +"ui\uef\game\tab-dock-left-btn\tab-dock-r_btn_up.dds" , "24" , "44" , "dds" +"ui\uef\game\tab-dock-right-btn\tab-dock-l_btn_dis.dds" , "24" , "44" , "dds" +"ui\uef\game\tab-dock-right-btn\tab-dock-l_btn_down.dds" , "24" , "44" , "dds" +"ui\uef\game\tab-dock-right-btn\tab-dock-l_btn_over.dds" , "24" , "44" , "dds" +"ui\uef\game\tab-dock-right-btn\tab-dock-l_btn_up.dds" , "24" , "44" , "dds" +"ui\uef\game\tab-dock-right-btn\tab-dock-r_btn_dis.dds" , "24" , "44" , "dds" +"ui\uef\game\tab-dock-right-btn\tab-dock-r_btn_down.dds" , "24" , "44" , "dds" +"ui\uef\game\tab-dock-right-btn\tab-dock-r_btn_over.dds" , "24" , "44" , "dds" +"ui\uef\game\tab-dock-right-btn\tab-dock-r_btn_up.dds" , "24" , "44" , "dds" +"ui\uef\game\tab-l-btn\tab-close_btn_dis.dds" , "20" , "28" , "dds" +"ui\uef\game\tab-l-btn\tab-close_btn_dis.png" , "20" , "28" , "png" +"ui\uef\game\tab-l-btn\tab-close_btn_down.dds" , "20" , "28" , "dds" +"ui\uef\game\tab-l-btn\tab-close_btn_down.png" , "20" , "28" , "png" +"ui\uef\game\tab-l-btn\tab-close_btn_over.dds" , "20" , "28" , "dds" +"ui\uef\game\tab-l-btn\tab-close_btn_over.png" , "20" , "28" , "png" +"ui\uef\game\tab-l-btn\tab-close_btn_up.dds" , "20" , "28" , "dds" +"ui\uef\game\tab-l-btn\tab-close_btn_up.png" , "20" , "28" , "png" +"ui\uef\game\tab-l-btn\tab-open_btn_dis.dds" , "20" , "28" , "dds" +"ui\uef\game\tab-l-btn\tab-open_btn_dis.png" , "20" , "28" , "png" +"ui\uef\game\tab-l-btn\tab-open_btn_down.dds" , "20" , "28" , "dds" +"ui\uef\game\tab-l-btn\tab-open_btn_down.png" , "20" , "28" , "png" +"ui\uef\game\tab-l-btn\tab-open_btn_over.dds" , "20" , "28" , "dds" +"ui\uef\game\tab-l-btn\tab-open_btn_over.png" , "20" , "28" , "png" +"ui\uef\game\tab-l-btn\tab-open_btn_up.dds" , "20" , "28" , "dds" +"ui\uef\game\tab-l-btn\tab-open_btn_up.png" , "20" , "28" , "png" +"ui\uef\game\tab-r-btn\tab-close_btn_dis.dds" , "20" , "28" , "dds" +"ui\uef\game\tab-r-btn\tab-close_btn_dis.png" , "20" , "28" , "png" +"ui\uef\game\tab-r-btn\tab-close_btn_down.dds" , "20" , "28" , "dds" +"ui\uef\game\tab-r-btn\tab-close_btn_down.png" , "20" , "28" , "png" +"ui\uef\game\tab-r-btn\tab-close_btn_over.dds" , "20" , "28" , "dds" +"ui\uef\game\tab-r-btn\tab-close_btn_over.png" , "20" , "28" , "png" +"ui\uef\game\tab-r-btn\tab-close_btn_up.dds" , "20" , "28" , "dds" +"ui\uef\game\tab-r-btn\tab-close_btn_up.png" , "20" , "28" , "png" +"ui\uef\game\tab-r-btn\tab-open_btn_dis.dds" , "20" , "28" , "dds" +"ui\uef\game\tab-r-btn\tab-open_btn_dis.png" , "20" , "28" , "png" +"ui\uef\game\tab-r-btn\tab-open_btn_down.dds" , "20" , "28" , "dds" +"ui\uef\game\tab-r-btn\tab-open_btn_down.png" , "20" , "28" , "png" +"ui\uef\game\tab-r-btn\tab-open_btn_over.dds" , "20" , "28" , "dds" +"ui\uef\game\tab-r-btn\tab-open_btn_over.png" , "20" , "28" , "png" +"ui\uef\game\tab-r-btn\tab-open_btn_up.dds" , "20" , "28" , "dds" +"ui\uef\game\tab-r-btn\tab-open_btn_up.png" , "20" , "28" , "png" +"ui\uef\game\tab-t-btn\tab-close_btn_dis.dds" , "28" , "20" , "dds" +"ui\uef\game\tab-t-btn\tab-close_btn_dis.png" , "28" , "20" , "png" +"ui\uef\game\tab-t-btn\tab-close_btn_down.dds" , "28" , "20" , "dds" +"ui\uef\game\tab-t-btn\tab-close_btn_down.png" , "28" , "20" , "png" +"ui\uef\game\tab-t-btn\tab-close_btn_over.dds" , "28" , "20" , "dds" +"ui\uef\game\tab-t-btn\tab-close_btn_over.png" , "28" , "20" , "png" +"ui\uef\game\tab-t-btn\tab-close_btn_up.dds" , "28" , "20" , "dds" +"ui\uef\game\tab-t-btn\tab-close_btn_up.png" , "28" , "20" , "png" +"ui\uef\game\tab-t-btn\tab-open_btn_dis.dds" , "28" , "20" , "dds" +"ui\uef\game\tab-t-btn\tab-open_btn_dis.png" , "28" , "20" , "png" +"ui\uef\game\tab-t-btn\tab-open_btn_down.dds" , "28" , "20" , "dds" +"ui\uef\game\tab-t-btn\tab-open_btn_down.png" , "28" , "20" , "png" +"ui\uef\game\tab-t-btn\tab-open_btn_over.dds" , "28" , "20" , "dds" +"ui\uef\game\tab-t-btn\tab-open_btn_over.png" , "28" , "20" , "png" +"ui\uef\game\tab-t-btn\tab-open_btn_up.dds" , "28" , "20" , "dds" +"ui\uef\game\tab-t-btn\tab-open_btn_up.png" , "28" , "20" , "png" +"ui\uef\game\temp_textures\checkmark_dark.dds" , "16" , "16" , "dds" +"ui\uef\game\temp_textures\checkmark.dds" , "16" , "16" , "dds" +"ui\uef\game\temp_textures\combo_sel.dds" , "20" , "20" , "dds" +"ui\uef\game\temp_textures\combo_up.dds" , "20" , "20" , "dds" +"ui\uef\game\temp_textures\mouse.dds" , "52" , "52" , "dds" +"ui\uef\game\toggle_btn\icon-allied-victory_bmp.dds" , "84" , "28" , "dds" +"ui\uef\game\toggle_btn\icon-shared-resources_bmp.dds" , "84" , "28" , "dds" +"ui\uef\game\toggle_btn\toggle-d_btn_dis.dds" , "84" , "28" , "dds" +"ui\uef\game\toggle_btn\toggle-d_btn_down.dds" , "84" , "28" , "dds" +"ui\uef\game\toggle_btn\toggle-d_btn_over.dds" , "84" , "28" , "dds" +"ui\uef\game\toggle_btn\toggle-d_btn_up.dds" , "84" , "28" , "dds" +"ui\uef\game\toggle_btn\toggle-s_btn_dis.dds" , "84" , "28" , "dds" +"ui\uef\game\toggle_btn\toggle-s_btn_down.dds" , "84" , "28" , "dds" +"ui\uef\game\toggle_btn\toggle-s_btn_over.dds" , "84" , "28" , "dds" +"ui\uef\game\toggle_btn\toggle-s_btn_up.dds" , "84" , "28" , "dds" +"ui\uef\game\toggle-lg_btn\toggle-d_btn_dis.dds" , "112" , "28" , "dds" +"ui\uef\game\toggle-lg_btn\toggle-d_btn_down.dds" , "112" , "28" , "dds" +"ui\uef\game\toggle-lg_btn\toggle-d_btn_over.dds" , "112" , "28" , "dds" +"ui\uef\game\toggle-lg_btn\toggle-d_btn_up.dds" , "112" , "28" , "dds" +"ui\uef\game\toggle-lg_btn\toggle-s_btn_dis.dds" , "112" , "28" , "dds" +"ui\uef\game\toggle-lg_btn\toggle-s_btn_down.dds" , "112" , "28" , "dds" +"ui\uef\game\toggle-lg_btn\toggle-s_btn_over.dds" , "112" , "28" , "dds" +"ui\uef\game\toggle-lg_btn\toggle-s_btn_up.dds" , "112" , "28" , "dds" +"ui\uef\game\transmission\title-bar_bmp.dds" , "188" , "20" , "dds" +"ui\uef\game\transmission\video-brackets.dds" , "244" , "232" , "dds" +"ui\uef\game\transmission\video-panel_bmp.dds" , "248" , "240" , "dds" +"ui\uef\game\transmission\video-panel.dds" , "208" , "208" , "dds" +"ui\uef\game\unit-build-over-panel\bracket-build_bmp.dds" , "344" , "132" , "dds" +"ui\uef\game\unit-build-over-panel\bracket-unit_bmp.dds" , "32" , "120" , "dds" +"ui\uef\game\unit-build-over-panel\build-over-back_bmp.dds" , "332" , "116" , "dds" +"ui\uef\game\unit-build-over-panel\fuelbar.dds" , "188" , "4" , "dds" +"ui\uef\game\unit-build-over-panel\healthbar_bg.dds" , "188" , "20" , "dds" +"ui\uef\game\unit-build-over-panel\healthbar_green.dds" , "192" , "24" , "dds" +"ui\uef\game\unit-build-over-panel\healthbar_red.dds" , "192" , "24" , "dds" +"ui\uef\game\unit-build-over-panel\healthbar_yellow.dds" , "192" , "24" , "dds" +"ui\uef\game\unit-build-over-panel\shieldbar.dds" , "188" , "4" , "dds" +"ui\uef\game\unit-build-over-panel\unit-over-back_bmp.dds" , "332" , "116" , "dds" +"warButton.png" , "60" , "61" , "png" From 4be5abe421501af5e8b85d5903a264efa5c3335c Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Thu, 25 Jun 2026 22:32:09 +0200 Subject: [PATCH 61/98] Add support for custom background images --- init_faf.lua | 5 +- init_fafbeta.lua | 3 + init_fafdevelop.lua | 3 + .../customlobby/CustomLobbyBackground.lua | 127 ++++++++++++++++++ .../customlobby/CustomLobbyBackgrounds.lua | 102 ++++++++++++++ .../customlobby/CustomLobbyInterface.lua | 58 +++++++- .../presetselect/CustomLobbyPresetSelect.lua | 24 ++-- lua/ui/lobby/lobby.lua | 1 - .../ui/common/lobby/backgrounds/aeon-omen.png | Bin 124950 -> 0 bytes .../lobby/backgrounds/cybran-galaxy.png | Bin 161110 -> 0 bytes .../lobby/backgrounds/seraphim-hauthuun.png | Bin 181319 -> 0 bytes .../common/lobby/backgrounds/uef-summit.png | Bin 169560 -> 0 bytes 12 files changed, 304 insertions(+), 19 deletions(-) create mode 100644 lua/ui/lobby/customlobby/CustomLobbyBackground.lua create mode 100644 lua/ui/lobby/customlobby/CustomLobbyBackgrounds.lua delete mode 100644 textures/ui/common/lobby/backgrounds/aeon-omen.png delete mode 100644 textures/ui/common/lobby/backgrounds/cybran-galaxy.png delete mode 100644 textures/ui/common/lobby/backgrounds/seraphim-hauthuun.png delete mode 100644 textures/ui/common/lobby/backgrounds/uef-summit.png diff --git a/init_faf.lua b/init_faf.lua index 8ad09ae6d8c..5a2267303d8 100644 --- a/init_faf.lua +++ b/init_faf.lua @@ -656,4 +656,7 @@ MountDirectory(SHGetFolderPath('LOCAL_APPDATA') .. 'Gas Powered Games/Supreme Co MountDirectory(fa_path .. "/movies", '/movies') MountDirectory(fa_path .. "/sounds", '/sounds') MountDirectory(fa_path .. "/maps", '/maps') -MountDirectory(fa_path .. "/fonts", '/fonts') \ No newline at end of file +MountDirectory(fa_path .. "/fonts", '/fonts') + +-- support for custom backgrounds in the lobby +MountDirectory(InitFileDir .. '/../gamedata/custom-lobby-backgrounds', '/textures/ui/common/lobby/backgrounds') diff --git a/init_fafbeta.lua b/init_fafbeta.lua index 26d83cce288..74f9bd125af 100644 --- a/init_fafbeta.lua +++ b/init_fafbeta.lua @@ -642,5 +642,8 @@ MountDirectory(fa_path .. "/sounds", '/sounds') MountDirectory(fa_path .. "/maps", '/maps') MountDirectory(fa_path .. "/fonts", '/fonts') +-- support for custom backgrounds in the lobby +MountDirectory(InitFileDir .. '/../gamedata/custom-lobby-backgrounds', '/textures/ui/common/lobby/backgrounds') + -- Allows developers to embed code to debug a replay table.insert(path, 1, { dir = InitFileDir .. '\\..\\Debug', mountpoint = '/' }) diff --git a/init_fafdevelop.lua b/init_fafdevelop.lua index 3d4569c530f..cf2c4ce41d5 100644 --- a/init_fafdevelop.lua +++ b/init_fafdevelop.lua @@ -642,5 +642,8 @@ MountDirectory(fa_path .. "/sounds", '/sounds') MountDirectory(fa_path .. "/maps", '/maps') MountDirectory(fa_path .. "/fonts", '/fonts') +-- support for custom backgrounds in the lobby +MountDirectory(InitFileDir .. '/../gamedata/custom-lobby-backgrounds', '/textures/ui/common/lobby/backgrounds') + -- Allows developers to embed code to debug a replay table.insert(path, 1, { dir = InitFileDir .. '\\..\\Debug', mountpoint = '/' }) diff --git a/lua/ui/lobby/customlobby/CustomLobbyBackground.lua b/lua/ui/lobby/customlobby/CustomLobbyBackground.lua new file mode 100644 index 00000000000..3a9fc93a753 --- /dev/null +++ b/lua/ui/lobby/customlobby/CustomLobbyBackground.lua @@ -0,0 +1,127 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- The lobby's full-window background surface: a single Bitmap that paints the selected background +-- image (CustomLobbyBackgrounds) so it **covers** the whole parent while keeping the texture's +-- aspect ratio — scaled to the larger of the two axis ratios, centred, with the overflow cropped by +-- the screen edge. Falls back to a solid backdrop when no image is selected or the file can't be +-- read. +-- +-- It subscribes to the reactive selection itself (the controller never touches it — it's a local +-- cosmetic choice) and re-covers automatically when the window resizes, because Width/Height are +-- bound as functions of the parent rect. + +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap +local Backgrounds = import("/lua/ui/lobby/customlobby/customlobbybackgrounds.lua") + +local LazyVarCreate = import("/lua/lazyvar.lua").Create +local LazyVarDerive = import("/lua/lazyvar.lua").Derive + +local Layouter = LayoutHelpers.ReusedLayoutFor + +--- The backdrop shown when no image is selected, or a selected file can't be read. +local FallbackColor = 'ff0a0a0a' + +---@class UICustomLobbyBackground : Bitmap +---@field Trash TrashBag +---@field AspectLazy LazyVar # { Width, Height } of the current texture in pixels (0 when none) +---@field SelectedObserver LazyVar +local CustomLobbyBackground = ClassUI(Bitmap) { + + ---@param self UICustomLobbyBackground + ---@param parent Control + __init = function(self, parent) + Bitmap.__init(self, parent) + self:DisableHitTest() + self:SetSolidColor(FallbackColor) + + self.Trash = TrashBag() + -- texture pixel size drives the cover math; 0 means "no texture -> solid fallback" + self.AspectLazy = self.Trash:Add(LazyVarCreate({ Width = 0, Height = 0 })) + + -- react to the per-peer background choice (fires once on creation, then on each change) + self.SelectedObserver = self.Trash:Add( + LazyVarDerive(Backgrounds.GetSelectedLazy(), function(pathLazy) + self:SetBackground(pathLazy()) + end)) + end, + + ---@param self UICustomLobbyBackground + ---@param parent Control + __post_init = function(self, parent) + -- Cover the parent while keeping the texture's aspect ratio: scale to the LARGER of the two + -- axis ratios so the image fills the window and overflows (is cropped) on the other axis, + -- then centre it. Raw pixels throughout — we fill the physical frame, so no ui_scale here. + -- Both edges are bound as functions of the parent rect, so a window resize re-covers for + -- free. + self.Width:Set(function() + local aspect = self.AspectLazy() + local parentWidth, parentHeight = parent.Width(), parent.Height() + if aspect.Width <= 0 or aspect.Height <= 0 then + return parentWidth + end + local scale = math.max(parentWidth / aspect.Width, parentHeight / aspect.Height) + return aspect.Width * scale + end) + self.Height:Set(function() + local aspect = self.AspectLazy() + local parentWidth, parentHeight = parent.Width(), parent.Height() + if aspect.Width <= 0 or aspect.Height <= 0 then + return parentHeight + end + local scale = math.max(parentWidth / aspect.Width, parentHeight / aspect.Height) + return aspect.Height * scale + end) + -- AtCenterIn only sets Left/Top (reading the Width/Height bound above) — no conflict. + Layouter(self):AtCenterIn(parent):End() + end, + + --- Paints `path` as a cover-fit background. Falls back to the solid backdrop when `path` is + --- false or the file can't be read (a stale prefs entry pointing at a deleted image). + ---@param self UICustomLobbyBackground + ---@param path FileName | false + SetBackground = function(self, path) + if path then + local width, height = GetTextureDimensions(path) + if width and height and width > 0 and height > 0 then + self:SetTexture(path) + self.AspectLazy:Set({ Width = width, Height = height }) + return + end + WARN("CustomLobby: background image could not be read: " .. tostring(path)) + end + self:SetSolidColor(FallbackColor) + self.AspectLazy:Set({ Width = 0, Height = 0 }) + end, + + ---@param self UICustomLobbyBackground + OnDestroy = function(self) + self.Trash:Destroy() + end, +} + +---@param parent Control +---@return UICustomLobbyBackground +Create = function(parent) + return CustomLobbyBackground(parent) +end diff --git a/lua/ui/lobby/customlobby/CustomLobbyBackgrounds.lua b/lua/ui/lobby/customlobby/CustomLobbyBackgrounds.lua new file mode 100644 index 00000000000..10b3bdf0bdd --- /dev/null +++ b/lua/ui/lobby/customlobby/CustomLobbyBackgrounds.lua @@ -0,0 +1,102 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- Lobby background discovery + the per-peer background selection. Purely cosmetic and never synced +-- (like a skin choice): it scans textures/ui/common/lobby/backgrounds for *.png, exposes the list, +-- and persists the chosen path in this machine's prefs. The reactive `Selected` handle lets the +-- background surface (CustomLobbyBackground) and the picker (CustomLobbyInterface's combo) stay in +-- sync without polling. +-- +-- Pure data + prefs: no models, no network. The chosen image is drawn to cover the whole window +-- while keeping its aspect ratio — that cover math lives in CustomLobbyBackground. + +local Prefs = import("/lua/user/prefs.lua") +local LazyVarCreate = import("/lua/lazyvar.lua").Create + +--- The directory scanned for background images (repo-root absolute). +BackgroundsDir = '/textures/ui/common/lobby/backgrounds' + +--- The prefs key the chosen background path is stored under (this machine only). +local PrefsKey = "customlobby_background" + +--- Reactive handle to the selected background path, or false for "no background" (the solid +--- backdrop). Lazily created + seeded from prefs on first access (see GetSelectedLazy). +---@type LazyVar | false +local SelectedLazy = false + +--- A human label for a background file: the bare filename without dir/extension, separators turned +--- to spaces and Title Cased (`aeon-omen.png` -> `Aeon Omen`). +---@param path string +---@return string +local function DisplayName(path) + local name = string.gsub(path, "^.*/", "") -- strip directory + name = string.gsub(name, "%.%w+$", "") -- strip extension + name = string.gsub(name, "[-_]", " ") -- separators -> spaces + name = string.gsub(name, "(%a)([%w]*)", function(first, rest) + return string.upper(first) .. rest + end) + return name +end + +--- All background images on disk as `{ Path, Name }`, sorted by path. Empty when the folder holds +--- none. Re-scanned on each call (cheap — a handful of files). +---@return { Path: FileName, Name: string }[] +function Discover() + local backgrounds = {} + for _, path in DiskFindFiles(BackgroundsDir, '*.png') do + table.insert(backgrounds, { Path = path, Name = DisplayName(path) }) + end + table.sort(backgrounds, function(a, b) return a.Path < b.Path end) + return backgrounds +end + +--- The selected-path LazyVar, created + seeded on first call. Subscribe with `Derive` to react to +--- background changes. Seeding rule: an explicit stored value (a path, or `false` for "None") is +--- honoured; with nothing stored yet we default to the first discovered image so a fresh lobby +--- shows a backdrop rather than a black frame. +---@return LazyVar +function GetSelectedLazy() + if not SelectedLazy then + local stored = Prefs.GetFromCurrentProfile(PrefsKey) + if stored == nil then + local all = Discover() + stored = all[1] and all[1].Path or false + end + SelectedLazy = LazyVarCreate(stored or false) + end + return SelectedLazy +end + +--- The currently selected background path, or false when none is chosen. +---@return FileName | false +function GetSelected() + return GetSelectedLazy()() +end + +--- Chooses `path` as the background (pass false to clear it): updates the reactive handle and +--- persists the choice to prefs. +---@param path FileName | false +function Select(path) + GetSelectedLazy():Set(path or false) + Prefs.SetToCurrentProfile(PrefsKey, path or false) + SavePreferences() +end diff --git a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua index a0cbd232881..713c3b8da74 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua @@ -58,6 +58,9 @@ local EscapeHandler = import("/lua/ui/dialogs/eschandler.lua") local Group = import("/lua/maui/group.lua").Group local Bitmap = import("/lua/maui/bitmap.lua").Bitmap local Button = import("/lua/maui/button.lua").Button +local Combo = import("/lua/ui/controls/combo.lua").Combo +local CustomLobbyBackground = import("/lua/ui/lobby/customlobby/customlobbybackground.lua") +local CustomLobbyBackgrounds = import("/lua/ui/lobby/customlobby/customlobbybackgrounds.lua") local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/customlobbylaunchmodel.lua") local CustomLobbySessionModel = import("/lua/ui/lobby/customlobby/customlobbysessionmodel.lua") local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/customlobbylocalmodel.lua") @@ -140,10 +143,12 @@ end ---@class UICustomLobbyInterface : Group ---@field Trash TrashBag ----@field Background Bitmap +---@field Background UICustomLobbyBackground ---@field Content Group ---@field TitleArea Group ---@field Title Text +---@field BackgroundCombo Combo +---@field BackgroundPaths (FileName | false)[] # parallel to the combo items; [1] is "(None)" ---@field LeaveButton Button ---@field SlotsArea Group ---@field Slots UICustomLobbySlotsInterface @@ -167,9 +172,9 @@ local CustomLobbyInterface = Class(Group) { self.Trash = TrashBag() - self.Background = Bitmap(self) - self.Background:SetSolidColor('ff0a0a0a') - self.Background:DisableHitTest() + -- full-window background image (cover-fit, keeps aspect ratio); subscribes to the per-peer + -- selection itself. A solid backdrop shows through when no image is chosen. + self.Background = CustomLobbyBackground.Create(self) -- everything below lives in a centered, size-capped content group (see __post_init) self.Content = Group(self, "CustomLobbyContent") @@ -182,9 +187,16 @@ local CustomLobbyInterface = Class(Group) { self.ActionArea = CreateArea(self.Content, "ActionArea", 'ff808080') --#endregion - --#region title bar (title) + --#region title bar (title + background picker) self.Title = UIUtil.CreateText(self.TitleArea, "Custom game", 20, UIUtil.titleFont) self.Title:DisableHitTest() + + -- a basic background picker: a combo of every *.png in the backgrounds folder (plus a + -- leading "(None)"). The choice is local + cosmetic, persisted via CustomLobbyBackgrounds. + self.BackgroundCombo = Combo(self.TitleArea, 14, 8, nil, nil, "UI_Tab_Click_01", "UI_Tab_Rollover_01") + self:PopulateBackgrounds() + Tooltip.AddControlTooltipManual(self.BackgroundCombo, "Background", + "Choose the lobby background image (a local, cosmetic choice).") --#endregion -- Leave lives in the action bar (bottom-left, beside the status text); created here so it's @@ -261,7 +273,7 @@ local CustomLobbyInterface = Class(Group) { ---@param parent Control __post_init = function(self, parent) Layouter(self):Fill(parent):End() - Layouter(self.Background):Fill(self):End() + -- self.Background lays itself out cover-fit against this root (see CustomLobbyBackground) -- centred content, capped at the 1024x768 design size (fills the frame at the minimum -- resolution; centred with a backdrop border on anything larger) @@ -296,8 +308,11 @@ local CustomLobbyInterface = Class(Group) { self.BottomLeftArea.Right:Set(function() return self.RightArea.Left() - LayoutHelpers.ScaleNumber(Pad) end) --#endregion - --#region title bar (title) + --#region title bar (title + background picker) Layouter(self.Title):AtLeftIn(self.TitleArea, 8):AtVerticalCenterIn(self.TitleArea):End() + Layouter(self.BackgroundCombo) + :AtRightIn(self.TitleArea, 8):AtVerticalCenterIn(self.TitleArea):Width(180) + :End() --#endregion --#region slots fill their area (the component stacks the rows + coordinates dragging) @@ -341,6 +356,35 @@ local CustomLobbyInterface = Class(Group) { end end, + --- Fills the background picker from the *.png files on disk, with a leading "(None)" entry, and + --- selects the one currently chosen. The combo writes the choice back through + --- CustomLobbyBackgrounds.Select; the Background surface reacts on its own. + ---@param self UICustomLobbyInterface + PopulateBackgrounds = function(self) + local labels = { "(None)" } + local paths = { false } + for _, background in CustomLobbyBackgrounds.Discover() do + table.insert(labels, background.Name) + table.insert(paths, background.Path) + end + self.BackgroundPaths = paths + + local selected = CustomLobbyBackgrounds.GetSelected() + local selectedIndex = 1 + for index, path in paths do + if path == selected then + selectedIndex = index + break + end + end + + self.BackgroundCombo:ClearItems() + self.BackgroundCombo:AddItems(labels, selectedIndex) + self.BackgroundCombo.OnClick = function(combo, index, text) + CustomLobbyBackgrounds.Select(self.BackgroundPaths[index] or false) + end + end, + ---@param self UICustomLobbyInterface OnDestroy = function(self) self.Trash:Destroy() diff --git a/lua/ui/lobby/customlobby/presetselect/CustomLobbyPresetSelect.lua b/lua/ui/lobby/customlobby/presetselect/CustomLobbyPresetSelect.lua index e6601597312..4c4646d9e30 100644 --- a/lua/ui/lobby/customlobby/presetselect/CustomLobbyPresetSelect.lua +++ b/lua/ui/lobby/customlobby/presetselect/CustomLobbyPresetSelect.lua @@ -51,14 +51,18 @@ local Layouter = LayoutHelpers.ReusedLayoutFor -- flip to tint each layout area so the regions are visible while iterating local Debug = false -local DialogWidth = 620 +-- five action buttons (Load / Save / Rename / Delete · Close) sit in one row; each `/BUTTON/small/` +-- is 152×40 unscaled (see /textures/texture-dimensions.csv), so the dialog is sized to hold +-- 5×152 + the inter-button gaps + padding without overlap. +local DialogWidth = 820 local DialogHeight = 470 local Pad = 12 local ColumnGap = 20 local ListWidth = 250 local ScrollbarInset = 20 local TitleHeight = 32 -local ActionHeight = 44 +local ButtonGap = 8 +local ActionHeight = 48 local LabelColor = 'ffc8ccd0' @@ -200,32 +204,32 @@ local CustomLobbyPresetSelect = ClassUI(Group) { --#endregion --#region actions - self.LoadButton = UIUtil.CreateButtonStd(self.ActionArea, '/scx_menu/small-btn/small', "Load", 16, 2) + self.LoadButton = UIUtil.CreateButtonStd(self.ActionArea, '/BUTTON/small/', "Load", 14, 2) self.LoadButton.OnClick = function(button, modifiers) self:LoadSelected() end Tooltip.AddControlTooltipManual(self.LoadButton, "Load preset", "Apply the selected preset's map, options, mods and restrictions to the lobby.") - self.SaveButton = UIUtil.CreateButtonStd(self.ActionArea, '/scx_menu/small-btn/small', "Save", 16, 2) + self.SaveButton = UIUtil.CreateButtonStd(self.ActionArea, '/BUTTON/small/', "Save", 14, 2) self.SaveButton.OnClick = function(button, modifiers) self:PromptSave() end Tooltip.AddControlTooltipManual(self.SaveButton, "Save preset", "Save the current lobby setup as a named preset.") - self.RenameButton = UIUtil.CreateButtonStd(self.ActionArea, '/scx_menu/small-btn/small', "Rename", 16, 2) + self.RenameButton = UIUtil.CreateButtonStd(self.ActionArea, '/BUTTON/small/', "Rename", 14, 2) self.RenameButton.OnClick = function(button, modifiers) self:PromptRename() end Tooltip.AddControlTooltipManual(self.RenameButton, "Rename preset", "Rename the selected preset.") - self.DeleteButton = UIUtil.CreateButtonStd(self.ActionArea, '/scx_menu/small-btn/small', "Delete", 16, 2) + self.DeleteButton = UIUtil.CreateButtonStd(self.ActionArea, '/BUTTON/small/', "Delete", 14, 2) self.DeleteButton.OnClick = function(button, modifiers) self:DeleteSelected() end Tooltip.AddControlTooltipManual(self.DeleteButton, "Delete preset", "Delete the selected preset.") - self.CloseButton = UIUtil.CreateButtonStd(self.ActionArea, '/scx_menu/small-btn/small', "Close", 16, 2) + self.CloseButton = UIUtil.CreateButtonStd(self.ActionArea, '/BUTTON/small/', "Close", 14, 2) self.CloseButton.OnClick = function(button, modifiers) self.OnCloseCb() end @@ -264,9 +268,9 @@ local CustomLobbyPresetSelect = ClassUI(Group) { --#region actions: Load / Save / Rename / Delete on the left, Close on the right Layouter(self.LoadButton):AtLeftIn(self.ActionArea):AtVerticalCenterIn(self.ActionArea):End() - Layouter(self.SaveButton):AnchorToRight(self.LoadButton, 8):AtVerticalCenterIn(self.ActionArea):End() - Layouter(self.RenameButton):AnchorToRight(self.SaveButton, 8):AtVerticalCenterIn(self.ActionArea):End() - Layouter(self.DeleteButton):AnchorToRight(self.RenameButton, 8):AtVerticalCenterIn(self.ActionArea):End() + Layouter(self.SaveButton):AnchorToRight(self.LoadButton, ButtonGap):AtVerticalCenterIn(self.ActionArea):End() + Layouter(self.RenameButton):AnchorToRight(self.SaveButton, ButtonGap):AtVerticalCenterIn(self.ActionArea):End() + Layouter(self.DeleteButton):AnchorToRight(self.RenameButton, ButtonGap):AtVerticalCenterIn(self.ActionArea):End() Layouter(self.CloseButton):AtRightIn(self.ActionArea):AtVerticalCenterIn(self.ActionArea):End() --#endregion end, diff --git a/lua/ui/lobby/lobby.lua b/lua/ui/lobby/lobby.lua index 99ee75cff7e..93be461a882 100644 --- a/lua/ui/lobby/lobby.lua +++ b/lua/ui/lobby/lobby.lua @@ -92,7 +92,6 @@ function CreateLobby(protocol, localPort, desiredPlayerName, localPlayerUID, nat Instance:Destroy() Instance = false end - CustomLobbyInterface.CloseDebug() -- Free everything registered in the session trash (the map catalog today; the models, -- interface and instance follow as they are converted to the same pattern). CustomLobbySession.Teardown() diff --git a/textures/ui/common/lobby/backgrounds/aeon-omen.png b/textures/ui/common/lobby/backgrounds/aeon-omen.png deleted file mode 100644 index d7e2052c0100a083dd3bdc1c7d73f7ee8273f3f7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 124950 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˴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

+d1`~s3{8UC)B#vi{`C8!(9D(D%Vy`zmKCPx;t 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 literal 0 HcmV?d00001 diff --git a/textures/ui/common/lobby/backgrounds/cybran-galaxy.png b/textures/ui/common/lobby/backgrounds/cybran-galaxy.png new file mode 100644 index 0000000000000000000000000000000000000000..68918abc4e8b2047c64c97fc40057c85c46162da GIT binary patch literal 161110 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

---@field Restrictions LazyVar # unit-restriction preset keys (folded into GameOptions.RestrictedCategories at launch) ---@field ScenarioFile LazyVar +---@field Destroyed boolean +local LaunchModel = ClassSimple { ----@type UICustomLobbyLaunchModel | nil -local ModelInstance = nil + ---@param self UICustomLobbyLaunchModel + __init = function(self) + local players = {} + for slot = 1, MaxSlots do + players[slot] = Create(false) + end + self.Players = players + self.Observers = Create({}) + self.SpawnMex = Create({}) + self.AutoTeams = Create({}) + self.GameOptions = Create({}) + self.GameMods = Create({}) + self.Restrictions = Create({}) + self.ScenarioFile = Create(false) + self.Destroyed = false + end, ---- Allocates a fresh launch-model singleton, replacing any existing instance. + --- `Destroyable`: thin teardown — drop the module singleton so the next session rebuilds (and + --- re-registers). The LazyVars GC once the views observing them are gone. Idempotent. + ---@param self UICustomLobbyLaunchModel + Destroy = function(self) + if self.Destroyed then + return + end + self.Destroyed = true + if Instance == self then + Instance = nil + end + end, +} + +--- Allocates a fresh launch-model singleton and registers it in the session trash. ---@return UICustomLobbyLaunchModel function SetupSingleton() - local players = {} - for slot = 1, MaxSlots do - players[slot] = Create(false) - end - - ---@type UICustomLobbyLaunchModel - local model = { - Players = players, - Observers = Create({}), - SpawnMex = Create({}), - AutoTeams = Create({}), - GameOptions = Create({}), - GameMods = Create({}), - Restrictions = Create({}), - ScenarioFile = Create(false), - } - - ModelInstance = model - return model + Instance = LaunchModel() + CustomLobbySession.GetTrash():Add(Instance) + return Instance end ---- Returns the launch-model singleton, creating it on first access. +--- Returns the launch-model singleton, creating (and registering) it on first access. ---@return UICustomLobbyLaunchModel function GetSingleton() - if not ModelInstance then + if not Instance then SetupSingleton() end - return ModelInstance --[[@as UICustomLobbyLaunchModel]] + return Instance --[[@as UICustomLobbyLaunchModel]] end --#endregion @@ -235,18 +262,18 @@ end --- its value is lost on every hot-reload. ---@param newModule any function __moduleinfo.OnReload(newModule) - if ModelInstance then + if Instance then local handle = newModule.SetupSingleton() for slot = 1, MaxSlots do - handle.Players[slot]:Set(ModelInstance.Players[slot]()) + handle.Players[slot]:Set(Instance.Players[slot]()) end - handle.Observers:Set(ModelInstance.Observers()) - handle.SpawnMex:Set(ModelInstance.SpawnMex()) - handle.AutoTeams:Set(ModelInstance.AutoTeams()) - handle.GameOptions:Set(ModelInstance.GameOptions()) - handle.GameMods:Set(ModelInstance.GameMods()) - handle.Restrictions:Set(ModelInstance.Restrictions()) - handle.ScenarioFile:Set(ModelInstance.ScenarioFile()) + handle.Observers:Set(Instance.Observers()) + handle.SpawnMex:Set(Instance.SpawnMex()) + handle.AutoTeams:Set(Instance.AutoTeams()) + handle.GameOptions:Set(Instance.GameOptions()) + handle.GameMods:Set(Instance.GameMods()) + handle.Restrictions:Set(Instance.Restrictions()) + handle.ScenarioFile:Set(Instance.ScenarioFile()) end end diff --git a/lua/ui/lobby/customlobby/models/CustomLobbyLocalModel.lua b/lua/ui/lobby/customlobby/models/CustomLobbyLocalModel.lua index d5136594936..37cb67b558e 100644 --- a/lua/ui/lobby/customlobby/models/CustomLobbyLocalModel.lua +++ b/lua/ui/lobby/customlobby/models/CustomLobbyLocalModel.lua @@ -34,39 +34,70 @@ -- * LocalModel (this) — per-peer, never synced. local Create = import("/lua/lazyvar.lua").Create +local CustomLobbySession = import("/lua/ui/lobby/customlobby/customlobbysession.lua") ------------------------------------------------------------------------------- --#region Reactive model +-- +-- LIFETIME. A `ClassSimple` implementing `Destroyable`, registered in the session trash bag (see +-- CustomLobbySession) on first access, so one `CustomLobbySession.Teardown()` resets it. Per the +-- teardown design's decision #3 the write helpers stay **free functions** (below) and `Destroy` is +-- **thin** — it just nils the module singleton so the next session rebuilds; the LazyVars are freed by +-- GC once the views observing them are torn down (we don't proactively destroy them, since the +-- interface that subscribes to them isn't in the bag yet). See design/session-trashbag-teardown.md. + +-- The singleton, forward-declared above the class so `Destroy` captures it as an upvalue. Assigned in +-- `SetupSingleton`, cleared in `Destroy`. +---@type UICustomLobbyLocalModel | nil +local Instance = nil --- Reactive local-state singleton (per-peer, never synced). ----@class UICustomLobbyLocalModel +---@class UICustomLobbyLocalModel : Destroyable ---@field LocalPeerId LazyVar # this client's peer id ---@field HostID LazyVar # the host's peer id ---@field IsHost LazyVar # whether this client is the host ---@field CpuBenchmarks LazyVar> # peer id -> in-game sim-performance history (see /lua/system/performance.lua) -local ModelInstance = nil +---@field Destroyed boolean +local LocalModel = ClassSimple { + + ---@param self UICustomLobbyLocalModel + __init = function(self) + self.LocalPeerId = Create("-1") + self.HostID = Create("-1") + self.IsHost = Create(false) + self.CpuBenchmarks = Create({}) + self.Destroyed = false + end, + + --- `Destroyable`: thin teardown — drop the module singleton so the next session rebuilds (and + --- re-registers). The LazyVars GC once the views observing them are gone. Idempotent. + ---@param self UICustomLobbyLocalModel + Destroy = function(self) + if self.Destroyed then + return + end + self.Destroyed = true + if Instance == self then + Instance = nil + end + end, +} ---- Allocates a fresh local-model singleton, replacing any existing one. +--- Allocates a fresh local-model singleton and registers it in the session trash. ---@return UICustomLobbyLocalModel function SetupSingleton() - ---@type UICustomLobbyLocalModel - local model = { - LocalPeerId = Create("-1"), - HostID = Create("-1"), - IsHost = Create(false), - CpuBenchmarks = Create({}), - } - ModelInstance = model - return model + Instance = LocalModel() + CustomLobbySession.GetTrash():Add(Instance) + return Instance end ---- Returns the local-model singleton, creating it on first access. +--- Returns the local-model singleton, creating (and registering) it on first access. ---@return UICustomLobbyLocalModel function GetSingleton() - if not ModelInstance then + if not Instance then SetupSingleton() end - return ModelInstance --[[@as UICustomLobbyLocalModel]] + return Instance --[[@as UICustomLobbyLocalModel]] end --#endregion @@ -89,15 +120,15 @@ end ------------------------------------------------------------------------------- --#region Debugging ---- Hot-reload hook: rebuilds the singleton and copies the values across. +--- Hot-reload hook: rebuilds the singleton (registering the new one) and copies the values across. ---@param newModule any function __moduleinfo.OnReload(newModule) - if ModelInstance then + if Instance then local handle = newModule.SetupSingleton() - handle.LocalPeerId:Set(ModelInstance.LocalPeerId()) - handle.HostID:Set(ModelInstance.HostID()) - handle.IsHost:Set(ModelInstance.IsHost()) - handle.CpuBenchmarks:Set(ModelInstance.CpuBenchmarks()) + handle.LocalPeerId:Set(Instance.LocalPeerId()) + handle.HostID:Set(Instance.HostID()) + handle.IsHost:Set(Instance.IsHost()) + handle.CpuBenchmarks:Set(Instance.CpuBenchmarks()) end end diff --git a/lua/ui/lobby/customlobby/models/CustomLobbySessionModel.lua b/lua/ui/lobby/customlobby/models/CustomLobbySessionModel.lua index 810f12c917a..52091a90e7b 100644 --- a/lua/ui/lobby/customlobby/models/CustomLobbySessionModel.lua +++ b/lua/ui/lobby/customlobby/models/CustomLobbySessionModel.lua @@ -33,41 +33,68 @@ -- Synced host -> clients as a whole snapshot (CustomLobbyController.BroadcastSessionState). local Create = import("/lua/lazyvar.lua").Create +local CustomLobbySession = import("/lua/ui/lobby/customlobby/customlobbysession.lua") ------------------------------------------------------------------------------- --#region Reactive model +-- +-- LIFETIME. A `ClassSimple` implementing `Destroyable`, registered in the session trash bag (see +-- CustomLobbySession) on first access, so one `CustomLobbySession.Teardown()` resets it. Per the +-- teardown design's decision #3 the write helpers stay **free functions** (below) and `Destroy` is +-- **thin** (nil the module singleton; the LazyVars GC once the views observing them are torn down). + +-- The singleton, forward-declared above the class so `Destroy` captures it as an upvalue. Assigned in +-- `SetupSingleton`, cleared in `Destroy`. +---@type UICustomLobbySessionModel | nil +local Instance = nil --- Reactive session-state singleton (shared, host-dictated, not launched). ----@class UICustomLobbySessionModel +---@class UICustomLobbySessionModel : Destroyable ---@field SlotCount LazyVar # player slots the current map supports ---@field ClosedSlots LazyVar> ---@field SlotsPinned LazyVar # host locked seating: only the host may change slots +---@field Destroyed boolean +local SessionModel = ClassSimple { + + ---@param self UICustomLobbySessionModel + ---@param slotCount? number + __init = function(self, slotCount) + self.SlotCount = Create(slotCount or 8) + self.ClosedSlots = Create({}) + self.SlotsPinned = Create(false) + self.Destroyed = false + end, + + --- `Destroyable`: thin teardown — drop the module singleton so the next session rebuilds (and + --- re-registers). The LazyVars GC once the views observing them are gone. Idempotent. + ---@param self UICustomLobbySessionModel + Destroy = function(self) + if self.Destroyed then + return + end + self.Destroyed = true + if Instance == self then + Instance = nil + end + end, +} ----@type UICustomLobbySessionModel | nil -local ModelInstance = nil - ---- Allocates a fresh session-model singleton, replacing any existing instance. +--- Allocates a fresh session-model singleton and registers it in the session trash. ---@param slotCount? number ---@return UICustomLobbySessionModel function SetupSingleton(slotCount) - ---@type UICustomLobbySessionModel - local model = { - SlotCount = Create(slotCount or 8), - ClosedSlots = Create({}), - SlotsPinned = Create(false), - } - - ModelInstance = model - return model + Instance = SessionModel(slotCount) + CustomLobbySession.GetTrash():Add(Instance) + return Instance end ---- Returns the session-model singleton, creating it on first access. +--- Returns the session-model singleton, creating (and registering) it on first access. ---@return UICustomLobbySessionModel function GetSingleton() - if not ModelInstance then + if not Instance then SetupSingleton() end - return ModelInstance --[[@as UICustomLobbySessionModel]] + return Instance --[[@as UICustomLobbySessionModel]] end --#endregion @@ -109,10 +136,10 @@ end --- NOTE: maintained by hand — add a field to the model, add a copy line here too. ---@param newModule any function __moduleinfo.OnReload(newModule) - if ModelInstance then - local handle = newModule.SetupSingleton(ModelInstance.SlotCount()) - handle.ClosedSlots:Set(ModelInstance.ClosedSlots()) - handle.SlotsPinned:Set(ModelInstance.SlotsPinned()) + if Instance then + local handle = newModule.SetupSingleton(Instance.SlotCount()) + handle.ClosedSlots:Set(Instance.ClosedSlots()) + handle.SlotsPinned:Set(Instance.SlotsPinned()) end end From 86c1d8b309fb475287bfb4c5334873ab6d3ab1ca Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sat, 27 Jun 2026 16:37:27 +0200 Subject: [PATCH 75/98] Add dedup to the derived options --- .../CustomLobbyOptionsDerivedModel.lua | 31 ++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/lua/ui/lobby/customlobby/models/derived/CustomLobbyOptionsDerivedModel.lua b/lua/ui/lobby/customlobby/models/derived/CustomLobbyOptionsDerivedModel.lua index 4d288abe3bc..fa2e127a424 100644 --- a/lua/ui/lobby/customlobby/models/derived/CustomLobbyOptionsDerivedModel.lua +++ b/lua/ui/lobby/customlobby/models/derived/CustomLobbyOptionsDerivedModel.lua @@ -236,9 +236,12 @@ end ---@field Trash TrashBag # owns the Options var + the 3 observers (freed on Destroy) ---@field Options LazyVar ---@field Observers LazyVar[] # internal: scenario/mods/options subscriptions (strong refs; also in Trash) ----@field Schema table | false # cached option schema (lobby/scenario/mod), keyed by SchemaKey ----@field SchemaKey string | false # scenario file + sorted mod uids the Schema was gathered for ----@field Destroyed boolean +---@field Schema table | false # cached option schema (lobby/scenario/mod), keyed by SchemaKey — the *disk* dedup +---@field SchemaKey string | false # scenario file + sorted mod uids the Schema was gathered for +---@field LoadedFile FileName | false # publish-dedup: the scenario file of the last published bundle +---@field LoadedMods table | false # publish-dedup: the sim-mod set of the last published bundle (held snapshot) +---@field LoadedValues table | false # publish-dedup: the option values of the last published bundle (false until first publish) +---@field Destroyed boolean local OptionsModel = ClassSimple { ---@param self UICustomLobbyOptionsDerivedModel @@ -248,6 +251,9 @@ local OptionsModel = ClassSimple { self.Observers = {} self.Schema = false self.SchemaKey = false + self.LoadedFile = false + self.LoadedMods = false + self.LoadedValues = false -- false until the first publish (the "have we published" guard) self.Destroyed = false -- re-derive on a scenario / mod-set / option-value change. Each observer is kept strongly on @@ -273,7 +279,24 @@ local OptionsModel = ClassSimple { Recompute = function(self) local scenario = CustomLobbyScenarioDerivedModel.GetScenario() local launch = CustomLobbyLaunchModel.GetSingleton() - self.Options:Set(BuildOptions(self, scenario, launch.GameMods(), launch.GameOptions())) + local file = scenario and scenario.File or false + local gameMods, values = launch.GameMods(), launch.GameOptions() + + -- publish-dedup: the bundle is a pure function of (scenario file, sim-mod set, option values), + -- so skip the re-publish *and* the enrichment when all three are unchanged. `table.equal` is the + -- cheap count + per-value compare; copy-then-Set means the held tables are stable snapshots. + -- `self.LoadedValues` is false until the first publish, which forces that first build. (This is + -- separate from the SchemaKey cache, which dedups the *disk* read inside BuildOptions.) + if self.LoadedValues + and file == self.LoadedFile + and table.equal(gameMods, self.LoadedMods) + and table.equal(values, self.LoadedValues) then + return + end + self.LoadedFile = file + self.LoadedMods = gameMods + self.LoadedValues = values + self.Options:Set(BuildOptions(self, scenario, gameMods, values)) end, --- `Destroyable`: frees the `Options` var + the 3 subscriptions (so they stop firing) and clears the From ed8a6c876623542ae8f466dd8a8bf7ca96a1d5c0 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sat, 27 Jun 2026 16:49:59 +0200 Subject: [PATCH 76/98] Implement auto balance --- lua/ui/lobby/USER_STORIES.md | 6 +- lua/ui/lobby/customlobby/CLAUDE.md | 22 +- .../customlobby/CustomLobbyBalancePreview.lua | 344 +++++++++++++++++ .../lobby/customlobby/CustomLobbyBalancer.lua | 356 ++++++++++++++++++ .../customlobby/CustomLobbyController.lua | 127 ++++++- lua/ui/lobby/customlobby/CustomLobbyMenus.lua | 15 + .../lobby/customlobby/CustomLobbyMessages.lua | 3 +- .../models/CustomLobbySessionModel.lua | 14 + .../customlobby/models/derived/CLAUDE.md | 4 +- .../derived/CustomLobbySlotsDerivedModel.lua | 16 +- .../customlobby/slots/CustomLobbySlotBase.lua | 12 + .../slots/CustomLobbySlotsInterface.lua | 69 +++- 12 files changed, 955 insertions(+), 33 deletions(-) create mode 100644 lua/ui/lobby/customlobby/CustomLobbyBalancePreview.lua create mode 100644 lua/ui/lobby/customlobby/CustomLobbyBalancer.lua diff --git a/lua/ui/lobby/USER_STORIES.md b/lua/ui/lobby/USER_STORIES.md index 7868d627daf..d05fe40cb36 100644 --- a/lua/ui/lobby/USER_STORIES.md +++ b/lua/ui/lobby/USER_STORIES.md @@ -160,15 +160,15 @@ Roles: **Host**, **Player** (human in a slot), **Observer**, **Joining client** ## R. New requests -- ⬜ As a **host**, I want the ability to lock players in-place when autobalance is applied so that players that want to play together stay in the same team. +- ✅ As a **host**, I want the ability to lock players in-place when autobalance is applied so that players that want to play together stay in the same team. *(Slot context menu "Lock in slot"; a locked seat is pinned (synced session state) and the balancer rearranges only the unlocked players around it — see `CustomLobbyBalancer`.)* - ⬜ As a **player**, I want the observer bug that can freeze/crash the lobby fixed — or at least a warning when it's hit — so that observing doesn't break the lobby. *(Reliable repro needs a debuggable lobby setup.)* - ⬜ As a **player**, I want to randomise among a chosen subset of factions (multi-choice), so that I get variety without pure random. - ⬜ As a **player**, I want my FAF avatar shown in the lobby, so that players are recognisable. - ⬜ As a **host**, I want a "players vs AI" AutoTeams option, so that all humans are teamed against the AIs automatically. - ⬜ As a **host**, I want closing a slot to auto-move its player to a free slot (in opti), so that rearranging doesn't drop anyone. -- ⬜ As a **host**, I want an opti team preview, so that I can see the balanced teams before committing. +- ✅ As a **host**, I want an opti team preview, so that I can see the balanced teams before committing. *(The auto-balance button opens a preview modal — `CustomLobbyBalancePreview` — showing the proposed two sides + match quality, with Apply / Cancel; nothing is re-seated until Apply.)* - ⬜ As a **host**, I want flexible mirror balancing, so that mirror match-ups can be balanced with some give. -- ⬜ As a **host**, I want a system for balancing premades, so that pre-made groups are distributed fairly across teams. *(Related to locking players in place during autobalance, above.)* +- 🟡 As a **host**, I want a system for balancing premades, so that pre-made groups are distributed fairly across teams. *(The mechanism exists — lock the premade's seats and the balancer holds them together while balancing the rest — but there is no automatic premade detection / grouping yet.)* - ⬜ As a **player**, I want easier rehosting — a dedicated in-game/lobby rehost button that works with the client, including rehosting someone else's lobby (when they're AFK, or just to duplicate it), so that rehosting is quick. *(Extends the "rehost my last game" stories in A/O.)* - ⬜ As a **non-host player**, I want to save presets as a client, so that I keep my setups even when I'm not hosting. - ⬜ As a **host**, I want to load presets without switching maps, so that applying a preset doesn't force a map change. diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index ce01bfe4cf8..eeef09320dd 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -29,10 +29,12 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` LazyVars** so one slot's change re-fires only that row. Write helpers (`SetPlayer`, `SetPlayerField`, `AddObserver`, …) keep the copy-then-`Set` discipline. - **[CustomLobbySessionModel.lua](models/CustomLobbySessionModel.lua)** — shared but **not** - launched: lobby-room management (slot count, closed slots, `SlotsPinned`). A closed slot is - just empty at launch and slot count is map-derived presentation — neither reaches the scenario. - `SlotsPinned` locks seating so the host rejects client slot-takes (only the host may move - players), enforced in `ProcessTakeSlot`. + launched: lobby-room management (slot count, closed slots, `LockedSlots`, `SlotsPinned`). A closed + slot is just empty at launch and slot count is map-derived presentation — neither reaches the + scenario. `LockedSlots` pins individual seats so auto-balance holds those players in place (only + the unlocked players are rearranged); the lock is cleared when its seat empties. `SlotsPinned` + locks *all* seating so the host rejects client slot-takes (only the host may move players), + enforced in `ProcessTakeSlot`. - **[CustomLobbyLocalModel.lua](models/CustomLobbyLocalModel.lua)** — **per-peer, never synced**: identity (`LocalPeerId` / `HostID` / `IsHost`, set on the connection handshake) + connectivity (CPU benchmarks now; ping later). Broadcasting identity would corrupt the @@ -43,20 +45,22 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | File | Role | |------|------| | [CustomLobbyLaunchModel.lua](models/CustomLobbyLaunchModel.lua) | shared + launched state — the launch payload (see above). | -| [CustomLobbySessionModel.lua](models/CustomLobbySessionModel.lua) | shared, lobby-room-only state (slot count, closed slots). | +| [CustomLobbySessionModel.lua](models/CustomLobbySessionModel.lua) | shared, lobby-room-only state (slot count, closed slots, locked slots, pinned seating). | | [CustomLobbyLocalModel.lua](models/CustomLobbyLocalModel.lua) | per-peer state, never synced: identity + CPU benchmarks. | -| [models/derived/](models/derived/CLAUDE.md) | **derived models** — read-only reactive projections of the authoritative state, the "fishing" layer between the compact synced fields and the views (never written by the controller). The first is [CustomLobbyScenarioDerivedModel](models/derived/CustomLobbyScenarioDerivedModel.lua): resolves the launch model's compact `ScenarioFile` into a rich `Scenario` bundle — info (for the map texture) + extracted save markers (`Spawns` / `MassPoints` / `HydroPoints` / `Wrecks`, never the raw save) + `MaxDimension` / `ArmyCount` / `Name` / `Size` / `Version`. **Does the fishing once so consumers just read the field they need** (the map preview, the config facts line, `CustomLobbyRules`); an internal observer **dedups by file** so a launch-info rebroadcast of the same map is a no-op (no preview reload). [CustomLobbyOptionsDerivedModel](models/derived/CustomLobbyOptionsDerivedModel.lua) splits `GameOptions` into lobby / scenario / mods categories and enriches each option (label, help, chosen value, is-default), caching the disk-loaded schema; [CustomLobbyRestrictionsDerivedModel](models/derived/CustomLobbyRestrictionsDerivedModel.lua) maps the active restriction preset keys to their name / icon / tooltip; [CustomLobbyModsDerivedModel](models/derived/CustomLobbyModsDerivedModel.lua) splits the enabled mods into game / ui groups enriched with name / icon / author / version; [CustomLobbySlotsDerivedModel](models/derived/CustomLobbySlotsDerivedModel.lua) is the `Slots` **lookup table** (one entry per slot) merging each seat's player + scenario placement + closed flag + CPU benchmark into a ready-to-paint entry (the slot/faction/CPU formatting moved out of the slot controls into here). See [models/derived/CLAUDE.md](models/derived/CLAUDE.md). | +| [models/derived/](models/derived/CLAUDE.md) | **derived models** — read-only reactive projections of the authoritative state, the "fishing" layer between the compact synced fields and the views (never written by the controller). The first is [CustomLobbyScenarioDerivedModel](models/derived/CustomLobbyScenarioDerivedModel.lua): resolves the launch model's compact `ScenarioFile` into a rich `Scenario` bundle — info (for the map texture) + extracted save markers (`Spawns` / `MassPoints` / `HydroPoints` / `Wrecks`, never the raw save) + `MaxDimension` / `ArmyCount` / `Name` / `Size` / `Version`. **Does the fishing once so consumers just read the field they need** (the map preview, the config facts line, `CustomLobbyRules`); an internal observer **dedups by file** so a launch-info rebroadcast of the same map is a no-op (no preview reload). [CustomLobbyOptionsDerivedModel](models/derived/CustomLobbyOptionsDerivedModel.lua) splits `GameOptions` into lobby / scenario / mods categories and enriches each option (label, help, chosen value, is-default), caching the disk-loaded schema; [CustomLobbyRestrictionsDerivedModel](models/derived/CustomLobbyRestrictionsDerivedModel.lua) maps the active restriction preset keys to their name / icon / tooltip; [CustomLobbyModsDerivedModel](models/derived/CustomLobbyModsDerivedModel.lua) splits the enabled mods into game / ui groups enriched with name / icon / author / version; [CustomLobbySlotsDerivedModel](models/derived/CustomLobbySlotsDerivedModel.lua) is the `Slots` **lookup table** (one entry per slot) merging each seat's player + scenario placement + closed flag + locked flag + CPU benchmark into a ready-to-paint entry (the slot/faction/CPU formatting moved out of the slot controls into here). See [models/derived/CLAUDE.md](models/derived/CLAUDE.md). | | [CustomLobbyPerformancePopover.lua](CustomLobbyPerformancePopover.lua) | hover popover over the CPU column; hand-built bitmap bar chart of a peer's `PerformanceTrackingV2` history, with a yellow recommended-unit-cap line. | | [CustomLobbyInstance.lua](CustomLobbyInstance.lua) | thin `moho.lobby_methods` shell; validates/dispatches traffic, forwards callbacks to the controller. Also feeds [`CustomLobbyLog`](CustomLobbyLog.lua) from its three network choke points (BroadcastData / SendData / DataReceived). | | [CustomLobbyLog.lua](CustomLobbyLog.lua) | the **network traffic log** — a reactive, per-peer, never-synced ring buffer (`Entries` LazyVar, capped) of every message this peer broadcasts / sends / receives, fed by the instance and rendered by the **Logs** tab. Each peer logs only its own traffic, so host and client views differ naturally. Not one of the three models (no game state); a diagnostic feed. | -| [CustomLobbyController.lua](CustomLobbyController.lua) | host-authority logic (free functions): seating, `Process*` handlers, intents (`RequestSetReady`, `RequestTakeSlot`, `RequestSwapSlots`, `RequestEject`, `RequestMoveToObserver`, `RequestSetScenario`, `RequestSetGameMods/Options`, `RequestResetGameOptions`, `RequestSetSlotsPinned`, `RequestAutoBalance` (stub), `RequestReopenClosedSlots`, `RequestLaunch`, `RequestSaveSetupPreset`/`RequestLoadSetupPreset` — all keyed by slot/bool/file/name so a chat command can call them too; permission is gated separately), sharing the stored CPU benchmark. **Launch:** `RequestLaunch` (host-only, readiness-validated) → auto-saves the `lastGame` preset (`BuildSetupSnapshot`) → `BuildGameConfiguration` (seed option defaults + scenario, resolve random factions, assign army numbers + push to server, stamp ratings/clan tags, resolve sim mods via `Mods.GetGameMods`) → broadcast `LaunchGame` + `instance:LaunchGame`; clients run `ProcessLaunchGame`. **Presets:** `BuildSetupSnapshot` (read the launch state → serializable **setup-only** snapshot: scenario / options / mods / restrictions) + `ApplySetup` (host-only: write scenario/mods/restrictions, reconcile options to the current schema, one broadcast); players, observers and teams/spawn-mex are not stored (see [presetselect/](presetselect/CLAUDE.md)). | +| [CustomLobbyController.lua](CustomLobbyController.lua) | host-authority logic (free functions): seating, `Process*` handlers, intents (`RequestSetReady`, `RequestTakeSlot`, `RequestSwapSlots`, `RequestEject`, `RequestMoveToObserver`, `RequestSetScenario`, `RequestSetGameMods/Options`, `RequestResetGameOptions`, `RequestSetSlotsPinned`, `RequestSetSlotLocked`, `RequestApplyBalance` (applies a previewed balance), `RequestReopenClosedSlots`, `RequestLaunch`, `RequestSaveSetupPreset`/`RequestLoadSetupPreset` — all keyed by slot/bool/file/name so a chat command can call them too; permission is gated separately), sharing the stored CPU benchmark. **Launch:** `RequestLaunch` (host-only, readiness-validated) → auto-saves the `lastGame` preset (`BuildSetupSnapshot`) → `BuildGameConfiguration` (seed option defaults + scenario, resolve random factions, assign army numbers + push to server, stamp ratings/clan tags, resolve sim mods via `Mods.GetGameMods`) → broadcast `LaunchGame` + `instance:LaunchGame`; clients run `ProcessLaunchGame`. **Presets:** `BuildSetupSnapshot` (read the launch state → serializable **setup-only** snapshot: scenario / options / mods / restrictions) + `ApplySetup` (host-only: write scenario/mods/restrictions, reconcile options to the current schema, one broadcast); players, observers and teams/spawn-mex are not stored (see [presetselect/](presetselect/CLAUDE.md)). | | [CustomLobbyRules.lua](CustomLobbyRules.lua) | the game-rule **pure kernel** (not view, not networking, **reads no models**): `RecommendedUnitCap(seatedCount, maxDimension)` (per-player cap by map size), `AutoTeamMode(gameOptions)` / `SideLabels(mode)` / `BuildSideResolver(mode, scenario)` (binary auto-team split). Every input is passed in, so the same functions serve the reactive [slots derived model](models/derived/CustomLobbySlotsDerivedModel.lua) (which applies them to publish `Side` / `Teams`) and, later, the host's launch-time team assignment — neither path makes this module read state. | +| [CustomLobbyBalancer.lua](CustomLobbyBalancer.lua) | the auto-balance **pure kernel** (**reads no models** — a snapshot is passed in, like `CustomLobbyRules`): `ComputeBalance(input)` proposes a balanced two-team re-seating. A port of the legacy `PenguinAutoBalance` onto the models — split players into two sides (positional modes place players into the map's side seats; manual / none modes reassign `Team` toward **even** sizes, keeping each player in its seat — the current team split doesn't constrain the result, so a lopsided 3-vs-1 rebalances to 2-vs-2), **hold locked seats in place** (`LockedSlots`; only the unlocked players are redistributed, an odd roster leaves the lowest-rated unlocked player put), combination-search the cheap rating-imbalance heuristic, **mirrored-pair shuffle** (positional only) so equivalent seats move together, and score the result once with `trueskill.computeQuality` for the preview. Returns the full target board (`arrangement` slot → `{ OwnerId, Team }`) + the two sides + quality; the controller's `RequestApplyBalance` applies it, the preview renders it. | | [CustomLobbyPresets.lua](CustomLobbyPresets.lua) | **named setup presets** (setup-only: scenario / options / mods / restrictions — no players/observers) — pure prefs CRUD (`GetPresets`/`GetPreset`/`SavePreset`/`DeletePreset`/`RenamePreset`) over one key (`customlobby_setup_presets`), an ordered `{ Name, Setup }` array mirroring the mod presets in [`/lua/ui/modutilities.lua`](/lua/ui/modutilities.lua). Holds no models, no network. A reserved `LastGamePresetName` entry is auto-saved at launch (the rehost source). Capturing/applying a setup lives in the controller (`BuildSetupSnapshot`/`ApplySetup`); see [presetselect/](presetselect/CLAUDE.md). | | [CustomLobbySession.lua](CustomLobbySession.lua) | the lobby session's **main trash bag**. The lobby lives in the persistent front-end Lua state, which is *not* reset when the game launches in its own state — so anything left reachable (a running thread, a cache, a singleton) leaks for the whole match. This module owns one session-lifetime `TrashBag` (`GetTrash()`); every lobby-scoped `Destroyable` registers in it, and one `Teardown()` (clean-slate in `CreateLobby`, on leave, on `OnGameLaunched`) frees the lot. Works despite the bag being weak-valued because each singleton is pinned by its own module `Instance` local. **Rollout in progress** — converted: the map catalog, all five [derived models](models/derived/CLAUDE.md), and the three authoritative [models/](models/) (Launch/Session/Local, with thin `Destroy` per the design's decision #3). Still to do: the mod catalog, the interface + performance popover (pending the teardown-ordering decision), and the lobby instance. See [design/session-trashbag-teardown.md](design/session-trashbag-teardown.md). | -| [CustomLobbyMessages.lua](CustomLobbyMessages.lua) | message registry: `AddPlayer`, `SetPlayers` (launch model: players + observers), `SentLaunchInfo` (launch model: scenario / options / mods / teams / spawn mex), `SetSessionState` (session model: slot count / closed slots), `SetReady`, `TakeSlot`, `DisconnectPeer`, `LaunchGame` (host's final game config → `instance:LaunchGame`), `ReportCpuBenchmark`, `SetCpuBenchmarks`. | +| [CustomLobbyMessages.lua](CustomLobbyMessages.lua) | message registry: `AddPlayer`, `SetPlayers` (launch model: players + observers), `SentLaunchInfo` (launch model: scenario / options / mods / teams / spawn mex), `SetSessionState` (session model: slot count / closed slots / locked slots / pinned), `SetReady`, `TakeSlot`, `DisconnectPeer`, `LaunchGame` (host's final game config → `instance:LaunchGame`), `ReportCpuBenchmark`, `SetCpuBenchmarks`. | +| [CustomLobbyBalancePreview.lua](CustomLobbyBalancePreview.lua) | the auto-balance **preview** (host-only `Popup`): the slots header's balance button opens it instead of re-seating directly. Reads the current snapshot, runs `CustomLobbyBalancer.ComputeBalance`, shows the proposed two sides (names + ratings) + match quality (and any odd one left in place / why it can't balance), with **Apply** (→ `RequestApplyBalance`) / **Cancel** (nothing mutated). A transient picker, owns no synced state. | | [CustomLobbyContextMenu.lua](CustomLobbyContextMenu.lua) | generic framed floating menu; `Show(entries, x, y)` renders any `{label, action, enabled}` list, dismisses on item click / click-outside / Esc. Knows nothing about the lobby. | | [CustomLobbyMenus.lua](CustomLobbyMenus.lua) | declarative menu **definitions**: entry lists with `when(ctx)`/`action(ctx)` filtered by lobby state (`BuildSlotMenu`). Adding/state-gating an item is a one-liner here. | -| [slots/](slots/) | the **slot subsystem** (its own folder, a sub-folder per layout). [`CustomLobbySlotsInterface`](slots/CustomLobbySlotsInterface.lua) is the entry the composition root mounts: a "Players" header over the **active layout body**, picked by the AutoTeams mode — one-column for the non-team modes, the two-column team layout for the binary modes (left/right, top/bottom, even/odd). It observes `GameOptions` and swaps the body when the kind flips (create-on-mode / destroy-on-switch); a change *within* the binary modes is handled by the two-column body's own re-layout. It is the rows' **drag coordinator** (`UICustomLobbySlotCoordinator`: hit-test which row a point is in, drop-highlight, drag ghost → `RequestSwapSlots`) for *every* layout — it alone needs to hit-test across rows — so the layout bodies stay pure build/place/reveal and never duplicate the drag logic; it also exposes `PreferredHeight()` (computed from the mode + model, swap-order-independent) so the root sizes the slot area. The header band carries a **host-only tool strip** (right-aligned: pin seating · auto-balance · reopen closed slots) of small icon buttons (local `SlotTool`, mirroring the config column's `PreviewTool`); the pin button is lit from the synced `SlotsPinned` and the strip is hidden for clients. A **"Locked" notice** (lock glyph + label) sits right of the "Players" label and is shown to **everyone** while `SlotsPinned` is on, so a client can see seating is host-controlled before clicking an open slot to no effect. [`CustomLobbySlotBase`](slots/CustomLobbySlotBase.lua) holds **all slot behaviour** (one subscription to the [slots derived model](models/derived/CustomLobbySlotsDerivedModel.lua) — which already merged this seat's player / placement / closed / CPU benchmark and did the faction/CPU formatting — click/right-click/drag-to-swap + intents, the background/highlight/click overlays) and the default `RenderPlayer`/`RenderCpu` that paint the standard named controls from the entry's resolved views; a presentation subclasses it and implements just `CreateContents` / `LayoutContents`. [`onecolumn/`](slots/onecolumn/): [`CustomLobbyOneColumnSlots`](slots/onecolumn/CustomLobbyOneColumnSlots.lua) (stacks thin rows, reveals 1..count) + [`CustomLobbySlotRow`](slots/onecolumn/CustomLobbySlotRow.lua) (thin one-line presentation). [`twocolumn/`](slots/twocolumn/): [`CustomLobbyTwoColumnSlots`](slots/twocolumn/CustomLobbyTwoColumnSlots.lua) (splits slots into two team columns by each seat's `Side` from the [slots derived model](models/derived/CustomLobbySlotsDerivedModel.lua) — no longer re-resolving the split itself — with a [`CustomLobbyTeamScore`](CustomLobbyTeamScore.lua) strip across the top as the Left/Right indicator, and the "two columns, unresolved" fallback — parity fill + the score self-hides until a positional map's start positions load) + [`CustomLobbySlotCard`](slots/twocolumn/CustomLobbySlotCard.lua) (fat half-width / two-line presentation; `SetMirrored` flips the right column's cards so the two teams face each other). Each body exports `HeightForCount`. | +| [slots/](slots/) | the **slot subsystem** (its own folder, a sub-folder per layout). [`CustomLobbySlotsInterface`](slots/CustomLobbySlotsInterface.lua) is the entry the composition root mounts: a "Players" header over the **active layout body**, picked by the AutoTeams mode — one-column for the non-team modes, the two-column team layout for the binary modes (left/right, top/bottom, even/odd). It observes `GameOptions` and swaps the body when the kind flips (create-on-mode / destroy-on-switch); a change *within* the binary modes is handled by the two-column body's own re-layout. It is the rows' **drag coordinator** (`UICustomLobbySlotCoordinator`: hit-test which row a point is in, drop-highlight, drag ghost → `RequestSwapSlots`) for *every* layout — it alone needs to hit-test across rows — so the layout bodies stay pure build/place/reveal and never duplicate the drag logic; it also exposes `PreferredHeight()` (computed from the mode + model, swap-order-independent) so the root sizes the slot area. The header band carries a **host-only tool strip** (right-aligned: pin seating · auto-balance · reopen closed slots) of small icon buttons (local `SlotTool`, mirroring the config column's `PreviewTool`); the pin button is lit from the synced `SlotsPinned` and the strip is hidden for clients. The **auto-balance** button is gated (`SlotTool:SetEnabled`, greyed when off) to exactly two teams — a binary AutoTeams mode (map loaded for the positional ones) or at most two teams in use in manual seating — and opens the [balance preview](CustomLobbyBalancePreview.lua) rather than re-seating on click. A **"Locked" notice** (lock glyph + label) sits right of the "Players" label and is shown to **everyone** while `SlotsPinned` is on, so a client can see seating is host-controlled before clicking an open slot to no effect. [`CustomLobbySlotBase`](slots/CustomLobbySlotBase.lua) holds **all slot behaviour** (one subscription to the [slots derived model](models/derived/CustomLobbySlotsDerivedModel.lua) — which already merged this seat's player / placement / closed / CPU benchmark and did the faction/CPU formatting — click/right-click/drag-to-swap + intents, the background/highlight/click overlays + a gold left-edge **lock stripe** shown when the seat's `Locked` flag is set) and the default `RenderPlayer`/`RenderCpu` that paint the standard named controls from the entry's resolved views; a presentation subclasses it and implements just `CreateContents` / `LayoutContents`. [`onecolumn/`](slots/onecolumn/): [`CustomLobbyOneColumnSlots`](slots/onecolumn/CustomLobbyOneColumnSlots.lua) (stacks thin rows, reveals 1..count) + [`CustomLobbySlotRow`](slots/onecolumn/CustomLobbySlotRow.lua) (thin one-line presentation). [`twocolumn/`](slots/twocolumn/): [`CustomLobbyTwoColumnSlots`](slots/twocolumn/CustomLobbyTwoColumnSlots.lua) (splits slots into two team columns by each seat's `Side` from the [slots derived model](models/derived/CustomLobbySlotsDerivedModel.lua) — no longer re-resolving the split itself — with a [`CustomLobbyTeamScore`](CustomLobbyTeamScore.lua) strip across the top as the Left/Right indicator, and the "two columns, unresolved" fallback — parity fill + the score self-hides until a positional map's start positions load) + [`CustomLobbySlotCard`](slots/twocolumn/CustomLobbySlotCard.lua) (fat half-width / two-line presentation; `SetMirrored` flips the right column's cards so the two teams face each other). Each body exports `HeightForCount`. | | [CustomLobbyObserversInterface.lua](CustomLobbyObserversInterface.lua) | observer strip; subscribes to the model's `Observers` list and shows the count + names (read-only). | | [CustomLobbyScenarioPreview.lua](CustomLobbyScenarioPreview.lua) | **shared** map-preview *surface*: the scenario's map texture + overlays (start spots, resource/wreck markers, plus a **dummy translucent `WaterMask`** placeholder until a real mask exists) with aspect-correct positioning, texture-leak-safe icon sharing, per-group visibility (`SetOverlayVisible('spawns'\|'resources'\|'wrecks'\|'water', …)`), and three-phase init. A **pure renderer**: `SetScenario(info, markers)` takes already-extracted markers (`UICustomLobbyScenarioMarkers`) and never touches the raw save. Chrome-free; `CustomLobbyMapPreview` wraps it with the frame. Spawn appearance is the owner's via a `CreateSpawnIcon` factory. | | [CustomLobbyMapPreview.lua](CustomLobbyMapPreview.lua) | the map preview **as one whole** — the chrome (glow border on top + dark backdrop, surface inset by `Padding`), the surface, and the faction spawn icon (local `MapPreviewSpawn`). Used by **both** consumers: created `Bound = true` it subscribes to [`CustomLobbyScenarioDerivedModel`](models/derived/CustomLobbyScenarioDerivedModel.lua) and renders the resolved scenario with per-slot faction spawns (no reload on take/swap, and no reload on a same-map rebroadcast — the model dedups); created unbound (the map-select dialog) it does no model wiring and the owner drives `preview.Surface` directly (numbered-dot spawns), feeding it markers from the catalog's `LoadMarkers`. Exposes `.Surface` for owners to drive / anchor overlays to. | diff --git a/lua/ui/lobby/customlobby/CustomLobbyBalancePreview.lua b/lua/ui/lobby/customlobby/CustomLobbyBalancePreview.lua new file mode 100644 index 00000000000..1f0344db1ac --- /dev/null +++ b/lua/ui/lobby/customlobby/CustomLobbyBalancePreview.lua @@ -0,0 +1,344 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- The auto-balance **preview**: a host-only modal that shows the proposed two-team split (and the +-- resulting match quality) BEFORE anything is re-seated, with Apply / Cancel — so the host commits a +-- balance on purpose (USER_STORIES § R: "an opti team preview"). It is a transient picker, owns no +-- synced state: it reads the current lobby snapshot, runs the pure CustomLobbyBalancer kernel, and on +-- Apply hands the proposed arrangement to the host-authoritative `RequestApplyBalance` intent. +-- +-- Built to the preset dialog's shape (areas layout, three-phase init, Popup singleton). + +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 +local Popup = import("/lua/ui/controls/popups/popup.lua").Popup +local ItemList = import("/lua/maui/itemlist.lua").ItemList + +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/models/customlobbylaunchmodel.lua") +local CustomLobbySessionModel = import("/lua/ui/lobby/customlobby/models/customlobbysessionmodel.lua") +local CustomLobbyScenarioDerivedModel = import("/lua/ui/lobby/customlobby/models/derived/customlobbyscenarioderivedmodel.lua") +local CustomLobbyRules = import("/lua/ui/lobby/customlobby/customlobbyrules.lua") +local CustomLobbyBalancer = import("/lua/ui/lobby/customlobby/customlobbybalancer.lua") +local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") + +local Layouter = LayoutHelpers.ReusedLayoutFor + +local Debug = false + +local DialogWidth = 520 +local DialogHeight = 420 +local Pad = 12 +local ColumnGap = 16 +local TitleHeight = 30 +local StatusHeight = 24 +local ActionHeight = 52 +local ScrollbarInset = 20 +local ButtonGap = 8 + +local LabelColor = 'ffc8ccd0' +local HeaderColor = 'ffd9c97a' + +--- Creates an invisible layout area (tinted while Debug is on). +---@param parent Control +---@param name string +---@param color string +---@return Group +local function CreateArea(parent, name, color) + local area = Group(parent, name) + local bg = Bitmap(area) + bg:SetSolidColor(color) + bg:SetAlpha(Debug and 0.18 or 0.0) + bg:DisableHitTest() + Layouter(bg):Fill(area):End() + return area +end + +--- Gathers the current lobby snapshot and runs the balancer. Reads the models (a transient picker +--- may); the kernel itself stays pure. +---@return UICustomLobbyBalanceResult +local function ComputeForCurrentLobby() + local launch = CustomLobbyLaunchModel.GetSingleton() + local session = CustomLobbySessionModel.GetSingleton() + + local players = {} + for slot = 1, CustomLobbyLaunchModel.MaxSlots do + local player = launch.Players[slot]() + if player then + players[slot] = player + end + end + + local mode = CustomLobbyRules.AutoTeamMode(launch.GameOptions()) + local resolver, resolved = CustomLobbyRules.BuildSideResolver(mode, CustomLobbyScenarioDerivedModel.GetScenario()) + + return CustomLobbyBalancer.ComputeBalance({ + players = players, + lockedSlots = session.LockedSlots(), + slotCount = session.SlotCount(), + closedSlots = session.ClosedSlots(), + sideResolver = resolver, + resolved = resolved, + labels = CustomLobbyRules.SideLabels(mode) or { "Team 1", "Team 2" }, + }) +end + +--- One player's row in a team column: "name · rating" (rating omitted for AI / unrated). +---@param player UICustomLobbyPlayer +---@return string +local function PlayerRow(player) + local name = player.PlayerName or "?" + if player.PL then + return name .. " · " .. tostring(player.PL) + end + return name +end + +---@class UICustomLobbyBalancePreview : Group +---@field Trash TrashBag +---@field OnCloseCb fun() +---@field Result UICustomLobbyBalanceResult +---@field TitleArea Group +---@field ColumnAArea Group +---@field ColumnBArea Group +---@field StatusArea Group +---@field ActionArea Group +---@field Title Text +---@field HeaderA Text +---@field HeaderB Text +---@field ListA ItemList +---@field ListB ItemList +---@field Status Text +---@field ApplyButton Button +---@field CancelButton Button +---@field Ready boolean +local CustomLobbyBalancePreview = ClassUI(Group) { + + ---@param self UICustomLobbyBalancePreview + ---@param parent Control + ---@param options { onClose: fun() } + __init = function(self, parent, options) + Group.__init(self, parent, "CustomLobbyBalancePreview") + + self.Trash = TrashBag() + self.OnCloseCb = options.onClose + self.Ready = false + + -- compute the proposal up front (pure read of the current snapshot; no layout involved) + self.Result = ComputeForCurrentLobby() + + self.TitleArea = CreateArea(self, "TitleArea", 'ffcc4040') + self.ColumnAArea = CreateArea(self, "ColumnAArea", 'ff4060cc') + self.ColumnBArea = CreateArea(self, "ColumnBArea", 'ff40cc60') + self.StatusArea = CreateArea(self, "StatusArea", 'ffcc40cc') + self.ActionArea = CreateArea(self, "ActionArea", 'ff808080') + + self.Title = UIUtil.CreateText(self.TitleArea, "Balance preview", 22, UIUtil.titleFont) + self.Title:DisableHitTest() + + local labels = self.Result.labels or { "Team 1", "Team 2" } + self.HeaderA = UIUtil.CreateText(self.ColumnAArea, labels[1], 14, UIUtil.titleFont) + self.HeaderA:SetColor(HeaderColor) + self.HeaderA:DisableHitTest() + self.HeaderB = UIUtil.CreateText(self.ColumnBArea, labels[2], 14, UIUtil.titleFont) + self.HeaderB:SetColor(HeaderColor) + self.HeaderB:DisableHitTest() + + self.ListA = ItemList(self.ColumnAArea) + self.ListA:SetFont(UIUtil.bodyFont, 14) + self.ListA:SetColors(LabelColor, "00000000") + self.ListA:DisableHitTest() + self.ListB = ItemList(self.ColumnBArea) + self.ListB:SetFont(UIUtil.bodyFont, 14) + self.ListB:SetColors(LabelColor, "00000000") + self.ListB:DisableHitTest() + + self.Status = UIUtil.CreateText(self.StatusArea, "", 14, UIUtil.bodyFont) + self.Status:SetColor(LabelColor) + self.Status:DisableHitTest() + + self.ApplyButton = UIUtil.CreateButtonWithDropshadow(self.ActionArea, '/BUTTON/medium/', "Apply") + self.ApplyButton.OnClick = function() + self:ApplyAndClose() + end + + self.CancelButton = UIUtil.CreateButtonWithDropshadow(self.ActionArea, '/BUTTON/medium/', "Cancel") + self.CancelButton.OnClick = function() + self.OnCloseCb() + end + end, + + ---@param self UICustomLobbyBalancePreview + __post_init = function(self) + self.Width:Set(LayoutHelpers.ScaleNumber(DialogWidth)) + self.Height:Set(LayoutHelpers.ScaleNumber(DialogHeight)) + + Layouter(self.TitleArea):AtLeftIn(self, Pad):AtRightIn(self, Pad):AtTopIn(self, Pad):Height(TitleHeight):End() + Layouter(self.ActionArea):AtLeftIn(self, Pad):AtRightIn(self, Pad):AtBottomIn(self, Pad):Height(ActionHeight):End() + Layouter(self.StatusArea):AtLeftIn(self, Pad):AtRightIn(self, Pad):AnchorToTop(self.ActionArea, Pad):Height(StatusHeight):End() + + -- two equal columns between the title and the status line + local columnWidth = function() return (self.Width() - LayoutHelpers.ScaleNumber(2 * Pad + ColumnGap)) / 2 end + Layouter(self.ColumnAArea) + :AtLeftIn(self, Pad):Width(columnWidth) + :AnchorToBottom(self.TitleArea, Pad):AnchorToTop(self.StatusArea, Pad) + :End() + Layouter(self.ColumnBArea) + :AtRightIn(self, Pad):Width(columnWidth) + :AnchorToBottom(self.TitleArea, Pad):AnchorToTop(self.StatusArea, Pad) + :End() + + Layouter(self.Title):AtHorizontalCenterIn(self.TitleArea):AtVerticalCenterIn(self.TitleArea):End() + + Layouter(self.HeaderA):AtLeftIn(self.ColumnAArea):AtTopIn(self.ColumnAArea):End() + Layouter(self.HeaderB):AtLeftIn(self.ColumnBArea):AtTopIn(self.ColumnBArea):End() + + Layouter(self.ListA):AtLeftIn(self.ColumnAArea):AnchorToBottom(self.HeaderA, 4):AtBottomIn(self.ColumnAArea):End() + self.ListA.Right:Set(function() return self.ColumnAArea.Right() - LayoutHelpers.ScaleNumber(ScrollbarInset) end) + Layouter(self.ListB):AtLeftIn(self.ColumnBArea):AnchorToBottom(self.HeaderB, 4):AtBottomIn(self.ColumnBArea):End() + self.ListB.Right:Set(function() return self.ColumnBArea.Right() - LayoutHelpers.ScaleNumber(ScrollbarInset) end) + + Layouter(self.Status):AtLeftIn(self.StatusArea):AtVerticalCenterIn(self.StatusArea):End() + + Layouter(self.CancelButton):AtRightIn(self.ActionArea):AtVerticalCenterIn(self.ActionArea):End() + Layouter(self.ApplyButton):AnchorToLeft(self.CancelButton, ButtonGap):AtVerticalCenterIn(self.ActionArea):End() + end, + + --- Post-mount render (the opener calls this after Popup centres the dialog, so the lists read + --- concrete geometry). + ---@param self UICustomLobbyBalancePreview + Initialize = function(self) + self.Ready = true + self:Render() + end, + + --- Paints the two columns + status line from the computed proposal, and enables Apply only when + --- there is something to apply. + ---@param self UICustomLobbyBalancePreview + Render = function(self) + local result = self.Result + local labels = result.labels or { "Team 1", "Team 2" } + + self.HeaderA:SetText(labels[1] .. " " .. tostring(result.totals[1])) + self.HeaderB:SetText(labels[2] .. " " .. tostring(result.totals[2])) + + self.ListA:DeleteAllItems() + for _, player in ipairs(result.sides[1]) do + self.ListA:AddItem(PlayerRow(player)) + end + self.ListB:DeleteAllItems() + for _, player in ipairs(result.sides[2]) do + self.ListB:AddItem(PlayerRow(player)) + end + + -- status line: the reason it can't balance, else the match quality (+ any odd one left out) + local status + if result.reason then + status = result.reason + elseif result.quality then + status = "Match quality: " .. tostring(result.quality) .. "%" + else + status = "Match quality: —" + end + if result.unassigned then + status = status .. " · " .. (result.unassigned.PlayerName or "?") .. " stays put (odd count)" + end + self.Status:SetText(status) + + if result.feasible then + self.ApplyButton:Enable() + else + self.ApplyButton:Disable() + end + end, + + --- Commits the proposed arrangement (host-authoritative) and closes. + ---@param self UICustomLobbyBalancePreview + ApplyAndClose = function(self) + if self.Result.feasible then + CustomLobbyController.RequestApplyBalance(self.Result.arrangement) + end + self.OnCloseCb() + end, + + ---@param self UICustomLobbyBalancePreview + OnDestroy = function(self) + self.Trash:Destroy() + end, +} + +------------------------------------------------------------------------------- +--#region Singleton + open / close + +---@type Popup | false +local Instance = false + +--- Opens the balance preview over `parent` (host-only entry point — the slots header's balance button). +---@param parent? Control +function Open(parent) + parent = parent or GetFrame(0) + + if Instance then + Instance:Close() + end + + local popup + local content = CustomLobbyBalancePreview(parent, { + onClose = function() + if popup then + popup:Close() + end + end, + }) + + popup = Popup(parent, content) + local baseOnClosed = popup.OnClosed + popup.OnClosed = function(self) + baseOnClosed(self) + Instance = false + end + Instance = popup + + -- Popup has mounted + centred the content; now it's safe to populate the lists + content:Initialize() +end + +--- Closes the dialog if open. +function Close() + if Instance then + Instance:Close() + Instance = false + end +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Debugging + +function __moduleinfo.OnDirty() + Close() +end + +--#endregion diff --git a/lua/ui/lobby/customlobby/CustomLobbyBalancer.lua b/lua/ui/lobby/customlobby/CustomLobbyBalancer.lua new file mode 100644 index 00000000000..efa7360f5e9 --- /dev/null +++ b/lua/ui/lobby/customlobby/CustomLobbyBalancer.lua @@ -0,0 +1,356 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- The auto-balance **kernel**: a pure function that proposes a balanced re-seating of the lobby. It +-- reads no models — the caller passes a snapshot in — so it can be reasoned about and tested in +-- isolation, like CustomLobbyRules (the side rule it reuses lives there). The controller applies the +-- result host-authoritatively (CustomLobbyController.RequestApplyBalance); the preview renders it +-- before the host commits (CustomLobbySlotsInterface / CustomLobbyBalancePreview). +-- +-- What it does (a port of the legacy PenguinAutoBalance, lobby-old.lua, onto the MVC models): +-- 1. Split the players into two sides. Positional modes assign players to the side's seats (from +-- the passed `sideResolver`, built from CustomLobbyRules.BuildSideResolver) — side sizes are +-- fixed by the map. Manual / none modes reassign Team freely toward EVEN sizes (the current team +-- split doesn't constrain the result), keeping each player in its seat. +-- 2. Hold LOCKED players in their exact seat (they still count toward their side's totals); only +-- the unlocked players are redistributed. An odd roster leaves the lowest-rated unlocked player +-- in place (never ejected) so the rest pair up. +-- 3. Search player→side combinations minimising a cheap rating-imbalance heuristic +-- (|Σdev−goal|·1.2 + |Σmean−goal|, as the legacy did — trueskill is too costly per combination). +-- 4. (Positional only) shuffle the seat assignment as mirrored pairs so equivalent positions across +-- the two sides move together. (Manual mode doesn't move seats, so it doesn't shuffle.) +-- 5. Score the chosen split ONCE with trueskill `computeQuality` for the preview (— if AI/unrated). +-- +-- Ratings: the search + quality use MEAN / DEV; the per-side display totals use PL (matching the +-- team-score strip). Team numbering is the model's backend form throughout (1 = no team, 2 = team 1, +-- 3 = team 2); only the display translates. + +local Trueskill = import("/lua/ui/lobby/trueskill.lua") + +-- safety cap on the combination search (C(16,8) = 12870 worst case, comfortably under this) +local MaxBalanceEvaluations = 20000 + +-- side index (1/2) -> model backend Team number +local TeamForSide = { [1] = 2, [2] = 3 } + +---@param player UICustomLobbyPlayer +---@return number +local function MeanOf(player) + return player.MEAN or 1500 +end + +---@param player UICustomLobbyPlayer +---@return number +local function DevOf(player) + return player.DEV or 500 +end + +--- The input snapshot for a balance computation. All data is passed in — the kernel reads no models. +---@class UICustomLobbyBalanceInput +---@field players table # occupied slots only (slot -> player) +---@field lockedSlots table # seats whose player is pinned in place +---@field slotCount number # active slots on the current map +---@field closedSlots table +---@field sideResolver (fun(startSpot: number): number | nil) | nil # positional split; nil = manual +---@field resolved boolean # positional map loaded (false hides positional) +---@field labels string[] | nil # the two side labels, for the preview + +--- One seat in a proposed arrangement. +---@class UICustomLobbyBalanceSeat +---@field OwnerId UILobbyPeerId +---@field Team number | nil # set only in manual mode (positional teams resolve from position at launch) + +--- The proposed balance. +---@class UICustomLobbyBalanceResult +---@field feasible boolean # false = nothing to apply (Apply disabled) +---@field reason string | nil # why it can't / didn't balance, for the preview +---@field arrangement table # the full target board (slot -> seat) +---@field sides UICustomLobbyPlayer[][] # the two proposed sides, for the preview +---@field totals number[] # per-side PL totals, for the preview +---@field labels string[] | nil +---@field unassigned UICustomLobbyPlayer | false # the odd one left in place, if any +---@field quality number | false # trueskill match quality %, or false + +--- Scores a proposed two-side split with trueskill match quality. Returns false for AI / unrated +--- players (MEAN 0) or a degenerate matrix — the preview then shows "—". +---@param sides UICustomLobbyPlayer[][] +---@return number | false +local function ComputeQuality(sides) + local teams = Trueskill.Teams.create() + for side = 1, 2 do + for _, player in ipairs(sides[side]) do + if not player.MEAN or player.MEAN == 0 then + return false + end + teams:addPlayer(side, Trueskill.Player.create( + player.PlayerName or "?", Trueskill.Rating.create(player.MEAN, player.DEV or 0))) + end + end + if table.getn(teams:getTeams()) ~= 2 then + return false + end + + local quality = Trueskill.computeQuality(teams) + if not quality or quality <= 0 then + return false + end + return quality +end + +--- Computes a proposed balanced re-seating. Pure — see UICustomLobbyBalanceInput. Always returns a +--- result; `feasible` is false (with a `reason`) when there is nothing to apply. +---@param input UICustomLobbyBalanceInput +---@return UICustomLobbyBalanceResult +function ComputeBalance(input) + ---@type UICustomLobbyBalanceResult + local result = { + feasible = false, + reason = nil, + arrangement = {}, + sides = { {}, {} }, + totals = { 0, 0 }, + labels = input.labels, + unassigned = false, + quality = false, + } + + -- a positional split needs a loaded map; without it there are no sides to balance into + if input.sideResolver and not input.resolved then + result.reason = "Pick a map first — the team sides aren't determined yet." + return result + end + + -- total roster (locked + free) drives the balance goal and the odd-one-out rule + local totalMean, totalDev, occupiedCount = 0, 0, 0 + for slot = 1, input.slotCount do + local player = input.players[slot] + if player then + totalMean = totalMean + MeanOf(player) + totalDev = totalDev + DevOf(player) + occupiedCount = occupiedCount + 1 + end + end + if occupiedCount < 2 then + result.reason = "Need at least two players to balance." + return result + end + + -- odd roster: leave the lowest-rated UNLOCKED player in place (never ejected) so the rest pair + -- up. Treat its seat as pinned for the rest of the computation. + local effectiveLocked = table.copy(input.lockedSlots) + if math.mod(occupiedCount, 2) == 1 then + local oddSlot, oddRating + for slot = 1, input.slotCount do + local player = input.players[slot] + if player and not input.lockedSlots[slot] then + local rating = MeanOf(player) - DevOf(player) * 2.2 + if not oddRating or rating < oddRating then + oddRating = rating + oddSlot = slot + end + end + end + if oddSlot then + effectiveLocked[oddSlot] = true + result.unassigned = input.players[oddSlot] + end + end + + local manual = input.sideResolver == nil + + -- A locked (or odd-one-out) player's side. Positional: its seat's resolved side. Manual: its + -- current Team (2 -> side 1, 3 -> side 2); nil when on no team. + local function lockedNaturalSide(slot) + if manual then + local team = input.players[slot].Team + if team == 2 then return 1 elseif team == 3 then return 2 end + return nil + end + return input.sideResolver(slot) + end + + -- Pin the locked / odd-one-out players to a side (never moved); the rest are the free pool the + -- search splits. A locked manual player with no team is dropped onto the lighter side. + local lockedRecords = { {}, {} } -- pinned player records per side + local lockedSeats = { {}, {} } -- their seats (kept as-is) + local freeUnits = {} -- { player =, slot = } for the movable players + for slot = 1, input.slotCount do + local player = input.players[slot] + if player then + if effectiveLocked[slot] then + local side = lockedNaturalSide(slot) + or ((table.getn(lockedRecords[1]) <= table.getn(lockedRecords[2])) and 1 or 2) + table.insert(lockedRecords[side], player) + table.insert(lockedSeats[side], slot) + else + table.insert(freeUnits, { player = player, slot = slot }) + end + end + end + local freeCount = table.getn(freeUnits) + + -- Free seats available per side. Manual reassigns teams (not seats), so capacity is flexible and + -- the target is simply even sizes. Positional places players into the side's open, non-locked + -- seats (including empty ones), so capacity is that seat count — a real map property. + local freeSeats = { {}, {} } + local capA, capB + if manual then + capA, capB = freeCount, freeCount + else + for slot = 1, input.slotCount do + if not input.closedSlots[slot] and not (effectiveLocked[slot] and input.players[slot]) then + local side = input.sideResolver(slot) + if side == 1 or side == 2 then + table.insert(freeSeats[side], slot) + end + end + end + capA, capB = table.getn(freeSeats[1]), table.getn(freeSeats[2]) + end + + -- locked ratings still count toward each side's balance target + local lockedMean, lockedDev = { 0, 0 }, { 0, 0 } + for side = 1, 2 do + for _, player in ipairs(lockedRecords[side]) do + lockedMean[side] = lockedMean[side] + MeanOf(player) + lockedDev[side] = lockedDev[side] + DevOf(player) + end + end + + -- how many free players go to side A: aim for equal final side sizes, clamped to capacity. An + -- empty feasible range means the locks can't fit the team layout. + local lockedCountA = table.getn(lockedRecords[1]) + local lockedCountB = table.getn(lockedRecords[2]) + local placedA = math.floor((freeCount + lockedCountB - lockedCountA) / 2 + 0.5) + local minA = math.max(0, freeCount - capB) + local maxA = math.min(freeCount, capA) + if minA > maxA then + result.reason = "The locked players don't fit the team layout." + return result + end + if placedA < minA then placedA = minA end + if placedA > maxA then placedA = maxA end + + -- search: choose `placedA` of the free players for side A, minimising rating imbalance against + -- the half-totals (the cheap heuristic the legacy used — never trueskill, which is too costly here) + local goalMean = totalMean / 2 + local goalDev = totalDev / 2 + local best = { value = nil, chosen = nil } + local evaluations = 0 + local current = {} + + local function evaluate() + local meanA, devA = lockedMean[1], lockedDev[1] + for i = 1, table.getn(current) do + local player = freeUnits[current[i]].player + meanA = meanA + MeanOf(player) + devA = devA + DevOf(player) + end + local value = math.abs(devA - goalDev) * 1.2 + math.abs(meanA - goalMean) + if not best.value or value < best.value then + best.value = value + best.chosen = table.copy(current) + end + evaluations = evaluations + 1 + end + + local function choose(startIndex, remaining) + if evaluations >= MaxBalanceEvaluations then + return + end + if remaining == 0 then + evaluate() + return + end + for i = startIndex, freeCount - remaining + 1 do + table.insert(current, i) + choose(i + 1, remaining - 1) + table.remove(current) + if evaluations >= MaxBalanceEvaluations then + return + end + end + end + + choose(1, placedA) + + -- partition the free units into the two sides per the best split + local chosen = {} + if best.chosen then + for _, index in ipairs(best.chosen) do + chosen[index] = true + end + end + local freeA, freeB = {}, {} + for i = 1, freeCount do + table.insert(chosen[i] and freeA or freeB, freeUnits[i]) + end + + -- record one seat into the arrangement + the preview list + local function record(slot, player, side, team) + result.arrangement[slot] = { OwnerId = player.OwnerID, Team = team } + table.insert(result.sides[side], player) + end + + -- locked players keep their seat; in manual mode their Team is (re)stamped to their side + for side = 1, 2 do + for i = 1, table.getn(lockedRecords[side]) do + record(lockedSeats[side][i], lockedRecords[side][i], side, manual and TeamForSide[side] or nil) + end + end + + if manual then + -- teams are reassigned in place: each free player keeps its seat, its Team set to its side. + -- No positional shuffle — seats don't determine team here. + for _, unit in ipairs(freeA) do record(unit.slot, unit.player, 1, TeamForSide[1]) end + for _, unit in ipairs(freeB) do record(unit.slot, unit.player, 2, TeamForSide[2]) end + else + -- positional: place players into the side's free seats, with a mirrored-pair shuffle so + -- equivalent positions across the two sides move together; team follows the seat at launch + local playersA, playersB = {}, {} + for _, unit in ipairs(freeA) do table.insert(playersA, unit.player) end + for _, unit in ipairs(freeB) do table.insert(playersB, unit.player) end + local pairCount = math.min(table.getn(playersA), table.getn(playersB)) + for i = 1, pairCount do + local r = Random(i, pairCount) + playersA[i], playersA[r] = playersA[r], playersA[i] + playersB[i], playersB[r] = playersB[r], playersB[i] + end + for i = 1, table.getn(playersA) do record(freeSeats[1][i], playersA[i], 1, nil) end + for i = 1, table.getn(playersB) do record(freeSeats[2][i], playersB[i], 2, nil) end + end + + for side = 1, 2 do + local total = 0 + for _, player in ipairs(result.sides[side]) do + total = total + (player.PL or 0) + end + result.totals[side] = total + end + + result.quality = ComputeQuality(result.sides) + result.feasible = freeCount > 0 + if freeCount == 0 then + result.reason = "Every player is locked in place — nothing to rearrange." + end + return result +end diff --git a/lua/ui/lobby/customlobby/CustomLobbyController.lua b/lua/ui/lobby/customlobby/CustomLobbyController.lua index 535ab440422..748218f430c 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyController.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyController.lua @@ -125,6 +125,21 @@ local function IsOpenSlot(slot) return not CustomLobbyLaunchModel.GetSingleton().Players[slot]() and not session.ClosedSlots()[slot] end +--- Host-side: drops any auto-balance lock pinning `slot`. A lock belongs to the player seated there, +--- so it must not outlive them and pin whoever takes the seat next (see CustomLobbyBalancer). Does +--- NOT broadcast — returns whether a lock was actually cleared so the caller can fold a session-state +--- snapshot into its existing broadcast only when something changed. +---@param slot number +---@return boolean # true if a lock was cleared +local function ClearSlotLock(slot) + local session = CustomLobbySessionModel.GetSingleton() + if not session.LockedSlots()[slot] then + return false + end + CustomLobbySessionModel.SetLocked(session, slot, false) + return true +end + --- Host-side: moves the player owned by `ownerId` into an open `slot` (or seats it from --- the observer list), forcing it unready and keeping StartSpot mirrored to the seat. --- Broadcasts the new snapshot. A no-op if the move isn't valid — the host is the gate. @@ -145,6 +160,10 @@ local function TakeSlot(instance, ownerId, slot) end player = table.copy(launch.Players[from]()) CustomLobbyLaunchModel.ClearPlayer(launch, from) + -- the player relocated; the seat it left must not stay locked (the lock was theirs) + if ClearSlotLock(from) then + BroadcastSessionState(instance) + end else -- not in a slot: an observer joining a slot (the reverse of Move to observers) local observer = CustomLobbyLaunchModel.RemoveObserver(launch, ownerId) @@ -188,17 +207,25 @@ local function SwapSlots(instance, slotA, slotB) b.Ready = false end + -- a seat that ends up empty must shed its lock (the lock belonged to the player who left it), + -- so a later occupant isn't silently pinned + local lockChanged = false if b then CustomLobbyLaunchModel.SetPlayer(launch, slotA, b) else CustomLobbyLaunchModel.ClearPlayer(launch, slotA) + lockChanged = ClearSlotLock(slotA) or lockChanged end if a then CustomLobbyLaunchModel.SetPlayer(launch, slotB, a) else CustomLobbyLaunchModel.ClearPlayer(launch, slotB) + lockChanged = ClearSlotLock(slotB) or lockChanged end BroadcastPlayers(instance) + if lockChanged then + BroadcastSessionState(instance) + end end --- Reads a numeric command-line argument (e.g. `/mean 1500`), falling back to the default @@ -348,10 +375,15 @@ function OnPeerDisconnected(instance, peerName, uid) instance:BroadcastData({ Type = 'DisconnectPeer', PeerID = uid }) local slot = FindSlotForOwner(uid) + local lockChanged = false if slot then CustomLobbyLaunchModel.ClearPlayer(CustomLobbyLaunchModel.GetSingleton(), slot) + lockChanged = ClearSlotLock(slot) end BroadcastPlayers(instance) + if lockChanged then + BroadcastSessionState(instance) + end end --- Called when the game launches. The engine has taken over in its own Lua state, so the lobby's @@ -431,6 +463,7 @@ function ProcessSetSessionState(instance, data) local session = CustomLobbySessionModel.GetSingleton() session.SlotCount:Set(data.SlotCount or session.SlotCount()) session.ClosedSlots:Set(data.ClosedSlots or {}) + session.LockedSlots:Set(data.LockedSlots or {}) session.SlotsPinned:Set(data.SlotsPinned and true or false) end @@ -519,6 +552,7 @@ function BroadcastSessionState(instance) Type = 'SetSessionState', SlotCount = session.SlotCount(), ClosedSlots = session.ClosedSlots(), + LockedSlots = session.LockedSlots(), SlotsPinned = session.SlotsPinned(), }) end @@ -1021,12 +1055,34 @@ function RequestSetSlotsPinned(pinned) BroadcastSessionState(instance) end ---- The host asks the lobby to auto-balance the seated players across teams. Host-only — ---- backs the slots header's auto-balance button. ---- ---- TODO: implement the balancer (group seated players into even teams by rating, re-seat / ---- set Team via the launch model, then BroadcastPlayers). Stubbed for now. -function RequestAutoBalance() +--- The host locks or unlocks a single seat. Host-only — backs the slot context menu's "Lock in +--- slot" toggle. A locked seat's player is held in place by auto-balance (only the unlocked players +--- are rearranged); see CustomLobbyBalancer. Synced via the session-state snapshot. +---@param slot number +---@param locked boolean +function RequestSetSlotLocked(slot, locked) + local instance = LobbyInstance + if not instance then + return + end + + if not CustomLobbyLocalModel.GetSingleton().IsHost() then + WARN("CustomLobby: only the host can lock slots") + return + end + + CustomLobbySessionModel.SetLocked(CustomLobbySessionModel.GetSingleton(), slot, locked and true or false) + BroadcastSessionState(instance) +end + +--- The host applies a balanced re-seating, after confirming it in the preview. Host-only — backs +--- the auto-balance preview's Apply button. `arrangement` (slot -> { OwnerId, Team }) comes from +--- CustomLobbyBalancer.ComputeBalance; it only says *where* each player sits (and, in manual mode, +--- their team), never *what* they are, so it can't smuggle edited ratings/factions. The whole board +--- is rewritten from one snapshot — read by owner, moved to the target seat — then broadcast once +--- (re-seating via the model directly, not N pairwise swaps). +---@param arrangement table +function RequestApplyBalance(arrangement) local instance = LobbyInstance if not instance then return @@ -1036,8 +1092,57 @@ function RequestAutoBalance() WARN("CustomLobby: only the host can auto-balance") return end + if type(arrangement) ~= 'table' then + return + end + + local launch = CustomLobbyLaunchModel.GetSingleton() + + -- snapshot the seated players by owner: lets us move players between seats safely, and validate + -- the arrangement against who is actually seated *now* (a player may have left since the preview) + local byOwner = {} + local seatedCount = 0 + for slot = 1, CustomLobbyLaunchModel.MaxSlots do + local player = launch.Players[slot]() + if player then + byOwner[player.OwnerID] = player + seatedCount = seatedCount + 1 + end + end + + -- build the target board; bail if the arrangement references someone no longer seated or doesn't + -- account for exactly the seated players (a stale preview) + local newPlayers = {} + local placedCount = 0 + for slot, seat in arrangement do + local source = byOwner[seat.OwnerId] + if not source then + WARN("CustomLobby: balance arrangement references an unseated player; ignoring") + return + end + local player = table.copy(source) + player.StartSpot = slot + player.Ready = false -- (re)seating resets readiness, as a manual move does + if seat.Team then + player.Team = seat.Team + end + newPlayers[slot] = player + placedCount = placedCount + 1 + end + if placedCount ~= seatedCount then + WARN("CustomLobby: balance arrangement doesn't match the seated players; ignoring") + return + end - LOG("CustomLobby: RequestAutoBalance — not implemented yet") + -- write the new occupants and clear any seat that emptied, then broadcast once + for slot = 1, CustomLobbyLaunchModel.MaxSlots do + if newPlayers[slot] then + CustomLobbyLaunchModel.SetPlayer(launch, slot, newPlayers[slot]) + elseif launch.Players[slot]() then + CustomLobbyLaunchModel.ClearPlayer(launch, slot) + end + end + BroadcastPlayers(instance) end --- Re-broadcasts the closed slots, then after a short delay opens every one of them. The @@ -1103,10 +1208,14 @@ function RequestEject(slot) return end if player.Human then + -- the resulting PeerDisconnected clears the slot (and its lock) and re-broadcasts instance:EjectPeer(player.OwnerID, "KickedByHost") else CustomLobbyLaunchModel.ClearPlayer(CustomLobbyLaunchModel.GetSingleton(), slot) BroadcastPlayers(instance) + if ClearSlotLock(slot) then + BroadcastSessionState(instance) + end end end @@ -1133,6 +1242,10 @@ function RequestMoveToObserver(slot) CustomLobbyLaunchModel.AddObserver(launch, player) CustomLobbyLaunchModel.ClearPlayer(launch, slot) BroadcastPlayers(instance) + -- the seat is now empty; drop any lock so the next occupant isn't pinned + if ClearSlotLock(slot) then + BroadcastSessionState(instance) + end end --- The local player toggles their ready flag. Host applies + broadcasts; a client diff --git a/lua/ui/lobby/customlobby/CustomLobbyMenus.lua b/lua/ui/lobby/customlobby/CustomLobbyMenus.lua index 6f42e6cddbe..bec0cbd9a30 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyMenus.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyMenus.lua @@ -34,6 +34,7 @@ local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/models/customlobbylaunchmodel.lua") local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/models/customlobbylocalmodel.lua") +local CustomLobbySessionModel = import("/lua/ui/lobby/customlobby/models/customlobbysessionmodel.lua") local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") ------------------------------------------------------------------------------- @@ -47,6 +48,7 @@ local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontr ---@field isYou boolean ---@field isOpen boolean ---@field localIsObserver boolean # the local player is currently spectating (no slot) +---@field locked boolean # this seat is pinned in place for auto-balance --- A declarative slot-menu entry. ---@class UICustomLobbySlotMenuEntry @@ -81,6 +83,18 @@ local SlotMenu = { }, -- Host actions: + { + -- pin a seated player so auto-balance keeps them where they are (e.g. to hold a premade + -- pair on the same team); only the unlocked players are rearranged + label = "Lock in slot", + when = function(ctx) return ctx.isHost and ctx.player and not ctx.locked end, + action = function(ctx) CustomLobbyController.RequestSetSlotLocked(ctx.slot, true) end, + }, + { + label = "Unlock slot", + when = function(ctx) return ctx.isHost and ctx.player and ctx.locked end, + action = function(ctx) CustomLobbyController.RequestSetSlotLocked(ctx.slot, false) end, + }, { label = "Move to observers", when = function(ctx) return ctx.isHost and ctx.player and ctx.player.Human end, @@ -122,6 +136,7 @@ local function SlotContext(slot) isYou = (player and player.OwnerID == localId) and true or false, isOpen = not player, localIsObserver = IsObserver(launch, localId), + locked = CustomLobbySessionModel.GetSingleton().LockedSlots()[slot] and true or false, } end diff --git a/lua/ui/lobby/customlobby/CustomLobbyMessages.lua b/lua/ui/lobby/customlobby/CustomLobbyMessages.lua index b4910eecbe9..6e833eab972 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyMessages.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyMessages.lua @@ -139,11 +139,12 @@ CustomLobbyMessages = { }, -- The host's session state — lobby-room management that is NOT launched (slot count, - -- closed slots). A separate snapshot from the launch config so each stays focused. + -- closed slots, locked slots). A separate snapshot from the launch config so each stays focused. SetSessionState = { ---@class UICustomLobbySetSessionStateMessage : UILobbyReceivedMessage ---@field SlotCount number ---@field ClosedSlots table + ---@field LockedSlots table ---@field SlotsPinned boolean ---@param data UICustomLobbySetSessionStateMessage diff --git a/lua/ui/lobby/customlobby/models/CustomLobbySessionModel.lua b/lua/ui/lobby/customlobby/models/CustomLobbySessionModel.lua index 52091a90e7b..bd73582a1e9 100644 --- a/lua/ui/lobby/customlobby/models/CustomLobbySessionModel.lua +++ b/lua/ui/lobby/customlobby/models/CustomLobbySessionModel.lua @@ -52,6 +52,7 @@ local Instance = nil ---@class UICustomLobbySessionModel : Destroyable ---@field SlotCount LazyVar # player slots the current map supports ---@field ClosedSlots LazyVar> +---@field LockedSlots LazyVar> # host pinned a seat: its player is held in place by auto-balance ---@field SlotsPinned LazyVar # host locked seating: only the host may change slots ---@field Destroyed boolean local SessionModel = ClassSimple { @@ -61,6 +62,7 @@ local SessionModel = ClassSimple { __init = function(self, slotCount) self.SlotCount = Create(slotCount or 8) self.ClosedSlots = Create({}) + self.LockedSlots = Create({}) self.SlotsPinned = Create(false) self.Destroyed = false end, @@ -119,6 +121,17 @@ function SetClosed(model, slot, closed) model.ClosedSlots:Set(closedSlots) end +--- Sets the locked flag for a slot (copy-then-Set). A locked seat's player is held in place by +--- auto-balance — see CustomLobbyBalancer. +---@param model UICustomLobbySessionModel +---@param slot number +---@param locked boolean +function SetLocked(model, slot, locked) + local lockedSlots = table.copy(model.LockedSlots()) + lockedSlots[slot] = locked or nil + model.LockedSlots:Set(lockedSlots) +end + --- Sets whether seating is pinned (only the host may change slots while on). ---@param model UICustomLobbySessionModel ---@param pinned boolean @@ -139,6 +152,7 @@ function __moduleinfo.OnReload(newModule) if Instance then local handle = newModule.SetupSingleton(Instance.SlotCount()) handle.ClosedSlots:Set(Instance.ClosedSlots()) + handle.LockedSlots:Set(Instance.LockedSlots()) handle.SlotsPinned:Set(Instance.SlotsPinned()) end end diff --git a/lua/ui/lobby/customlobby/models/derived/CLAUDE.md b/lua/ui/lobby/customlobby/models/derived/CLAUDE.md index bf49075c610..a7aee210659 100644 --- a/lua/ui/lobby/customlobby/models/derived/CLAUDE.md +++ b/lua/ui/lobby/customlobby/models/derived/CLAUDE.md @@ -43,10 +43,10 @@ layer between the compact synced fields and the views. | File | Derives | From | Read by | |------|---------|------|---------| | [CustomLobbyScenarioDerivedModel.lua](CustomLobbyScenarioDerivedModel.lua) | the resolved `Scenario` (info for the texture + extracted save markers `Spawns`/`MassPoints`/`HydroPoints`/`Wrecks` + `MaxDimension`/`ArmyCount`/`Name`/`Size`/`Version`) | the launch model's `ScenarioFile` | the bound [`CustomLobbyMapPreview`](/lua/ui/lobby/customlobby/CustomLobbyMapPreview.lua), the [`config/CustomLobbyConfigInterface`](/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua) facts line, [`CustomLobbyRules`](/lua/ui/lobby/customlobby/CustomLobbyRules.lua) (map size + start spots) | -| [CustomLobbyOptionsDerivedModel.lua](CustomLobbyOptionsDerivedModel.lua) | the `Options` view: options split into **Categories** lobby / scenario / mods, each option **enriched** (label, help, chosen value-key + display, `IsDefault`, origin), plus `NonDefaultCount` | the launch model's `GameOptions` + `GameMods` and the scenario derived model (map file + name); the scenario `_options.lua` schema via the catalog's `LoadOptions`, the lobby/mod schema + value interpretation via [`/lua/ui/optionutil.lua`](/lua/ui/optionutil.lua) | the [`config/CustomLobbyOptionsPanel`](/lua/ui/lobby/customlobby/config/CustomLobbyOptionsPanel.lua) and the Options tab badge in [`config/CustomLobbyConfigInterface`](/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua) | +| [CustomLobbyOptionsDerivedModel.lua](CustomLobbyOptionsDerivedModel.lua) | the `Options` view: options split into **Categories** lobby / scenario / mods, each option **enriched** (label, help, chosen value-key + display, `IsDefault`, origin), plus `NonDefaultCount`. **Two dedups:** a *disk* dedup (the `SchemaKey`-keyed schema cache, so a value change doesn't re-read the option files) and a *publish* dedup (`table.equal` over the scenario file + mod set + option values, so an unrelated launch-info rebroadcast doesn't rebuild the panel). | the launch model's `GameOptions` + `GameMods` and the scenario derived model (map file + name); the scenario `_options.lua` schema via the catalog's `LoadOptions`, the lobby/mod schema + value interpretation via [`/lua/ui/optionutil.lua`](/lua/ui/optionutil.lua) | the [`config/CustomLobbyOptionsPanel`](/lua/ui/lobby/customlobby/config/CustomLobbyOptionsPanel.lua) and the Options tab badge in [`config/CustomLobbyConfigInterface`](/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua) | | [CustomLobbyRestrictionsDerivedModel.lua](CustomLobbyRestrictionsDerivedModel.lua) | the `Restrictions` view: each active **preset** key **enriched** with its `Name` / `Icon` / `Tooltip`, plus `Count`. De-duped by an order-independent set signature (`table.concat(table.sorted(keys), …)`). Keys can also be **specific unit ids**, but enriching those (name + icon) is **parked** — see [TODO.md](/lua/ui/lobby/customlobby/TODO.md) — so a unit id currently shows as its raw id. | the launch model's `Restrictions`, joined with the preset table in [`/lua/ui/lobby/unitsrestrictions.lua`](/lua/ui/lobby/unitsrestrictions.lua) | the [`config/CustomLobbyUnitsPanel`](/lua/ui/lobby/customlobby/config/CustomLobbyUnitsPanel.lua) (icon + name rows) and the Restrictions tab badge | | [CustomLobbyModsDerivedModel.lua](CustomLobbyModsDerivedModel.lua) | the `Mods` view: enabled mods split into **game** (sim) + **ui** `Groups`, each mod **enriched** (`Name` / `Icon` / `Author` / `Version` / `UiOnly`), plus `GameCount` / `UiCount`. No disk load (`Mods.AllMods()` is synchronous). De-duped by an order-independent set signature (`table.concatkeys` over the game + ui sets). | the launch model's `GameMods` (synced sim mods) + `ModUtilities.GetSelectedUIMods()` (per-peer prefs, re-read on each sim-mod change), joined with `/lua/mods.lua` | the [`config/CustomLobbyModsPanel`](/lua/ui/lobby/customlobby/config/CustomLobbyModsPanel.lua) (icon + name rows) and the Mods tab badge (`sim / ui`) | -| [CustomLobbySlotsDerivedModel.lua](CustomLobbySlotsDerivedModel.lua) | **two faces** over the seating board. `Slots` — a **lookup table** (one entry per slot, 1..MaxSlots): each seat merges its **player** (resolved `PlayerView`), scenario **placement** (`StartSpot` + map `Position`), **closed** flag, **CPU benchmark** (`CpuView` + raw `Benchmark`/`UnitCap` for the popover), and its binary auto-team **`Side`** (1/2/false). `Teams` — the **aggregate** (`Mode` / `Labels` / `Resolved` / per-side rating `Totals`). The side rule stays in [`CustomLobbyRules`](/lua/ui/lobby/customlobby/CustomLobbyRules.lua) (`BuildSideResolver`); this model *applies* it once so the two-column layout reads `entry.Side` and the score reads `Teams`. Each face deduped by its **own** signature, so a rating change re-fires `Teams` but not the rows. | the launch model's `Players[slot]` + `GameOptions` (the AutoTeams mode), the session model's `ClosedSlots`, the local model's `CpuBenchmarks` (per-peer, **not launched**), and the scenario derived model (placement + map size, via `CustomLobbyRules`) | the [`slots/CustomLobbySlotBase`](/lua/ui/lobby/customlobby/slots/CustomLobbySlotBase.lua) (`Slots`), the [`slots/twocolumn/CustomLobbyTwoColumnSlots`](/lua/ui/lobby/customlobby/slots/twocolumn/CustomLobbyTwoColumnSlots.lua) (`entry.Side`) and [`CustomLobbyTeamScore`](/lua/ui/lobby/customlobby/CustomLobbyTeamScore.lua) (`Teams`) | +| [CustomLobbySlotsDerivedModel.lua](CustomLobbySlotsDerivedModel.lua) | **two faces** over the seating board. `Slots` — a **lookup table** (one entry per slot, 1..MaxSlots): each seat merges its **player** (resolved `PlayerView`), scenario **placement** (`StartSpot` + map `Position`), **closed** flag, **locked** flag (the gold lock stripe / auto-balance pin), **CPU benchmark** (`CpuView` + raw `Benchmark`/`UnitCap` for the popover), and its binary auto-team **`Side`** (1/2/false). `Teams` — the **aggregate** (`Mode` / `Labels` / `Resolved` / per-side rating `Totals`). The side rule stays in [`CustomLobbyRules`](/lua/ui/lobby/customlobby/CustomLobbyRules.lua) (`BuildSideResolver`); this model *applies* it once so the two-column layout reads `entry.Side` and the score reads `Teams`. Each face deduped by its **own** signature, so a rating change re-fires `Teams` but not the rows. | the launch model's `Players[slot]` + `GameOptions` (the AutoTeams mode), the session model's `ClosedSlots`, the local model's `CpuBenchmarks` (per-peer, **not launched**), and the scenario derived model (placement + map size, via `CustomLobbyRules`) | the [`slots/CustomLobbySlotBase`](/lua/ui/lobby/customlobby/slots/CustomLobbySlotBase.lua) (`Slots`), the [`slots/twocolumn/CustomLobbyTwoColumnSlots`](/lua/ui/lobby/customlobby/slots/twocolumn/CustomLobbyTwoColumnSlots.lua) (`entry.Side`) and [`CustomLobbyTeamScore`](/lua/ui/lobby/customlobby/CustomLobbyTeamScore.lua) (`Teams`) | The options model also shows a second trait worth copying: it **caches the expensive part**. Gathering the option schema is disk work (the map's `_options.lua` `doscript`, mod option files) and only changes diff --git a/lua/ui/lobby/customlobby/models/derived/CustomLobbySlotsDerivedModel.lua b/lua/ui/lobby/customlobby/models/derived/CustomLobbySlotsDerivedModel.lua index c04308cecbe..5a3539dc577 100644 --- a/lua/ui/lobby/customlobby/models/derived/CustomLobbySlotsDerivedModel.lua +++ b/lua/ui/lobby/customlobby/models/derived/CustomLobbySlotsDerivedModel.lua @@ -107,6 +107,7 @@ local MaxSlots = CustomLobbyLaunchModel.MaxSlots ---@field Slot number # slot index 1..MaxSlots ---@field Player UICustomLobbyPlayer | false # the seated player (raw), false when empty — for intents/drag ---@field Closed boolean # session: seat closed (no army at launch) +---@field Locked boolean # session: seat pinned in place for auto-balance ---@field StartSpot number | false # the player's chosen start spot ---@field Position table | false # {x, z} of that start spot on the map, or false ---@field Side 1 | 2 | false # binary auto-team side (keyed on start spot, else slot), false when none/unresolved @@ -258,15 +259,16 @@ local Instance = nil ---@param slot number ---@param player UICustomLobbyPlayer | false ---@param closed boolean +---@param locked boolean ---@param benchmarks table ---@param spawns table | nil # scenario start-spot -> {x, z} ---@param cap number | nil # recommended unit cap (seated-count × per-player tier) ---@param side 1 | 2 | false # resolved binary auto-team side for this seat ---@return UICustomLobbySlot -local function BuildSlot(slot, player, closed, benchmarks, spawns, cap, side) +local function BuildSlot(slot, player, closed, locked, benchmarks, spawns, cap, side) if not player then return { - Slot = slot, Player = false, Closed = closed, + Slot = slot, Player = false, Closed = closed, Locked = locked, StartSpot = false, Position = false, Side = side, PlayerView = false, CpuView = false, Benchmark = false, UnitCap = false, } @@ -278,6 +280,7 @@ local function BuildSlot(slot, player, closed, benchmarks, spawns, cap, side) Slot = slot, Player = player, Closed = closed, + Locked = locked, StartSpot = startSpot, Position = (spawns and startSpot and spawns[startSpot]) or false, Side = side, @@ -303,6 +306,7 @@ local function Signature(slots) -- rejects non-string elements parts[slot] = tostring(slot) .. "|" .. (entry.Closed and "C" or "_") + .. (entry.Locked and "L" or "_") .. "|" .. (pv and (pv.colorHex .. pv.name .. pv.nameColor .. pv.faction .. pv.team .. pv.ready .. pv.readyColor) or "-") .. "|" .. (cv and (cv.text .. cv.textColor .. (cv.showIndicator and tostring(cv.indicatorColor) or "0")) or "-") .. "|" .. tostring(entry.StartSpot) @@ -363,6 +367,7 @@ local SlotsModel = ClassSimple { subscribe(launch.Players[slot]) end subscribe(CustomLobbySessionModel.GetSingleton().ClosedSlots) + subscribe(CustomLobbySessionModel.GetSingleton().LockedSlots) subscribe(CustomLobbyLocalModel.GetSingleton().CpuBenchmarks) subscribe(CustomLobbyScenarioDerivedModel.GetScenarioVar()) -- GameOptions feeds the AutoTeams mode (the side split); the per-face dedup keeps an unrelated @@ -375,7 +380,9 @@ local SlotsModel = ClassSimple { ---@param self UICustomLobbySlotsDerivedModel Recompute = function(self) local launch = CustomLobbyLaunchModel.GetSingleton() - local closedSlots = CustomLobbySessionModel.GetSingleton().ClosedSlots() + local session = CustomLobbySessionModel.GetSingleton() + local closedSlots = session.ClosedSlots() + local lockedSlots = session.LockedSlots() local benchmarks = CustomLobbyLocalModel.GetSingleton().CpuBenchmarks() local scenario = CustomLobbyScenarioDerivedModel.GetScenario() @@ -405,7 +412,8 @@ local SlotsModel = ClassSimple { local player = launch.Players[slot]() local spot = (player and player.StartSpot) or slot local side = (resolved and resolver and resolver(spot)) or false - slots[slot] = BuildSlot(slot, player, closedSlots[slot] and true or false, benchmarks, spawns, cap, side) + slots[slot] = BuildSlot(slot, player, closedSlots[slot] and true or false, + lockedSlots[slot] and true or false, benchmarks, spawns, cap, side) if player then if side == 1 then totalA = totalA + (player.PL or 0) diff --git a/lua/ui/lobby/customlobby/slots/CustomLobbySlotBase.lua b/lua/ui/lobby/customlobby/slots/CustomLobbySlotBase.lua index 7da0d454d45..400e7948e16 100644 --- a/lua/ui/lobby/customlobby/slots/CustomLobbySlotBase.lua +++ b/lua/ui/lobby/customlobby/slots/CustomLobbySlotBase.lua @@ -96,6 +96,7 @@ local DragThreshold = 5 ---@field Coordinator UICustomLobbySlotCoordinator ---@field Background Bitmap ---@field DropHighlight Bitmap +---@field LockStripe Bitmap # left-edge accent shown while this seat is locked ---@field ClickArea Bitmap ---@field SlotObserver LazyVar ---@field CurrentEntry UICustomLobbySlot | nil # the seat's last resolved entry (interaction reads it) @@ -132,6 +133,14 @@ local CustomLobbySlotBase = Class(Group) { self.DropHighlight:SetAlpha(0.0) self.DropHighlight:DisableHitTest() + -- left-edge accent shown while this seat is locked for auto-balance (same gold as the header's + -- "Locked" notice); hidden otherwise. Layout-agnostic, so it never overlaps a presentation's + -- content. + self.LockStripe = Bitmap(self) + self.LockStripe:SetSolidColor('ffd9c97a') + self.LockStripe:SetAlpha(0.0) + self.LockStripe:DisableHitTest() + -- transparent overlay that catches clicks on the whole row (take / ready / context / drag) self.ClickArea = Bitmap(self) self.ClickArea:SetSolidColor('00000000') @@ -165,6 +174,7 @@ local CustomLobbySlotBase = Class(Group) { __post_init = function(self, parent) Layouter(self.Background):Fill(self):End() Layouter(self.DropHighlight):Fill(self):End() + Layouter(self.LockStripe):AtLeftIn(self):AtTopIn(self):AtBottomIn(self):Width(3):Over(self, 15):End() Layouter(self.ClickArea):Fill(self):Over(self, 10):End() -- the presentation lays out its widgets (its CPU hover zone sits above ClickArea, Over 20) @@ -242,6 +252,8 @@ local CustomLobbySlotBase = Class(Group) { self.CurrentPlayer = entry.Player self:RenderPlayer(entry.PlayerView or nil) self:RenderCpu(entry.CpuView or nil) + -- a locked seat (only meaningful when occupied) shows the gold left-edge accent + self.LockStripe:SetAlpha((entry.Locked and entry.Player) and 1.0 or 0.0) end, --#endregion diff --git a/lua/ui/lobby/customlobby/slots/CustomLobbySlotsInterface.lua b/lua/ui/lobby/customlobby/slots/CustomLobbySlotsInterface.lua index d8c46b6209d..c2cbffcceac 100644 --- a/lua/ui/lobby/customlobby/slots/CustomLobbySlotsInterface.lua +++ b/lua/ui/lobby/customlobby/slots/CustomLobbySlotsInterface.lua @@ -45,6 +45,9 @@ local CustomLobbySessionModel = import("/lua/ui/lobby/customlobby/models/customl local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/models/customlobbylocalmodel.lua") local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") local CustomLobbyRules = import("/lua/ui/lobby/customlobby/customlobbyrules.lua") +local CustomLobbyScenarioDerivedModel = import("/lua/ui/lobby/customlobby/models/derived/customlobbyscenarioderivedmodel.lua") +local CustomLobbySlotsDerivedModel = import("/lua/ui/lobby/customlobby/models/derived/customlobbyslotsderivedmodel.lua") +local CustomLobbyBalancePreview = import("/lua/ui/lobby/customlobby/customlobbybalancepreview.lua") local CustomLobbyOneColumnSlots = import("/lua/ui/lobby/customlobby/slots/onecolumn/customlobbyonecolumnslots.lua") local CustomLobbyTwoColumnSlots = import("/lua/ui/lobby/customlobby/slots/twocolumn/customlobbytwocolumnslots.lua") @@ -83,6 +86,7 @@ local ReopenIcon = '/game/recall-panel/icon-recall_bmp.dds' ---@field IdleTexture FileName ---@field ActiveTexture FileName | nil ---@field Active boolean +---@field Enabled boolean ---@field Hovered boolean ---@field OnPress? fun() local SlotTool = Class(Group) { @@ -95,6 +99,7 @@ local SlotTool = Class(Group) { Group.__init(self, parent, "CustomLobbySlotTool") self.Active = false + self.Enabled = true self.Hovered = false self.IdleTexture = texture self.ActiveTexture = activeTexture @@ -107,7 +112,7 @@ local SlotTool = Class(Group) { self.Bg.HandleEvent = function(control, event) if event.Type == 'ButtonPress' then - if self.OnPress then + if self.Enabled and self.OnPress then self.OnPress() end return true @@ -136,12 +141,16 @@ local SlotTool = Class(Group) { ---@param self UICustomLobbySlotTool ApplyVisual = function(self) local bg = ToolIdle - if self.Active then - bg = ToolActive - elseif self.Hovered then - bg = ToolHover + if self.Enabled then + if self.Active then + bg = ToolActive + elseif self.Hovered then + bg = ToolHover + end end self.Bg:SetSolidColor(bg) + -- a disabled action reads as greyed: dim its glyph and don't light on hover + self.Icon:SetAlpha(self.Enabled and 1 or 0.3) if self.ActiveTexture then self.Icon:SetTexture(UIUtil.UIFile(self.Active and self.ActiveTexture or self.IdleTexture)) end @@ -154,6 +163,15 @@ local SlotTool = Class(Group) { self.Active = active self:ApplyVisual() end, + + --- Enables or disables the button: a disabled button ignores presses and reads greyed. The + --- balance button drives this from the seated teams (auto-balance needs exactly two). + ---@param self UICustomLobbySlotTool + ---@param enabled boolean + SetEnabled = function(self, enabled) + self.Enabled = enabled and true or false + self:ApplyVisual() + end, } ---@alias UICustomLobbySlotsBody UICustomLobbyOneColumnSlots | UICustomLobbyTwoColumnSlots @@ -173,6 +191,7 @@ local SlotTool = Class(Group) { ---@field GameOptionsObserver LazyVar ---@field IsHostObserver LazyVar ---@field SlotsPinnedObserver LazyVar +---@field BalanceGateObserver LazyVar ---@field HighlightedSlot number | false # slot currently shown as a drop target ---@field DragGhost Group | false # floating label following the cursor mid-drag local CustomLobbySlotsInterface = Class(Group) { @@ -217,10 +236,10 @@ local CustomLobbySlotsInterface = Class(Group) { self.BalanceButton = SlotTool(self.Tools, BalanceIcon) self.BalanceButton.OnPress = function() - CustomLobbyController.RequestAutoBalance() + CustomLobbyBalancePreview.Open() end Tooltip.AddControlTooltipManual(self.BalanceButton.Bg, "Auto balance", - "Balance the seated players across the teams.") + "Preview a balanced two-team split before applying it. Locked players stay put.") self.ReopenButton = SlotTool(self.Tools, ReopenIcon) self.ReopenButton.OnPress = function() @@ -254,6 +273,15 @@ local CustomLobbySlotsInterface = Class(Group) { end end)) + -- gate the auto-balance button: it needs exactly two sides (see CanAutoBalance). The slots + -- derived model re-fires on any seating / team / mode / map change, so one subscription covers + -- every input the gate depends on. + self.BalanceGateObserver = self.Trash:Add( + LazyVarDerive(CustomLobbySlotsDerivedModel.GetSlotsVar(), function(slotsLazy) + slotsLazy() + self:UpdateBalanceGate() + end)) + -- the active layout body, picked by the AutoTeams mode (created now, laid out on mount) self:RebuildBody() @@ -292,6 +320,33 @@ local CustomLobbySlotsInterface = Class(Group) { self:LayoutBody() end, + --- Re-evaluates whether auto-balance is offered and (en/dis)ables the button. + ---@param self UICustomLobbySlotsInterface + UpdateBalanceGate = function(self) + self.BalanceButton:SetEnabled(self:CanAutoBalance()) + end, + + --- Auto-balance needs exactly two sides: a binary AutoTeams mode (with its map loaded, for the + --- positional ones), or — in manual seating — at most two teams in use (no seated player on a + --- third team, i.e. model Team >= 4). Mirrors the legacy gate. + ---@param self UICustomLobbySlotsInterface + ---@return boolean + CanAutoBalance = function(self) + local launch = CustomLobbyLaunchModel.GetSingleton() + local mode = CustomLobbyRules.AutoTeamMode(launch.GameOptions()) + if mode then + local _, resolved = CustomLobbyRules.BuildSideResolver(mode, CustomLobbyScenarioDerivedModel.GetScenario()) + return resolved + end + for slot = 1, CustomLobbyLaunchModel.MaxSlots do + local player = launch.Players[slot]() + if player and player.Team and player.Team >= 4 then + return false + end + end + return true + end, + --- The layout kind the current AutoTeams mode calls for: "two" for a binary team mode, else "one". ---@param self UICustomLobbySlotsInterface ---@return "one" | "two" From 51435b6c2193ea14ac564c7d307d8112ece7e0ae Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sat, 27 Jun 2026 19:18:00 +0200 Subject: [PATCH 77/98] Enhance the lobby balancer dialog --- lua/ui/lobby/customlobby/CLAUDE.md | 6 +- .../customlobby/CustomLobbyBalancePreview.lua | 80 ++-- .../lobby/customlobby/CustomLobbyBalancer.lua | 443 +++++++++--------- .../customlobby/CustomLobbyController.lua | 19 +- .../slots/CustomLobbySlotsInterface.lua | 32 +- 5 files changed, 287 insertions(+), 293 deletions(-) diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index eeef09320dd..e3123b1b643 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -53,14 +53,14 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | [CustomLobbyLog.lua](CustomLobbyLog.lua) | the **network traffic log** — a reactive, per-peer, never-synced ring buffer (`Entries` LazyVar, capped) of every message this peer broadcasts / sends / receives, fed by the instance and rendered by the **Logs** tab. Each peer logs only its own traffic, so host and client views differ naturally. Not one of the three models (no game state); a diagnostic feed. | | [CustomLobbyController.lua](CustomLobbyController.lua) | host-authority logic (free functions): seating, `Process*` handlers, intents (`RequestSetReady`, `RequestTakeSlot`, `RequestSwapSlots`, `RequestEject`, `RequestMoveToObserver`, `RequestSetScenario`, `RequestSetGameMods/Options`, `RequestResetGameOptions`, `RequestSetSlotsPinned`, `RequestSetSlotLocked`, `RequestApplyBalance` (applies a previewed balance), `RequestReopenClosedSlots`, `RequestLaunch`, `RequestSaveSetupPreset`/`RequestLoadSetupPreset` — all keyed by slot/bool/file/name so a chat command can call them too; permission is gated separately), sharing the stored CPU benchmark. **Launch:** `RequestLaunch` (host-only, readiness-validated) → auto-saves the `lastGame` preset (`BuildSetupSnapshot`) → `BuildGameConfiguration` (seed option defaults + scenario, resolve random factions, assign army numbers + push to server, stamp ratings/clan tags, resolve sim mods via `Mods.GetGameMods`) → broadcast `LaunchGame` + `instance:LaunchGame`; clients run `ProcessLaunchGame`. **Presets:** `BuildSetupSnapshot` (read the launch state → serializable **setup-only** snapshot: scenario / options / mods / restrictions) + `ApplySetup` (host-only: write scenario/mods/restrictions, reconcile options to the current schema, one broadcast); players, observers and teams/spawn-mex are not stored (see [presetselect/](presetselect/CLAUDE.md)). | | [CustomLobbyRules.lua](CustomLobbyRules.lua) | the game-rule **pure kernel** (not view, not networking, **reads no models**): `RecommendedUnitCap(seatedCount, maxDimension)` (per-player cap by map size), `AutoTeamMode(gameOptions)` / `SideLabels(mode)` / `BuildSideResolver(mode, scenario)` (binary auto-team split). Every input is passed in, so the same functions serve the reactive [slots derived model](models/derived/CustomLobbySlotsDerivedModel.lua) (which applies them to publish `Side` / `Teams`) and, later, the host's launch-time team assignment — neither path makes this module read state. | -| [CustomLobbyBalancer.lua](CustomLobbyBalancer.lua) | the auto-balance **pure kernel** (**reads no models** — a snapshot is passed in, like `CustomLobbyRules`): `ComputeBalance(input)` proposes a balanced two-team re-seating. A port of the legacy `PenguinAutoBalance` onto the models — split players into two sides (positional modes place players into the map's side seats; manual / none modes reassign `Team` toward **even** sizes, keeping each player in its seat — the current team split doesn't constrain the result, so a lopsided 3-vs-1 rebalances to 2-vs-2), **hold locked seats in place** (`LockedSlots`; only the unlocked players are redistributed, an odd roster leaves the lowest-rated unlocked player put), combination-search the cheap rating-imbalance heuristic, **mirrored-pair shuffle** (positional only) so equivalent seats move together, and score the result once with `trueskill.computeQuality` for the preview. Returns the full target board (`arrangement` slot → `{ OwnerId, Team }`) + the two sides + quality; the controller's `RequestApplyBalance` applies it, the preview renders it. | +| [CustomLobbyBalancer.lua](CustomLobbyBalancer.lua) | the auto-balance **pure kernel** (**reads no models**) — **positional only** (the feature is gated to a binary AutoTeams mode, so there are always exactly two sides and the **position decides the team**; the kernel never writes `Team`). `BuildPlan(slots, teams)` takes the [slots derived model](models/derived/CustomLobbySlotsDerivedModel.lua)'s **already-resolved** snapshot — its `Slots` table (per-seat player / side / locked / closed) + `Teams` aggregate (mode / labels / resolved) — so it never re-resolves sides or re-reads locks. Builds a **plan** in three phases (a port of the legacy `PenguinAutoBalance`): **(1) Teams** — split the free players into two even sides minimising the cheap rating heuristic (locked players held on their position's side; odd roster leaves the lowest-rated unlocked player put). **(2) Pairs** — rank-match across the teams (strongest of A faces strongest of B; by rating, never random). **(3) Positions** — `ShufflePairsIntoSeats` (its own exported function) scatters the pairs across the free mirror rows; locked players keep their exact seat. Then score once with `trueskill.computeQuality`. The plan's two `sides` are rating-sorted (preview columns = the facing pairs), `lockedOwners` flags locked players, and `ToArrangement(plan)` returns the lobby update — **slot → ownerId**, no team. The controller's `RequestApplyBalance` re-seats from it (position → team is then resolved at launch — the pending item **B**, USER_STORIES § H); the preview renders the plan. | | [CustomLobbyPresets.lua](CustomLobbyPresets.lua) | **named setup presets** (setup-only: scenario / options / mods / restrictions — no players/observers) — pure prefs CRUD (`GetPresets`/`GetPreset`/`SavePreset`/`DeletePreset`/`RenamePreset`) over one key (`customlobby_setup_presets`), an ordered `{ Name, Setup }` array mirroring the mod presets in [`/lua/ui/modutilities.lua`](/lua/ui/modutilities.lua). Holds no models, no network. A reserved `LastGamePresetName` entry is auto-saved at launch (the rehost source). Capturing/applying a setup lives in the controller (`BuildSetupSnapshot`/`ApplySetup`); see [presetselect/](presetselect/CLAUDE.md). | | [CustomLobbySession.lua](CustomLobbySession.lua) | the lobby session's **main trash bag**. The lobby lives in the persistent front-end Lua state, which is *not* reset when the game launches in its own state — so anything left reachable (a running thread, a cache, a singleton) leaks for the whole match. This module owns one session-lifetime `TrashBag` (`GetTrash()`); every lobby-scoped `Destroyable` registers in it, and one `Teardown()` (clean-slate in `CreateLobby`, on leave, on `OnGameLaunched`) frees the lot. Works despite the bag being weak-valued because each singleton is pinned by its own module `Instance` local. **Rollout in progress** — converted: the map catalog, all five [derived models](models/derived/CLAUDE.md), and the three authoritative [models/](models/) (Launch/Session/Local, with thin `Destroy` per the design's decision #3). Still to do: the mod catalog, the interface + performance popover (pending the teardown-ordering decision), and the lobby instance. See [design/session-trashbag-teardown.md](design/session-trashbag-teardown.md). | | [CustomLobbyMessages.lua](CustomLobbyMessages.lua) | message registry: `AddPlayer`, `SetPlayers` (launch model: players + observers), `SentLaunchInfo` (launch model: scenario / options / mods / teams / spawn mex), `SetSessionState` (session model: slot count / closed slots / locked slots / pinned), `SetReady`, `TakeSlot`, `DisconnectPeer`, `LaunchGame` (host's final game config → `instance:LaunchGame`), `ReportCpuBenchmark`, `SetCpuBenchmarks`. | -| [CustomLobbyBalancePreview.lua](CustomLobbyBalancePreview.lua) | the auto-balance **preview** (host-only `Popup`): the slots header's balance button opens it instead of re-seating directly. Reads the current snapshot, runs `CustomLobbyBalancer.ComputeBalance`, shows the proposed two sides (names + ratings) + match quality (and any odd one left in place / why it can't balance), with **Apply** (→ `RequestApplyBalance`) / **Cancel** (nothing mutated). A transient picker, owns no synced state. | +| [CustomLobbyBalancePreview.lua](CustomLobbyBalancePreview.lua) | the auto-balance **preview** (host-only `Popup`): the slots header's balance button opens it instead of re-seating directly. Reads the derived snapshot (`GetSlots()` + `GetTeams()`), runs `CustomLobbyBalancer.BuildPlan`, shows the proposed two sides (names + ratings, rank-sorted so each row is the facing pair, locked players flagged) + match quality (and any odd one left in place / why it can't balance), with **Apply** (→ `RequestApplyBalance` with `ToArrangement(plan)`) / **Retry** (re-rolls the plan — the free pool is shuffled, so ties and positional seating vary) / **Cancel** (nothing mutated). A transient picker, owns no synced state. | | [CustomLobbyContextMenu.lua](CustomLobbyContextMenu.lua) | generic framed floating menu; `Show(entries, x, y)` renders any `{label, action, enabled}` list, dismisses on item click / click-outside / Esc. Knows nothing about the lobby. | | [CustomLobbyMenus.lua](CustomLobbyMenus.lua) | declarative menu **definitions**: entry lists with `when(ctx)`/`action(ctx)` filtered by lobby state (`BuildSlotMenu`). Adding/state-gating an item is a one-liner here. | -| [slots/](slots/) | the **slot subsystem** (its own folder, a sub-folder per layout). [`CustomLobbySlotsInterface`](slots/CustomLobbySlotsInterface.lua) is the entry the composition root mounts: a "Players" header over the **active layout body**, picked by the AutoTeams mode — one-column for the non-team modes, the two-column team layout for the binary modes (left/right, top/bottom, even/odd). It observes `GameOptions` and swaps the body when the kind flips (create-on-mode / destroy-on-switch); a change *within* the binary modes is handled by the two-column body's own re-layout. It is the rows' **drag coordinator** (`UICustomLobbySlotCoordinator`: hit-test which row a point is in, drop-highlight, drag ghost → `RequestSwapSlots`) for *every* layout — it alone needs to hit-test across rows — so the layout bodies stay pure build/place/reveal and never duplicate the drag logic; it also exposes `PreferredHeight()` (computed from the mode + model, swap-order-independent) so the root sizes the slot area. The header band carries a **host-only tool strip** (right-aligned: pin seating · auto-balance · reopen closed slots) of small icon buttons (local `SlotTool`, mirroring the config column's `PreviewTool`); the pin button is lit from the synced `SlotsPinned` and the strip is hidden for clients. The **auto-balance** button is gated (`SlotTool:SetEnabled`, greyed when off) to exactly two teams — a binary AutoTeams mode (map loaded for the positional ones) or at most two teams in use in manual seating — and opens the [balance preview](CustomLobbyBalancePreview.lua) rather than re-seating on click. A **"Locked" notice** (lock glyph + label) sits right of the "Players" label and is shown to **everyone** while `SlotsPinned` is on, so a client can see seating is host-controlled before clicking an open slot to no effect. [`CustomLobbySlotBase`](slots/CustomLobbySlotBase.lua) holds **all slot behaviour** (one subscription to the [slots derived model](models/derived/CustomLobbySlotsDerivedModel.lua) — which already merged this seat's player / placement / closed / CPU benchmark and did the faction/CPU formatting — click/right-click/drag-to-swap + intents, the background/highlight/click overlays + a gold left-edge **lock stripe** shown when the seat's `Locked` flag is set) and the default `RenderPlayer`/`RenderCpu` that paint the standard named controls from the entry's resolved views; a presentation subclasses it and implements just `CreateContents` / `LayoutContents`. [`onecolumn/`](slots/onecolumn/): [`CustomLobbyOneColumnSlots`](slots/onecolumn/CustomLobbyOneColumnSlots.lua) (stacks thin rows, reveals 1..count) + [`CustomLobbySlotRow`](slots/onecolumn/CustomLobbySlotRow.lua) (thin one-line presentation). [`twocolumn/`](slots/twocolumn/): [`CustomLobbyTwoColumnSlots`](slots/twocolumn/CustomLobbyTwoColumnSlots.lua) (splits slots into two team columns by each seat's `Side` from the [slots derived model](models/derived/CustomLobbySlotsDerivedModel.lua) — no longer re-resolving the split itself — with a [`CustomLobbyTeamScore`](CustomLobbyTeamScore.lua) strip across the top as the Left/Right indicator, and the "two columns, unresolved" fallback — parity fill + the score self-hides until a positional map's start positions load) + [`CustomLobbySlotCard`](slots/twocolumn/CustomLobbySlotCard.lua) (fat half-width / two-line presentation; `SetMirrored` flips the right column's cards so the two teams face each other). Each body exports `HeightForCount`. | +| [slots/](slots/) | the **slot subsystem** (its own folder, a sub-folder per layout). [`CustomLobbySlotsInterface`](slots/CustomLobbySlotsInterface.lua) is the entry the composition root mounts: a "Players" header over the **active layout body**, picked by the AutoTeams mode — one-column for the non-team modes, the two-column team layout for the binary modes (left/right, top/bottom, even/odd). It observes `GameOptions` and swaps the body when the kind flips (create-on-mode / destroy-on-switch); a change *within* the binary modes is handled by the two-column body's own re-layout. It is the rows' **drag coordinator** (`UICustomLobbySlotCoordinator`: hit-test which row a point is in, drop-highlight, drag ghost → `RequestSwapSlots`) for *every* layout — it alone needs to hit-test across rows — so the layout bodies stay pure build/place/reveal and never duplicate the drag logic; it also exposes `PreferredHeight()` (computed from the mode + model, swap-order-independent) so the root sizes the slot area. The header band carries a **host-only tool strip** (right-aligned: pin seating · auto-balance · reopen closed slots) of small icon buttons (local `SlotTool`, mirroring the config column's `PreviewTool`); the pin button is lit from the synced `SlotsPinned` and the strip is hidden for clients. The **auto-balance** button is gated (`SlotTool:SetEnabled`, greyed when off) to a **resolved binary AutoTeams mode** (the derived `Teams` aggregate's `Mode` + `Resolved` — two well-defined sides, map loaded for the positional ones) and opens the [balance preview](CustomLobbyBalancePreview.lua) rather than re-seating on click. A **"Locked" notice** (lock glyph + label) sits right of the "Players" label and is shown to **everyone** while `SlotsPinned` is on, so a client can see seating is host-controlled before clicking an open slot to no effect. [`CustomLobbySlotBase`](slots/CustomLobbySlotBase.lua) holds **all slot behaviour** (one subscription to the [slots derived model](models/derived/CustomLobbySlotsDerivedModel.lua) — which already merged this seat's player / placement / closed / CPU benchmark and did the faction/CPU formatting — click/right-click/drag-to-swap + intents, the background/highlight/click overlays + a gold left-edge **lock stripe** shown when the seat's `Locked` flag is set) and the default `RenderPlayer`/`RenderCpu` that paint the standard named controls from the entry's resolved views; a presentation subclasses it and implements just `CreateContents` / `LayoutContents`. [`onecolumn/`](slots/onecolumn/): [`CustomLobbyOneColumnSlots`](slots/onecolumn/CustomLobbyOneColumnSlots.lua) (stacks thin rows, reveals 1..count) + [`CustomLobbySlotRow`](slots/onecolumn/CustomLobbySlotRow.lua) (thin one-line presentation). [`twocolumn/`](slots/twocolumn/): [`CustomLobbyTwoColumnSlots`](slots/twocolumn/CustomLobbyTwoColumnSlots.lua) (splits slots into two team columns by each seat's `Side` from the [slots derived model](models/derived/CustomLobbySlotsDerivedModel.lua) — no longer re-resolving the split itself — with a [`CustomLobbyTeamScore`](CustomLobbyTeamScore.lua) strip across the top as the Left/Right indicator, and the "two columns, unresolved" fallback — parity fill + the score self-hides until a positional map's start positions load) + [`CustomLobbySlotCard`](slots/twocolumn/CustomLobbySlotCard.lua) (fat half-width / two-line presentation; `SetMirrored` flips the right column's cards so the two teams face each other). Each body exports `HeightForCount`. | | [CustomLobbyObserversInterface.lua](CustomLobbyObserversInterface.lua) | observer strip; subscribes to the model's `Observers` list and shows the count + names (read-only). | | [CustomLobbyScenarioPreview.lua](CustomLobbyScenarioPreview.lua) | **shared** map-preview *surface*: the scenario's map texture + overlays (start spots, resource/wreck markers, plus a **dummy translucent `WaterMask`** placeholder until a real mask exists) with aspect-correct positioning, texture-leak-safe icon sharing, per-group visibility (`SetOverlayVisible('spawns'\|'resources'\|'wrecks'\|'water', …)`), and three-phase init. A **pure renderer**: `SetScenario(info, markers)` takes already-extracted markers (`UICustomLobbyScenarioMarkers`) and never touches the raw save. Chrome-free; `CustomLobbyMapPreview` wraps it with the frame. Spawn appearance is the owner's via a `CreateSpawnIcon` factory. | | [CustomLobbyMapPreview.lua](CustomLobbyMapPreview.lua) | the map preview **as one whole** — the chrome (glow border on top + dark backdrop, surface inset by `Padding`), the surface, and the faction spawn icon (local `MapPreviewSpawn`). Used by **both** consumers: created `Bound = true` it subscribes to [`CustomLobbyScenarioDerivedModel`](models/derived/CustomLobbyScenarioDerivedModel.lua) and renders the resolved scenario with per-slot faction spawns (no reload on take/swap, and no reload on a same-map rebroadcast — the model dedups); created unbound (the map-select dialog) it does no model wiring and the owner drives `preview.Surface` directly (numbered-dot spawns), feeding it markers from the catalog's `LoadMarkers`. Exposes `.Surface` for owners to drive / anchor overlays to. | diff --git a/lua/ui/lobby/customlobby/CustomLobbyBalancePreview.lua b/lua/ui/lobby/customlobby/CustomLobbyBalancePreview.lua index 1f0344db1ac..0ea1ce3e360 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyBalancePreview.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyBalancePreview.lua @@ -36,10 +36,7 @@ local Bitmap = import("/lua/maui/bitmap.lua").Bitmap local Popup = import("/lua/ui/controls/popups/popup.lua").Popup local ItemList = import("/lua/maui/itemlist.lua").ItemList -local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/models/customlobbylaunchmodel.lua") -local CustomLobbySessionModel = import("/lua/ui/lobby/customlobby/models/customlobbysessionmodel.lua") -local CustomLobbyScenarioDerivedModel = import("/lua/ui/lobby/customlobby/models/derived/customlobbyscenarioderivedmodel.lua") -local CustomLobbyRules = import("/lua/ui/lobby/customlobby/customlobbyrules.lua") +local CustomLobbySlotsDerivedModel = import("/lua/ui/lobby/customlobby/models/derived/customlobbyslotsderivedmodel.lua") local CustomLobbyBalancer = import("/lua/ui/lobby/customlobby/customlobbybalancer.lua") local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") @@ -77,48 +74,30 @@ end --- Gathers the current lobby snapshot and runs the balancer. Reads the models (a transient picker --- may); the kernel itself stays pure. ----@return UICustomLobbyBalanceResult +---@return UICustomLobbyBalancePlan local function ComputeForCurrentLobby() - local launch = CustomLobbyLaunchModel.GetSingleton() - local session = CustomLobbySessionModel.GetSingleton() - - local players = {} - for slot = 1, CustomLobbyLaunchModel.MaxSlots do - local player = launch.Players[slot]() - if player then - players[slot] = player - end - end - - local mode = CustomLobbyRules.AutoTeamMode(launch.GameOptions()) - local resolver, resolved = CustomLobbyRules.BuildSideResolver(mode, CustomLobbyScenarioDerivedModel.GetScenario()) - - return CustomLobbyBalancer.ComputeBalance({ - players = players, - lockedSlots = session.LockedSlots(), - slotCount = session.SlotCount(), - closedSlots = session.ClosedSlots(), - sideResolver = resolver, - resolved = resolved, - labels = CustomLobbyRules.SideLabels(mode) or { "Team 1", "Team 2" }, - }) + -- the slots derived model already resolved every seat (player / side / locked / closed) and the + -- team aggregate (mode / labels / resolved), so the balancer reads that snapshot directly + return CustomLobbyBalancer.BuildPlan( + CustomLobbySlotsDerivedModel.GetSlots(), + CustomLobbySlotsDerivedModel.GetTeams()) end ---- One player's row in a team column: "name · rating" (rating omitted for AI / unrated). ----@param player UICustomLobbyPlayer +--- One player's row in a team column: "name · rating", with a lock marker for a user-locked seat. +--- The two columns are rating-sorted, so row k of each is the rank-matched pair that faces off. +---@param player UICustomLobbyBalancePlayer +---@param locked boolean ---@return string -local function PlayerRow(player) - local name = player.PlayerName or "?" - if player.PL then - return name .. " · " .. tostring(player.PL) - end - return name +local function PlayerRow(player, locked) + local rating = player.pl > 0 and (" · " .. tostring(player.pl)) or "" + local lock = locked and " [locked]" or "" + return player.name .. rating .. lock end ---@class UICustomLobbyBalancePreview : Group ---@field Trash TrashBag ---@field OnCloseCb fun() ----@field Result UICustomLobbyBalanceResult +---@field Result UICustomLobbyBalancePlan ---@field TitleArea Group ---@field ColumnAArea Group ---@field ColumnBArea Group @@ -131,6 +110,7 @@ end ---@field ListB ItemList ---@field Status Text ---@field ApplyButton Button +---@field RetryButton Button ---@field CancelButton Button ---@field Ready boolean local CustomLobbyBalancePreview = ClassUI(Group) { @@ -183,6 +163,13 @@ local CustomLobbyBalancePreview = ClassUI(Group) { self:ApplyAndClose() end + -- re-roll the proposal: the balance is randomised (tie-breaks, and the positional mirrored-pair + -- seating), so Retry recomputes for a different equally-good arrangement without committing + self.RetryButton = UIUtil.CreateButtonWithDropshadow(self.ActionArea, '/BUTTON/medium/', "Retry") + self.RetryButton.OnClick = function() + self:Retry() + end + self.CancelButton = UIUtil.CreateButtonWithDropshadow(self.ActionArea, '/BUTTON/medium/', "Cancel") self.CancelButton.OnClick = function() self.OnCloseCb() @@ -223,6 +210,7 @@ local CustomLobbyBalancePreview = ClassUI(Group) { Layouter(self.CancelButton):AtRightIn(self.ActionArea):AtVerticalCenterIn(self.ActionArea):End() Layouter(self.ApplyButton):AnchorToLeft(self.CancelButton, ButtonGap):AtVerticalCenterIn(self.ActionArea):End() + Layouter(self.RetryButton):AnchorToLeft(self.ApplyButton, ButtonGap):AtVerticalCenterIn(self.ActionArea):End() end, --- Post-mount render (the opener calls this after Popup centres the dialog, so the lists read @@ -243,13 +231,14 @@ local CustomLobbyBalancePreview = ClassUI(Group) { self.HeaderA:SetText(labels[1] .. " " .. tostring(result.totals[1])) self.HeaderB:SetText(labels[2] .. " " .. tostring(result.totals[2])) + local locked = result.lockedOwners or {} self.ListA:DeleteAllItems() for _, player in ipairs(result.sides[1]) do - self.ListA:AddItem(PlayerRow(player)) + self.ListA:AddItem(PlayerRow(player, locked[player.ownerId])) end self.ListB:DeleteAllItems() for _, player in ipairs(result.sides[2]) do - self.ListB:AddItem(PlayerRow(player)) + self.ListB:AddItem(PlayerRow(player, locked[player.ownerId])) end -- status line: the reason it can't balance, else the match quality (+ any odd one left out) @@ -262,22 +251,31 @@ local CustomLobbyBalancePreview = ClassUI(Group) { status = "Match quality: —" end if result.unassigned then - status = status .. " · " .. (result.unassigned.PlayerName or "?") .. " stays put (odd count)" + status = status .. " · " .. result.unassigned.name .. " stays put (odd count)" end self.Status:SetText(status) if result.feasible then self.ApplyButton:Enable() + self.RetryButton:Enable() else self.ApplyButton:Disable() + self.RetryButton:Disable() end end, + --- Re-rolls the proposal (the balance is randomised) and re-renders, without committing. + ---@param self UICustomLobbyBalancePreview + Retry = function(self) + self.Result = ComputeForCurrentLobby() + self:Render() + end, + --- Commits the proposed arrangement (host-authoritative) and closes. ---@param self UICustomLobbyBalancePreview ApplyAndClose = function(self) if self.Result.feasible then - CustomLobbyController.RequestApplyBalance(self.Result.arrangement) + CustomLobbyController.RequestApplyBalance(CustomLobbyBalancer.ToArrangement(self.Result)) end self.OnCloseCb() end, diff --git a/lua/ui/lobby/customlobby/CustomLobbyBalancer.lua b/lua/ui/lobby/customlobby/CustomLobbyBalancer.lua index efa7360f5e9..6ea5f078390 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyBalancer.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyBalancer.lua @@ -20,89 +20,78 @@ --** SOFTWARE. --****************************************************************************************************** --- The auto-balance **kernel**: a pure function that proposes a balanced re-seating of the lobby. It --- reads no models — the caller passes a snapshot in — so it can be reasoned about and tested in --- isolation, like CustomLobbyRules (the side rule it reuses lives there). The controller applies the --- result host-authoritatively (CustomLobbyController.RequestApplyBalance); the preview renders it --- before the host commits (CustomLobbySlotsInterface / CustomLobbyBalancePreview). +-- The auto-balance **kernel**: a pure function that builds a balance *plan* from the lobby snapshot. +-- It reads no models — the caller passes the slots derived model's already-resolved state — so it can +-- be reasoned about and tested in isolation, like CustomLobbyRules. -- --- What it does (a port of the legacy PenguinAutoBalance, lobby-old.lua, onto the MVC models): --- 1. Split the players into two sides. Positional modes assign players to the side's seats (from --- the passed `sideResolver`, built from CustomLobbyRules.BuildSideResolver) — side sizes are --- fixed by the map. Manual / none modes reassign Team freely toward EVEN sizes (the current team --- split doesn't constrain the result), keeping each player in its seat. --- 2. Hold LOCKED players in their exact seat (they still count toward their side's totals); only --- the unlocked players are redistributed. An odd roster leaves the lowest-rated unlocked player --- in place (never ejected) so the rest pair up. --- 3. Search player→side combinations minimising a cheap rating-imbalance heuristic --- (|Σdev−goal|·1.2 + |Σmean−goal|, as the legacy did — trueskill is too costly per combination). --- 4. (Positional only) shuffle the seat assignment as mirrored pairs so equivalent positions across --- the two sides move together. (Manual mode doesn't move seats, so it doesn't shuffle.) --- 5. Score the chosen split ONCE with trueskill `computeQuality` for the preview (— if AI/unrated). +-- **Positional only.** Auto-balance is offered only for binary AutoTeams modes (top/bottom, +-- left/right, odd/even), which guarantees exactly two sides. Under AutoTeams the **position decides +-- the team**, so this kernel never touches `Team`: it decides the two balanced teams + the pairing +-- and expresses the result as a **player -> slot arrangement**. The lobby applies the arrangement by +-- re-seating, and position -> team is resolved at launch (BuildGameConfiguration). +-- +-- The plan is built in three phases (USER_STORIES § R): +-- 1. TEAMS — split the free players into two even sides minimising a cheap rating-imbalance +-- heuristic (|Σdev−goal|·1.2 + |Σmean−goal|, as the legacy did — trueskill is too costly per +-- combination). Locked players are held on their position's side; an odd roster leaves the +-- lowest-rated unlocked player in place (never ejected). +-- 2. PAIRS — rank-match across the teams: the strongest of side A faces the strongest of side B, +-- etc. Who faces whom is decided by rating, never at random. +-- 3. POSITIONS — `ShufflePairsIntoSeats` scatters the pairs across the free mirror rows, so each +-- pair's *position* is random while the *pairing* stays fixed. Locked players keep their seat. +-- Then the chosen split is scored once with trueskill `computeQuality` for the preview. -- -- Ratings: the search + quality use MEAN / DEV; the per-side display totals use PL (matching the --- team-score strip). Team numbering is the model's backend form throughout (1 = no team, 2 = team 1, --- 3 = team 2); only the display translates. +-- team-score strip). local Trueskill = import("/lua/ui/lobby/trueskill.lua") -- safety cap on the combination search (C(16,8) = 12870 worst case, comfortably under this) local MaxBalanceEvaluations = 20000 --- side index (1/2) -> model backend Team number -local TeamForSide = { [1] = 2, [2] = 3 } +--- A player reduced to just what balancing needs (projected from a slots-derived-model entry). +---@class UICustomLobbyBalancePlayer +---@field ownerId UILobbyPeerId +---@field name string +---@field pl number # display rating (per-side totals + rank sort) +---@field mean number # trueskill mean (search + quality) +---@field dev number # trueskill deviation +---@field locked boolean # the host pinned this seat +---@field side 1 | 2 # the seat's resolved auto-team side (current) +---@field slot number # current seat ----@param player UICustomLobbyPlayer ----@return number -local function MeanOf(player) - return player.MEAN or 1500 -end +--- A proposed balance. +---@class UICustomLobbyBalancePlan +---@field labels string[] # the two side labels, for the preview +---@field sides UICustomLobbyBalancePlayer[][] # the two teams, rank-sorted (row k = a pair) +---@field totals number[] # per-side PL totals +---@field lockedOwners table # user-locked players (the preview marks them) +---@field unassigned UICustomLobbyBalancePlayer | false # the odd one left in place, if any +---@field feasible boolean # false = nothing to apply (Apply disabled) +---@field reason string | nil # why it can't / didn't balance, for the preview +---@field quality number | false # trueskill match quality %, or false +---@field arrangement table # the lobby update: slot -> ownerId (no team) ----@param player UICustomLobbyPlayer ----@return number -local function DevOf(player) - return player.DEV or 500 +--- Sorts players strongest-first by display rating, so two rating-sorted sides line up rank for rank. +---@param a UICustomLobbyBalancePlayer +---@param b UICustomLobbyBalancePlayer +---@return boolean +local function ByRatingDesc(a, b) + return a.pl > b.pl end ---- The input snapshot for a balance computation. All data is passed in — the kernel reads no models. ----@class UICustomLobbyBalanceInput ----@field players table # occupied slots only (slot -> player) ----@field lockedSlots table # seats whose player is pinned in place ----@field slotCount number # active slots on the current map ----@field closedSlots table ----@field sideResolver (fun(startSpot: number): number | nil) | nil # positional split; nil = manual ----@field resolved boolean # positional map loaded (false hides positional) ----@field labels string[] | nil # the two side labels, for the preview - ---- One seat in a proposed arrangement. ----@class UICustomLobbyBalanceSeat ----@field OwnerId UILobbyPeerId ----@field Team number | nil # set only in manual mode (positional teams resolve from position at launch) - ---- The proposed balance. ----@class UICustomLobbyBalanceResult ----@field feasible boolean # false = nothing to apply (Apply disabled) ----@field reason string | nil # why it can't / didn't balance, for the preview ----@field arrangement table # the full target board (slot -> seat) ----@field sides UICustomLobbyPlayer[][] # the two proposed sides, for the preview ----@field totals number[] # per-side PL totals, for the preview ----@field labels string[] | nil ----@field unassigned UICustomLobbyPlayer | false # the odd one left in place, if any ----@field quality number | false # trueskill match quality %, or false - --- Scores a proposed two-side split with trueskill match quality. Returns false for AI / unrated ---- players (MEAN 0) or a degenerate matrix — the preview then shows "—". ----@param sides UICustomLobbyPlayer[][] +--- players (mean 0) or a degenerate matrix — the preview then shows "—". +---@param sides UICustomLobbyBalancePlayer[][] ---@return number | false local function ComputeQuality(sides) local teams = Trueskill.Teams.create() for side = 1, 2 do - for _, player in ipairs(sides[side]) do - if not player.MEAN or player.MEAN == 0 then + for _, bp in ipairs(sides[side]) do + if not bp.mean or bp.mean == 0 then return false end - teams:addPlayer(side, Trueskill.Player.create( - player.PlayerName or "?", Trueskill.Rating.create(player.MEAN, player.DEV or 0))) + teams:addPlayer(side, Trueskill.Player.create(bp.name, Trueskill.Rating.create(bp.mean, bp.dev))) end end if table.getn(teams:getTeams()) ~= 2 then @@ -116,154 +105,191 @@ local function ComputeQuality(sides) return quality end ---- Computes a proposed balanced re-seating. Pure — see UICustomLobbyBalanceInput. Always returns a ---- result; `feasible` is false (with a `reason`) when there is nothing to apply. ----@param input UICustomLobbyBalanceInput ----@return UICustomLobbyBalanceResult -function ComputeBalance(input) - ---@type UICustomLobbyBalanceResult - local result = { - feasible = false, - reason = nil, - arrangement = {}, +--- Phase 3 — seats already-formed rank-matched pairs at randomly-chosen mirror rows: `pairsA[i]` vs +--- `pairsB[i]` is the i-th pair (formed by rating in phase 2), and it lands at the same free row on +--- both sides, so WHO faces whom stays fixed while the POSITION is random. `seatsA`/`seatsB` are the +--- free seats per side, paired by order into mirror rows (locked seats are excluded by the caller, so +--- locked players don't move). Invokes `place(slot, player)` for each seated player; any leftover +--- players — when the two sides have unequal free counts (asymmetric locks) — fill the remaining +--- seats so none are dropped. +---@param pairsA UICustomLobbyBalancePlayer[] +---@param pairsB UICustomLobbyBalancePlayer[] +---@param seatsA number[] +---@param seatsB number[] +---@param place fun(slot: number, player: UICustomLobbyBalancePlayer) +function ShufflePairsIntoSeats(pairsA, pairsB, seatsA, seatsB, place) + local rowCount = math.min(table.getn(seatsA), table.getn(seatsB)) + local rowOrder = {} + for k = 1, rowCount do + rowOrder[k] = k + end + for k = rowCount, 2, -1 do + local j = Random(1, k) + rowOrder[k], rowOrder[j] = rowOrder[j], rowOrder[k] + end + + local usedA, usedB = {}, {} + local pairCount = math.min(table.getn(pairsA), table.getn(pairsB), rowCount) + for i = 1, pairCount do + local row = rowOrder[i] + place(seatsA[row], pairsA[i]) + place(seatsB[row], pairsB[i]) + usedA[row], usedB[row] = true, true + end + + local function seatRest(players, seats, used, fromIndex) + local free = {} + for k = 1, table.getn(seats) do + if not used[k] then + table.insert(free, k) + end + end + local r = 1 + for i = fromIndex, table.getn(players) do + if not free[r] then + break + end + place(seats[free[r]], players[i]) + r = r + 1 + end + end + seatRest(pairsA, seatsA, usedA, pairCount + 1) + seatRest(pairsB, seatsB, usedB, pairCount + 1) +end + +--- Builds a balance plan from the slots derived model's resolved snapshot: `slots` is its `Slots` +--- table (per-seat Player / Side / Locked / Closed), `teams` its `Teams` aggregate (Mode / Labels / +--- Resolved). Pure — reads no models. Always returns a plan; `feasible` is false (with a `reason`) +--- when there is nothing to apply. +---@param slots UICustomLobbySlot[] +---@param teams UICustomLobbyTeams +---@return UICustomLobbyBalancePlan +function BuildPlan(slots, teams) + ---@type UICustomLobbyBalancePlan + local plan = { + labels = (teams and teams.Labels) or { "Team 1", "Team 2" }, sides = { {}, {} }, totals = { 0, 0 }, - labels = input.labels, + lockedOwners = {}, unassigned = false, + feasible = false, + reason = nil, quality = false, + arrangement = {}, } - -- a positional split needs a loaded map; without it there are no sides to balance into - if input.sideResolver and not input.resolved then - result.reason = "Pick a map first — the team sides aren't determined yet." - return result - end - - -- total roster (locked + free) drives the balance goal and the odd-one-out rule - local totalMean, totalDev, occupiedCount = 0, 0, 0 - for slot = 1, input.slotCount do - local player = input.players[slot] - if player then - totalMean = totalMean + MeanOf(player) - totalDev = totalDev + DevOf(player) - occupiedCount = occupiedCount + 1 - end - end - if occupiedCount < 2 then - result.reason = "Need at least two players to balance." - return result + -- the feature is gated to a binary AutoTeams mode with a resolved split; bail defensively otherwise + if not (teams and teams.Mode) or not teams.Resolved then + plan.reason = "Auto-balance needs an AutoTeams mode with a loaded map." + return plan end - -- odd roster: leave the lowest-rated UNLOCKED player in place (never ejected) so the rest pair - -- up. Treat its seat as pinned for the rest of the computation. - local effectiveLocked = table.copy(input.lockedSlots) - if math.mod(occupiedCount, 2) == 1 then - local oddSlot, oddRating - for slot = 1, input.slotCount do - local player = input.players[slot] - if player and not input.lockedSlots[slot] then - local rating = MeanOf(player) - DevOf(player) * 2.2 - if not oddRating or rating < oddRating then - oddRating = rating - oddSlot = slot - end + -- project each seat into the minimal info balancing needs, and collect the free seats per side + -- (open, non-locked-occupied — empty open seats count, so players can move into vacant positions) + local players = {} + local freeSeats = { {}, {} } + for _, entry in ipairs(slots) do + local side = entry.Side + if side == 1 or side == 2 then + if entry.Player then + table.insert(players, { + ownerId = entry.Player.OwnerID, + name = entry.Player.PlayerName or "?", + pl = entry.Player.PL or 0, + mean = entry.Player.MEAN or 1500, + dev = entry.Player.DEV or 500, + locked = entry.Locked and true or false, + side = side, + slot = entry.Slot, + }) + end + if not entry.Closed and not (entry.Locked and entry.Player) then + table.insert(freeSeats[side], entry.Slot) end - end - if oddSlot then - effectiveLocked[oddSlot] = true - result.unassigned = input.players[oddSlot] end end - local manual = input.sideResolver == nil + local occupiedCount = table.getn(players) + if occupiedCount < 2 then + plan.reason = "Need at least two players to balance." + return plan + end - -- A locked (or odd-one-out) player's side. Positional: its seat's resolved side. Manual: its - -- current Team (2 -> side 1, 3 -> side 2); nil when on no team. - local function lockedNaturalSide(slot) - if manual then - local team = input.players[slot].Team - if team == 2 then return 1 elseif team == 3 then return 2 end - return nil + -- pinned = the host-locked players + (odd roster) the lowest-rated unlocked player, left in place + -- so the rest pair up (never ejected). Pinned players hold their exact seat and side. + local pinned = {} + for _, bp in ipairs(players) do + if bp.locked then + pinned[bp.ownerId] = true + plan.lockedOwners[bp.ownerId] = true end - return input.sideResolver(slot) end - - -- Pin the locked / odd-one-out players to a side (never moved); the rest are the free pool the - -- search splits. A locked manual player with no team is dropped onto the lighter side. - local lockedRecords = { {}, {} } -- pinned player records per side - local lockedSeats = { {}, {} } -- their seats (kept as-is) - local freeUnits = {} -- { player =, slot = } for the movable players - for slot = 1, input.slotCount do - local player = input.players[slot] - if player then - if effectiveLocked[slot] then - local side = lockedNaturalSide(slot) - or ((table.getn(lockedRecords[1]) <= table.getn(lockedRecords[2])) and 1 or 2) - table.insert(lockedRecords[side], player) - table.insert(lockedSeats[side], slot) - else - table.insert(freeUnits, { player = player, slot = slot }) + if math.mod(occupiedCount, 2) == 1 then + local odd + for _, bp in ipairs(players) do + if not pinned[bp.ownerId] and (not odd or (bp.mean - bp.dev * 2.2) < (odd.mean - odd.dev * 2.2)) then + odd = bp end end + if odd then + pinned[odd.ownerId] = true + plan.unassigned = odd + end end - local freeCount = table.getn(freeUnits) - -- Free seats available per side. Manual reassigns teams (not seats), so capacity is flexible and - -- the target is simply even sizes. Positional places players into the side's open, non-locked - -- seats (including empty ones), so capacity is that seat count — a real map property. - local freeSeats = { {}, {} } - local capA, capB - if manual then - capA, capB = freeCount, freeCount - else - for slot = 1, input.slotCount do - if not input.closedSlots[slot] and not (effectiveLocked[slot] and input.players[slot]) then - local side = input.sideResolver(slot) - if side == 1 or side == 2 then - table.insert(freeSeats[side], slot) - end - end + -- partition into pinned (held on their position's side) and the free pool the search splits + local lockedRecords = { {}, {} } + local freePool = {} + local totalMean, totalDev = 0, 0 + for _, bp in ipairs(players) do + totalMean = totalMean + bp.mean + totalDev = totalDev + bp.dev + if pinned[bp.ownerId] then + table.insert(lockedRecords[bp.side], bp) + plan.arrangement[bp.slot] = bp.ownerId -- stays exactly where it is + else + table.insert(freePool, bp) end - capA, capB = table.getn(freeSeats[1]), table.getn(freeSeats[2]) end + local freeCount = table.getn(freePool) - -- locked ratings still count toward each side's balance target - local lockedMean, lockedDev = { 0, 0 }, { 0, 0 } - for side = 1, 2 do - for _, player in ipairs(lockedRecords[side]) do - lockedMean[side] = lockedMean[side] + MeanOf(player) - lockedDev[side] = lockedDev[side] + DevOf(player) - end + -- shuffle the free pool so re-running varies which equally-good split / seating is picked + for i = freeCount, 2, -1 do + local j = Random(1, i) + freePool[i], freePool[j] = freePool[j], freePool[i] end - -- how many free players go to side A: aim for equal final side sizes, clamped to capacity. An - -- empty feasible range means the locks can't fit the team layout. + -- how many free players go to side A: aim for equal final sizes, clamped to each side's free seats local lockedCountA = table.getn(lockedRecords[1]) local lockedCountB = table.getn(lockedRecords[2]) + local capA, capB = table.getn(freeSeats[1]), table.getn(freeSeats[2]) local placedA = math.floor((freeCount + lockedCountB - lockedCountA) / 2 + 0.5) local minA = math.max(0, freeCount - capB) local maxA = math.min(freeCount, capA) if minA > maxA then - result.reason = "The locked players don't fit the team layout." - return result + plan.reason = "The locked players don't fit the team layout." + return plan end if placedA < minA then placedA = minA end if placedA > maxA then placedA = maxA end - -- search: choose `placedA` of the free players for side A, minimising rating imbalance against - -- the half-totals (the cheap heuristic the legacy used — never trueskill, which is too costly here) - local goalMean = totalMean / 2 - local goalDev = totalDev / 2 + -- search: choose placedA free players for side A, minimising the cheap rating-imbalance heuristic + local goalMean, goalDev = totalMean / 2, totalDev / 2 + local lockedMeanA, lockedDevA = 0, 0 + for _, bp in ipairs(lockedRecords[1]) do + lockedMeanA = lockedMeanA + bp.mean + lockedDevA = lockedDevA + bp.dev + end + local best = { value = nil, chosen = nil } local evaluations = 0 local current = {} - local function evaluate() - local meanA, devA = lockedMean[1], lockedDev[1] + local meanA, devA = lockedMeanA, lockedDevA for i = 1, table.getn(current) do - local player = freeUnits[current[i]].player - meanA = meanA + MeanOf(player) - devA = devA + DevOf(player) + local bp = freePool[current[i]] + meanA = meanA + bp.mean + devA = devA + bp.dev end local value = math.abs(devA - goalDev) * 1.2 + math.abs(meanA - goalMean) if not best.value or value < best.value then @@ -272,7 +298,6 @@ function ComputeBalance(input) end evaluations = evaluations + 1 end - local function choose(startIndex, remaining) if evaluations >= MaxBalanceEvaluations then return @@ -290,10 +315,9 @@ function ComputeBalance(input) end end end - choose(1, placedA) - -- partition the free units into the two sides per the best split + -- split the free pool into the two sides per the best result local chosen = {} if best.chosen then for _, index in ipairs(best.chosen) do @@ -302,55 +326,42 @@ function ComputeBalance(input) end local freeA, freeB = {}, {} for i = 1, freeCount do - table.insert(chosen[i] and freeA or freeB, freeUnits[i]) - end - - -- record one seat into the arrangement + the preview list - local function record(slot, player, side, team) - result.arrangement[slot] = { OwnerId = player.OwnerID, Team = team } - table.insert(result.sides[side], player) - end - - -- locked players keep their seat; in manual mode their Team is (re)stamped to their side - for side = 1, 2 do - for i = 1, table.getn(lockedRecords[side]) do - record(lockedSeats[side][i], lockedRecords[side][i], side, manual and TeamForSide[side] or nil) - end + table.insert(chosen[i] and freeA or freeB, freePool[i]) end - if manual then - -- teams are reassigned in place: each free player keeps its seat, its Team set to its side. - -- No positional shuffle — seats don't determine team here. - for _, unit in ipairs(freeA) do record(unit.slot, unit.player, 1, TeamForSide[1]) end - for _, unit in ipairs(freeB) do record(unit.slot, unit.player, 2, TeamForSide[2]) end - else - -- positional: place players into the side's free seats, with a mirrored-pair shuffle so - -- equivalent positions across the two sides move together; team follows the seat at launch - local playersA, playersB = {}, {} - for _, unit in ipairs(freeA) do table.insert(playersA, unit.player) end - for _, unit in ipairs(freeB) do table.insert(playersB, unit.player) end - local pairCount = math.min(table.getn(playersA), table.getn(playersB)) - for i = 1, pairCount do - local r = Random(i, pairCount) - playersA[i], playersA[r] = playersA[r], playersA[i] - playersB[i], playersB[r] = playersB[r], playersB[i] - end - for i = 1, table.getn(playersA) do record(freeSeats[1][i], playersA[i], 1, nil) end - for i = 1, table.getn(playersB) do record(freeSeats[2][i], playersB[i], 2, nil) end - end + -- phase 2 + 3: rank-match the free players, scatter the pairs across the free mirror rows + table.sort(freeA, ByRatingDesc) + table.sort(freeB, ByRatingDesc) + ShufflePairsIntoSeats(freeA, freeB, freeSeats[1], freeSeats[2], + function(slot, bp) plan.arrangement[slot] = bp.ownerId end) + -- the two teams, rank-sorted for display (row k = the k-th strongest of each side — who face off) + for _, bp in ipairs(lockedRecords[1]) do table.insert(plan.sides[1], bp) end + for _, bp in ipairs(freeA) do table.insert(plan.sides[1], bp) end + for _, bp in ipairs(lockedRecords[2]) do table.insert(plan.sides[2], bp) end + for _, bp in ipairs(freeB) do table.insert(plan.sides[2], bp) end + table.sort(plan.sides[1], ByRatingDesc) + table.sort(plan.sides[2], ByRatingDesc) for side = 1, 2 do local total = 0 - for _, player in ipairs(result.sides[side]) do - total = total + (player.PL or 0) + for _, bp in ipairs(plan.sides[side]) do + total = total + bp.pl end - result.totals[side] = total + plan.totals[side] = total end - result.quality = ComputeQuality(result.sides) - result.feasible = freeCount > 0 + plan.quality = ComputeQuality(plan.sides) + plan.feasible = freeCount > 0 if freeCount == 0 then - result.reason = "Every player is locked in place — nothing to rearrange." + plan.reason = "Every player is locked in place — nothing to rearrange." end - return result + return plan +end + +--- The lobby update for a plan: the player -> slot arrangement to apply. `Team` is intentionally +--- absent — under AutoTeams the position decides the team (resolved at launch). +---@param plan UICustomLobbyBalancePlan +---@return table +function ToArrangement(plan) + return plan.arrangement end diff --git a/lua/ui/lobby/customlobby/CustomLobbyController.lua b/lua/ui/lobby/customlobby/CustomLobbyController.lua index 748218f430c..831fc3d609c 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyController.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyController.lua @@ -1076,12 +1076,12 @@ function RequestSetSlotLocked(slot, locked) end --- The host applies a balanced re-seating, after confirming it in the preview. Host-only — backs ---- the auto-balance preview's Apply button. `arrangement` (slot -> { OwnerId, Team }) comes from ---- CustomLobbyBalancer.ComputeBalance; it only says *where* each player sits (and, in manual mode, ---- their team), never *what* they are, so it can't smuggle edited ratings/factions. The whole board ---- is rewritten from one snapshot — read by owner, moved to the target seat — then broadcast once ---- (re-seating via the model directly, not N pairwise swaps). ----@param arrangement table +--- the auto-balance preview's Apply button. `arrangement` (slot -> ownerId) comes from +--- CustomLobbyBalancer; it only says *where* each player sits, never *what* they are (no rating / +--- faction / team — under AutoTeams the position decides the team), so it can't smuggle edits. The +--- whole board is rewritten from one snapshot — read by owner, moved to the target seat — then +--- broadcast once (re-seating via the model directly, not N pairwise swaps). +---@param arrangement table function RequestApplyBalance(arrangement) local instance = LobbyInstance if not instance then @@ -1114,8 +1114,8 @@ function RequestApplyBalance(arrangement) -- account for exactly the seated players (a stale preview) local newPlayers = {} local placedCount = 0 - for slot, seat in arrangement do - local source = byOwner[seat.OwnerId] + for slot, ownerId in arrangement do + local source = byOwner[ownerId] if not source then WARN("CustomLobby: balance arrangement references an unseated player; ignoring") return @@ -1123,9 +1123,6 @@ function RequestApplyBalance(arrangement) local player = table.copy(source) player.StartSpot = slot player.Ready = false -- (re)seating resets readiness, as a manual move does - if seat.Team then - player.Team = seat.Team - end newPlayers[slot] = player placedCount = placedCount + 1 end diff --git a/lua/ui/lobby/customlobby/slots/CustomLobbySlotsInterface.lua b/lua/ui/lobby/customlobby/slots/CustomLobbySlotsInterface.lua index c2cbffcceac..866a9b416d8 100644 --- a/lua/ui/lobby/customlobby/slots/CustomLobbySlotsInterface.lua +++ b/lua/ui/lobby/customlobby/slots/CustomLobbySlotsInterface.lua @@ -45,7 +45,6 @@ local CustomLobbySessionModel = import("/lua/ui/lobby/customlobby/models/customl local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/models/customlobbylocalmodel.lua") local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") local CustomLobbyRules = import("/lua/ui/lobby/customlobby/customlobbyrules.lua") -local CustomLobbyScenarioDerivedModel = import("/lua/ui/lobby/customlobby/models/derived/customlobbyscenarioderivedmodel.lua") local CustomLobbySlotsDerivedModel = import("/lua/ui/lobby/customlobby/models/derived/customlobbyslotsderivedmodel.lua") local CustomLobbyBalancePreview = import("/lua/ui/lobby/customlobby/customlobbybalancepreview.lua") local CustomLobbyOneColumnSlots = import("/lua/ui/lobby/customlobby/slots/onecolumn/customlobbyonecolumnslots.lua") @@ -273,12 +272,12 @@ local CustomLobbySlotsInterface = Class(Group) { end end)) - -- gate the auto-balance button: it needs exactly two sides (see CanAutoBalance). The slots - -- derived model re-fires on any seating / team / mode / map change, so one subscription covers - -- every input the gate depends on. + -- gate the auto-balance button: it needs a resolved binary AutoTeams split, which is exactly + -- the slots derived model's team aggregate — so one subscription to it covers the gate (it + -- re-fires when the mode or the resolved-ness changes). self.BalanceGateObserver = self.Trash:Add( - LazyVarDerive(CustomLobbySlotsDerivedModel.GetSlotsVar(), function(slotsLazy) - slotsLazy() + LazyVarDerive(CustomLobbySlotsDerivedModel.GetTeamsVar(), function(teamsLazy) + teamsLazy() self:UpdateBalanceGate() end)) @@ -326,25 +325,14 @@ local CustomLobbySlotsInterface = Class(Group) { self.BalanceButton:SetEnabled(self:CanAutoBalance()) end, - --- Auto-balance needs exactly two sides: a binary AutoTeams mode (with its map loaded, for the - --- positional ones), or — in manual seating — at most two teams in use (no seated player on a - --- third team, i.e. model Team >= 4). Mirrors the legacy gate. + --- Auto-balance is offered only when there are exactly two sides — i.e. a binary AutoTeams mode + --- whose split is resolved (a map loaded, for the positional ones; odd/even needs none). That's + --- exactly the slots derived model's `Teams` aggregate, so the gate reads it directly. ---@param self UICustomLobbySlotsInterface ---@return boolean CanAutoBalance = function(self) - local launch = CustomLobbyLaunchModel.GetSingleton() - local mode = CustomLobbyRules.AutoTeamMode(launch.GameOptions()) - if mode then - local _, resolved = CustomLobbyRules.BuildSideResolver(mode, CustomLobbyScenarioDerivedModel.GetScenario()) - return resolved - end - for slot = 1, CustomLobbyLaunchModel.MaxSlots do - local player = launch.Players[slot]() - if player and player.Team and player.Team >= 4 then - return false - end - end - return true + local teams = CustomLobbySlotsDerivedModel.GetTeams() + return (teams.Mode and teams.Resolved) and true or false end, --- The layout kind the current AutoTeams mode calls for: "two" for a binary team mode, else "one". From 1a5d90deb3f00ccc1d26936bb58bb67b7dcb3938 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sat, 27 Jun 2026 19:18:12 +0200 Subject: [PATCH 78/98] Apply the auto teams setting on launch --- lua/ui/lobby/USER_STORIES.md | 4 +-- lua/ui/lobby/customlobby/CLAUDE.md | 4 +-- .../customlobby/CustomLobbyController.lua | 26 ++++++++++++++++--- 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/lua/ui/lobby/USER_STORIES.md b/lua/ui/lobby/USER_STORIES.md index d05fe40cb36..df146b04685 100644 --- a/lua/ui/lobby/USER_STORIES.md +++ b/lua/ui/lobby/USER_STORIES.md @@ -84,11 +84,11 @@ Roles: **Host**, **Player** (human in a slot), **Observer**, **Joining client** ## H. Auto-teams & spawn -- 🟡 As a **host**, I want AutoTeams modes (top/bottom, left/right, odd/even, manual), so that teams are assigned by position without manual fiddling. *(Selectable as a lobby option; the actual auto-teaming/launch resolution isn't wired.)* +- 🟡 As a **host**, I want AutoTeams modes (top/bottom, left/right, odd/even, manual), so that teams are assigned by position without manual fiddling. *(Selectable as a lobby option; the **binary positional modes** (top/bottom, left/right, odd/even) now resolve team-from-position at launch in `BuildGameConfiguration` — and drive the two-column display + auto-balance. The `manual` marker mode isn't wired.)* - ⬜ As a **host**, I want manual AutoTeams by clicking map markers, so that I can hand-place teams on random spawn. - 🟡 As a **host**, I want spawn variants (fixed, random, balanced/flex/reveal, penguin-autobalance), so that start placement matches the desired fairness/secrecy. *(Selectable as a lobby option; placement is resolved at launch, which isn't wired.)* - ⬜ As a **host on an adaptive map**, I want per-slot spawn-mex, so that closed positions still contribute economy. -- 🟡 As a **system**, I want random factions/start spots/AI names and the ratings/clan tables resolved at launch, so that the final config is complete and fair. *(At launch `BuildGameConfiguration` resolves random factions to a concrete one, assigns army numbers in slot order, and stamps the ratings/clan-tag tables into the game options; start-spot/AutoTeams resolution and AI names aren't done yet.)* +- 🟡 As a **system**, I want random factions/start spots/AI names and the ratings/clan tables resolved at launch, so that the final config is complete and fair. *(At launch `BuildGameConfiguration` resolves random factions to a concrete one, assigns army numbers in slot order, stamps the ratings/clan-tag tables, and resolves team-from-position for the binary AutoTeams modes; random start-spot assignment and AI names aren't done yet.)* ## I. Map selection (host) diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index e3123b1b643..8409f5048ea 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -51,9 +51,9 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | [CustomLobbyPerformancePopover.lua](CustomLobbyPerformancePopover.lua) | hover popover over the CPU column; hand-built bitmap bar chart of a peer's `PerformanceTrackingV2` history, with a yellow recommended-unit-cap line. | | [CustomLobbyInstance.lua](CustomLobbyInstance.lua) | thin `moho.lobby_methods` shell; validates/dispatches traffic, forwards callbacks to the controller. Also feeds [`CustomLobbyLog`](CustomLobbyLog.lua) from its three network choke points (BroadcastData / SendData / DataReceived). | | [CustomLobbyLog.lua](CustomLobbyLog.lua) | the **network traffic log** — a reactive, per-peer, never-synced ring buffer (`Entries` LazyVar, capped) of every message this peer broadcasts / sends / receives, fed by the instance and rendered by the **Logs** tab. Each peer logs only its own traffic, so host and client views differ naturally. Not one of the three models (no game state); a diagnostic feed. | -| [CustomLobbyController.lua](CustomLobbyController.lua) | host-authority logic (free functions): seating, `Process*` handlers, intents (`RequestSetReady`, `RequestTakeSlot`, `RequestSwapSlots`, `RequestEject`, `RequestMoveToObserver`, `RequestSetScenario`, `RequestSetGameMods/Options`, `RequestResetGameOptions`, `RequestSetSlotsPinned`, `RequestSetSlotLocked`, `RequestApplyBalance` (applies a previewed balance), `RequestReopenClosedSlots`, `RequestLaunch`, `RequestSaveSetupPreset`/`RequestLoadSetupPreset` — all keyed by slot/bool/file/name so a chat command can call them too; permission is gated separately), sharing the stored CPU benchmark. **Launch:** `RequestLaunch` (host-only, readiness-validated) → auto-saves the `lastGame` preset (`BuildSetupSnapshot`) → `BuildGameConfiguration` (seed option defaults + scenario, resolve random factions, assign army numbers + push to server, stamp ratings/clan tags, resolve sim mods via `Mods.GetGameMods`) → broadcast `LaunchGame` + `instance:LaunchGame`; clients run `ProcessLaunchGame`. **Presets:** `BuildSetupSnapshot` (read the launch state → serializable **setup-only** snapshot: scenario / options / mods / restrictions) + `ApplySetup` (host-only: write scenario/mods/restrictions, reconcile options to the current schema, one broadcast); players, observers and teams/spawn-mex are not stored (see [presetselect/](presetselect/CLAUDE.md)). | +| [CustomLobbyController.lua](CustomLobbyController.lua) | host-authority logic (free functions): seating, `Process*` handlers, intents (`RequestSetReady`, `RequestTakeSlot`, `RequestSwapSlots`, `RequestEject`, `RequestMoveToObserver`, `RequestSetScenario`, `RequestSetGameMods/Options`, `RequestResetGameOptions`, `RequestSetSlotsPinned`, `RequestSetSlotLocked`, `RequestApplyBalance` (applies a previewed balance), `RequestReopenClosedSlots`, `RequestLaunch`, `RequestSaveSetupPreset`/`RequestLoadSetupPreset` — all keyed by slot/bool/file/name so a chat command can call them too; permission is gated separately), sharing the stored CPU benchmark. **Launch:** `RequestLaunch` (host-only, readiness-validated) → auto-saves the `lastGame` preset (`BuildSetupSnapshot`) → `BuildGameConfiguration` (seed option defaults + scenario, resolve random factions, assign army numbers + push to server, stamp ratings/clan tags, resolve team-from-position for the binary AutoTeams modes, resolve sim mods via `Mods.GetGameMods`) → broadcast `LaunchGame` + `instance:LaunchGame`; clients run `ProcessLaunchGame`. **Presets:** `BuildSetupSnapshot` (read the launch state → serializable **setup-only** snapshot: scenario / options / mods / restrictions) + `ApplySetup` (host-only: write scenario/mods/restrictions, reconcile options to the current schema, one broadcast); players, observers and teams/spawn-mex are not stored (see [presetselect/](presetselect/CLAUDE.md)). | | [CustomLobbyRules.lua](CustomLobbyRules.lua) | the game-rule **pure kernel** (not view, not networking, **reads no models**): `RecommendedUnitCap(seatedCount, maxDimension)` (per-player cap by map size), `AutoTeamMode(gameOptions)` / `SideLabels(mode)` / `BuildSideResolver(mode, scenario)` (binary auto-team split). Every input is passed in, so the same functions serve the reactive [slots derived model](models/derived/CustomLobbySlotsDerivedModel.lua) (which applies them to publish `Side` / `Teams`) and, later, the host's launch-time team assignment — neither path makes this module read state. | -| [CustomLobbyBalancer.lua](CustomLobbyBalancer.lua) | the auto-balance **pure kernel** (**reads no models**) — **positional only** (the feature is gated to a binary AutoTeams mode, so there are always exactly two sides and the **position decides the team**; the kernel never writes `Team`). `BuildPlan(slots, teams)` takes the [slots derived model](models/derived/CustomLobbySlotsDerivedModel.lua)'s **already-resolved** snapshot — its `Slots` table (per-seat player / side / locked / closed) + `Teams` aggregate (mode / labels / resolved) — so it never re-resolves sides or re-reads locks. Builds a **plan** in three phases (a port of the legacy `PenguinAutoBalance`): **(1) Teams** — split the free players into two even sides minimising the cheap rating heuristic (locked players held on their position's side; odd roster leaves the lowest-rated unlocked player put). **(2) Pairs** — rank-match across the teams (strongest of A faces strongest of B; by rating, never random). **(3) Positions** — `ShufflePairsIntoSeats` (its own exported function) scatters the pairs across the free mirror rows; locked players keep their exact seat. Then score once with `trueskill.computeQuality`. The plan's two `sides` are rating-sorted (preview columns = the facing pairs), `lockedOwners` flags locked players, and `ToArrangement(plan)` returns the lobby update — **slot → ownerId**, no team. The controller's `RequestApplyBalance` re-seats from it (position → team is then resolved at launch — the pending item **B**, USER_STORIES § H); the preview renders the plan. | +| [CustomLobbyBalancer.lua](CustomLobbyBalancer.lua) | the auto-balance **pure kernel** (**reads no models**) — **positional only** (the feature is gated to a binary AutoTeams mode, so there are always exactly two sides and the **position decides the team**; the kernel never writes `Team`). `BuildPlan(slots, teams)` takes the [slots derived model](models/derived/CustomLobbySlotsDerivedModel.lua)'s **already-resolved** snapshot — its `Slots` table (per-seat player / side / locked / closed) + `Teams` aggregate (mode / labels / resolved) — so it never re-resolves sides or re-reads locks. Builds a **plan** in three phases (a port of the legacy `PenguinAutoBalance`): **(1) Teams** — split the free players into two even sides minimising the cheap rating heuristic (locked players held on their position's side; odd roster leaves the lowest-rated unlocked player put). **(2) Pairs** — rank-match across the teams (strongest of A faces strongest of B; by rating, never random). **(3) Positions** — `ShufflePairsIntoSeats` (its own exported function) scatters the pairs across the free mirror rows; locked players keep their exact seat. Then score once with `trueskill.computeQuality`. The plan's two `sides` are rating-sorted (preview columns = the facing pairs), `lockedOwners` flags locked players, and `ToArrangement(plan)` returns the lobby update — **slot → ownerId**, no team. The controller's `RequestApplyBalance` re-seats from it; the team falls out of the position, resolved at launch by `BuildGameConfiguration` (binary AutoTeams → team-from-`StartSpot`). The preview renders the plan. | | [CustomLobbyPresets.lua](CustomLobbyPresets.lua) | **named setup presets** (setup-only: scenario / options / mods / restrictions — no players/observers) — pure prefs CRUD (`GetPresets`/`GetPreset`/`SavePreset`/`DeletePreset`/`RenamePreset`) over one key (`customlobby_setup_presets`), an ordered `{ Name, Setup }` array mirroring the mod presets in [`/lua/ui/modutilities.lua`](/lua/ui/modutilities.lua). Holds no models, no network. A reserved `LastGamePresetName` entry is auto-saved at launch (the rehost source). Capturing/applying a setup lives in the controller (`BuildSetupSnapshot`/`ApplySetup`); see [presetselect/](presetselect/CLAUDE.md). | | [CustomLobbySession.lua](CustomLobbySession.lua) | the lobby session's **main trash bag**. The lobby lives in the persistent front-end Lua state, which is *not* reset when the game launches in its own state — so anything left reachable (a running thread, a cache, a singleton) leaks for the whole match. This module owns one session-lifetime `TrashBag` (`GetTrash()`); every lobby-scoped `Destroyable` registers in it, and one `Teardown()` (clean-slate in `CreateLobby`, on leave, on `OnGameLaunched`) frees the lot. Works despite the bag being weak-valued because each singleton is pinned by its own module `Instance` local. **Rollout in progress** — converted: the map catalog, all five [derived models](models/derived/CLAUDE.md), and the three authoritative [models/](models/) (Launch/Session/Local, with thin `Destroy` per the design's decision #3). Still to do: the mod catalog, the interface + performance popover (pending the teardown-ordering decision), and the lobby instance. See [design/session-trashbag-teardown.md](design/session-trashbag-teardown.md). | | [CustomLobbyMessages.lua](CustomLobbyMessages.lua) | message registry: `AddPlayer`, `SetPlayers` (launch model: players + observers), `SentLaunchInfo` (launch model: scenario / options / mods / teams / spawn mex), `SetSessionState` (session model: slot count / closed slots / locked slots / pinned), `SetReady`, `TakeSlot`, `DisconnectPeer`, `LaunchGame` (host's final game config → `instance:LaunchGame`), `ReportCpuBenchmark`, `SetCpuBenchmarks`. | diff --git a/lua/ui/lobby/customlobby/CustomLobbyController.lua b/lua/ui/lobby/customlobby/CustomLobbyController.lua index 831fc3d609c..99aefea34eb 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyController.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyController.lua @@ -849,6 +849,25 @@ local function BuildGameConfiguration(instance) gameOptions.Ratings = ratings gameOptions.ClanTags = clanTags + -- AutoTeams: under a binary positional mode (top/bottom, left/right, odd/even) the team is decided + -- by the start position, not the player's picked team — so resolve each seated player's `Team` from + -- its spot here. This is the lobby's position->team step (the balancer only re-seats; the team + -- falls out of position). Non-binary / manual teams are left untouched. + local CustomLobbyRules = import("/lua/ui/lobby/customlobby/customlobbyrules.lua") + local mode = CustomLobbyRules.AutoTeamMode(gameOptions) + if mode then + local CustomLobbyScenarioDerivedModel = import("/lua/ui/lobby/customlobby/models/derived/customlobbyscenarioderivedmodel.lua") + local resolver, resolved = CustomLobbyRules.BuildSideResolver(mode, CustomLobbyScenarioDerivedModel.GetScenario()) + if resolved and resolver then + for _, options in playerOptions do + local side = resolver(options.StartSpot) + if side == 1 or side == 2 then + options.Team = side + 1 -- backend numbering: side 1 -> team 2, side 2 -> team 3 + end + end + end + end + -- army numbers are assigned in slot order; tell the server each seated player's army settings local slots = {} for slot, _ in playerOptions do @@ -876,9 +895,10 @@ end --- the launch configuration, broadcasts it so every peer launches with the same config, then --- launches locally. The engine takes over from here (see `OnGameLaunched`). --- ---- TODO: resolve AutoTeams (the mode lives in `GameOptions` but teams aren't applied at launch yet ---- — see USER_STORIES.md H), and gate the button reactively / surface the block reason in the UI ---- rather than only warning. +--- TODO: gate the button reactively / surface the block reason in the UI rather than only warning. +--- (The binary AutoTeams modes now resolve team-from-position in `BuildGameConfiguration`; the +--- remaining AutoTeams work — the `manual` marker mode and the spawn-variant resolution — is the +--- broader USER_STORIES § H slice.) function RequestLaunch() local instance = LobbyInstance if not instance then From 514a036d37571409a541452be1e242ee76845edf Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sat, 27 Jun 2026 19:58:05 +0200 Subject: [PATCH 79/98] Enrich the balance dialog --- lua/ui/lobby/USER_STORIES.md | 6 +- lua/ui/lobby/customlobby/CLAUDE.md | 4 +- .../customlobby/CustomLobbyBalancePreview.lua | 264 +++++++++++++----- .../lobby/customlobby/CustomLobbyBalancer.lua | 73 ++++- 4 files changed, 271 insertions(+), 76 deletions(-) diff --git a/lua/ui/lobby/USER_STORIES.md b/lua/ui/lobby/USER_STORIES.md index df146b04685..f214bcfe39c 100644 --- a/lua/ui/lobby/USER_STORIES.md +++ b/lua/ui/lobby/USER_STORIES.md @@ -122,7 +122,6 @@ Roles: **Host**, **Player** (human in a slot), **Observer**, **Joining client** - ⬜ As a **player**, I want join/leave/connection notices in chat, so that I'm aware of lobby changes. - ⬜ As a **player**, I want to click a name in chat to prefill a whisper, so that replying is quick. - ⬜ As a **player**, I want command history (up/down) and auto-scroll with a "new message" indicator, so that chatting is comfortable. -- ⬜ As a **player**, I want ratings shown alongside names, so that I gauge the lobby's skill. ## M. Connectivity & health @@ -185,4 +184,7 @@ Roles: **Host**, **Player** (human in a slot), **Observer**, **Joining client** - ⬜ As a **host**, I want last game's mods NOT auto-enabled when the game didn't launch (but kept when it did, for rehosting), so that a failed launch doesn't silently carry mods forward. - ⬜ As a **player**, I want "disable all sim / UI / all mods" buttons, so that I can clear mods in one click. - ⬜ As a **player**, I want the mod manager's dependency-related UI updates fixed, so that enabling/disabling reflects dependencies correctly. -- ⬜ As a **developer**, I want mod-dependency management reworked in the lobby frontend so it no longer keys off singular version UUIDs, so that the dependency system is fixed at the source. \ No newline at end of file +- ⬜ As a **developer**, I want mod-dependency management reworked in the lobby frontend so it no longer keys off singular version UUIDs, so that the dependency system is fixed at the source. +- ⬜ As a **player**, I want a button to show where civilians are on map preview. +- ⬜ As a **player**, I want a small team number (and slot?) on the map preview. to show where civilians are on map. +- ⬜ As a **player**, I want the logs of the custom lobby traffic to go to disk. \ No newline at end of file diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index 8409f5048ea..28182b76b34 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -53,11 +53,11 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | [CustomLobbyLog.lua](CustomLobbyLog.lua) | the **network traffic log** — a reactive, per-peer, never-synced ring buffer (`Entries` LazyVar, capped) of every message this peer broadcasts / sends / receives, fed by the instance and rendered by the **Logs** tab. Each peer logs only its own traffic, so host and client views differ naturally. Not one of the three models (no game state); a diagnostic feed. | | [CustomLobbyController.lua](CustomLobbyController.lua) | host-authority logic (free functions): seating, `Process*` handlers, intents (`RequestSetReady`, `RequestTakeSlot`, `RequestSwapSlots`, `RequestEject`, `RequestMoveToObserver`, `RequestSetScenario`, `RequestSetGameMods/Options`, `RequestResetGameOptions`, `RequestSetSlotsPinned`, `RequestSetSlotLocked`, `RequestApplyBalance` (applies a previewed balance), `RequestReopenClosedSlots`, `RequestLaunch`, `RequestSaveSetupPreset`/`RequestLoadSetupPreset` — all keyed by slot/bool/file/name so a chat command can call them too; permission is gated separately), sharing the stored CPU benchmark. **Launch:** `RequestLaunch` (host-only, readiness-validated) → auto-saves the `lastGame` preset (`BuildSetupSnapshot`) → `BuildGameConfiguration` (seed option defaults + scenario, resolve random factions, assign army numbers + push to server, stamp ratings/clan tags, resolve team-from-position for the binary AutoTeams modes, resolve sim mods via `Mods.GetGameMods`) → broadcast `LaunchGame` + `instance:LaunchGame`; clients run `ProcessLaunchGame`. **Presets:** `BuildSetupSnapshot` (read the launch state → serializable **setup-only** snapshot: scenario / options / mods / restrictions) + `ApplySetup` (host-only: write scenario/mods/restrictions, reconcile options to the current schema, one broadcast); players, observers and teams/spawn-mex are not stored (see [presetselect/](presetselect/CLAUDE.md)). | | [CustomLobbyRules.lua](CustomLobbyRules.lua) | the game-rule **pure kernel** (not view, not networking, **reads no models**): `RecommendedUnitCap(seatedCount, maxDimension)` (per-player cap by map size), `AutoTeamMode(gameOptions)` / `SideLabels(mode)` / `BuildSideResolver(mode, scenario)` (binary auto-team split). Every input is passed in, so the same functions serve the reactive [slots derived model](models/derived/CustomLobbySlotsDerivedModel.lua) (which applies them to publish `Side` / `Teams`) and, later, the host's launch-time team assignment — neither path makes this module read state. | -| [CustomLobbyBalancer.lua](CustomLobbyBalancer.lua) | the auto-balance **pure kernel** (**reads no models**) — **positional only** (the feature is gated to a binary AutoTeams mode, so there are always exactly two sides and the **position decides the team**; the kernel never writes `Team`). `BuildPlan(slots, teams)` takes the [slots derived model](models/derived/CustomLobbySlotsDerivedModel.lua)'s **already-resolved** snapshot — its `Slots` table (per-seat player / side / locked / closed) + `Teams` aggregate (mode / labels / resolved) — so it never re-resolves sides or re-reads locks. Builds a **plan** in three phases (a port of the legacy `PenguinAutoBalance`): **(1) Teams** — split the free players into two even sides minimising the cheap rating heuristic (locked players held on their position's side; odd roster leaves the lowest-rated unlocked player put). **(2) Pairs** — rank-match across the teams (strongest of A faces strongest of B; by rating, never random). **(3) Positions** — `ShufflePairsIntoSeats` (its own exported function) scatters the pairs across the free mirror rows; locked players keep their exact seat. Then score once with `trueskill.computeQuality`. The plan's two `sides` are rating-sorted (preview columns = the facing pairs), `lockedOwners` flags locked players, and `ToArrangement(plan)` returns the lobby update — **slot → ownerId**, no team. The controller's `RequestApplyBalance` re-seats from it; the team falls out of the position, resolved at launch by `BuildGameConfiguration` (binary AutoTeams → team-from-`StartSpot`). The preview renders the plan. | +| [CustomLobbyBalancer.lua](CustomLobbyBalancer.lua) | the auto-balance **pure kernel** (**reads no models**) — **positional only** (the feature is gated to a binary AutoTeams mode, so there are always exactly two sides and the **position decides the team**; the kernel never writes `Team`). `BuildPlan(slots, teams)` takes the [slots derived model](models/derived/CustomLobbySlotsDerivedModel.lua)'s **already-resolved** snapshot — its `Slots` table (per-seat player / side / locked / closed) + `Teams` aggregate (mode / labels / resolved) — so it never re-resolves sides or re-reads locks. Builds a **plan** in three phases (a port of the legacy `PenguinAutoBalance`): **(1) Teams** — split the free players into two even sides minimising the cheap rating heuristic (locked players held on their position's side; odd roster leaves the lowest-rated unlocked player put). **(2) Pairs** — rank-match across the teams (strongest of A faces strongest of B; by rating, never random). **(3) Positions** — `ShufflePairsIntoSeats` (its own exported function) scatters the pairs across the free mirror rows; locked players keep their exact seat. Then it scores the proposal for the preview: `quality` + `currentQuality` (`trueskill.computeQuality` of the proposed vs. the current seating, for a "before → after"), `winChance` (predicted win % per side — a local normal-CDF over the side mean/variance sums, the directional counterpart to the symmetric quality), and a per-player `moved` flag (proposed seat ≠ current seat, for the preview highlight). The plan's two `sides` are rating-sorted (preview columns = the facing pairs), `lockedOwners` flags locked players, and `ToArrangement(plan)` returns the lobby update — **slot → ownerId**, no team. The controller's `RequestApplyBalance` re-seats from it; the team falls out of the position, resolved at launch by `BuildGameConfiguration` (binary AutoTeams → team-from-`StartSpot`). The preview renders the plan. | | [CustomLobbyPresets.lua](CustomLobbyPresets.lua) | **named setup presets** (setup-only: scenario / options / mods / restrictions — no players/observers) — pure prefs CRUD (`GetPresets`/`GetPreset`/`SavePreset`/`DeletePreset`/`RenamePreset`) over one key (`customlobby_setup_presets`), an ordered `{ Name, Setup }` array mirroring the mod presets in [`/lua/ui/modutilities.lua`](/lua/ui/modutilities.lua). Holds no models, no network. A reserved `LastGamePresetName` entry is auto-saved at launch (the rehost source). Capturing/applying a setup lives in the controller (`BuildSetupSnapshot`/`ApplySetup`); see [presetselect/](presetselect/CLAUDE.md). | | [CustomLobbySession.lua](CustomLobbySession.lua) | the lobby session's **main trash bag**. The lobby lives in the persistent front-end Lua state, which is *not* reset when the game launches in its own state — so anything left reachable (a running thread, a cache, a singleton) leaks for the whole match. This module owns one session-lifetime `TrashBag` (`GetTrash()`); every lobby-scoped `Destroyable` registers in it, and one `Teardown()` (clean-slate in `CreateLobby`, on leave, on `OnGameLaunched`) frees the lot. Works despite the bag being weak-valued because each singleton is pinned by its own module `Instance` local. **Rollout in progress** — converted: the map catalog, all five [derived models](models/derived/CLAUDE.md), and the three authoritative [models/](models/) (Launch/Session/Local, with thin `Destroy` per the design's decision #3). Still to do: the mod catalog, the interface + performance popover (pending the teardown-ordering decision), and the lobby instance. See [design/session-trashbag-teardown.md](design/session-trashbag-teardown.md). | | [CustomLobbyMessages.lua](CustomLobbyMessages.lua) | message registry: `AddPlayer`, `SetPlayers` (launch model: players + observers), `SentLaunchInfo` (launch model: scenario / options / mods / teams / spawn mex), `SetSessionState` (session model: slot count / closed slots / locked slots / pinned), `SetReady`, `TakeSlot`, `DisconnectPeer`, `LaunchGame` (host's final game config → `instance:LaunchGame`), `ReportCpuBenchmark`, `SetCpuBenchmarks`. | -| [CustomLobbyBalancePreview.lua](CustomLobbyBalancePreview.lua) | the auto-balance **preview** (host-only `Popup`): the slots header's balance button opens it instead of re-seating directly. Reads the derived snapshot (`GetSlots()` + `GetTeams()`), runs `CustomLobbyBalancer.BuildPlan`, shows the proposed two sides (names + ratings, rank-sorted so each row is the facing pair, locked players flagged) + match quality (and any odd one left in place / why it can't balance), with **Apply** (→ `RequestApplyBalance` with `ToArrangement(plan)`) / **Retry** (re-rolls the plan — the free pool is shuffled, so ties and positional seating vary) / **Cancel** (nothing mutated). A transient picker, owns no synced state. | +| [CustomLobbyBalancePreview.lua](CustomLobbyBalancePreview.lua) | the auto-balance **preview** (host-only `Popup`): the slots header's balance button opens it instead of re-seating directly. Reads the derived snapshot (`GetSlots()` + `GetTeams()`), runs `CustomLobbyBalancer.BuildPlan`, and renders the proposal as **rank-matched pair rows** (row k = the k-th strongest of each side, who face off) — each row shows both players + the pair's rating gap (Δ, colour-coded green→red), with **moved** players in gold and **locked** players in blue. A summary reports each team's total + average rating, the match quality **before → after**, the predicted **win** split, the rating **Δ**, and any odd one left in place (or why it can't balance). **Apply** (→ `RequestApplyBalance` with `ToArrangement(plan)`) / **Retry** (re-rolls the plan — the free pool is shuffled, so ties and positional seating vary) / **Cancel** (nothing mutated). A transient picker, owns no synced state. | | [CustomLobbyContextMenu.lua](CustomLobbyContextMenu.lua) | generic framed floating menu; `Show(entries, x, y)` renders any `{label, action, enabled}` list, dismisses on item click / click-outside / Esc. Knows nothing about the lobby. | | [CustomLobbyMenus.lua](CustomLobbyMenus.lua) | declarative menu **definitions**: entry lists with `when(ctx)`/`action(ctx)` filtered by lobby state (`BuildSlotMenu`). Adding/state-gating an item is a one-liner here. | | [slots/](slots/) | the **slot subsystem** (its own folder, a sub-folder per layout). [`CustomLobbySlotsInterface`](slots/CustomLobbySlotsInterface.lua) is the entry the composition root mounts: a "Players" header over the **active layout body**, picked by the AutoTeams mode — one-column for the non-team modes, the two-column team layout for the binary modes (left/right, top/bottom, even/odd). It observes `GameOptions` and swaps the body when the kind flips (create-on-mode / destroy-on-switch); a change *within* the binary modes is handled by the two-column body's own re-layout. It is the rows' **drag coordinator** (`UICustomLobbySlotCoordinator`: hit-test which row a point is in, drop-highlight, drag ghost → `RequestSwapSlots`) for *every* layout — it alone needs to hit-test across rows — so the layout bodies stay pure build/place/reveal and never duplicate the drag logic; it also exposes `PreferredHeight()` (computed from the mode + model, swap-order-independent) so the root sizes the slot area. The header band carries a **host-only tool strip** (right-aligned: pin seating · auto-balance · reopen closed slots) of small icon buttons (local `SlotTool`, mirroring the config column's `PreviewTool`); the pin button is lit from the synced `SlotsPinned` and the strip is hidden for clients. The **auto-balance** button is gated (`SlotTool:SetEnabled`, greyed when off) to a **resolved binary AutoTeams mode** (the derived `Teams` aggregate's `Mode` + `Resolved` — two well-defined sides, map loaded for the positional ones) and opens the [balance preview](CustomLobbyBalancePreview.lua) rather than re-seating on click. A **"Locked" notice** (lock glyph + label) sits right of the "Players" label and is shown to **everyone** while `SlotsPinned` is on, so a client can see seating is host-controlled before clicking an open slot to no effect. [`CustomLobbySlotBase`](slots/CustomLobbySlotBase.lua) holds **all slot behaviour** (one subscription to the [slots derived model](models/derived/CustomLobbySlotsDerivedModel.lua) — which already merged this seat's player / placement / closed / CPU benchmark and did the faction/CPU formatting — click/right-click/drag-to-swap + intents, the background/highlight/click overlays + a gold left-edge **lock stripe** shown when the seat's `Locked` flag is set) and the default `RenderPlayer`/`RenderCpu` that paint the standard named controls from the entry's resolved views; a presentation subclasses it and implements just `CreateContents` / `LayoutContents`. [`onecolumn/`](slots/onecolumn/): [`CustomLobbyOneColumnSlots`](slots/onecolumn/CustomLobbyOneColumnSlots.lua) (stacks thin rows, reveals 1..count) + [`CustomLobbySlotRow`](slots/onecolumn/CustomLobbySlotRow.lua) (thin one-line presentation). [`twocolumn/`](slots/twocolumn/): [`CustomLobbyTwoColumnSlots`](slots/twocolumn/CustomLobbyTwoColumnSlots.lua) (splits slots into two team columns by each seat's `Side` from the [slots derived model](models/derived/CustomLobbySlotsDerivedModel.lua) — no longer re-resolving the split itself — with a [`CustomLobbyTeamScore`](CustomLobbyTeamScore.lua) strip across the top as the Left/Right indicator, and the "two columns, unresolved" fallback — parity fill + the score self-hides until a positional map's start positions load) + [`CustomLobbySlotCard`](slots/twocolumn/CustomLobbySlotCard.lua) (fat half-width / two-line presentation; `SetMirrored` flips the right column's cards so the two teams face each other). Each body exports `HeightForCount`. | diff --git a/lua/ui/lobby/customlobby/CustomLobbyBalancePreview.lua b/lua/ui/lobby/customlobby/CustomLobbyBalancePreview.lua index 0ea1ce3e360..7653239cefd 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyBalancePreview.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyBalancePreview.lua @@ -26,6 +26,13 @@ -- synced state: it reads the current lobby snapshot, runs the pure CustomLobbyBalancer kernel, and on -- Apply hands the proposed arrangement to the host-authoritative `RequestApplyBalance` intent. -- +-- The body is rendered as **rank-matched pair rows** (row k = the k-th strongest of each side, who +-- face off), so the host reads the proposal the way the game plays out: each row shows the two players +-- and the rating gap between them (colour-coded), with players that *move* from their current seat in +-- gold and host-*locked* players in blue. The summary reports per-team average + total, the match +-- quality "before -> after", the predicted win split, and the rating delta — enough to judge a balance +-- at a glance and re-roll (Retry) until it reads well. +-- -- Built to the preset dialog's shape (areas layout, three-phase init, Popup singleton). local UIUtil = import("/lua/ui/uiutil.lua") @@ -34,7 +41,6 @@ local LayoutHelpers = import("/lua/maui/layouthelpers.lua") local Group = import("/lua/maui/group.lua").Group local Bitmap = import("/lua/maui/bitmap.lua").Bitmap local Popup = import("/lua/ui/controls/popups/popup.lua").Popup -local ItemList = import("/lua/maui/itemlist.lua").ItemList local CustomLobbySlotsDerivedModel = import("/lua/ui/lobby/customlobby/models/derived/customlobbyslotsderivedmodel.lua") local CustomLobbyBalancer = import("/lua/ui/lobby/customlobby/customlobbybalancer.lua") @@ -44,18 +50,26 @@ local Layouter = LayoutHelpers.ReusedLayoutFor local Debug = false -local DialogWidth = 520 +local DialogWidth = 540 local DialogHeight = 420 local Pad = 12 -local ColumnGap = 16 local TitleHeight = 30 +local HeaderHeight = 22 local StatusHeight = 24 local ActionHeight = 52 -local ScrollbarInset = 20 +local RowHeight = 22 +local MaxRows = 8 -- binary AutoTeams on a 16-spawn map is at most 8 per side local ButtonGap = 8 -local LabelColor = 'ffc8ccd0' +local LabelColor = 'ffc8ccd0' -- a player that stays where it is +local MoveColor = 'ffe6c64f' -- a player the balance moves (gold) +local LockColor = 'ff7fb2ff' -- a host-locked player, pinned in place (blue) local HeaderColor = 'ffd9c97a' +local MutedColor = 'ff8c9096' + +local GapLowColor = 'ff66bb66' -- a close pair +local GapMidColor = 'ffd9c97a' -- a noticeable gap +local GapHighColor = 'ffcc6666' -- a lopsided pair --- Creates an invisible layout area (tinted while Debug is on). ---@param parent Control @@ -83,31 +97,66 @@ local function ComputeForCurrentLobby() CustomLobbySlotsDerivedModel.GetTeams()) end ---- One player's row in a team column: "name · rating", with a lock marker for a user-locked seat. ---- The two columns are rating-sorted, so row k of each is the rank-matched pair that faces off. +--- "Name · 1850" for a player (rating omitted when unrated / AI). `mirror` reverses it to +--- "1850 · Name" so the right column faces the left — the name stays on the outer edge and the +--- rating reads inward toward the centre gap, exactly like the mirrored slot cards. ---@param player UICustomLobbyBalancePlayer ----@param locked boolean +---@param mirror boolean ---@return string -local function PlayerRow(player, locked) - local rating = player.pl > 0 and (" · " .. tostring(player.pl)) or "" - local lock = locked and " [locked]" or "" - return player.name .. rating .. lock +local function FormatPlayer(player, mirror) + if player.pl > 0 then + if mirror then + return tostring(player.pl) .. " · " .. player.name + end + return player.name .. " · " .. tostring(player.pl) + end + return player.name end +--- The display colour for a player row: blue = host-locked (pinned), gold = moved by the balance, +--- grey = unchanged. Locked wins (a locked player never moves, so it can't also read as a mover). +---@param player UICustomLobbyBalancePlayer +---@return string +local function PlayerColor(player) + if player.locked then + return LockColor + elseif player.moved then + return MoveColor + end + return LabelColor +end + +--- Colour for a pair's rating gap: green close, amber noticeable, red lopsided. +---@param gap number +---@return string +local function GapColor(gap) + if gap < 100 then + return GapLowColor + elseif gap < 250 then + return GapMidColor + end + return GapHighColor +end + +---@class UICustomLobbyBalanceRow +---@field Group Group +---@field Left Text +---@field Center Text +---@field Right Text + ---@class UICustomLobbyBalancePreview : Group ---@field Trash TrashBag ---@field OnCloseCb fun() ---@field Result UICustomLobbyBalancePlan ---@field TitleArea Group ----@field ColumnAArea Group ----@field ColumnBArea Group +---@field HeaderArea Group +---@field RowsArea Group ---@field StatusArea Group ---@field ActionArea Group ---@field Title Text ---@field HeaderA Text ---@field HeaderB Text ----@field ListA ItemList ----@field ListB ItemList +---@field Rows UICustomLobbyBalanceRow[] ---@field Status Text ---@field ApplyButton Button ---@field RetryButton Button @@ -129,8 +178,8 @@ local CustomLobbyBalancePreview = ClassUI(Group) { self.Result = ComputeForCurrentLobby() self.TitleArea = CreateArea(self, "TitleArea", 'ffcc4040') - self.ColumnAArea = CreateArea(self, "ColumnAArea", 'ff4060cc') - self.ColumnBArea = CreateArea(self, "ColumnBArea", 'ff40cc60') + self.HeaderArea = CreateArea(self, "HeaderArea", 'ff4060cc') + self.RowsArea = CreateArea(self, "RowsArea", 'ff406060') self.StatusArea = CreateArea(self, "StatusArea", 'ffcc40cc') self.ActionArea = CreateArea(self, "ActionArea", 'ff808080') @@ -138,21 +187,26 @@ local CustomLobbyBalancePreview = ClassUI(Group) { self.Title:DisableHitTest() local labels = self.Result.labels or { "Team 1", "Team 2" } - self.HeaderA = UIUtil.CreateText(self.ColumnAArea, labels[1], 14, UIUtil.titleFont) + self.HeaderA = UIUtil.CreateText(self.HeaderArea, labels[1], 14, UIUtil.titleFont) self.HeaderA:SetColor(HeaderColor) self.HeaderA:DisableHitTest() - self.HeaderB = UIUtil.CreateText(self.ColumnBArea, labels[2], 14, UIUtil.titleFont) + self.HeaderB = UIUtil.CreateText(self.HeaderArea, labels[2], 14, UIUtil.titleFont) self.HeaderB:SetColor(HeaderColor) self.HeaderB:DisableHitTest() - self.ListA = ItemList(self.ColumnAArea) - self.ListA:SetFont(UIUtil.bodyFont, 14) - self.ListA:SetColors(LabelColor, "00000000") - self.ListA:DisableHitTest() - self.ListB = ItemList(self.ColumnBArea) - self.ListB:SetFont(UIUtil.bodyFont, 14) - self.ListB:SetColors(LabelColor, "00000000") - self.ListB:DisableHitTest() + -- a fixed pool of pair rows; Render shows/hides + fills them from the proposal + self.Rows = {} + for i = 1, MaxRows do + local row = Group(self.RowsArea, "BalanceRow" .. i) + local left = UIUtil.CreateText(row, "", 14, UIUtil.bodyFont) + left:DisableHitTest() + local center = UIUtil.CreateText(row, "", 12, UIUtil.bodyFont) + center:SetColor(MutedColor) + center:DisableHitTest() + local right = UIUtil.CreateText(row, "", 14, UIUtil.bodyFont) + right:DisableHitTest() + self.Rows[i] = { Group = row, Left = left, Center = center, Right = right } + end self.Status = UIUtil.CreateText(self.StatusArea, "", 14, UIUtil.bodyFont) self.Status:SetColor(LabelColor) @@ -182,29 +236,32 @@ local CustomLobbyBalancePreview = ClassUI(Group) { self.Height:Set(LayoutHelpers.ScaleNumber(DialogHeight)) Layouter(self.TitleArea):AtLeftIn(self, Pad):AtRightIn(self, Pad):AtTopIn(self, Pad):Height(TitleHeight):End() + Layouter(self.HeaderArea):AtLeftIn(self, Pad):AtRightIn(self, Pad):AnchorToBottom(self.TitleArea, Pad):Height(HeaderHeight):End() Layouter(self.ActionArea):AtLeftIn(self, Pad):AtRightIn(self, Pad):AtBottomIn(self, Pad):Height(ActionHeight):End() Layouter(self.StatusArea):AtLeftIn(self, Pad):AtRightIn(self, Pad):AnchorToTop(self.ActionArea, Pad):Height(StatusHeight):End() - - -- two equal columns between the title and the status line - local columnWidth = function() return (self.Width() - LayoutHelpers.ScaleNumber(2 * Pad + ColumnGap)) / 2 end - Layouter(self.ColumnAArea) - :AtLeftIn(self, Pad):Width(columnWidth) - :AnchorToBottom(self.TitleArea, Pad):AnchorToTop(self.StatusArea, Pad) - :End() - Layouter(self.ColumnBArea) - :AtRightIn(self, Pad):Width(columnWidth) - :AnchorToBottom(self.TitleArea, Pad):AnchorToTop(self.StatusArea, Pad) + Layouter(self.RowsArea) + :AtLeftIn(self, Pad):AtRightIn(self, Pad) + :AnchorToBottom(self.HeaderArea, Pad):AnchorToTop(self.StatusArea, Pad) :End() Layouter(self.Title):AtHorizontalCenterIn(self.TitleArea):AtVerticalCenterIn(self.TitleArea):End() - Layouter(self.HeaderA):AtLeftIn(self.ColumnAArea):AtTopIn(self.ColumnAArea):End() - Layouter(self.HeaderB):AtLeftIn(self.ColumnBArea):AtTopIn(self.ColumnBArea):End() - - Layouter(self.ListA):AtLeftIn(self.ColumnAArea):AnchorToBottom(self.HeaderA, 4):AtBottomIn(self.ColumnAArea):End() - self.ListA.Right:Set(function() return self.ColumnAArea.Right() - LayoutHelpers.ScaleNumber(ScrollbarInset) end) - Layouter(self.ListB):AtLeftIn(self.ColumnBArea):AnchorToBottom(self.HeaderB, 4):AtBottomIn(self.ColumnBArea):End() - self.ListB.Right:Set(function() return self.ColumnBArea.Right() - LayoutHelpers.ScaleNumber(ScrollbarInset) end) + Layouter(self.HeaderA):AtLeftIn(self.HeaderArea):AtVerticalCenterIn(self.HeaderArea):End() + Layouter(self.HeaderB):AtRightIn(self.HeaderArea):AtVerticalCenterIn(self.HeaderArea):End() + + -- stack the pair rows from the top of the rows area + for i = 1, MaxRows do + local row = self.Rows[i] + Layouter(row.Group):AtLeftIn(self.RowsArea):AtRightIn(self.RowsArea):Height(RowHeight):End() + if i == 1 then + Layouter(row.Group):AtTopIn(self.RowsArea):End() + else + Layouter(row.Group):AnchorToBottom(self.Rows[i - 1].Group, 0):End() + end + Layouter(row.Left):AtLeftIn(row.Group):AtVerticalCenterIn(row.Group):End() + Layouter(row.Center):AtHorizontalCenterIn(row.Group):AtVerticalCenterIn(row.Group):End() + Layouter(row.Right):AtRightIn(row.Group):AtVerticalCenterIn(row.Group):End() + end Layouter(self.Status):AtLeftIn(self.StatusArea):AtVerticalCenterIn(self.StatusArea):End() @@ -213,7 +270,7 @@ local CustomLobbyBalancePreview = ClassUI(Group) { Layouter(self.RetryButton):AnchorToLeft(self.ApplyButton, ButtonGap):AtVerticalCenterIn(self.ActionArea):End() end, - --- Post-mount render (the opener calls this after Popup centres the dialog, so the lists read + --- Post-mount render (the opener calls this after Popup centres the dialog, so the rows read --- concrete geometry). ---@param self UICustomLobbyBalancePreview Initialize = function(self) @@ -221,39 +278,73 @@ local CustomLobbyBalancePreview = ClassUI(Group) { self:Render() end, - --- Paints the two columns + status line from the computed proposal, and enables Apply only when - --- there is something to apply. + --- The per-team header: "label total (avg avg)", mirrored on the right side ("total (avg avg) + --- label") so the team label stays on the outer edge of its column. ---@param self UICustomLobbyBalancePreview - Render = function(self) + ---@param side 1 | 2 + ---@return string + SideHeader = function(self, side) local result = self.Result local labels = result.labels or { "Team 1", "Team 2" } + local count = table.getn(result.sides[side]) + local total = result.totals[side] + local avg = count > 0 and math.floor(total / count + 0.5) or 0 + local stats = tostring(total) .. " (" .. tostring(avg) .. " avg)" + if side == 2 then + return stats .. " " .. labels[side] + end + return labels[side] .. " " .. stats + end, - self.HeaderA:SetText(labels[1] .. " " .. tostring(result.totals[1])) - self.HeaderB:SetText(labels[2] .. " " .. tostring(result.totals[2])) - - local locked = result.lockedOwners or {} - self.ListA:DeleteAllItems() - for _, player in ipairs(result.sides[1]) do - self.ListA:AddItem(PlayerRow(player, locked[player.ownerId])) + --- Fills one pair row from the k-th players of each side (either may be absent on an uneven split). + ---@param self UICustomLobbyBalancePreview + ---@param row UICustomLobbyBalanceRow + ---@param a UICustomLobbyBalancePlayer | nil + ---@param b UICustomLobbyBalancePlayer | nil + FillRow = function(self, row, a, b) + row.Group:Show() + if a then + row.Left:SetText(FormatPlayer(a, false)) + row.Left:SetColor(PlayerColor(a)) + else + row.Left:SetText("") end - self.ListB:DeleteAllItems() - for _, player in ipairs(result.sides[2]) do - self.ListB:AddItem(PlayerRow(player, locked[player.ownerId])) + if b then + row.Right:SetText(FormatPlayer(b, true)) + row.Right:SetColor(PlayerColor(b)) + else + row.Right:SetText("") end - - -- status line: the reason it can't balance, else the match quality (+ any odd one left out) - local status - if result.reason then - status = result.reason - elseif result.quality then - status = "Match quality: " .. tostring(result.quality) .. "%" + -- the pair's rating gap, only when both players are rated + if a and b and a.pl > 0 and b.pl > 0 then + local gap = math.abs(a.pl - b.pl) + row.Center:SetText("+" .. tostring(gap)) + row.Center:SetColor(GapColor(gap)) else - status = "Match quality: —" + row.Center:SetText("") end - if result.unassigned then - status = status .. " · " .. result.unassigned.name .. " stays put (odd count)" + end, + + --- Paints the pair rows + the summary line from the computed proposal, and enables Apply only when + --- there is something to apply. + ---@param self UICustomLobbyBalancePreview + Render = function(self) + local result = self.Result + + self.HeaderA:SetText(self:SideHeader(1)) + self.HeaderB:SetText(self:SideHeader(2)) + + local rowCount = math.max(table.getn(result.sides[1]), table.getn(result.sides[2])) + for i = 1, MaxRows do + local row = self.Rows[i] + if i <= rowCount then + self:FillRow(row, result.sides[1][i], result.sides[2][i]) + else + row.Group:Hide() + end end - self.Status:SetText(status) + + self.Status:SetText(self:StatusLine()) if result.feasible then self.ApplyButton:Enable() @@ -264,6 +355,37 @@ local CustomLobbyBalancePreview = ClassUI(Group) { end end, + --- The summary: the reason it can't balance, else "Quality before -> after · Win a/b · Δ delta", + --- plus any odd one left out. + ---@param self UICustomLobbyBalancePreview + ---@return string + StatusLine = function(self) + local result = self.Result + if result.reason then + return result.reason + end + + -- match quality, as "current -> proposed" when both are known + local quality + if result.quality and result.currentQuality then + quality = "Quality " .. tostring(result.currentQuality) .. "% -> " .. tostring(result.quality) .. "%" + elseif result.quality then + quality = "Quality " .. tostring(result.quality) .. "%" + else + quality = "Quality n/a" + end + + local status = quality + if result.winChance then + status = status .. " · Win " .. tostring(result.winChance[1]) .. "% / " .. tostring(result.winChance[2]) .. "%" + end + status = status .. " · Gap " .. tostring(math.abs(result.totals[1] - result.totals[2])) + if result.unassigned then + status = status .. " · " .. result.unassigned.name .. " stays put (odd count)" + end + return status + end, + --- Re-rolls the proposal (the balance is randomised) and re-renders, without committing. ---@param self UICustomLobbyBalancePreview Retry = function(self) @@ -318,7 +440,7 @@ function Open(parent) end Instance = popup - -- Popup has mounted + centred the content; now it's safe to populate the lists + -- Popup has mounted + centred the content; now it's safe to populate the rows content:Initialize() end diff --git a/lua/ui/lobby/customlobby/CustomLobbyBalancer.lua b/lua/ui/lobby/customlobby/CustomLobbyBalancer.lua index 6ea5f078390..96a416d595e 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyBalancer.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyBalancer.lua @@ -59,6 +59,7 @@ local MaxBalanceEvaluations = 20000 ---@field locked boolean # the host pinned this seat ---@field side 1 | 2 # the seat's resolved auto-team side (current) ---@field slot number # current seat +---@field moved boolean # this player's proposed seat differs from its current seat (preview highlight) --- A proposed balance. ---@class UICustomLobbyBalancePlan @@ -69,7 +70,9 @@ local MaxBalanceEvaluations = 20000 ---@field unassigned UICustomLobbyBalancePlayer | false # the odd one left in place, if any ---@field feasible boolean # false = nothing to apply (Apply disabled) ---@field reason string | nil # why it can't / didn't balance, for the preview ----@field quality number | false # trueskill match quality %, or false +---@field quality number | false # trueskill match quality % of the proposal, or false +---@field currentQuality number | false # trueskill match quality % of the CURRENT seating, for "before -> after" +---@field winChance number[] | false # predicted win % per side {a, b} for the proposal, or false ---@field arrangement table # the lobby update: slot -> ownerId (no team) --- Sorts players strongest-first by display rating, so two rating-sorted sides line up rank for rank. @@ -105,6 +108,53 @@ local function ComputeQuality(sides) return quality end +-- trueskill's per-player performance variance (beta); matches the 250 baked into computeQuality. +local Beta = 250 + +--- Gauss error function (Abramowitz & Stegun 7.1.26) — max abs error ~1.5e-7, plenty for a display %. +---@param x number +---@return number +local function Erf(x) + local sign = x < 0 and -1 or 1 + x = math.abs(x) + local t = 1 / (1 + 0.3275911 * x) + local y = 1 - (((((1.061405429 * t - 1.453152027) * t) + 1.421413741) * t - 0.284496736) * t + 0.254829592) + * t * math.exp(-x * x) + return sign * y +end + +--- Predicted win split for a two-side proposal, as integer percentages {winA, winB} summing to 100. +--- This is the probabilistic counterpart to the symmetric match quality: quality says "how close", +--- this says "which way". P(A beats B) = Phi(Δμ / sqrt(N·β² + Σσ²)) over both sides' players. Returns +--- false for AI / unrated players (mean 0) or an empty side — the preview then shows "—". +---@param sides UICustomLobbyBalancePlayer[][] +---@return number[] | false +local function ComputeWinChance(sides) + if table.getn(sides[1]) == 0 or table.getn(sides[2]) == 0 then + return false + end + local sumMean = { 0, 0 } + local sumVar = { 0, 0 } + local count = 0 + for side = 1, 2 do + for _, bp in ipairs(sides[side]) do + if not bp.mean or bp.mean == 0 then + return false + end + sumMean[side] = sumMean[side] + bp.mean + sumVar[side] = sumVar[side] + bp.dev * bp.dev + count = count + 1 + end + end + local denom = math.sqrt(count * Beta * Beta + sumVar[1] + sumVar[2]) + if denom <= 0 then + return false + end + local pA = 0.5 * (1 + Erf((sumMean[1] - sumMean[2]) / (denom * math.sqrt(2)))) + local a = math.floor(pA * 100 + 0.5) + return { a, 100 - a } +end + --- Phase 3 — seats already-formed rank-matched pairs at randomly-chosen mirror rows: `pairsA[i]` vs --- `pairsB[i]` is the i-th pair (formed by rating in phase 2), and it lands at the same free row on --- both sides, so WHO faces whom stays fixed while the POSITION is random. `seatsA`/`seatsB` are the @@ -175,6 +225,8 @@ function BuildPlan(slots, teams) feasible = false, reason = nil, quality = false, + currentQuality = false, + winChance = false, arrangement = {}, } @@ -215,6 +267,14 @@ function BuildPlan(slots, teams) return plan end + -- score the CURRENT seating too, so the preview can show "before -> after" and the host can tell + -- whether re-balancing is even worth applying + local currentSides = { {}, {} } + for _, bp in ipairs(players) do + table.insert(currentSides[bp.side], bp) + end + plan.currentQuality = ComputeQuality(currentSides) + -- pinned = the host-locked players + (odd roster) the lowest-rated unlocked player, left in place -- so the rest pair up (never ejected). Pinned players hold their exact seat and side. local pinned = {} @@ -350,7 +410,18 @@ function BuildPlan(slots, teams) plan.totals[side] = total end + -- flag who actually moves vs. the current seating, so the preview can highlight the changes (locked + -- and unassigned players map to their own seat, so they read as not moved) + local assignedSlot = {} + for slot, ownerId in plan.arrangement do + assignedSlot[ownerId] = slot + end + for _, bp in ipairs(players) do + bp.moved = assignedSlot[bp.ownerId] ~= bp.slot + end + plan.quality = ComputeQuality(plan.sides) + plan.winChance = ComputeWinChance(plan.sides) plan.feasible = freeCount > 0 if freeCount == 0 then plan.reason = "Every player is locked in place — nothing to rearrange." From 3d596c91df1d9565d45ec94ba50ef587d7311258 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sat, 27 Jun 2026 21:02:27 +0200 Subject: [PATCH 80/98] Add drag-and-drop functionality to update the balance --- lua/ui/lobby/customlobby/CLAUDE.md | 4 +- .../customlobby/CustomLobbyBalancePreview.lua | 559 +++++++++++++++--- .../lobby/customlobby/CustomLobbyBalancer.lua | 311 +++++++--- 3 files changed, 712 insertions(+), 162 deletions(-) diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index 28182b76b34..b6c467257fc 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -53,11 +53,11 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | [CustomLobbyLog.lua](CustomLobbyLog.lua) | the **network traffic log** — a reactive, per-peer, never-synced ring buffer (`Entries` LazyVar, capped) of every message this peer broadcasts / sends / receives, fed by the instance and rendered by the **Logs** tab. Each peer logs only its own traffic, so host and client views differ naturally. Not one of the three models (no game state); a diagnostic feed. | | [CustomLobbyController.lua](CustomLobbyController.lua) | host-authority logic (free functions): seating, `Process*` handlers, intents (`RequestSetReady`, `RequestTakeSlot`, `RequestSwapSlots`, `RequestEject`, `RequestMoveToObserver`, `RequestSetScenario`, `RequestSetGameMods/Options`, `RequestResetGameOptions`, `RequestSetSlotsPinned`, `RequestSetSlotLocked`, `RequestApplyBalance` (applies a previewed balance), `RequestReopenClosedSlots`, `RequestLaunch`, `RequestSaveSetupPreset`/`RequestLoadSetupPreset` — all keyed by slot/bool/file/name so a chat command can call them too; permission is gated separately), sharing the stored CPU benchmark. **Launch:** `RequestLaunch` (host-only, readiness-validated) → auto-saves the `lastGame` preset (`BuildSetupSnapshot`) → `BuildGameConfiguration` (seed option defaults + scenario, resolve random factions, assign army numbers + push to server, stamp ratings/clan tags, resolve team-from-position for the binary AutoTeams modes, resolve sim mods via `Mods.GetGameMods`) → broadcast `LaunchGame` + `instance:LaunchGame`; clients run `ProcessLaunchGame`. **Presets:** `BuildSetupSnapshot` (read the launch state → serializable **setup-only** snapshot: scenario / options / mods / restrictions) + `ApplySetup` (host-only: write scenario/mods/restrictions, reconcile options to the current schema, one broadcast); players, observers and teams/spawn-mex are not stored (see [presetselect/](presetselect/CLAUDE.md)). | | [CustomLobbyRules.lua](CustomLobbyRules.lua) | the game-rule **pure kernel** (not view, not networking, **reads no models**): `RecommendedUnitCap(seatedCount, maxDimension)` (per-player cap by map size), `AutoTeamMode(gameOptions)` / `SideLabels(mode)` / `BuildSideResolver(mode, scenario)` (binary auto-team split). Every input is passed in, so the same functions serve the reactive [slots derived model](models/derived/CustomLobbySlotsDerivedModel.lua) (which applies them to publish `Side` / `Teams`) and, later, the host's launch-time team assignment — neither path makes this module read state. | -| [CustomLobbyBalancer.lua](CustomLobbyBalancer.lua) | the auto-balance **pure kernel** (**reads no models**) — **positional only** (the feature is gated to a binary AutoTeams mode, so there are always exactly two sides and the **position decides the team**; the kernel never writes `Team`). `BuildPlan(slots, teams)` takes the [slots derived model](models/derived/CustomLobbySlotsDerivedModel.lua)'s **already-resolved** snapshot — its `Slots` table (per-seat player / side / locked / closed) + `Teams` aggregate (mode / labels / resolved) — so it never re-resolves sides or re-reads locks. Builds a **plan** in three phases (a port of the legacy `PenguinAutoBalance`): **(1) Teams** — split the free players into two even sides minimising the cheap rating heuristic (locked players held on their position's side; odd roster leaves the lowest-rated unlocked player put). **(2) Pairs** — rank-match across the teams (strongest of A faces strongest of B; by rating, never random). **(3) Positions** — `ShufflePairsIntoSeats` (its own exported function) scatters the pairs across the free mirror rows; locked players keep their exact seat. Then it scores the proposal for the preview: `quality` + `currentQuality` (`trueskill.computeQuality` of the proposed vs. the current seating, for a "before → after"), `winChance` (predicted win % per side — a local normal-CDF over the side mean/variance sums, the directional counterpart to the symmetric quality), and a per-player `moved` flag (proposed seat ≠ current seat, for the preview highlight). The plan's two `sides` are rating-sorted (preview columns = the facing pairs), `lockedOwners` flags locked players, and `ToArrangement(plan)` returns the lobby update — **slot → ownerId**, no team. The controller's `RequestApplyBalance` re-seats from it; the team falls out of the position, resolved at launch by `BuildGameConfiguration` (binary AutoTeams → team-from-`StartSpot`). The preview renders the plan. | +| [CustomLobbyBalancer.lua](CustomLobbyBalancer.lua) | the auto-balance **pure kernel** (**reads no models**) — **positional only** (the feature is gated to a binary AutoTeams mode, so there are always exactly two sides and the **position decides the team**; the kernel never writes `Team`). Takes the [slots derived model](models/derived/CustomLobbySlotsDerivedModel.lua)'s **already-resolved** snapshot — its `Slots` table (per-seat player / side / locked / closed) + `Teams` aggregate (mode / labels / resolved) — so it never re-resolves sides. **Solve and score are split** so the preview can hand-edit a balance: **`Rebalance(slots, teams, arrangement, locks)`** re-solves the *unlocked* players in three phases (a port of the legacy `PenguinAutoBalance`): **(1) Teams** — split into two even sides minimising the cheap rating heuristic; **(2) Pairs** — rank-match across the teams (strongest of A faces strongest of B; by rating, never random); **(3) Positions** — `ShufflePairsIntoSeats` (its own exported function) scatters the pairs across the free mirror rows. Locked players (and an odd roster's lowest-rated unlocked player) are **pinned at their seat in `arrangement`** — not their lobby seat — so locking a player the balance already moved keeps them put. **`ScoreArrangement(slots, teams, arrangement, locks)`** scores a concrete arrangement *without* re-solving (for a manual swap / lock toggle). Both run `ScorePlan`, which fills the plan: two rating-sorted `sides` (used for `totals` / quality), the ordered mirror **`positions`** (the k-th side-1 seat vs the k-th side-2 seat, sorted by slot so the list is stable across re-scores — this is what the preview renders + drag-reorders), per-side PL `totals`, `quality` + `currentQuality` (`trueskill.computeQuality` proposed vs. current, for a "before → after"), `winChance` (predicted win % per side — a local normal-CDF over the side mean/variance sums, the directional counterpart to the symmetric quality), and a per-player `moved` flag. `locks` (ownerId→true) is an optional **override** of the seats' own Locked flags — the preview passes its working, preview-local lock set; nil falls back to `entry.Locked`. **`BuildPlan(slots, teams, locks?)`** is the convenience entry (a fresh solve from the lobby's current seating). `lockedOwners` flags locked players; `ToArrangement(plan)` returns the lobby update — **slot → ownerId**, no team. The controller's `RequestApplyBalance` re-seats from it; the team falls out of the position, resolved at launch by `BuildGameConfiguration` (binary AutoTeams → team-from-`StartSpot`). | | [CustomLobbyPresets.lua](CustomLobbyPresets.lua) | **named setup presets** (setup-only: scenario / options / mods / restrictions — no players/observers) — pure prefs CRUD (`GetPresets`/`GetPreset`/`SavePreset`/`DeletePreset`/`RenamePreset`) over one key (`customlobby_setup_presets`), an ordered `{ Name, Setup }` array mirroring the mod presets in [`/lua/ui/modutilities.lua`](/lua/ui/modutilities.lua). Holds no models, no network. A reserved `LastGamePresetName` entry is auto-saved at launch (the rehost source). Capturing/applying a setup lives in the controller (`BuildSetupSnapshot`/`ApplySetup`); see [presetselect/](presetselect/CLAUDE.md). | | [CustomLobbySession.lua](CustomLobbySession.lua) | the lobby session's **main trash bag**. The lobby lives in the persistent front-end Lua state, which is *not* reset when the game launches in its own state — so anything left reachable (a running thread, a cache, a singleton) leaks for the whole match. This module owns one session-lifetime `TrashBag` (`GetTrash()`); every lobby-scoped `Destroyable` registers in it, and one `Teardown()` (clean-slate in `CreateLobby`, on leave, on `OnGameLaunched`) frees the lot. Works despite the bag being weak-valued because each singleton is pinned by its own module `Instance` local. **Rollout in progress** — converted: the map catalog, all five [derived models](models/derived/CLAUDE.md), and the three authoritative [models/](models/) (Launch/Session/Local, with thin `Destroy` per the design's decision #3). Still to do: the mod catalog, the interface + performance popover (pending the teardown-ordering decision), and the lobby instance. See [design/session-trashbag-teardown.md](design/session-trashbag-teardown.md). | | [CustomLobbyMessages.lua](CustomLobbyMessages.lua) | message registry: `AddPlayer`, `SetPlayers` (launch model: players + observers), `SentLaunchInfo` (launch model: scenario / options / mods / teams / spawn mex), `SetSessionState` (session model: slot count / closed slots / locked slots / pinned), `SetReady`, `TakeSlot`, `DisconnectPeer`, `LaunchGame` (host's final game config → `instance:LaunchGame`), `ReportCpuBenchmark`, `SetCpuBenchmarks`. | -| [CustomLobbyBalancePreview.lua](CustomLobbyBalancePreview.lua) | the auto-balance **preview** (host-only `Popup`): the slots header's balance button opens it instead of re-seating directly. Reads the derived snapshot (`GetSlots()` + `GetTeams()`), runs `CustomLobbyBalancer.BuildPlan`, and renders the proposal as **rank-matched pair rows** (row k = the k-th strongest of each side, who face off) — each row shows both players + the pair's rating gap (Δ, colour-coded green→red), with **moved** players in gold and **locked** players in blue. A summary reports each team's total + average rating, the match quality **before → after**, the predicted **win** split, the rating **Δ**, and any odd one left in place (or why it can't balance). **Apply** (→ `RequestApplyBalance` with `ToArrangement(plan)`) / **Retry** (re-rolls the plan — the free pool is shuffled, so ties and positional seating vary) / **Cancel** (nothing mutated). A transient picker, owns no synced state. | +| [CustomLobbyBalancePreview.lua](CustomLobbyBalancePreview.lua) | the auto-balance **preview + tuner** (host-only `Popup`): the slots header's balance button opens it instead of re-seating directly. It owns a **working state** nothing synced sees until Apply — `Arrangement` (slot→ownerId) + `Locks` (a preview-local lock set, **seeded** from the lobby's real locks but never written back). Renders the proposal as **mirror-position rows** (row k = the k-th seat on each side, who face off, in start-position order with a position label; the right column **mirrored** so the teams face each other) — each row shows both players + the pair's rating gap (colour-coded green→red), **moved** players in gold, **locked** players in blue. Four gestures: **drag a row** onto another → swap those two positions' pairs (both players move together — "D → A"; press-vs-drag threshold like the lobby slot rows, with a drop-target highlight); **click two players** → swap just those two (`ScoreArrangement` re-scores, no re-solve); **click a player's lock dot** → pin/unpin them (preview-local); **Retry** → re-solve the unlocked players around the locked ones (`Rebalance`). A summary reports each team's total + average, match quality **before → after**, predicted **win** split, the rating **gap**, and any odd one out (or why it can't balance). **Apply** commits only `Arrangement` (→ `RequestApplyBalance`); **Cancel** discards everything (locks included). A transient picker, owns no synced state. | | [CustomLobbyContextMenu.lua](CustomLobbyContextMenu.lua) | generic framed floating menu; `Show(entries, x, y)` renders any `{label, action, enabled}` list, dismisses on item click / click-outside / Esc. Knows nothing about the lobby. | | [CustomLobbyMenus.lua](CustomLobbyMenus.lua) | declarative menu **definitions**: entry lists with `when(ctx)`/`action(ctx)` filtered by lobby state (`BuildSlotMenu`). Adding/state-gating an item is a one-liner here. | | [slots/](slots/) | the **slot subsystem** (its own folder, a sub-folder per layout). [`CustomLobbySlotsInterface`](slots/CustomLobbySlotsInterface.lua) is the entry the composition root mounts: a "Players" header over the **active layout body**, picked by the AutoTeams mode — one-column for the non-team modes, the two-column team layout for the binary modes (left/right, top/bottom, even/odd). It observes `GameOptions` and swaps the body when the kind flips (create-on-mode / destroy-on-switch); a change *within* the binary modes is handled by the two-column body's own re-layout. It is the rows' **drag coordinator** (`UICustomLobbySlotCoordinator`: hit-test which row a point is in, drop-highlight, drag ghost → `RequestSwapSlots`) for *every* layout — it alone needs to hit-test across rows — so the layout bodies stay pure build/place/reveal and never duplicate the drag logic; it also exposes `PreferredHeight()` (computed from the mode + model, swap-order-independent) so the root sizes the slot area. The header band carries a **host-only tool strip** (right-aligned: pin seating · auto-balance · reopen closed slots) of small icon buttons (local `SlotTool`, mirroring the config column's `PreviewTool`); the pin button is lit from the synced `SlotsPinned` and the strip is hidden for clients. The **auto-balance** button is gated (`SlotTool:SetEnabled`, greyed when off) to a **resolved binary AutoTeams mode** (the derived `Teams` aggregate's `Mode` + `Resolved` — two well-defined sides, map loaded for the positional ones) and opens the [balance preview](CustomLobbyBalancePreview.lua) rather than re-seating on click. A **"Locked" notice** (lock glyph + label) sits right of the "Players" label and is shown to **everyone** while `SlotsPinned` is on, so a client can see seating is host-controlled before clicking an open slot to no effect. [`CustomLobbySlotBase`](slots/CustomLobbySlotBase.lua) holds **all slot behaviour** (one subscription to the [slots derived model](models/derived/CustomLobbySlotsDerivedModel.lua) — which already merged this seat's player / placement / closed / CPU benchmark and did the faction/CPU formatting — click/right-click/drag-to-swap + intents, the background/highlight/click overlays + a gold left-edge **lock stripe** shown when the seat's `Locked` flag is set) and the default `RenderPlayer`/`RenderCpu` that paint the standard named controls from the entry's resolved views; a presentation subclasses it and implements just `CreateContents` / `LayoutContents`. [`onecolumn/`](slots/onecolumn/): [`CustomLobbyOneColumnSlots`](slots/onecolumn/CustomLobbyOneColumnSlots.lua) (stacks thin rows, reveals 1..count) + [`CustomLobbySlotRow`](slots/onecolumn/CustomLobbySlotRow.lua) (thin one-line presentation). [`twocolumn/`](slots/twocolumn/): [`CustomLobbyTwoColumnSlots`](slots/twocolumn/CustomLobbyTwoColumnSlots.lua) (splits slots into two team columns by each seat's `Side` from the [slots derived model](models/derived/CustomLobbySlotsDerivedModel.lua) — no longer re-resolving the split itself — with a [`CustomLobbyTeamScore`](CustomLobbyTeamScore.lua) strip across the top as the Left/Right indicator, and the "two columns, unresolved" fallback — parity fill + the score self-hides until a positional map's start positions load) + [`CustomLobbySlotCard`](slots/twocolumn/CustomLobbySlotCard.lua) (fat half-width / two-line presentation; `SetMirrored` flips the right column's cards so the two teams face each other). Each body exports `HeightForCount`. | diff --git a/lua/ui/lobby/customlobby/CustomLobbyBalancePreview.lua b/lua/ui/lobby/customlobby/CustomLobbyBalancePreview.lua index 7653239cefd..7d9a4b2f08a 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyBalancePreview.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyBalancePreview.lua @@ -20,18 +20,26 @@ --** SOFTWARE. --****************************************************************************************************** --- The auto-balance **preview**: a host-only modal that shows the proposed two-team split (and the --- resulting match quality) BEFORE anything is re-seated, with Apply / Cancel — so the host commits a --- balance on purpose (USER_STORIES § R: "an opti team preview"). It is a transient picker, owns no --- synced state: it reads the current lobby snapshot, runs the pure CustomLobbyBalancer kernel, and on --- Apply hands the proposed arrangement to the host-authoritative `RequestApplyBalance` intent. +-- The auto-balance **preview**: a host-only modal that proposes a two-team split (and its match +-- quality) BEFORE anything is re-seated, with Apply / Cancel — so the host commits a balance on +-- purpose (USER_STORIES § R). It is also where the host *tunes* the balance with a few clicks. -- --- The body is rendered as **rank-matched pair rows** (row k = the k-th strongest of each side, who --- face off), so the host reads the proposal the way the game plays out: each row shows the two players --- and the rating gap between them (colour-coded), with players that *move* from their current seat in --- gold and host-*locked* players in blue. The summary reports per-team average + total, the match --- quality "before -> after", the predicted win split, and the rating delta — enough to judge a balance --- at a glance and re-roll (Retry) until it reads well. +-- It owns a **working state** that nothing synced ever sees until Apply: +-- * `Arrangement` — the proposed seating (slot -> ownerId). +-- * `Locks` — a working lock set (ownerId -> true), SEEDED from the lobby's real locks but +-- kept preview-local: toggling one here is just a balancing constraint, never +-- written back (Cancel discards everything; Apply commits only the seating). +-- +-- The body renders as **mirror positions** (row k = the k-th seat on each side, who face off; the +-- right column is mirrored so the teams face each other). Each row shows both players + the rating gap. +-- Three gestures drive it: +-- * **Drag a row** onto another → swap those two positions' pairs (both players move together), +-- so the host arranges which pair spawns where ("D -> A"). +-- * **Click a player, then another** → swap just those two (`ScoreArrangement` re-scores, no solve). +-- * **Click a player's lock** → pin / unpin them (the next Retry holds them in place). +-- **Retry** re-solves the unlocked players around the locked ones (`Rebalance`). Moved players are +-- gold, locked players blue. The summary reports per-team total + average, the quality before -> after, +-- the predicted win split and the rating gap. -- -- Built to the preset dialog's shape (areas layout, three-phase init, Popup singleton). @@ -40,6 +48,7 @@ local LayoutHelpers = import("/lua/maui/layouthelpers.lua") local Group = import("/lua/maui/group.lua").Group local Bitmap = import("/lua/maui/bitmap.lua").Bitmap +local Dragger = import("/lua/maui/dragger.lua").Dragger local Popup = import("/lua/ui/controls/popups/popup.lua").Popup local CustomLobbySlotsDerivedModel = import("/lua/ui/lobby/customlobby/models/derived/customlobbyslotsderivedmodel.lua") @@ -51,21 +60,26 @@ local Layouter = LayoutHelpers.ReusedLayoutFor local Debug = false local DialogWidth = 540 -local DialogHeight = 420 +local DialogHeight = 452 local Pad = 12 -local TitleHeight = 30 +local TitleHeight = 28 +local HintHeight = 16 local HeaderHeight = 22 local StatusHeight = 24 local ActionHeight = 52 -local RowHeight = 22 +local RowHeight = 24 local MaxRows = 8 -- binary AutoTeams on a 16-spawn map is at most 8 per side +local LockSize = 12 +local PosLabelWidth = 16 local ButtonGap = 8 +local DragThreshold = 5 -- cursor travel (screen px) before a press becomes a drag, not a click local LabelColor = 'ffc8ccd0' -- a player that stays where it is local MoveColor = 'ffe6c64f' -- a player the balance moves (gold) local LockColor = 'ff7fb2ff' -- a host-locked player, pinned in place (blue) local HeaderColor = 'ffd9c97a' local MutedColor = 'ff8c9096' +local SelectColor = 'ffffffff' -- swap-selection + drop-target highlight (low alpha) local GapLowColor = 'ff66bb66' -- a close pair local GapMidColor = 'ffd9c97a' -- a noticeable gap @@ -86,15 +100,11 @@ local function CreateArea(parent, name, color) return area end ---- Gathers the current lobby snapshot and runs the balancer. Reads the models (a transient picker ---- may); the kernel itself stays pure. ----@return UICustomLobbyBalancePlan -local function ComputeForCurrentLobby() - -- the slots derived model already resolved every seat (player / side / locked / closed) and the - -- team aggregate (mode / labels / resolved), so the balancer reads that snapshot directly - return CustomLobbyBalancer.BuildPlan( - CustomLobbySlotsDerivedModel.GetSlots(), - CustomLobbySlotsDerivedModel.GetTeams()) +--- The current lobby snapshot the kernel reads (slots + teams aggregate). +---@return UICustomLobbySlot[] slots +---@return UICustomLobbyTeams teams +local function CurrentSnapshot() + return CustomLobbySlotsDerivedModel.GetSlots(), CustomLobbySlotsDerivedModel.GetTeams() end --- "Name · 1850" for a player (rating omitted when unrated / AI). `mirror` reverses it to @@ -113,8 +123,8 @@ local function FormatPlayer(player, mirror) return player.name end ---- The display colour for a player row: blue = host-locked (pinned), gold = moved by the balance, ---- grey = unchanged. Locked wins (a locked player never moves, so it can't also read as a mover). +--- The display colour for a player: blue = host-locked (pinned), gold = moved by the balance, grey = +--- unchanged. Locked wins (a locked player never moves, so it can't also read as a mover). ---@param player UICustomLobbyBalancePlayer ---@return string local function PlayerColor(player) @@ -139,21 +149,38 @@ local function GapColor(gap) end ---@class UICustomLobbyBalanceRow ----@field Group Group ----@field Left Text ----@field Center Text ----@field Right Text +---@field Group Group +---@field DropHighlight Bitmap # full-row tint while it's a drag drop-target +---@field LeftSelect Bitmap # left half: swap-click / drag target + selection highlight +---@field RightSelect Bitmap +---@field PosLabel Text # the position index (1..N) +---@field Left Text +---@field Center Text +---@field Right Text +---@field LeftLock Bitmap # left player's lock toggle +---@field RightLock Bitmap +---@field LeftOwner UILobbyPeerId | nil # who is on each half right now (set by Render) +---@field LeftSlot number | nil +---@field RightOwner UILobbyPeerId | nil +---@field RightSlot number | nil ---@class UICustomLobbyBalancePreview : Group ---@field Trash TrashBag ---@field OnCloseCb fun() ---@field Result UICustomLobbyBalancePlan +---@field Arrangement table # working seating (slot -> ownerId) +---@field Locks table # working lock set (preview-local) +---@field SelectedSlot number | nil # first half clicked for a swap +---@field SelectedOwner UILobbyPeerId | nil +---@field DragGhost Group | false # floating pair label following the cursor mid-drag ---@field TitleArea Group +---@field HintArea Group ---@field HeaderArea Group ---@field RowsArea Group ---@field StatusArea Group ---@field ActionArea Group ---@field Title Text +---@field Hint Text ---@field HeaderA Text ---@field HeaderB Text ---@field Rows UICustomLobbyBalanceRow[] @@ -173,11 +200,20 @@ local CustomLobbyBalancePreview = ClassUI(Group) { self.Trash = TrashBag() self.OnCloseCb = options.onClose self.Ready = false - - -- compute the proposal up front (pure read of the current snapshot; no layout involved) - self.Result = ComputeForCurrentLobby() + self.SelectedSlot = nil + self.SelectedOwner = nil + self.DragGhost = false + + -- seed the working state from the lobby: a fresh solve honouring the lobby's existing locks + -- (locks nil -> the kernel reads each seat's own Locked flag), then keep those locks local + local slots, teams = CurrentSnapshot() + local plan = CustomLobbyBalancer.BuildPlan(slots, teams) + self.Result = plan + self.Arrangement = plan.arrangement + self.Locks = table.copy(plan.lockedOwners) self.TitleArea = CreateArea(self, "TitleArea", 'ffcc4040') + self.HintArea = CreateArea(self, "HintArea", 'ff404040') self.HeaderArea = CreateArea(self, "HeaderArea", 'ff4060cc') self.RowsArea = CreateArea(self, "RowsArea", 'ff406060') self.StatusArea = CreateArea(self, "StatusArea", 'ffcc40cc') @@ -186,6 +222,11 @@ local CustomLobbyBalancePreview = ClassUI(Group) { self.Title = UIUtil.CreateText(self.TitleArea, "Balance preview", 22, UIUtil.titleFont) self.Title:DisableHitTest() + self.Hint = UIUtil.CreateText(self.HintArea, + "Drag a row to move a pair · click two players to swap · click a lock to pin", 11, UIUtil.bodyFont) + self.Hint:SetColor(MutedColor) + self.Hint:DisableHitTest() + local labels = self.Result.labels or { "Team 1", "Team 2" } self.HeaderA = UIUtil.CreateText(self.HeaderArea, labels[1], 14, UIUtil.titleFont) self.HeaderA:SetColor(HeaderColor) @@ -194,18 +235,10 @@ local CustomLobbyBalancePreview = ClassUI(Group) { self.HeaderB:SetColor(HeaderColor) self.HeaderB:DisableHitTest() - -- a fixed pool of pair rows; Render shows/hides + fills them from the proposal + -- a fixed pool of interactive position rows; Render shows/hides + fills them from the proposal self.Rows = {} for i = 1, MaxRows do - local row = Group(self.RowsArea, "BalanceRow" .. i) - local left = UIUtil.CreateText(row, "", 14, UIUtil.bodyFont) - left:DisableHitTest() - local center = UIUtil.CreateText(row, "", 12, UIUtil.bodyFont) - center:SetColor(MutedColor) - center:DisableHitTest() - local right = UIUtil.CreateText(row, "", 14, UIUtil.bodyFont) - right:DisableHitTest() - self.Rows[i] = { Group = row, Left = left, Center = center, Right = right } + self.Rows[i] = self:CreateRow(self.RowsArea, i) end self.Status = UIUtil.CreateText(self.StatusArea, "", 14, UIUtil.bodyFont) @@ -217,11 +250,11 @@ local CustomLobbyBalancePreview = ClassUI(Group) { self:ApplyAndClose() end - -- re-roll the proposal: the balance is randomised (tie-breaks, and the positional mirrored-pair - -- seating), so Retry recomputes for a different equally-good arrangement without committing + -- re-roll: re-solve the unlocked players around the locked ones, for a different equally-good + -- arrangement, without committing self.RetryButton = UIUtil.CreateButtonWithDropshadow(self.ActionArea, '/BUTTON/medium/', "Retry") self.RetryButton.OnClick = function() - self:Retry() + self:Reroll() end self.CancelButton = UIUtil.CreateButtonWithDropshadow(self.ActionArea, '/BUTTON/medium/', "Cancel") @@ -230,13 +263,90 @@ local CustomLobbyBalancePreview = ClassUI(Group) { end end, + --- Builds one interactive position row: a drop-target tint, two clickable halves (swap-select / + --- drag start + selection highlight), the position label, the two player texts + the centre gap, + --- and a lock toggle per side. Handlers read the row's current owner/slot (set by Render), so the + --- closures stay valid across re-renders. + ---@param self UICustomLobbyBalancePreview + ---@param parent Control + ---@param index number + ---@return UICustomLobbyBalanceRow + CreateRow = function(self, parent, index) + local row = {} + row.Group = Group(parent, "BalanceRow" .. tostring(index)) + + row.DropHighlight = Bitmap(row.Group) + row.DropHighlight:SetSolidColor(SelectColor) + row.DropHighlight:SetAlpha(0.0) + row.DropHighlight:DisableHitTest() + + row.LeftSelect = Bitmap(row.Group) + row.LeftSelect:SetSolidColor(SelectColor) + row.LeftSelect:SetAlpha(0.0) + row.LeftSelect.HandleEvent = function(control, event) + if event.Type == 'ButtonPress' and not event.Modifiers.Right then + self:BeginRowGesture(index, row.LeftSlot, row.LeftOwner, event) + return true + end + return false + end + + row.RightSelect = Bitmap(row.Group) + row.RightSelect:SetSolidColor(SelectColor) + row.RightSelect:SetAlpha(0.0) + row.RightSelect.HandleEvent = function(control, event) + if event.Type == 'ButtonPress' and not event.Modifiers.Right then + self:BeginRowGesture(index, row.RightSlot, row.RightOwner, event) + return true + end + return false + end + + row.PosLabel = UIUtil.CreateText(row.Group, "", 11, UIUtil.bodyFont) + row.PosLabel:SetColor(MutedColor) + row.PosLabel:DisableHitTest() + + row.Left = UIUtil.CreateText(row.Group, "", 14, UIUtil.bodyFont) + row.Left:DisableHitTest() + row.Center = UIUtil.CreateText(row.Group, "", 12, UIUtil.bodyFont) + row.Center:SetColor(MutedColor) + row.Center:DisableHitTest() + row.Right = UIUtil.CreateText(row.Group, "", 14, UIUtil.bodyFont) + row.Right:DisableHitTest() + + row.LeftLock = Bitmap(row.Group) + row.LeftLock:SetSolidColor(LockColor) + row.LeftLock:SetAlpha(0.0) + row.LeftLock.HandleEvent = function(control, event) + if event.Type == 'ButtonPress' and not event.Modifiers.Right then + self:OnLockToggled(row.LeftOwner) + return true + end + return false + end + + row.RightLock = Bitmap(row.Group) + row.RightLock:SetSolidColor(LockColor) + row.RightLock:SetAlpha(0.0) + row.RightLock.HandleEvent = function(control, event) + if event.Type == 'ButtonPress' and not event.Modifiers.Right then + self:OnLockToggled(row.RightOwner) + return true + end + return false + end + + return row + end, + ---@param self UICustomLobbyBalancePreview __post_init = function(self) self.Width:Set(LayoutHelpers.ScaleNumber(DialogWidth)) self.Height:Set(LayoutHelpers.ScaleNumber(DialogHeight)) Layouter(self.TitleArea):AtLeftIn(self, Pad):AtRightIn(self, Pad):AtTopIn(self, Pad):Height(TitleHeight):End() - Layouter(self.HeaderArea):AtLeftIn(self, Pad):AtRightIn(self, Pad):AnchorToBottom(self.TitleArea, Pad):Height(HeaderHeight):End() + Layouter(self.HintArea):AtLeftIn(self, Pad):AtRightIn(self, Pad):AnchorToBottom(self.TitleArea, 2):Height(HintHeight):End() + Layouter(self.HeaderArea):AtLeftIn(self, Pad):AtRightIn(self, Pad):AnchorToBottom(self.HintArea, Pad):Height(HeaderHeight):End() Layouter(self.ActionArea):AtLeftIn(self, Pad):AtRightIn(self, Pad):AtBottomIn(self, Pad):Height(ActionHeight):End() Layouter(self.StatusArea):AtLeftIn(self, Pad):AtRightIn(self, Pad):AnchorToTop(self.ActionArea, Pad):Height(StatusHeight):End() Layouter(self.RowsArea) @@ -245,11 +355,12 @@ local CustomLobbyBalancePreview = ClassUI(Group) { :End() Layouter(self.Title):AtHorizontalCenterIn(self.TitleArea):AtVerticalCenterIn(self.TitleArea):End() + Layouter(self.Hint):AtHorizontalCenterIn(self.HintArea):AtVerticalCenterIn(self.HintArea):End() Layouter(self.HeaderA):AtLeftIn(self.HeaderArea):AtVerticalCenterIn(self.HeaderArea):End() Layouter(self.HeaderB):AtRightIn(self.HeaderArea):AtVerticalCenterIn(self.HeaderArea):End() - -- stack the pair rows from the top of the rows area + -- stack the position rows from the top of the rows area for i = 1, MaxRows do local row = self.Rows[i] Layouter(row.Group):AtLeftIn(self.RowsArea):AtRightIn(self.RowsArea):Height(RowHeight):End() @@ -258,9 +369,25 @@ local CustomLobbyBalancePreview = ClassUI(Group) { else Layouter(row.Group):AnchorToBottom(self.Rows[i - 1].Group, 0):End() end - Layouter(row.Left):AtLeftIn(row.Group):AtVerticalCenterIn(row.Group):End() + + Layouter(row.DropHighlight):Fill(row.Group):End() + + Layouter(row.PosLabel):AtLeftIn(row.Group):AtVerticalCenterIn(row.Group):Width(PosLabelWidth):End() + Layouter(row.LeftLock):AnchorToRight(row.PosLabel, 2):AtVerticalCenterIn(row.Group) + :Width(LockSize):Height(LockSize):Over(row.Group, 10):End() + Layouter(row.RightLock):AtRightIn(row.Group):AtVerticalCenterIn(row.Group) + :Width(LockSize):Height(LockSize):Over(row.Group, 10):End() + Layouter(row.Center):AtHorizontalCenterIn(row.Group):AtVerticalCenterIn(row.Group):End() - Layouter(row.Right):AtRightIn(row.Group):AtVerticalCenterIn(row.Group):End() + Layouter(row.Left):AnchorToRight(row.LeftLock, 6):AtVerticalCenterIn(row.Group):End() + Layouter(row.Right):AnchorToLeft(row.RightLock, 6):AtVerticalCenterIn(row.Group):End() + + -- the swap-click / drag halves sit above the drop tint but below the texts (which ignore + -- hits) and stop short of the centre gap; the lock bitmaps sit above them (Over) + Layouter(row.LeftSelect):AtTopIn(row.Group):AtBottomIn(row.Group) + :AtLeftIn(row.Group):AnchorToLeft(row.Center, 4):End() + Layouter(row.RightSelect):AtTopIn(row.Group):AtBottomIn(row.Group) + :AtRightIn(row.Group):AnchorToRight(row.Center, 4):End() end Layouter(self.Status):AtLeftIn(self.StatusArea):AtVerticalCenterIn(self.StatusArea):End() @@ -278,6 +405,262 @@ local CustomLobbyBalancePreview = ClassUI(Group) { self:Render() end, + --------------------------------------------------------------------------- + --#region Interaction + + --- A press on a row half: it becomes a **drag** (move this whole pair to another position) once the + --- cursor travels past the threshold, otherwise the release is a **click** (select this player for a + --- swap). Mirrors the lobby slot rows' press-vs-drag handling. + ---@param self UICustomLobbyBalancePreview + ---@param rowIndex number + ---@param slot number | nil + ---@param ownerId UILobbyPeerId | nil + ---@param event KeyEvent + BeginRowGesture = function(self, rowIndex, slot, ownerId, event) + local startX, startY = event.MouseX, event.MouseY + local moved = false + + local drag = Dragger() + drag.OnMove = function(dragSelf, x, y) + if not moved and (math.abs(x - startX) > DragThreshold or math.abs(y - startY) > DragThreshold) then + moved = true + end + if moved then + -- float the pair ghost beside the cursor + highlight the row under it (lobby feel) + if not self.DragGhost then + self.DragGhost = self:CreateDragGhost(rowIndex) + end + self.DragGhost.Left:Set(x + LayoutHelpers.ScaleNumber(12)) + self.DragGhost.Top:Set(y + LayoutHelpers.ScaleNumber(8)) + self:SetDropTarget(self:RowIndexAt(y), rowIndex) + end + end + drag.OnRelease = function(dragSelf, x, y) + if moved then + local target = self:RowIndexAt(y) + if target and target ~= rowIndex then + self:SwapPositions(rowIndex, target) + end + else + self:OnPlayerClicked(slot, ownerId) + end + self:EndDrag() + drag:Destroy() + end + drag.OnCancel = function(dragSelf) + self:EndDrag() + drag:Destroy() + end + PostDragger(self:GetRootFrame(), event.KeyCode, drag) + end, + + --- Clears the transient drag visuals (drop highlight + floating ghost). + ---@param self UICustomLobbyBalancePreview + EndDrag = function(self) + self:ClearDropHighlight() + if self.DragGhost then + self.DragGhost:Destroy() + self.DragGhost = false + end + end, + + --- Builds the floating drag ghost for a position's pair (both names, the lobby's dark chip), drawn + --- above the rows and offset from the cursor. Destroyed in EndDrag. + ---@param self UICustomLobbyBalancePreview + ---@param index number + ---@return Group + CreateDragGhost = function(self, index) + local position = (self.Result.positions or {})[index] + local a = position and position.a + local b = position and position.b + local name + if a and b then + name = a.name .. " · " .. b.name + elseif a then + name = a.name + elseif b then + name = b.name + else + name = "Position " .. tostring(index) + end + + local ghost = Group(self, "CustomLobbyBalanceDragGhost") + ghost:DisableHitTest() + + local bg = Bitmap(ghost) + bg:SetSolidColor('cc101418') + bg:DisableHitTest() + + local label = UIUtil.CreateText(ghost, name, 14, UIUtil.bodyFont) + label:DisableHitTest() + + Layouter(label):AtLeftTopIn(ghost, 6, 3):End() + Layouter(bg):Fill(ghost):End() + ghost.Width:Set(function() return label.Width() + LayoutHelpers.ScaleNumber(12) end) + ghost.Height:Set(function() return label.Height() + LayoutHelpers.ScaleNumber(6) end) + return ghost + end, + + --- A player half was clicked: select it for a swap, deselect it if it was the selection, or — if + --- another player is already selected — swap their seats and re-score. Empty halves and locked + --- players are inert (a locked player is pinned; unlock it first to move it). + ---@param self UICustomLobbyBalancePreview + ---@param slot number | nil + ---@param ownerId UILobbyPeerId | nil + OnPlayerClicked = function(self, slot, ownerId) + if not (slot and ownerId) or self.Locks[ownerId] then + return + end + if self.SelectedSlot == slot then + self.SelectedSlot = nil + self.SelectedOwner = nil + self:Render() + elseif self.SelectedSlot then + local arrangement = table.copy(self.Arrangement) + local other = self.SelectedSlot + arrangement[other], arrangement[slot] = arrangement[slot], arrangement[other] + self.Arrangement = arrangement + self.SelectedSlot = nil + self.SelectedOwner = nil + self:Rescore() + else + self.SelectedSlot = slot + self.SelectedOwner = ownerId + self:Render() + end + end, + + --- True if either player at position `index` is locked (so that whole row is pinned — it can't be + --- the source or target of a pair drag; unlock first to move it). + ---@param self UICustomLobbyBalancePreview + ---@param index number + ---@return boolean + PositionLocked = function(self, index) + local position = (self.Result.positions or {})[index] + if not position then + return false + end + return (position.a and position.a.locked or position.b and position.b.locked) and true or false + end, + + --- Swaps two positions' pairs in the working arrangement: both players of position `i` move to + --- position `j` and vice versa (sides preserved, so each pair stays together). Refuses if either + --- row holds a locked player. Re-scores. + ---@param self UICustomLobbyBalancePreview + ---@param i number + ---@param j number + SwapPositions = function(self, i, j) + local positions = self.Result.positions + local pi, pj = positions[i], positions[j] + if not (pi and pj) or self:PositionLocked(i) or self:PositionLocked(j) then + return + end + local arrangement = table.copy(self.Arrangement) + if pi.slotA and pj.slotA then + arrangement[pi.slotA], arrangement[pj.slotA] = arrangement[pj.slotA], arrangement[pi.slotA] + end + if pi.slotB and pj.slotB then + arrangement[pi.slotB], arrangement[pj.slotB] = arrangement[pj.slotB], arrangement[pi.slotB] + end + self.Arrangement = arrangement + self.SelectedSlot = nil + self.SelectedOwner = nil + self:Rescore() + end, + + --- A player's lock was clicked: toggle it in the working set and re-score. The player stays put; + --- locking just pins them so the next Retry holds them in place (and recolours them blue). Clears + --- any pending swap selection, since the pinning changed. + ---@param self UICustomLobbyBalancePreview + ---@param ownerId UILobbyPeerId | nil + OnLockToggled = function(self, ownerId) + if not ownerId then + return + end + local locks = table.copy(self.Locks) + locks[ownerId] = (not locks[ownerId]) or nil + self.Locks = locks + self.SelectedSlot = nil + self.SelectedOwner = nil + self:Rescore() + end, + + --- Re-solves the unlocked players around the locked ones (Retry), adopting a fresh arrangement. + ---@param self UICustomLobbyBalancePreview + Reroll = function(self) + local slots, teams = CurrentSnapshot() + self:Adopt(CustomLobbyBalancer.Rebalance(slots, teams, self.Arrangement, self.Locks)) + end, + + --- Re-scores the current working arrangement in place (after a swap / drag / lock toggle) — no + --- re-solve, so the hand-made arrangement is preserved; only the metrics and colours refresh. + ---@param self UICustomLobbyBalancePreview + Rescore = function(self) + local slots, teams = CurrentSnapshot() + self.Result = CustomLobbyBalancer.ScoreArrangement(slots, teams, self.Arrangement, self.Locks) + if self.Ready then + self:Render() + end + end, + + --- Adopts a freshly-solved plan as the new working state (clears the swap selection). + ---@param self UICustomLobbyBalancePreview + ---@param plan UICustomLobbyBalancePlan + Adopt = function(self, plan) + self.Result = plan + self.Arrangement = plan.arrangement + self.SelectedSlot = nil + self.SelectedOwner = nil + if self.Ready then + self:Render() + end + end, + + --#endregion + + --------------------------------------------------------------------------- + --#region Drag drop-target + + --- The visible row whose vertical bounds contain screen-y `y`, or nil. + ---@param self UICustomLobbyBalancePreview + ---@param y number + ---@return number | nil + RowIndexAt = function(self, y) + local count = table.getn(self.Result.positions or {}) + for i = 1, math.min(count, MaxRows) do + local group = self.Rows[i].Group + if y >= group.Top() and y <= group.Bottom() then + return i + end + end + return nil + end, + + --- Highlights `target` as the drop row during a drag — nothing if it's the source, invalid, or + --- either the source or target row is locked (a pinned pair can't move). + ---@param self UICustomLobbyBalancePreview + ---@param target number | nil + ---@param source number + SetDropTarget = function(self, target, source) + local droppable = target and target ~= source + and not self:PositionLocked(source) and not self:PositionLocked(target) + for i = 1, MaxRows do + self.Rows[i].DropHighlight:SetAlpha((droppable and target == i) and 0.15 or 0.0) + end + end, + + ---@param self UICustomLobbyBalancePreview + ClearDropHighlight = function(self) + for i = 1, MaxRows do + self.Rows[i].DropHighlight:SetAlpha(0.0) + end + end, + + --#endregion + + --------------------------------------------------------------------------- + --#region Rendering + --- The per-team header: "label total (avg avg)", mirrored on the right side ("total (avg avg) --- label") so the team label stays on the outer edge of its column. ---@param self UICustomLobbyBalancePreview @@ -296,25 +679,23 @@ local CustomLobbyBalancePreview = ClassUI(Group) { return labels[side] .. " " .. stats end, - --- Fills one pair row from the k-th players of each side (either may be absent on an uneven split). + --- Fills one position row from a `UICustomLobbyBalancePosition` (either player may be absent on an + --- uneven split), and records its slots/owners for the click + drag handlers. ---@param self UICustomLobbyBalancePreview ---@param row UICustomLobbyBalanceRow - ---@param a UICustomLobbyBalancePlayer | nil - ---@param b UICustomLobbyBalancePlayer | nil - FillRow = function(self, row, a, b) + ---@param position UICustomLobbyBalancePosition + ---@param index number + FillRow = function(self, row, position, index) row.Group:Show() - if a then - row.Left:SetText(FormatPlayer(a, false)) - row.Left:SetColor(PlayerColor(a)) - else - row.Left:SetText("") - end - if b then - row.Right:SetText(FormatPlayer(b, true)) - row.Right:SetColor(PlayerColor(b)) - else - row.Right:SetText("") - end + row.PosLabel:SetText(tostring(index)) + local a, b = position.a, position.b + self:FillHalf(row.Left, row.LeftLock, row.LeftSelect, a, position.slotA, false) + self:FillHalf(row.Right, row.RightLock, row.RightSelect, b, position.slotB, true) + row.LeftOwner = a and a.ownerId or nil + row.LeftSlot = position.slotA + row.RightOwner = b and b.ownerId or nil + row.RightSlot = position.slotB + -- the pair's rating gap, only when both players are rated if a and b and a.pl > 0 and b.pl > 0 then local gap = math.abs(a.pl - b.pl) @@ -325,8 +706,33 @@ local CustomLobbyBalancePreview = ClassUI(Group) { end end, - --- Paints the pair rows + the summary line from the computed proposal, and enables Apply only when - --- there is something to apply. + --- Paints one half of a row (its text + lock dot + selection highlight) for a player, or clears it + --- when the half is empty. + ---@param self UICustomLobbyBalancePreview + ---@param text Text + ---@param lock Bitmap + ---@param select Bitmap + ---@param player UICustomLobbyBalancePlayer | nil + ---@param slot number | nil + ---@param mirror boolean + FillHalf = function(self, text, lock, select, player, slot, mirror) + if not player then + text:SetText("") + lock:SetAlpha(0.0) + select:SetAlpha(0.0) + return + end + text:SetText(FormatPlayer(player, mirror)) + text:SetColor(PlayerColor(player)) + -- the lock dot: solid blue when pinned, faint when not (a click toggles it) + lock:SetSolidColor(player.locked and LockColor or SelectColor) + lock:SetAlpha(player.locked and 1.0 or 0.25) + -- highlight the half that is the pending swap selection + select:SetAlpha(slot == self.SelectedSlot and 0.12 or 0.0) + end, + + --- Paints the position rows + the summary line from the working plan, and enables Apply / Retry + --- only when there is something to apply. ---@param self UICustomLobbyBalancePreview Render = function(self) local result = self.Result @@ -334,11 +740,12 @@ local CustomLobbyBalancePreview = ClassUI(Group) { self.HeaderA:SetText(self:SideHeader(1)) self.HeaderB:SetText(self:SideHeader(2)) - local rowCount = math.max(table.getn(result.sides[1]), table.getn(result.sides[2])) + local positions = result.positions or {} + local rowCount = table.getn(positions) for i = 1, MaxRows do local row = self.Rows[i] if i <= rowCount then - self:FillRow(row, result.sides[1][i], result.sides[2][i]) + self:FillRow(row, positions[i], i) else row.Group:Hide() end @@ -355,7 +762,7 @@ local CustomLobbyBalancePreview = ClassUI(Group) { end end, - --- The summary: the reason it can't balance, else "Quality before -> after · Win a/b · Δ delta", + --- The summary: the reason it can't balance, else "Quality before -> after · Win a/b · Gap g", --- plus any odd one left out. ---@param self UICustomLobbyBalancePreview ---@return string @@ -386,18 +793,14 @@ local CustomLobbyBalancePreview = ClassUI(Group) { return status end, - --- Re-rolls the proposal (the balance is randomised) and re-renders, without committing. - ---@param self UICustomLobbyBalancePreview - Retry = function(self) - self.Result = ComputeForCurrentLobby() - self:Render() - end, + --#endregion - --- Commits the proposed arrangement (host-authoritative) and closes. + --- Commits the working arrangement (host-authoritative) and closes. Locks toggled here are NOT + --- written back — they were only a balancing constraint for this session. ---@param self UICustomLobbyBalancePreview ApplyAndClose = function(self) if self.Result.feasible then - CustomLobbyController.RequestApplyBalance(CustomLobbyBalancer.ToArrangement(self.Result)) + CustomLobbyController.RequestApplyBalance(self.Arrangement) end self.OnCloseCb() end, diff --git a/lua/ui/lobby/customlobby/CustomLobbyBalancer.lua b/lua/ui/lobby/customlobby/CustomLobbyBalancer.lua index 96a416d595e..15833a6507d 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyBalancer.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyBalancer.lua @@ -61,10 +61,20 @@ local MaxBalanceEvaluations = 20000 ---@field slot number # current seat ---@field moved boolean # this player's proposed seat differs from its current seat (preview highlight) +--- One mirror row of the layout: the k-th seat on side A faces the k-th seat on side B. The preview +--- renders the plan as an ordered list of these (a player may be absent on an uneven split), and a +--- pair drag-and-drop swaps two positions' occupants. +---@class UICustomLobbyBalancePosition +---@field slotA number | nil +---@field slotB number | nil +---@field a UICustomLobbyBalancePlayer | nil +---@field b UICustomLobbyBalancePlayer | nil + --- A proposed balance. ---@class UICustomLobbyBalancePlan ---@field labels string[] # the two side labels, for the preview ----@field sides UICustomLobbyBalancePlayer[][] # the two teams, rank-sorted (row k = a pair) +---@field sides UICustomLobbyBalancePlayer[][] # the two teams, rank-sorted (totals / quality) +---@field positions UICustomLobbyBalancePosition[] # ordered mirror rows (k-th seat each side), for the preview ---@field totals number[] # per-side PL totals ---@field lockedOwners table # user-locked players (the preview marks them) ---@field unassigned UICustomLobbyBalancePlayer | false # the odd one left in place, if any @@ -207,18 +217,70 @@ function ShufflePairsIntoSeats(pairsA, pairsB, seatsA, seatsB, place) seatRest(pairsB, seatsB, usedB, pairCount + 1) end ---- Builds a balance plan from the slots derived model's resolved snapshot: `slots` is its `Slots` ---- table (per-seat Player / Side / Locked / Closed), `teams` its `Teams` aggregate (Mode / Labels / ---- Resolved). Pure — reads no models. Always returns a plan; `feasible` is false (with a `reason`) ---- when there is nothing to apply. +--- Projects the slots snapshot into the minimal records balancing needs. `locks` (ownerId -> true), +--- when given, OVERRIDES each seat's own Locked flag — the preview passes its working lock set so a +--- lock toggled in the dialog is honoured without touching the synced lobby; nil falls back to +--- `entry.Locked`. ---@param slots UICustomLobbySlot[] +---@param locks table | nil +---@return UICustomLobbyBalancePlayer[] players # one per occupied side-1/2 seat (bp.locked from `locks`) +---@return table seatSide # slot -> side, for every side-1/2 seat +---@return table seatClosed # slot -> closed, for every side-1/2 seat +local function Project(slots, locks) + local players = {} + local seatSide = {} + local seatClosed = {} + for _, entry in ipairs(slots) do + local side = entry.Side + if side == 1 or side == 2 then + seatSide[entry.Slot] = side + seatClosed[entry.Slot] = entry.Closed and true or false + if entry.Player then + local locked + if locks then + locked = locks[entry.Player.OwnerID] and true or false + else + locked = entry.Locked and true or false + end + table.insert(players, { + ownerId = entry.Player.OwnerID, + name = entry.Player.PlayerName or "?", + pl = entry.Player.PL or 0, + mean = entry.Player.MEAN or 1500, + dev = entry.Player.DEV or 500, + locked = locked, + side = side, + slot = entry.Slot, + }) + end + end + end + return players, seatSide, seatClosed +end + +--- The identity arrangement (every seated player on its current seat) — the starting point for a fresh +--- solve from the lobby's current seating. +---@param slots UICustomLobbySlot[] +---@return table +local function IdentityArrangement(slots) + local arrangement = {} + for _, entry in ipairs(slots) do + if (entry.Side == 1 or entry.Side == 2) and entry.Player then + arrangement[entry.Slot] = entry.Player.OwnerID + end + end + return arrangement +end + +--- A fresh, empty plan; the gated fields are filled in by the scorer / solver. ---@param teams UICustomLobbyTeams ---@return UICustomLobbyBalancePlan -function BuildPlan(slots, teams) +local function NewPlan(teams) ---@type UICustomLobbyBalancePlan - local plan = { + return { labels = (teams and teams.Labels) or { "Team 1", "Team 2" }, sides = { {}, {} }, + positions = {}, totals = { 0, 0 }, lockedOwners = {}, unassigned = false, @@ -229,59 +291,145 @@ function BuildPlan(slots, teams) winChance = false, arrangement = {}, } +end - -- the feature is gated to a binary AutoTeams mode with a resolved split; bail defensively otherwise +--- Scores a concrete `arrangement` (slot -> ownerId) into `plan`: the two rank-sorted sides, the +--- per-side PL totals, the moved flags (vs. each player's current lobby seat), the current-seating +--- quality, and the proposed quality + win split. Pure display computation — it moves no one. +--- `players` / `seatSide` come from Project. +---@param plan UICustomLobbyBalancePlan +---@param players UICustomLobbyBalancePlayer[] +---@param seatSide table +---@param arrangement table +local function ScorePlan(plan, players, seatSide, arrangement) + local byId = {} + for _, bp in ipairs(players) do + byId[bp.ownerId] = bp + if bp.locked then + plan.lockedOwners[bp.ownerId] = true + end + end + + -- the current seating's quality, for the "before -> after" readout + local currentSides = { {}, {} } + for _, bp in ipairs(players) do + table.insert(currentSides[bp.side], bp) + end + plan.currentQuality = ComputeQuality(currentSides) + + -- the proposed sides, from the arrangement; a player's side is its seat's side + plan.arrangement = arrangement + for slot, ownerId in arrangement do + local bp = byId[ownerId] + local side = seatSide[slot] + if bp and side then + bp.moved = slot ~= bp.slot + table.insert(plan.sides[side], bp) + end + end + table.sort(plan.sides[1], ByRatingDesc) + table.sort(plan.sides[2], ByRatingDesc) + for side = 1, 2 do + local total = 0 + for _, bp in ipairs(plan.sides[side]) do + total = total + bp.pl + end + plan.totals[side] = total + end + + plan.quality = ComputeQuality(plan.sides) + plan.winChance = ComputeWinChance(plan.sides) + + -- ordered mirror positions for the preview: the k-th side-1 seat faces the k-th side-2 seat (both + -- sorted by slot, so the list is stable across re-scores — a position swap moves the players + -- between seats, not the row order) + local slotsA, slotsB = {}, {} + for slot, _ in arrangement do + local side = seatSide[slot] + if side == 1 then + table.insert(slotsA, slot) + elseif side == 2 then + table.insert(slotsB, slot) + end + end + table.sort(slotsA) + table.sort(slotsB) + local posCount = math.max(table.getn(slotsA), table.getn(slotsB)) + for k = 1, posCount do + local slotA, slotB = slotsA[k], slotsB[k] + plan.positions[k] = { + slotA = slotA, + slotB = slotB, + a = slotA and byId[arrangement[slotA]] or nil, + b = slotB and byId[arrangement[slotB]] or nil, + } + end +end + +--- Scores an arrangement the host hand-edited (a manual swap, or after a lock toggle) WITHOUT +--- re-solving — everyone stays exactly where `arrangement` puts them, only the metrics refresh. `locks` +--- is the preview's working lock set (see Project). Apply is enabled whenever both sides are non-empty. +---@param slots UICustomLobbySlot[] +---@param teams UICustomLobbyTeams +---@param arrangement table +---@param locks table | nil +---@return UICustomLobbyBalancePlan +function ScoreArrangement(slots, teams, arrangement, locks) + local plan = NewPlan(teams) if not (teams and teams.Mode) or not teams.Resolved then plan.reason = "Auto-balance needs an AutoTeams mode with a loaded map." return plan end + local players, seatSide = Project(slots, locks) + if table.getn(players) < 2 then + plan.reason = "Need at least two players to balance." + return plan + end + ScorePlan(plan, players, seatSide, arrangement) + plan.feasible = table.getn(plan.sides[1]) > 0 and table.getn(plan.sides[2]) > 0 + return plan +end - -- project each seat into the minimal info balancing needs, and collect the free seats per side - -- (open, non-locked-occupied — empty open seats count, so players can move into vacant positions) - local players = {} - local freeSeats = { {}, {} } - for _, entry in ipairs(slots) do - local side = entry.Side - if side == 1 or side == 2 then - if entry.Player then - table.insert(players, { - ownerId = entry.Player.OwnerID, - name = entry.Player.PlayerName or "?", - pl = entry.Player.PL or 0, - mean = entry.Player.MEAN or 1500, - dev = entry.Player.DEV or 500, - locked = entry.Locked and true or false, - side = side, - slot = entry.Slot, - }) - end - if not entry.Closed and not (entry.Locked and entry.Player) then - table.insert(freeSeats[side], entry.Slot) - end - end +--- Re-solves the UNLOCKED players into a balanced two-side split, keeping locked players (and, for an +--- odd roster, the lowest-rated unlocked player) pinned at their seat in `arrangement` — so a balance +--- honours where the host has already locked people, even when those seats differ from the lobby (e.g. +--- a player the balance itself moved, then the host locked). Used for the first proposal (from the +--- identity arrangement) and for Retry. `locks` is the working lock set (see Project). +---@param slots UICustomLobbySlot[] +---@param teams UICustomLobbyTeams +---@param arrangement table # current seats (pinned players are read from here) +---@param locks table | nil +---@return UICustomLobbyBalancePlan +function Rebalance(slots, teams, arrangement, locks) + local plan = NewPlan(teams) + + -- the feature is gated to a binary AutoTeams mode with a resolved split; bail defensively otherwise + if not (teams and teams.Mode) or not teams.Resolved then + plan.reason = "Auto-balance needs an AutoTeams mode with a loaded map." + return plan end + local players, seatSide, seatClosed = Project(slots, locks) local occupiedCount = table.getn(players) if occupiedCount < 2 then plan.reason = "Need at least two players to balance." return plan end - -- score the CURRENT seating too, so the preview can show "before -> after" and the host can tell - -- whether re-balancing is even worth applying - local currentSides = { {}, {} } - for _, bp in ipairs(players) do - table.insert(currentSides[bp.side], bp) + -- where each player currently sits (its seat in the working arrangement, or its lobby seat if it + -- isn't in one). Pinned players are held here rather than at their lobby seat, so locking a player + -- the balance already moved keeps them where the host sees them. + local slotOf = {} + for slot, ownerId in arrangement do + slotOf[ownerId] = slot end - plan.currentQuality = ComputeQuality(currentSides) - -- pinned = the host-locked players + (odd roster) the lowest-rated unlocked player, left in place - -- so the rest pair up (never ejected). Pinned players hold their exact seat and side. + -- pinned = locked players + (odd roster) the lowest-rated unlocked player, left in place so the rest + -- pair up (never ejected) local pinned = {} for _, bp in ipairs(players) do if bp.locked then pinned[bp.ownerId] = true - plan.lockedOwners[bp.ownerId] = true end end if math.mod(occupiedCount, 2) == 1 then @@ -297,20 +445,41 @@ function BuildPlan(slots, teams) end end - -- partition into pinned (held on their position's side) and the free pool the search splits - local lockedRecords = { {}, {} } + -- pinned players hold their current seat (off-limits to the search); everyone else is the free pool + local solved = {} + local pinnedSlots = {} + local lockedMean = { 0, 0 } + local lockedDev = { 0, 0 } + local lockedCount = { 0, 0 } local freePool = {} local totalMean, totalDev = 0, 0 for _, bp in ipairs(players) do totalMean = totalMean + bp.mean totalDev = totalDev + bp.dev if pinned[bp.ownerId] then - table.insert(lockedRecords[bp.side], bp) - plan.arrangement[bp.slot] = bp.ownerId -- stays exactly where it is + local seat = slotOf[bp.ownerId] or bp.slot + local side = seatSide[seat] or bp.side + solved[seat] = bp.ownerId + pinnedSlots[seat] = true + lockedMean[side] = lockedMean[side] + bp.mean + lockedDev[side] = lockedDev[side] + bp.dev + lockedCount[side] = lockedCount[side] + 1 else table.insert(freePool, bp) end end + + -- the seats the search can fill: every side-1/2 seat that isn't closed or pinned (the seats freed by + -- the unlocked players + any open seats) + local freeSeats = { {}, {} } + for slot, side in seatSide do + if not seatClosed[slot] and not pinnedSlots[slot] then + table.insert(freeSeats[side], slot) + end + end + table.sort(freeSeats[1]) + table.sort(freeSeats[2]) + local freeCount = table.getn(freePool) -- shuffle the free pool so re-running varies which equally-good split / seating is picked @@ -320,14 +489,13 @@ function BuildPlan(slots, teams) end -- how many free players go to side A: aim for equal final sizes, clamped to each side's free seats - local lockedCountA = table.getn(lockedRecords[1]) - local lockedCountB = table.getn(lockedRecords[2]) local capA, capB = table.getn(freeSeats[1]), table.getn(freeSeats[2]) - local placedA = math.floor((freeCount + lockedCountB - lockedCountA) / 2 + 0.5) + local placedA = math.floor((freeCount + lockedCount[2] - lockedCount[1]) / 2 + 0.5) local minA = math.max(0, freeCount - capB) local maxA = math.min(freeCount, capA) if minA > maxA then plan.reason = "The locked players don't fit the team layout." + ScorePlan(plan, players, seatSide, solved) -- still show the pinned players return plan end if placedA < minA then placedA = minA end @@ -335,17 +503,11 @@ function BuildPlan(slots, teams) -- search: choose placedA free players for side A, minimising the cheap rating-imbalance heuristic local goalMean, goalDev = totalMean / 2, totalDev / 2 - local lockedMeanA, lockedDevA = 0, 0 - for _, bp in ipairs(lockedRecords[1]) do - lockedMeanA = lockedMeanA + bp.mean - lockedDevA = lockedDevA + bp.dev - end - local best = { value = nil, chosen = nil } local evaluations = 0 local current = {} local function evaluate() - local meanA, devA = lockedMeanA, lockedDevA + local meanA, devA = lockedMean[1], lockedDev[1] for i = 1, table.getn(current) do local bp = freePool[current[i]] meanA = meanA + bp.mean @@ -393,35 +555,9 @@ function BuildPlan(slots, teams) table.sort(freeA, ByRatingDesc) table.sort(freeB, ByRatingDesc) ShufflePairsIntoSeats(freeA, freeB, freeSeats[1], freeSeats[2], - function(slot, bp) plan.arrangement[slot] = bp.ownerId end) - - -- the two teams, rank-sorted for display (row k = the k-th strongest of each side — who face off) - for _, bp in ipairs(lockedRecords[1]) do table.insert(plan.sides[1], bp) end - for _, bp in ipairs(freeA) do table.insert(plan.sides[1], bp) end - for _, bp in ipairs(lockedRecords[2]) do table.insert(plan.sides[2], bp) end - for _, bp in ipairs(freeB) do table.insert(plan.sides[2], bp) end - table.sort(plan.sides[1], ByRatingDesc) - table.sort(plan.sides[2], ByRatingDesc) - for side = 1, 2 do - local total = 0 - for _, bp in ipairs(plan.sides[side]) do - total = total + bp.pl - end - plan.totals[side] = total - end - - -- flag who actually moves vs. the current seating, so the preview can highlight the changes (locked - -- and unassigned players map to their own seat, so they read as not moved) - local assignedSlot = {} - for slot, ownerId in plan.arrangement do - assignedSlot[ownerId] = slot - end - for _, bp in ipairs(players) do - bp.moved = assignedSlot[bp.ownerId] ~= bp.slot - end + function(slot, bp) solved[slot] = bp.ownerId end) - plan.quality = ComputeQuality(plan.sides) - plan.winChance = ComputeWinChance(plan.sides) + ScorePlan(plan, players, seatSide, solved) plan.feasible = freeCount > 0 if freeCount == 0 then plan.reason = "Every player is locked in place — nothing to rearrange." @@ -429,6 +565,17 @@ function BuildPlan(slots, teams) return plan end +--- Builds the first balance plan from the lobby's current seating — a fresh solve honouring `locks` +--- (the preview's working lock set; nil = the seats' own Locked flags). A convenience wrapper over +--- Rebalance from the identity arrangement. +---@param slots UICustomLobbySlot[] +---@param teams UICustomLobbyTeams +---@param locks? table +---@return UICustomLobbyBalancePlan +function BuildPlan(slots, teams, locks) + return Rebalance(slots, teams, IdentityArrangement(slots), locks) +end + --- The lobby update for a plan: the player -> slot arrangement to apply. `Team` is intentionally --- absent — under AutoTeams the position decides the team (resolved at launch). ---@param plan UICustomLobbyBalancePlan From 8c65764bd34849bd6caae6027b8e5a21214bf57a Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sat, 27 Jun 2026 21:32:09 +0200 Subject: [PATCH 81/98] Improve balance preview dialog --- .../customlobby/CustomLobbyBalancePreview.lua | 339 ++++++++++++------ .../lobby/customlobby/CustomLobbyBalancer.lua | 173 ++++++--- 2 files changed, 364 insertions(+), 148 deletions(-) diff --git a/lua/ui/lobby/customlobby/CustomLobbyBalancePreview.lua b/lua/ui/lobby/customlobby/CustomLobbyBalancePreview.lua index 7d9a4b2f08a..63f95682cfc 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyBalancePreview.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyBalancePreview.lua @@ -30,16 +30,21 @@ -- kept preview-local: toggling one here is just a balancing constraint, never -- written back (Cancel discards everything; Apply commits only the seating). -- +-- The host first **browses the candidate balances** — the kernel returns the top-N distinct splits +-- (`BuildCandidates`), shown one at a time with a "< i / N >" browser; stepping through adopts each. +-- -- The body renders as **mirror positions** (row k = the k-th seat on each side, who face off; the --- right column is mirrored so the teams face each other). Each row shows both players + the rating gap. --- Three gestures drive it: +-- right column is mirrored so the teams face each other): each row puts the name on the outer edge and +-- the rating in a column hugging the centre (with the mean muted beside it) + the pair's gap in the +-- centre. The header is the same shape — team label (outer), average (column) + total (muted), and the +-- gap total in the centre band atop the per-row gaps. Three gestures tune the shown candidate: -- * **Drag a row** onto another → swap those two positions' pairs (both players move together), -- so the host arranges which pair spawns where ("D -> A"). -- * **Click a player, then another** → swap just those two (`ScoreArrangement` re-scores, no solve). --- * **Click a player's lock** → pin / unpin them (the next Retry holds them in place). --- **Retry** re-solves the unlocked players around the locked ones (`Rebalance`). Moved players are --- gold, locked players blue. The summary reports per-team total + average, the quality before -> after, --- the predicted win split and the rating gap. +-- * **Click a player's lock** → pin / unpin them, which **regenerates** the candidates around that +-- constraint (the locked player held at its seat, the rest re-balanced). +-- Moved players are gold, locked players blue. The status line reports quality before -> after and the +-- predicted win split. -- -- Built to the preset dialog's shape (areas layout, three-phase init, Popup singleton). @@ -71,6 +76,8 @@ local RowHeight = 24 local MaxRows = 8 -- binary AutoTeams on a 16-spawn map is at most 8 per side local LockSize = 12 local PosLabelWidth = 16 +local CenterWidth = 54 -- fixed centre band (the gap number) so the rating columns line up across rows +local NavSize = 22 local ButtonGap = 8 local DragThreshold = 5 -- cursor travel (screen px) before a press becomes a drag, not a click @@ -107,22 +114,6 @@ local function CurrentSnapshot() return CustomLobbySlotsDerivedModel.GetSlots(), CustomLobbySlotsDerivedModel.GetTeams() end ---- "Name · 1850" for a player (rating omitted when unrated / AI). `mirror` reverses it to ---- "1850 · Name" so the right column faces the left — the name stays on the outer edge and the ---- rating reads inward toward the centre gap, exactly like the mirrored slot cards. ----@param player UICustomLobbyBalancePlayer ----@param mirror boolean ----@return string -local function FormatPlayer(player, mirror) - if player.pl > 0 then - if mirror then - return tostring(player.pl) .. " · " .. player.name - end - return player.name .. " · " .. tostring(player.pl) - end - return player.name -end - --- The display colour for a player: blue = host-locked (pinned), gold = moved by the balance, grey = --- unchanged. Locked wins (a locked player never moves, so it can't also read as a mover). ---@param player UICustomLobbyBalancePlayer @@ -154,9 +145,14 @@ end ---@field LeftSelect Bitmap # left half: swap-click / drag target + selection highlight ---@field RightSelect Bitmap ---@field PosLabel Text # the position index (1..N) ----@field Left Text ----@field Center Text ----@field Right Text +---@field LeftName Text # outer edge +---@field LeftMean Text # muted mean, just outside the rating +---@field LeftRating Text # display rating, aligned in a column beside the centre +---@field CenterArea Group # fixed-width centre band (holds the gap number) +---@field Center Text # the pair's rating gap +---@field RightRating Text +---@field RightMean Text +---@field RightName Text ---@field LeftLock Bitmap # left player's lock toggle ---@field RightLock Bitmap ---@field LeftOwner UILobbyPeerId | nil # who is on each half right now (set by Render) @@ -168,6 +164,8 @@ end ---@field Trash TrashBag ---@field OnCloseCb fun() ---@field Result UICustomLobbyBalancePlan +---@field Candidates UICustomLobbyBalancePlan[] # the browsable top-N balances (best-first) +---@field CandidateIndex number # which candidate is shown (1-based) ---@field Arrangement table # working seating (slot -> ownerId) ---@field Locks table # working lock set (preview-local) ---@field SelectedSlot number | nil # first half clicked for a swap @@ -181,12 +179,20 @@ end ---@field ActionArea Group ---@field Title Text ---@field Hint Text ----@field HeaderA Text ----@field HeaderB Text +---@field TeamLabelA Text # team label, outer edge (like a player name) +---@field TeamLabelB Text +---@field TeamAvgA Text # team average, centre column (aligned above the player ratings) +---@field TeamAvgB Text +---@field TeamTotalA Text # team total, muted, just outside the average (like a player mean) +---@field TeamTotalB Text +---@field HeaderCenterArea Group # centre band (holds the gap total), aligned above the rows' centre band +---@field HeaderGap Text # the total rating gap between the teams ---@field Rows UICustomLobbyBalanceRow[] ---@field Status Text +---@field NavPrev Bitmap # browse to the previous candidate ("<") +---@field NavNext Bitmap # browse to the next candidate (">") +---@field NavLabel Text # "i / N" ---@field ApplyButton Button ----@field RetryButton Button ---@field CancelButton Button ---@field Ready boolean local CustomLobbyBalancePreview = ClassUI(Group) { @@ -204,13 +210,15 @@ local CustomLobbyBalancePreview = ClassUI(Group) { self.SelectedOwner = nil self.DragGhost = false - -- seed the working state from the lobby: a fresh solve honouring the lobby's existing locks - -- (locks nil -> the kernel reads each seat's own Locked flag), then keep those locks local + -- seed the working state from the lobby: the candidate balances honouring the lobby's existing + -- locks (locks nil -> the kernel reads each seat's own Locked flag), then keep those locks local local slots, teams = CurrentSnapshot() - local plan = CustomLobbyBalancer.BuildPlan(slots, teams) - self.Result = plan - self.Arrangement = plan.arrangement - self.Locks = table.copy(plan.lockedOwners) + local candidates = CustomLobbyBalancer.BuildPlan(slots, teams) + self.Candidates = candidates + self.CandidateIndex = 1 + self.Result = candidates[1] + self.Arrangement = self.Result.arrangement + self.Locks = table.copy(self.Result.lockedOwners) self.TitleArea = CreateArea(self, "TitleArea", 'ffcc4040') self.HintArea = CreateArea(self, "HintArea", 'ff404040') @@ -227,13 +235,34 @@ local CustomLobbyBalancePreview = ClassUI(Group) { self.Hint:SetColor(MutedColor) self.Hint:DisableHitTest() + -- the team summary mirrors a player row: label (outer), total (muted) + average (centre column, + -- above the player ratings), and the gap total in the centre band above the per-row gaps local labels = self.Result.labels or { "Team 1", "Team 2" } - self.HeaderA = UIUtil.CreateText(self.HeaderArea, labels[1], 14, UIUtil.titleFont) - self.HeaderA:SetColor(HeaderColor) - self.HeaderA:DisableHitTest() - self.HeaderB = UIUtil.CreateText(self.HeaderArea, labels[2], 14, UIUtil.titleFont) - self.HeaderB:SetColor(HeaderColor) - self.HeaderB:DisableHitTest() + self.TeamLabelA = UIUtil.CreateText(self.HeaderArea, labels[1], 14, UIUtil.titleFont) + self.TeamLabelA:SetColor(HeaderColor) + self.TeamLabelA:DisableHitTest() + self.TeamLabelB = UIUtil.CreateText(self.HeaderArea, labels[2], 14, UIUtil.titleFont) + self.TeamLabelB:SetColor(HeaderColor) + self.TeamLabelB:DisableHitTest() + + self.TeamAvgA = UIUtil.CreateText(self.HeaderArea, "", 14, UIUtil.titleFont) + self.TeamAvgA:SetColor(HeaderColor) + self.TeamAvgA:DisableHitTest() + self.TeamAvgB = UIUtil.CreateText(self.HeaderArea, "", 14, UIUtil.titleFont) + self.TeamAvgB:SetColor(HeaderColor) + self.TeamAvgB:DisableHitTest() + + self.TeamTotalA = UIUtil.CreateText(self.HeaderArea, "", 11, UIUtil.bodyFont) + self.TeamTotalA:SetColor(MutedColor) + self.TeamTotalA:DisableHitTest() + self.TeamTotalB = UIUtil.CreateText(self.HeaderArea, "", 11, UIUtil.bodyFont) + self.TeamTotalB:SetColor(MutedColor) + self.TeamTotalB:DisableHitTest() + + self.HeaderCenterArea = Group(self.HeaderArea, "BalanceHeaderCenter") + self.HeaderGap = UIUtil.CreateText(self.HeaderCenterArea, "", 12, UIUtil.bodyFont) + self.HeaderGap:SetColor(HeaderColor) + self.HeaderGap:DisableHitTest() -- a fixed pool of interactive position rows; Render shows/hides + fills them from the proposal self.Rows = {} @@ -245,18 +274,18 @@ local CustomLobbyBalancePreview = ClassUI(Group) { self.Status:SetColor(LabelColor) self.Status:DisableHitTest() + -- candidate browser (left of the action buttons): "< i / N >" to step through the top balances + self.NavPrev = self:CreateNavArrow("<", function() self:ShowCandidate(self.CandidateIndex - 1) end) + self.NavLabel = UIUtil.CreateText(self.ActionArea, "", 13, UIUtil.bodyFont) + self.NavLabel:SetColor(LabelColor) + self.NavLabel:DisableHitTest() + self.NavNext = self:CreateNavArrow(">", function() self:ShowCandidate(self.CandidateIndex + 1) end) + self.ApplyButton = UIUtil.CreateButtonWithDropshadow(self.ActionArea, '/BUTTON/medium/', "Apply") self.ApplyButton.OnClick = function() self:ApplyAndClose() end - -- re-roll: re-solve the unlocked players around the locked ones, for a different equally-good - -- arrangement, without committing - self.RetryButton = UIUtil.CreateButtonWithDropshadow(self.ActionArea, '/BUTTON/medium/', "Retry") - self.RetryButton.OnClick = function() - self:Reroll() - end - self.CancelButton = UIUtil.CreateButtonWithDropshadow(self.ActionArea, '/BUTTON/medium/', "Cancel") self.CancelButton.OnClick = function() self.OnCloseCb() @@ -306,13 +335,28 @@ local CustomLobbyBalancePreview = ClassUI(Group) { row.PosLabel:SetColor(MutedColor) row.PosLabel:DisableHitTest() - row.Left = UIUtil.CreateText(row.Group, "", 14, UIUtil.bodyFont) - row.Left:DisableHitTest() - row.Center = UIUtil.CreateText(row.Group, "", 12, UIUtil.bodyFont) + -- per half: the name on the outer edge, then the muted mean, then the rating in a fixed column + -- beside the centre band; the gap number lives in the centre band so the rating columns line up + row.LeftName = UIUtil.CreateText(row.Group, "", 14, UIUtil.bodyFont) + row.LeftName:DisableHitTest() + row.LeftMean = UIUtil.CreateText(row.Group, "", 11, UIUtil.bodyFont) + row.LeftMean:SetColor(MutedColor) + row.LeftMean:DisableHitTest() + row.LeftRating = UIUtil.CreateText(row.Group, "", 14, UIUtil.bodyFont) + row.LeftRating:DisableHitTest() + + row.CenterArea = Group(row.Group, "BalanceRowCenter") + row.Center = UIUtil.CreateText(row.CenterArea, "", 12, UIUtil.bodyFont) row.Center:SetColor(MutedColor) row.Center:DisableHitTest() - row.Right = UIUtil.CreateText(row.Group, "", 14, UIUtil.bodyFont) - row.Right:DisableHitTest() + + row.RightRating = UIUtil.CreateText(row.Group, "", 14, UIUtil.bodyFont) + row.RightRating:DisableHitTest() + row.RightMean = UIUtil.CreateText(row.Group, "", 11, UIUtil.bodyFont) + row.RightMean:SetColor(MutedColor) + row.RightMean:DisableHitTest() + row.RightName = UIUtil.CreateText(row.Group, "", 14, UIUtil.bodyFont) + row.RightName:DisableHitTest() row.LeftLock = Bitmap(row.Group) row.LeftLock:SetSolidColor(LockColor) @@ -339,6 +383,31 @@ local CustomLobbyBalancePreview = ClassUI(Group) { return row end, + --- Builds a candidate-browser arrow ("<" / ">") — a faint clickable chip with a centred glyph. + --- `SetNavArrow` lights/dims it; clicking runs `onClick`. + ---@param self UICustomLobbyBalancePreview + ---@param glyph string + ---@param onClick fun() + ---@return Bitmap + CreateNavArrow = function(self, glyph, onClick) + local arrow = Bitmap(self.ActionArea) + arrow:SetSolidColor(SelectColor) + arrow:SetAlpha(0.0) + arrow.HandleEvent = function(control, event) + if event.Type == 'ButtonPress' and not event.Modifiers.Right then + onClick() + return true + end + return false + end + local label = UIUtil.CreateText(arrow, glyph, 16, UIUtil.bodyFont) + label:SetColor(LabelColor) + label:DisableHitTest() + Layouter(label):AtHorizontalCenterIn(arrow):AtVerticalCenterIn(arrow):End() + arrow.Label = label + return arrow + end, + ---@param self UICustomLobbyBalancePreview __post_init = function(self) self.Width:Set(LayoutHelpers.ScaleNumber(DialogWidth)) @@ -357,8 +426,17 @@ local CustomLobbyBalancePreview = ClassUI(Group) { Layouter(self.Title):AtHorizontalCenterIn(self.TitleArea):AtVerticalCenterIn(self.TitleArea):End() Layouter(self.Hint):AtHorizontalCenterIn(self.HintArea):AtVerticalCenterIn(self.HintArea):End() - Layouter(self.HeaderA):AtLeftIn(self.HeaderArea):AtVerticalCenterIn(self.HeaderArea):End() - Layouter(self.HeaderB):AtRightIn(self.HeaderArea):AtVerticalCenterIn(self.HeaderArea):End() + -- team summary, laid out on the same columns as the player rows below it + Layouter(self.HeaderCenterArea):AtHorizontalCenterIn(self.HeaderArea):AtTopIn(self.HeaderArea) + :AtBottomIn(self.HeaderArea):Width(CenterWidth):End() + Layouter(self.HeaderGap):AtHorizontalCenterIn(self.HeaderCenterArea):AtVerticalCenterIn(self.HeaderCenterArea):End() + + Layouter(self.TeamLabelA):AtLeftIn(self.HeaderArea):AtVerticalCenterIn(self.HeaderArea):End() + Layouter(self.TeamAvgA):AnchorToLeft(self.HeaderCenterArea, 8):AtVerticalCenterIn(self.HeaderArea):End() + Layouter(self.TeamTotalA):AnchorToLeft(self.TeamAvgA, 5):AtVerticalCenterIn(self.HeaderArea):End() + Layouter(self.TeamLabelB):AtRightIn(self.HeaderArea):AtVerticalCenterIn(self.HeaderArea):End() + Layouter(self.TeamAvgB):AnchorToRight(self.HeaderCenterArea, 8):AtVerticalCenterIn(self.HeaderArea):End() + Layouter(self.TeamTotalB):AnchorToRight(self.TeamAvgB, 5):AtVerticalCenterIn(self.HeaderArea):End() -- stack the position rows from the top of the rows area for i = 1, MaxRows do @@ -378,23 +456,39 @@ local CustomLobbyBalancePreview = ClassUI(Group) { Layouter(row.RightLock):AtRightIn(row.Group):AtVerticalCenterIn(row.Group) :Width(LockSize):Height(LockSize):Over(row.Group, 10):End() - Layouter(row.Center):AtHorizontalCenterIn(row.Group):AtVerticalCenterIn(row.Group):End() - Layouter(row.Left):AnchorToRight(row.LeftLock, 6):AtVerticalCenterIn(row.Group):End() - Layouter(row.Right):AnchorToLeft(row.RightLock, 6):AtVerticalCenterIn(row.Group):End() + -- fixed-width centre band (so the rating columns either side line up across every row) + Layouter(row.CenterArea):AtHorizontalCenterIn(row.Group):AtTopIn(row.Group):AtBottomIn(row.Group) + :Width(CenterWidth):End() + Layouter(row.Center):AtHorizontalCenterIn(row.CenterArea):AtVerticalCenterIn(row.CenterArea):End() + + -- ratings: a column hugging the centre band (left right-aligned, right left-aligned); the + -- muted mean sits just outside each rating; names run from the outer edge + Layouter(row.LeftRating):AnchorToLeft(row.CenterArea, 8):AtVerticalCenterIn(row.Group):End() + Layouter(row.LeftMean):AnchorToLeft(row.LeftRating, 5):AtVerticalCenterIn(row.Group):End() + Layouter(row.LeftName):AnchorToRight(row.LeftLock, 6):AtVerticalCenterIn(row.Group):End() + Layouter(row.RightRating):AnchorToRight(row.CenterArea, 8):AtVerticalCenterIn(row.Group):End() + Layouter(row.RightMean):AnchorToRight(row.RightRating, 5):AtVerticalCenterIn(row.Group):End() + Layouter(row.RightName):AnchorToLeft(row.RightLock, 6):AtVerticalCenterIn(row.Group):End() -- the swap-click / drag halves sit above the drop tint but below the texts (which ignore - -- hits) and stop short of the centre gap; the lock bitmaps sit above them (Over) + -- hits) and stop short of the centre band; the lock bitmaps sit above them (Over) Layouter(row.LeftSelect):AtTopIn(row.Group):AtBottomIn(row.Group) - :AtLeftIn(row.Group):AnchorToLeft(row.Center, 4):End() + :AtLeftIn(row.Group):AnchorToLeft(row.CenterArea, 2):End() Layouter(row.RightSelect):AtTopIn(row.Group):AtBottomIn(row.Group) - :AtRightIn(row.Group):AnchorToRight(row.Center, 4):End() + :AtRightIn(row.Group):AnchorToRight(row.CenterArea, 2):End() end Layouter(self.Status):AtLeftIn(self.StatusArea):AtVerticalCenterIn(self.StatusArea):End() + -- candidate browser on the left of the action row + Layouter(self.NavPrev):AtLeftIn(self.ActionArea):AtVerticalCenterIn(self.ActionArea) + :Width(NavSize):Height(NavSize):End() + Layouter(self.NavLabel):AnchorToRight(self.NavPrev, 6):AtVerticalCenterIn(self.ActionArea):End() + Layouter(self.NavNext):AnchorToRight(self.NavLabel, 6):AtVerticalCenterIn(self.ActionArea) + :Width(NavSize):Height(NavSize):End() + Layouter(self.CancelButton):AtRightIn(self.ActionArea):AtVerticalCenterIn(self.ActionArea):End() Layouter(self.ApplyButton):AnchorToLeft(self.CancelButton, ButtonGap):AtVerticalCenterIn(self.ActionArea):End() - Layouter(self.RetryButton):AnchorToLeft(self.ApplyButton, ButtonGap):AtVerticalCenterIn(self.ActionArea):End() end, --- Post-mount render (the opener calls this after Popup centres the dialog, so the rows read @@ -568,9 +662,9 @@ local CustomLobbyBalancePreview = ClassUI(Group) { self:Rescore() end, - --- A player's lock was clicked: toggle it in the working set and re-score. The player stays put; - --- locking just pins them so the next Retry holds them in place (and recolours them blue). Clears - --- any pending swap selection, since the pinning changed. + --- A player's lock was clicked: toggle it in the working set and regenerate the candidates around + --- the new constraint (the locked player is pinned at its current seat; the rest re-balance). Blue + --- marks a locked player. ---@param self UICustomLobbyBalancePreview ---@param ownerId UILobbyPeerId | nil OnLockToggled = function(self, ownerId) @@ -580,16 +674,32 @@ local CustomLobbyBalancePreview = ClassUI(Group) { local locks = table.copy(self.Locks) locks[ownerId] = (not locks[ownerId]) or nil self.Locks = locks - self.SelectedSlot = nil - self.SelectedOwner = nil - self:Rescore() + self:Regenerate(self.Arrangement) end, - --- Re-solves the unlocked players around the locked ones (Retry), adopting a fresh arrangement. + --- Regenerates the candidate balances around the current locks, pinning locked players at their + --- seats in `arrangement`, and shows the best one. Used by open / Retry / a lock toggle. ---@param self UICustomLobbyBalancePreview - Reroll = function(self) + ---@param arrangement table + Regenerate = function(self, arrangement) local slots, teams = CurrentSnapshot() - self:Adopt(CustomLobbyBalancer.Rebalance(slots, teams, self.Arrangement, self.Locks)) + self.Candidates = CustomLobbyBalancer.BuildCandidates(slots, teams, arrangement, self.Locks) + self.CandidateIndex = 1 + self:Adopt(self.Candidates[1]) + end, + + --- Browses to candidate `index` (clamped), discarding any unsaved manual swap on the shown one. + ---@param self UICustomLobbyBalancePreview + ---@param index number + ShowCandidate = function(self, index) + local count = table.getn(self.Candidates) + if index < 1 then + index = 1 + elseif index > count then + index = count + end + self.CandidateIndex = index + self:Adopt(self.Candidates[index]) end, --- Re-scores the current working arrangement in place (after a swap / drag / lock toggle) — no @@ -661,22 +771,18 @@ local CustomLobbyBalancePreview = ClassUI(Group) { --------------------------------------------------------------------------- --#region Rendering - --- The per-team header: "label total (avg avg)", mirrored on the right side ("total (avg avg) - --- label") so the team label stays on the outer edge of its column. + --- Sets one team's header column — average (centre column) + total (muted) — from the plan. ---@param self UICustomLobbyBalancePreview + ---@param avgText Text + ---@param totalText Text ---@param side 1 | 2 - ---@return string - SideHeader = function(self, side) + SetTeamHeader = function(self, avgText, totalText, side) local result = self.Result - local labels = result.labels or { "Team 1", "Team 2" } local count = table.getn(result.sides[side]) local total = result.totals[side] local avg = count > 0 and math.floor(total / count + 0.5) or 0 - local stats = tostring(total) .. " (" .. tostring(avg) .. " avg)" - if side == 2 then - return stats .. " " .. labels[side] - end - return labels[side] .. " " .. stats + avgText:SetText(tostring(avg)) + totalText:SetText("(" .. tostring(total) .. ")") end, --- Fills one position row from a `UICustomLobbyBalancePosition` (either player may be absent on an @@ -689,8 +795,8 @@ local CustomLobbyBalancePreview = ClassUI(Group) { row.Group:Show() row.PosLabel:SetText(tostring(index)) local a, b = position.a, position.b - self:FillHalf(row.Left, row.LeftLock, row.LeftSelect, a, position.slotA, false) - self:FillHalf(row.Right, row.RightLock, row.RightSelect, b, position.slotB, true) + self:FillHalf(row.LeftName, row.LeftRating, row.LeftMean, row.LeftLock, row.LeftSelect, a, position.slotA) + self:FillHalf(row.RightName, row.RightRating, row.RightMean, row.RightLock, row.RightSelect, b, position.slotB) row.LeftOwner = a and a.ownerId or nil row.LeftSlot = position.slotA row.RightOwner = b and b.ownerId or nil @@ -706,24 +812,37 @@ local CustomLobbyBalancePreview = ClassUI(Group) { end end, - --- Paints one half of a row (its text + lock dot + selection highlight) for a player, or clears it - --- when the half is empty. + --- Paints one half of a row — name (outer), display rating (centre column) + muted mean, the lock + --- dot and the selection highlight — for a player, or clears it when the half is empty. ---@param self UICustomLobbyBalancePreview - ---@param text Text + ---@param name Text + ---@param rating Text + ---@param mean Text ---@param lock Bitmap ---@param select Bitmap ---@param player UICustomLobbyBalancePlayer | nil ---@param slot number | nil - ---@param mirror boolean - FillHalf = function(self, text, lock, select, player, slot, mirror) + FillHalf = function(self, name, rating, mean, lock, select, player, slot) if not player then - text:SetText("") + name:SetText("") + rating:SetText("") + mean:SetText("") lock:SetAlpha(0.0) select:SetAlpha(0.0) return end - text:SetText(FormatPlayer(player, mirror)) - text:SetColor(PlayerColor(player)) + local color = PlayerColor(player) + name:SetText(player.name) + name:SetColor(color) + -- rating + mean only for rated players (an unrated / AI player's mean is a placeholder) + if player.pl > 0 then + rating:SetText(tostring(player.pl)) + rating:SetColor(color) + mean:SetText("(" .. tostring(math.floor(player.mean + 0.5)) .. ")") + else + rating:SetText("") + mean:SetText("") + end -- the lock dot: solid blue when pinned, faint when not (a click toggles it) lock:SetSolidColor(player.locked and LockColor or SelectColor) lock:SetAlpha(player.locked and 1.0 or 0.25) @@ -731,14 +850,15 @@ local CustomLobbyBalancePreview = ClassUI(Group) { select:SetAlpha(slot == self.SelectedSlot and 0.12 or 0.0) end, - --- Paints the position rows + the summary line from the working plan, and enables Apply / Retry - --- only when there is something to apply. + --- Paints the position rows + the summary line from the working plan, and enables Apply only when + --- there is something to apply. ---@param self UICustomLobbyBalancePreview Render = function(self) local result = self.Result - self.HeaderA:SetText(self:SideHeader(1)) - self.HeaderB:SetText(self:SideHeader(2)) + self:SetTeamHeader(self.TeamAvgA, self.TeamTotalA, 1) + self:SetTeamHeader(self.TeamAvgB, self.TeamTotalB, 2) + self.HeaderGap:SetText("+" .. tostring(math.abs(result.totals[1] - result.totals[2]))) local positions = result.positions or {} local rowCount = table.getn(positions) @@ -752,16 +872,34 @@ local CustomLobbyBalancePreview = ClassUI(Group) { end self.Status:SetText(self:StatusLine()) + self:UpdateNav() if result.feasible then self.ApplyButton:Enable() - self.RetryButton:Enable() else self.ApplyButton:Disable() - self.RetryButton:Disable() end end, + --- Lights or dims a nav arrow (a dim arrow reads as unavailable — at the first / last candidate). + ---@param self UICustomLobbyBalancePreview + ---@param arrow Bitmap + ---@param enabled boolean + SetNavArrow = function(self, arrow, enabled) + arrow:SetAlpha(enabled and 0.12 or 0.0) + arrow.Label:SetColor(enabled and LabelColor or '00000000') + end, + + --- Updates the candidate browser: "i / N" + arrow availability. Hidden entirely with one candidate. + ---@param self UICustomLobbyBalancePreview + UpdateNav = function(self) + local count = table.getn(self.Candidates) + local multiple = count > 1 + self.NavLabel:SetText(multiple and (tostring(self.CandidateIndex) .. " / " .. tostring(count)) or "") + self:SetNavArrow(self.NavPrev, multiple and self.CandidateIndex > 1) + self:SetNavArrow(self.NavNext, multiple and self.CandidateIndex < count) + end, + --- The summary: the reason it can't balance, else "Quality before -> after · Win a/b · Gap g", --- plus any odd one left out. ---@param self UICustomLobbyBalancePreview @@ -772,12 +910,14 @@ local CustomLobbyBalancePreview = ClassUI(Group) { return result.reason end - -- match quality, as "current -> proposed" when both are known + -- match quality, as "current -> proposed" when both are known (whole percent — the trueskill + -- value carries two decimals we don't need on screen) local quality if result.quality and result.currentQuality then - quality = "Quality " .. tostring(result.currentQuality) .. "% -> " .. tostring(result.quality) .. "%" + quality = "Quality " .. tostring(math.floor(result.currentQuality + 0.5)) + .. "% -> " .. tostring(math.floor(result.quality + 0.5)) .. "%" elseif result.quality then - quality = "Quality " .. tostring(result.quality) .. "%" + quality = "Quality " .. tostring(math.floor(result.quality + 0.5)) .. "%" else quality = "Quality n/a" end @@ -786,7 +926,6 @@ local CustomLobbyBalancePreview = ClassUI(Group) { if result.winChance then status = status .. " · Win " .. tostring(result.winChance[1]) .. "% / " .. tostring(result.winChance[2]) .. "%" end - status = status .. " · Gap " .. tostring(math.abs(result.totals[1] - result.totals[2])) if result.unassigned then status = status .. " · " .. result.unassigned.name .. " stays put (odd count)" end diff --git a/lua/ui/lobby/customlobby/CustomLobbyBalancer.lua b/lua/ui/lobby/customlobby/CustomLobbyBalancer.lua index 15833a6507d..370ebd5d765 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyBalancer.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyBalancer.lua @@ -390,30 +390,38 @@ function ScoreArrangement(slots, teams, arrangement, locks) return plan end ---- Re-solves the UNLOCKED players into a balanced two-side split, keeping locked players (and, for an ---- odd roster, the lowest-rated unlocked player) pinned at their seat in `arrangement` — so a balance ---- honours where the host has already locked people, even when those seats differ from the lobby (e.g. ---- a player the balance itself moved, then the host locked). Used for the first proposal (from the ---- identity arrangement) and for Retry. `locks` is the working lock set (see Project). +-- how many candidate splits the search keeps (caller asks for `count`; we collect a wider pool so that +-- after discarding mirror-equivalent splits there are still enough genuinely-different ones) +local DefaultCandidateCount = 5 + +--- Builds up to `count` candidate balance plans — the best distinct team splits, each a complete, +--- scored plan — best-first by match quality. The host browses them in the preview. Re-solves the +--- UNLOCKED players, keeping locked players (and, for an odd roster, the lowest-rated unlocked player) +--- pinned at their seat in `arrangement` — so a balance honours where the host has already locked +--- people, even when those seats differ from the lobby. Each candidate gets its own random position +--- shuffle. Always returns a non-empty list; an infeasible case is a single plan carrying the reason. ---@param slots UICustomLobbySlot[] ---@param teams UICustomLobbyTeams ---@param arrangement table # current seats (pinned players are read from here) ---@param locks table | nil ----@return UICustomLobbyBalancePlan -function Rebalance(slots, teams, arrangement, locks) - local plan = NewPlan(teams) +---@param count? number # how many candidates to return (default 5) +---@return UICustomLobbyBalancePlan[] +function BuildCandidates(slots, teams, arrangement, locks, count) + count = count or DefaultCandidateCount -- the feature is gated to a binary AutoTeams mode with a resolved split; bail defensively otherwise if not (teams and teams.Mode) or not teams.Resolved then + local plan = NewPlan(teams) plan.reason = "Auto-balance needs an AutoTeams mode with a loaded map." - return plan + return { plan } end local players, seatSide, seatClosed = Project(slots, locks) local occupiedCount = table.getn(players) if occupiedCount < 2 then + local plan = NewPlan(teams) plan.reason = "Need at least two players to balance." - return plan + return { plan } end -- where each player currently sits (its seat in the working arrangement, or its lobby seat if it @@ -427,6 +435,8 @@ function Rebalance(slots, teams, arrangement, locks) -- pinned = locked players + (odd roster) the lowest-rated unlocked player, left in place so the rest -- pair up (never ejected) local pinned = {} + ---@type UICustomLobbyBalancePlayer | false + local unassigned = false for _, bp in ipairs(players) do if bp.locked then pinned[bp.ownerId] = true @@ -441,12 +451,12 @@ function Rebalance(slots, teams, arrangement, locks) end if odd then pinned[odd.ownerId] = true - plan.unassigned = odd + unassigned = odd end end -- pinned players hold their current seat (off-limits to the search); everyone else is the free pool - local solved = {} + local solvedBase = {} local pinnedSlots = {} local lockedMean = { 0, 0 } local lockedDev = { 0, 0 } @@ -459,7 +469,7 @@ function Rebalance(slots, teams, arrangement, locks) if pinned[bp.ownerId] then local seat = slotOf[bp.ownerId] or bp.slot local side = seatSide[seat] or bp.side - solved[seat] = bp.ownerId + solvedBase[seat] = bp.ownerId pinnedSlots[seat] = true lockedMean[side] = lockedMean[side] + bp.mean lockedDev[side] = lockedDev[side] + bp.dev @@ -488,24 +498,76 @@ function Rebalance(slots, teams, arrangement, locks) freePool[i], freePool[j] = freePool[j], freePool[i] end + --- Builds + scores one plan from a chosen side-A index set (a position shuffle per call). + ---@param chosenList number[] + ---@return UICustomLobbyBalancePlan + local function PlanFor(chosenList) + local chosen = {} + for _, index in ipairs(chosenList) do + chosen[index] = true + end + local freeA, freeB = {}, {} + for i = 1, freeCount do + table.insert(chosen[i] and freeA or freeB, freePool[i]) + end + table.sort(freeA, ByRatingDesc) + table.sort(freeB, ByRatingDesc) + local arr = table.copy(solvedBase) + ShufflePairsIntoSeats(freeA, freeB, freeSeats[1], freeSeats[2], + function(slot, bp) arr[slot] = bp.ownerId end) + local plan = NewPlan(teams) + plan.unassigned = unassigned + ScorePlan(plan, players, seatSide, arr) + plan.feasible = freeCount > 0 + return plan + end + + -- nothing to rearrange (everyone pinned): one plan showing the pinned board + if freeCount == 0 then + local plan = PlanFor({}) + plan.feasible = false + plan.reason = "Every player is locked in place — nothing to rearrange." + return { plan } + end + -- how many free players go to side A: aim for equal final sizes, clamped to each side's free seats local capA, capB = table.getn(freeSeats[1]), table.getn(freeSeats[2]) local placedA = math.floor((freeCount + lockedCount[2] - lockedCount[1]) / 2 + 0.5) local minA = math.max(0, freeCount - capB) local maxA = math.min(freeCount, capA) if minA > maxA then + local plan = NewPlan(teams) plan.reason = "The locked players don't fit the team layout." - ScorePlan(plan, players, seatSide, solved) -- still show the pinned players - return plan + ScorePlan(plan, players, seatSide, solvedBase) -- still show the pinned players + return { plan } end if placedA < minA then placedA = minA end if placedA > maxA then placedA = maxA end - -- search: choose placedA free players for side A, minimising the cheap rating-imbalance heuristic + -- search: enumerate side-A choices, keeping the top `poolSize` by the cheap rating-imbalance + -- heuristic (a wider pool than `count`, so mirror-equivalent splits can be dropped below) local goalMean, goalDev = totalMean / 2, totalDev / 2 - local best = { value = nil, chosen = nil } + local poolSize = math.max(count * 4, 12) + local kept = {} -- {value, chosen}, sorted ascending by value local evaluations = 0 local current = {} + local function consider(value) + local n = table.getn(kept) + if n >= poolSize and value >= kept[n].value then + return + end + local pos = n + 1 + for i = 1, n do + if value < kept[i].value then + pos = i + break + end + end + table.insert(kept, pos, { value = value, chosen = table.copy(current) }) + if table.getn(kept) > poolSize then + table.remove(kept) + end + end local function evaluate() local meanA, devA = lockedMean[1], lockedDev[1] for i = 1, table.getn(current) do @@ -513,11 +575,7 @@ function Rebalance(slots, teams, arrangement, locks) meanA = meanA + bp.mean devA = devA + bp.dev end - local value = math.abs(devA - goalDev) * 1.2 + math.abs(meanA - goalMean) - if not best.value or value < best.value then - best.value = value - best.chosen = table.copy(current) - end + consider(math.abs(devA - goalDev) * 1.2 + math.abs(meanA - goalMean)) evaluations = evaluations + 1 end local function choose(startIndex, remaining) @@ -539,41 +597,60 @@ function Rebalance(slots, teams, arrangement, locks) end choose(1, placedA) - -- split the free pool into the two sides per the best result - local chosen = {} - if best.chosen then - for _, index in ipairs(best.chosen) do - chosen[index] = true + -- build the best distinct splits, skipping mirror-equivalent ones (side A = the complement of an + -- already-taken split is the same matchup with the teams flipped) + local plans = {} + local seen = {} + for _, entry in ipairs(kept) do + if table.getn(plans) >= count then + break + end + local inA = {} + for _, index in ipairs(entry.chosen) do + inA[index] = true + end + local sigA, sigB = "", "" + for i = 1, freeCount do + if inA[i] then + sigA = sigA .. tostring(i) .. "," + else + sigB = sigB .. tostring(i) .. "," + end + end + local signature = (sigA < sigB) and (sigA .. "|" .. sigB) or (sigB .. "|" .. sigA) + if not seen[signature] then + seen[signature] = true + table.insert(plans, PlanFor(entry.chosen)) end - end - local freeA, freeB = {}, {} - for i = 1, freeCount do - table.insert(chosen[i] and freeA or freeB, freePool[i]) end - -- phase 2 + 3: rank-match the free players, scatter the pairs across the free mirror rows - table.sort(freeA, ByRatingDesc) - table.sort(freeB, ByRatingDesc) - ShufflePairsIntoSeats(freeA, freeB, freeSeats[1], freeSeats[2], - function(slot, bp) solved[slot] = bp.ownerId end) + -- present the most balanced first (by the displayed metric — match quality) + table.sort(plans, function(p, q) + return (p.quality or 0) > (q.quality or 0) + end) + return plans +end - ScorePlan(plan, players, seatSide, solved) - plan.feasible = freeCount > 0 - if freeCount == 0 then - plan.reason = "Every player is locked in place — nothing to rearrange." - end - return plan +--- Re-solves into a single best balance plan — the first of BuildCandidates. Used where one plan is +--- enough (Retry's re-roll picks among the candidate set in the preview). +---@param slots UICustomLobbySlot[] +---@param teams UICustomLobbyTeams +---@param arrangement table +---@param locks table | nil +---@return UICustomLobbyBalancePlan +function Rebalance(slots, teams, arrangement, locks) + return BuildCandidates(slots, teams, arrangement, locks, 1)[1] end ---- Builds the first balance plan from the lobby's current seating — a fresh solve honouring `locks` ---- (the preview's working lock set; nil = the seats' own Locked flags). A convenience wrapper over ---- Rebalance from the identity arrangement. +--- Builds the candidate balance plans from the lobby's current seating — a fresh solve honouring +--- `locks` (the preview's working lock set; nil = the seats' own Locked flags). ---@param slots UICustomLobbySlot[] ---@param teams UICustomLobbyTeams ---@param locks? table ----@return UICustomLobbyBalancePlan -function BuildPlan(slots, teams, locks) - return Rebalance(slots, teams, IdentityArrangement(slots), locks) +---@param count? number +---@return UICustomLobbyBalancePlan[] +function BuildPlan(slots, teams, locks, count) + return BuildCandidates(slots, teams, IdentityArrangement(slots), locks, count) end --- The lobby update for a plan: the player -> slot arrangement to apply. `Team` is intentionally From 3fe306cc725044d26e6fbc974f3377f5947e8eb6 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sat, 27 Jun 2026 21:34:26 +0200 Subject: [PATCH 82/98] Add a basic chat panel --- lua/ui/lobby/customlobby/CLAUDE.md | 12 +- .../customlobby/CustomLobbyController.lua | 72 ++++- .../customlobby/CustomLobbyInterface.lua | 9 +- .../lobby/customlobby/CustomLobbyMessages.lua | 57 ++++ .../social/CustomLobbyChatController.lua | 115 ++++++++ .../social/CustomLobbyChatModel.lua | 220 ++++++++++++++ .../social/CustomLobbyChatPanel.lua | 276 +++++++++++++++++- .../lobby/customlobby/social/chat-design.md | 80 +++++ 8 files changed, 826 insertions(+), 15 deletions(-) create mode 100644 lua/ui/lobby/customlobby/social/CustomLobbyChatController.lua create mode 100644 lua/ui/lobby/customlobby/social/CustomLobbyChatModel.lua create mode 100644 lua/ui/lobby/customlobby/social/chat-design.md diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index b6c467257fc..df040e39a2d 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -51,13 +51,15 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | [CustomLobbyPerformancePopover.lua](CustomLobbyPerformancePopover.lua) | hover popover over the CPU column; hand-built bitmap bar chart of a peer's `PerformanceTrackingV2` history, with a yellow recommended-unit-cap line. | | [CustomLobbyInstance.lua](CustomLobbyInstance.lua) | thin `moho.lobby_methods` shell; validates/dispatches traffic, forwards callbacks to the controller. Also feeds [`CustomLobbyLog`](CustomLobbyLog.lua) from its three network choke points (BroadcastData / SendData / DataReceived). | | [CustomLobbyLog.lua](CustomLobbyLog.lua) | the **network traffic log** — a reactive, per-peer, never-synced ring buffer (`Entries` LazyVar, capped) of every message this peer broadcasts / sends / receives, fed by the instance and rendered by the **Logs** tab. Each peer logs only its own traffic, so host and client views differ naturally. Not one of the three models (no game state); a diagnostic feed. | -| [CustomLobbyController.lua](CustomLobbyController.lua) | host-authority logic (free functions): seating, `Process*` handlers, intents (`RequestSetReady`, `RequestTakeSlot`, `RequestSwapSlots`, `RequestEject`, `RequestMoveToObserver`, `RequestSetScenario`, `RequestSetGameMods/Options`, `RequestResetGameOptions`, `RequestSetSlotsPinned`, `RequestSetSlotLocked`, `RequestApplyBalance` (applies a previewed balance), `RequestReopenClosedSlots`, `RequestLaunch`, `RequestSaveSetupPreset`/`RequestLoadSetupPreset` — all keyed by slot/bool/file/name so a chat command can call them too; permission is gated separately), sharing the stored CPU benchmark. **Launch:** `RequestLaunch` (host-only, readiness-validated) → auto-saves the `lastGame` preset (`BuildSetupSnapshot`) → `BuildGameConfiguration` (seed option defaults + scenario, resolve random factions, assign army numbers + push to server, stamp ratings/clan tags, resolve team-from-position for the binary AutoTeams modes, resolve sim mods via `Mods.GetGameMods`) → broadcast `LaunchGame` + `instance:LaunchGame`; clients run `ProcessLaunchGame`. **Presets:** `BuildSetupSnapshot` (read the launch state → serializable **setup-only** snapshot: scenario / options / mods / restrictions) + `ApplySetup` (host-only: write scenario/mods/restrictions, reconcile options to the current schema, one broadcast); players, observers and teams/spawn-mex are not stored (see [presetselect/](presetselect/CLAUDE.md)). | +| [CustomLobbyController.lua](CustomLobbyController.lua) | host-authority logic (free functions): seating, `Process*` handlers, intents (`RequestSetReady`, `RequestTakeSlot`, `RequestSwapSlots`, `RequestEject`, `RequestMoveToObserver`, `RequestSetScenario`, `RequestSetGameMods/Options`, `RequestResetGameOptions`, `RequestSetSlotsPinned`, `RequestSetSlotLocked`, `RequestApplyBalance` (applies a previewed balance), `RequestReopenClosedSlots`, `RequestLaunch`, `RequestSaveSetupPreset`/`RequestLoadSetupPreset`, `RequestChat` — all keyed by slot/bool/file/name so a chat command can call them too; permission is gated separately). **Chat relay:** `RequestChat` (host short-circuits, client sends to host) → `ProcessRequestChat` is the **single chat chokepoint** (resolves the sender name authoritatively via `FindNameForOwner`, broadcasts `ChatMessage`, and shows it locally since a broadcast doesn't loop back; future mute/rate-limit lives here) → `ProcessChatMessage` appends/reconciles into the [chat model](social/CustomLobbyChatModel.lua). The controller also shares each peer's stored CPU benchmark. **Launch:** `RequestLaunch` (host-only, readiness-validated) → auto-saves the `lastGame` preset (`BuildSetupSnapshot`) → `BuildGameConfiguration` (seed option defaults + scenario, resolve random factions, assign army numbers + push to server, stamp ratings/clan tags, resolve team-from-position for the binary AutoTeams modes, resolve sim mods via `Mods.GetGameMods`) → broadcast `LaunchGame` + `instance:LaunchGame`; clients run `ProcessLaunchGame`. **Presets:** `BuildSetupSnapshot` (read the launch state → serializable **setup-only** snapshot: scenario / options / mods / restrictions) + `ApplySetup` (host-only: write scenario/mods/restrictions, reconcile options to the current schema, one broadcast); players, observers and teams/spawn-mex are not stored (see [presetselect/](presetselect/CLAUDE.md)). | | [CustomLobbyRules.lua](CustomLobbyRules.lua) | the game-rule **pure kernel** (not view, not networking, **reads no models**): `RecommendedUnitCap(seatedCount, maxDimension)` (per-player cap by map size), `AutoTeamMode(gameOptions)` / `SideLabels(mode)` / `BuildSideResolver(mode, scenario)` (binary auto-team split). Every input is passed in, so the same functions serve the reactive [slots derived model](models/derived/CustomLobbySlotsDerivedModel.lua) (which applies them to publish `Side` / `Teams`) and, later, the host's launch-time team assignment — neither path makes this module read state. | -| [CustomLobbyBalancer.lua](CustomLobbyBalancer.lua) | the auto-balance **pure kernel** (**reads no models**) — **positional only** (the feature is gated to a binary AutoTeams mode, so there are always exactly two sides and the **position decides the team**; the kernel never writes `Team`). Takes the [slots derived model](models/derived/CustomLobbySlotsDerivedModel.lua)'s **already-resolved** snapshot — its `Slots` table (per-seat player / side / locked / closed) + `Teams` aggregate (mode / labels / resolved) — so it never re-resolves sides. **Solve and score are split** so the preview can hand-edit a balance: **`Rebalance(slots, teams, arrangement, locks)`** re-solves the *unlocked* players in three phases (a port of the legacy `PenguinAutoBalance`): **(1) Teams** — split into two even sides minimising the cheap rating heuristic; **(2) Pairs** — rank-match across the teams (strongest of A faces strongest of B; by rating, never random); **(3) Positions** — `ShufflePairsIntoSeats` (its own exported function) scatters the pairs across the free mirror rows. Locked players (and an odd roster's lowest-rated unlocked player) are **pinned at their seat in `arrangement`** — not their lobby seat — so locking a player the balance already moved keeps them put. **`ScoreArrangement(slots, teams, arrangement, locks)`** scores a concrete arrangement *without* re-solving (for a manual swap / lock toggle). Both run `ScorePlan`, which fills the plan: two rating-sorted `sides` (used for `totals` / quality), the ordered mirror **`positions`** (the k-th side-1 seat vs the k-th side-2 seat, sorted by slot so the list is stable across re-scores — this is what the preview renders + drag-reorders), per-side PL `totals`, `quality` + `currentQuality` (`trueskill.computeQuality` proposed vs. current, for a "before → after"), `winChance` (predicted win % per side — a local normal-CDF over the side mean/variance sums, the directional counterpart to the symmetric quality), and a per-player `moved` flag. `locks` (ownerId→true) is an optional **override** of the seats' own Locked flags — the preview passes its working, preview-local lock set; nil falls back to `entry.Locked`. **`BuildPlan(slots, teams, locks?)`** is the convenience entry (a fresh solve from the lobby's current seating). `lockedOwners` flags locked players; `ToArrangement(plan)` returns the lobby update — **slot → ownerId**, no team. The controller's `RequestApplyBalance` re-seats from it; the team falls out of the position, resolved at launch by `BuildGameConfiguration` (binary AutoTeams → team-from-`StartSpot`). | +| [CustomLobbyBalancer.lua](CustomLobbyBalancer.lua) | the auto-balance **pure kernel** (**reads no models**) — **positional only** (the feature is gated to a binary AutoTeams mode, so there are always exactly two sides and the **position decides the team**; the kernel never writes `Team`). Takes the [slots derived model](models/derived/CustomLobbySlotsDerivedModel.lua)'s **already-resolved** snapshot — its `Slots` table (per-seat player / side / locked / closed) + `Teams` aggregate (mode / labels / resolved) — so it never re-resolves sides. **Solve and score are split** so the preview can hand-edit a balance. **`BuildCandidates(slots, teams, arrangement, locks, count?)`** returns the top-N **distinct** team splits (best-first by match quality, mirror-equivalent splits dropped, each a complete scored plan with its own random position shuffle) — the preview browses them. **`Rebalance(slots, teams, arrangement, locks)`** is the single-best wrapper (`BuildCandidates(...)[1]`); it re-solves the *unlocked* players in three phases (a port of the legacy `PenguinAutoBalance`): **(1) Teams** — split into two even sides minimising the cheap rating heuristic; **(2) Pairs** — rank-match across the teams (strongest of A faces strongest of B; by rating, never random); **(3) Positions** — `ShufflePairsIntoSeats` (its own exported function) scatters the pairs across the free mirror rows. Locked players (and an odd roster's lowest-rated unlocked player) are **pinned at their seat in `arrangement`** — not their lobby seat — so locking a player the balance already moved keeps them put. **`ScoreArrangement(slots, teams, arrangement, locks)`** scores a concrete arrangement *without* re-solving (for a manual swap / lock toggle). Both run `ScorePlan`, which fills the plan: two rating-sorted `sides` (used for `totals` / quality), the ordered mirror **`positions`** (the k-th side-1 seat vs the k-th side-2 seat, sorted by slot so the list is stable across re-scores — this is what the preview renders + drag-reorders), per-side PL `totals`, `quality` + `currentQuality` (`trueskill.computeQuality` proposed vs. current, for a "before → after"), `winChance` (predicted win % per side — a local normal-CDF over the side mean/variance sums, the directional counterpart to the symmetric quality), and a per-player `moved` flag. `locks` (ownerId→true) is an optional **override** of the seats' own Locked flags — the preview passes its working, preview-local lock set; nil falls back to `entry.Locked`. **`BuildPlan(slots, teams, locks?, count?)`** is the convenience entry — `BuildCandidates` from the lobby's current seating (returns the candidate **list**). `lockedOwners` flags locked players; `ToArrangement(plan)` returns the lobby update — **slot → ownerId**, no team. The controller's `RequestApplyBalance` re-seats from it; the team falls out of the position, resolved at launch by `BuildGameConfiguration` (binary AutoTeams → team-from-`StartSpot`). | | [CustomLobbyPresets.lua](CustomLobbyPresets.lua) | **named setup presets** (setup-only: scenario / options / mods / restrictions — no players/observers) — pure prefs CRUD (`GetPresets`/`GetPreset`/`SavePreset`/`DeletePreset`/`RenamePreset`) over one key (`customlobby_setup_presets`), an ordered `{ Name, Setup }` array mirroring the mod presets in [`/lua/ui/modutilities.lua`](/lua/ui/modutilities.lua). Holds no models, no network. A reserved `LastGamePresetName` entry is auto-saved at launch (the rehost source). Capturing/applying a setup lives in the controller (`BuildSetupSnapshot`/`ApplySetup`); see [presetselect/](presetselect/CLAUDE.md). | | [CustomLobbySession.lua](CustomLobbySession.lua) | the lobby session's **main trash bag**. The lobby lives in the persistent front-end Lua state, which is *not* reset when the game launches in its own state — so anything left reachable (a running thread, a cache, a singleton) leaks for the whole match. This module owns one session-lifetime `TrashBag` (`GetTrash()`); every lobby-scoped `Destroyable` registers in it, and one `Teardown()` (clean-slate in `CreateLobby`, on leave, on `OnGameLaunched`) frees the lot. Works despite the bag being weak-valued because each singleton is pinned by its own module `Instance` local. **Rollout in progress** — converted: the map catalog, all five [derived models](models/derived/CLAUDE.md), and the three authoritative [models/](models/) (Launch/Session/Local, with thin `Destroy` per the design's decision #3). Still to do: the mod catalog, the interface + performance popover (pending the teardown-ordering decision), and the lobby instance. See [design/session-trashbag-teardown.md](design/session-trashbag-teardown.md). | -| [CustomLobbyMessages.lua](CustomLobbyMessages.lua) | message registry: `AddPlayer`, `SetPlayers` (launch model: players + observers), `SentLaunchInfo` (launch model: scenario / options / mods / teams / spawn mex), `SetSessionState` (session model: slot count / closed slots / locked slots / pinned), `SetReady`, `TakeSlot`, `DisconnectPeer`, `LaunchGame` (host's final game config → `instance:LaunchGame`), `ReportCpuBenchmark`, `SetCpuBenchmarks`. | -| [CustomLobbyBalancePreview.lua](CustomLobbyBalancePreview.lua) | the auto-balance **preview + tuner** (host-only `Popup`): the slots header's balance button opens it instead of re-seating directly. It owns a **working state** nothing synced sees until Apply — `Arrangement` (slot→ownerId) + `Locks` (a preview-local lock set, **seeded** from the lobby's real locks but never written back). Renders the proposal as **mirror-position rows** (row k = the k-th seat on each side, who face off, in start-position order with a position label; the right column **mirrored** so the teams face each other) — each row shows both players + the pair's rating gap (colour-coded green→red), **moved** players in gold, **locked** players in blue. Four gestures: **drag a row** onto another → swap those two positions' pairs (both players move together — "D → A"; press-vs-drag threshold like the lobby slot rows, with a drop-target highlight); **click two players** → swap just those two (`ScoreArrangement` re-scores, no re-solve); **click a player's lock dot** → pin/unpin them (preview-local); **Retry** → re-solve the unlocked players around the locked ones (`Rebalance`). A summary reports each team's total + average, match quality **before → after**, predicted **win** split, the rating **gap**, and any odd one out (or why it can't balance). **Apply** commits only `Arrangement` (→ `RequestApplyBalance`); **Cancel** discards everything (locks included). A transient picker, owns no synced state. | +| [CustomLobbyMessages.lua](CustomLobbyMessages.lua) | message registry: `AddPlayer`, `SetPlayers` (launch model: players + observers), `SentLaunchInfo` (launch model: scenario / options / mods / teams / spawn mex), `SetSessionState` (session model: slot count / closed slots / locked slots / pinned), `SetReady`, `TakeSlot`, `RequestChat` (any peer → host: a chat line to relay) / `ChatMessage` (host → everyone: the accepted line), `DisconnectPeer`, `LaunchGame` (host's final game config → `instance:LaunchGame`), `ReportCpuBenchmark`, `SetCpuBenchmarks`. | +| [social/CustomLobbyChatModel.lua](social/CustomLobbyChatModel.lua) | the **chat feed** — a per-peer, never-synced reactive `Entries` ring buffer (`UICustomLobbyChatEntry`: `Id` / `SenderId` / `SenderName` / `Text` / `Status` / `Kind`), the chat equivalent of [`CustomLobbyLog`](CustomLobbyLog.lua) but **written by the controller**, so it is a `ClassSimple : Destroyable` in the session trash (freed on `Teardown()`). `Append` adds an optimistic `Pending` line / system notice; `Receive` reconciles the sender's `Pending` line to `Confirmed` by the echoed `Id` (or appends others' lines). Not one of the three models — no game state. See [social/chat-design.md](social/chat-design.md). | +| [social/CustomLobbyChatController.lua](social/CustomLobbyChatController.lua) | the chat **send pipeline** (writes the chat model, never the wire): `Send` decides command-vs-chat (a `/` line is handled locally and never broadcast — registry lands in slice 2), echoes a plain line optimistically (`Pending`) and hands it to the controller's `RequestChat`; `AppendLocalSystem` for local notices. Mirrors the in-game [`ChatController.Send`](/lua/ui/game/chat/ChatController.lua) split. | +| [CustomLobbyBalancePreview.lua](CustomLobbyBalancePreview.lua) | the auto-balance **preview + tuner** (host-only `Popup`): the slots header's balance button opens it instead of re-seating directly. It owns a **working state** nothing synced sees until Apply — `Arrangement` (slot→ownerId) + `Locks` (a preview-local lock set, **seeded** from the lobby's real locks but never written back). Renders the proposal as **mirror-position rows** (row k = the k-th seat on each side, who face off, in start-position order with a position label; the right column **mirrored** so the teams face each other) — each row puts the **name on the outer edge** and the **display rating in a fixed column hugging the centre** (so the two rating columns line up across rows, flanking the gap number), with the **mean** as a muted value just outside it; **moved** players are gold, **locked** players blue. The host **browses the candidate balances** with a "< i / N >" browser (the kernel's top-N distinct splits via `BuildCandidates`); stepping adopts each. Three gestures tune the shown one: **drag a row** onto another → swap those two positions' pairs (both players move together — "D → A"; same feel as the lobby slot rows — press-vs-drag threshold, a floating pair ghost beside the cursor, and a drop-target highlight); **click two players** → swap just those two (`ScoreArrangement` re-scores, no re-solve); **click a player's lock dot** → pin/unpin them (preview-local) which **regenerates** the candidates around that lock. The **header mirrors a row**: team label (outer), average (centre column, aligned above the player ratings) + total (muted), and the **gap total** in the centre band atop the per-row gaps. The status line reports match quality **before → after** (whole percent), predicted **win** split, and any odd one out (or why it can't balance). **Apply** commits only `Arrangement` (→ `RequestApplyBalance`); **Cancel** discards everything (locks included). A transient picker, owns no synced state. | | [CustomLobbyContextMenu.lua](CustomLobbyContextMenu.lua) | generic framed floating menu; `Show(entries, x, y)` renders any `{label, action, enabled}` list, dismisses on item click / click-outside / Esc. Knows nothing about the lobby. | | [CustomLobbyMenus.lua](CustomLobbyMenus.lua) | declarative menu **definitions**: entry lists with `when(ctx)`/`action(ctx)` filtered by lobby state (`BuildSlotMenu`). Adding/state-gating an item is a one-liner here. | | [slots/](slots/) | the **slot subsystem** (its own folder, a sub-folder per layout). [`CustomLobbySlotsInterface`](slots/CustomLobbySlotsInterface.lua) is the entry the composition root mounts: a "Players" header over the **active layout body**, picked by the AutoTeams mode — one-column for the non-team modes, the two-column team layout for the binary modes (left/right, top/bottom, even/odd). It observes `GameOptions` and swaps the body when the kind flips (create-on-mode / destroy-on-switch); a change *within* the binary modes is handled by the two-column body's own re-layout. It is the rows' **drag coordinator** (`UICustomLobbySlotCoordinator`: hit-test which row a point is in, drop-highlight, drag ghost → `RequestSwapSlots`) for *every* layout — it alone needs to hit-test across rows — so the layout bodies stay pure build/place/reveal and never duplicate the drag logic; it also exposes `PreferredHeight()` (computed from the mode + model, swap-order-independent) so the root sizes the slot area. The header band carries a **host-only tool strip** (right-aligned: pin seating · auto-balance · reopen closed slots) of small icon buttons (local `SlotTool`, mirroring the config column's `PreviewTool`); the pin button is lit from the synced `SlotsPinned` and the strip is hidden for clients. The **auto-balance** button is gated (`SlotTool:SetEnabled`, greyed when off) to a **resolved binary AutoTeams mode** (the derived `Teams` aggregate's `Mode` + `Resolved` — two well-defined sides, map loaded for the positional ones) and opens the [balance preview](CustomLobbyBalancePreview.lua) rather than re-seating on click. A **"Locked" notice** (lock glyph + label) sits right of the "Players" label and is shown to **everyone** while `SlotsPinned` is on, so a client can see seating is host-controlled before clicking an open slot to no effect. [`CustomLobbySlotBase`](slots/CustomLobbySlotBase.lua) holds **all slot behaviour** (one subscription to the [slots derived model](models/derived/CustomLobbySlotsDerivedModel.lua) — which already merged this seat's player / placement / closed / CPU benchmark and did the faction/CPU formatting — click/right-click/drag-to-swap + intents, the background/highlight/click overlays + a gold left-edge **lock stripe** shown when the seat's `Locked` flag is set) and the default `RenderPlayer`/`RenderCpu` that paint the standard named controls from the entry's resolved views; a presentation subclasses it and implements just `CreateContents` / `LayoutContents`. [`onecolumn/`](slots/onecolumn/): [`CustomLobbyOneColumnSlots`](slots/onecolumn/CustomLobbyOneColumnSlots.lua) (stacks thin rows, reveals 1..count) + [`CustomLobbySlotRow`](slots/onecolumn/CustomLobbySlotRow.lua) (thin one-line presentation). [`twocolumn/`](slots/twocolumn/): [`CustomLobbyTwoColumnSlots`](slots/twocolumn/CustomLobbyTwoColumnSlots.lua) (splits slots into two team columns by each seat's `Side` from the [slots derived model](models/derived/CustomLobbySlotsDerivedModel.lua) — no longer re-resolving the split itself — with a [`CustomLobbyTeamScore`](CustomLobbyTeamScore.lua) strip across the top as the Left/Right indicator, and the "two columns, unresolved" fallback — parity fill + the score self-hides until a positional map's start positions load) + [`CustomLobbySlotCard`](slots/twocolumn/CustomLobbySlotCard.lua) (fat half-width / two-line presentation; `SetMirrored` flips the right column's cards so the two teams face each other). Each body exports `HeightForCount`. | @@ -73,7 +75,7 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | [CustomLobbyBackground.lua](CustomLobbyBackground.lua) | the **full-window background surface** — a single `Bitmap` that paints the selected image so it **covers** the whole parent while keeping aspect ratio (scale to the larger axis ratio, centre, crop the overflow at the screen edge), with a solid backdrop fallback when none is chosen / the file can't be read. Subscribes to [`CustomLobbyBackgrounds`](CustomLobbyBackgrounds.lua) itself; `Width`/`Height` are bound to the parent rect so a resize re-covers for free. | | [CustomLobbyTeamScore.lua](CustomLobbyTeamScore.lua) | the **accumulated team rating** side indicator — `Side A N · M Side B`. Hosted in the strip atop the two-column slot layout (it doubles as the columns' Left/Right header). Shown only for the binary auto-team formations; **hidden** for `none`/`manual` or until a positional map's start positions load. A **single subscription** to the [slots derived model](models/derived/CustomLobbySlotsDerivedModel.lua)'s `Teams` aggregate (mode / labels / resolved / per-side rating totals — all computed once there); no side/rating logic of its own. Reference data; never writes. | | [CustomLobbyTabs.lua](CustomLobbyTabs.lua) | a **generic tabbed panel** (strip + content; one panel alive, created on select / destroyed on switch). Tabs **divide the strip evenly** across its width. Construct with a `{ Label, Create, Badge?, Action?, Icon?, Compact? }` list + optional `OnSelect`. A tab's optional `Badge` LazyVar drives a grey **count pill** to the right of the label; its optional `Action` (`{ Create, Visible? }`) is a small button the owner builds **inside the tab, left of the label** (e.g. a config gear), whose `Visible` LazyVar hides it (collapsing it from the layout) when it doesn't apply. The action, label and pill are centred together as one cluster; any absent/hidden/empty piece contributes 0 width so the rest re-centres. A tab can instead be **`Compact`** (a fixed narrow width, excluded from the even division — the flexible tabs share what's left) and/or show an **`Icon`** centred instead of its label (an icon-only utility tab); the default active tab is the first non-compact one. The container just mirrors the LazyVars, the owner decides what they mean. Used for the bottom-left (Logs / Chat / Observers) and the config interface's Options / Mods / Restrictions. | -| [social/](social/) | the lobby's **bottom-left** column (the `CustomLobbyTabs` content): [`CustomLobbyChatPanel`](social/CustomLobbyChatPanel.lua) (the **Chat** tab — placeholder until the chat slice lands) and [`CustomLobbyObserversPanel`](social/CustomLobbyObserversPanel.lua) (the **Observers** tab — the shared observer list + a host-authoritative **Become observer** button → `RequestMoveToObserver`). Each is a tab content component (`Create(parent)`, created on select / destroyed on switch). The Chat / Observers tabs mirror the config column's shape — a per-tab **config gear** (`CustomLobbyInterface`'s local `GearAction`; both no-ops with a "coming soon" tooltip for now) + a right-side **count pill**: Observers shows the live observer count, Chat a dummy until the chat slice lands. A third **compact, icon-only [`CustomLobbyLogsPanel`](social/CustomLobbyLogsPanel.lua)** sits left of Chat (the **Logs** tab — a live **tail view** of this peer's network traffic from [`CustomLobbyLog`](CustomLobbyLog.lua): the most recent entries that fit, in columns `time · kind · ⚠ · name`, with a malformed/unauthorised message tinted + a ⚠ icon whose tooltip is the failure reason). | +| [social/](social/) | the lobby's **bottom-left** column (the `CustomLobbyTabs` content): [`CustomLobbyChatPanel`](social/CustomLobbyChatPanel.lua) (the **Chat** tab — a scrollable feed over an edit box, built to the Logs-tab shape; reads [`CustomLobbyChatModel`](social/CustomLobbyChatModel.lua) and sends through [`CustomLobbyChatController`](social/CustomLobbyChatController.lua)) and [`CustomLobbyObserversPanel`](social/CustomLobbyObserversPanel.lua) (the **Observers** tab — the shared observer list + a host-authoritative **Become observer** button → `RequestMoveToObserver`). Each is a tab content component (`Create(parent)`, created on select / destroyed on switch). The Chat / Observers tabs mirror the config column's shape — a per-tab **config gear** (`CustomLobbyInterface`'s local `GearAction`; both no-ops with a "coming soon" tooltip for now) + a right-side **count pill**: Observers shows the live observer count, Chat a dummy until the chat slice lands. A third **compact, icon-only [`CustomLobbyLogsPanel`](social/CustomLobbyLogsPanel.lua)** sits left of Chat (the **Logs** tab — a live **tail view** of this peer's network traffic from [`CustomLobbyLog`](CustomLobbyLog.lua): the most recent entries that fit, in columns `time · kind · ⚠ · name`, with a malformed/unauthorised message tinted + a ⚠ icon whose tooltip is the failure reason). | | [config/](config/) | the lobby's **right** column. [`CustomLobbyConfigInterface.lua`](config/CustomLobbyConfigInterface.lua) is the column **composition**: a bound square `CustomLobbyMapPreview` **pinned** at the top with a vertical **preview tool strip** to its right (local `PreviewTool` icon buttons — toggles for army/start icons, mass+hydro deposits and the dummy water mask, driving `preview.Surface:SetOverlayVisible`, + a host-only **change-map** config icon at the bottom → the map-select dialog), a name + size/players/version facts line under it, and a [`CustomLobbyTabs`](CustomLobbyTabs.lua) (**Options / Mods / Restrictions**) filling the rest. Each tab carries its own **config gear** (`CustomLobbyTabs`' per-tab `Action`, built by the interface's `GearAction` helper) — a skinned button **inside the tab, left of the label** — that opens that tab's editor: Options → `CustomLobbyOptionSelect`, Mods → `CustomLobbyModSelect`, Restrictions → `CustomLobbyUnitSelect` (see [unitselect/](unitselect/)). The Options + Restrictions gears are **host-only**: their `Visible` LazyVar is the `IsHost` field, so they're **hidden** for clients and the label re-centres; the Mods gear shows for everyone (UI mods are local; the sim portion is host-gated inside the dialog). The interface also owns the tabs' **count badges** as computed LazyVars over the launch model — Options shows the non-default-option count (the options derived model's `NonDefaultCount`), Mods shows `sim / ui` (synced sim mods / this peer's UI-mod prefs), Restrictions shows the active restriction count (preset keys in the launch model's `Restrictions`). All three tab panels are now **read-only**: their per-domain action buttons (open editor / reset / manage mods) are removed — the grid/content fills the whole panel — and the action-bar **Settings** button (now joined by this gear) is the edit entry point. [`CustomLobbyOptionsPanel`](config/CustomLobbyOptionsPanel.lua) (options grouped **Lobby / Scenario / Mods** + hide-defaults toggle; map/mod options gold-flagged with an origin tooltip, option help as a label tooltip — all read from the [options derived model](models/derived/CustomLobbyOptionsDerivedModel.lua), the panel does no schema gathering), [`CustomLobbyModsPanel`](config/CustomLobbyModsPanel.lua) (enabled mods in **Game / UI** sections, each an **icon + name** row with author/version on hover, from the [mods derived model](models/derived/CustomLobbyModsDerivedModel.lua)), [`CustomLobbyUnitsPanel`](config/CustomLobbyUnitsPanel.lua) (the **Restrictions** read-only list — each active restriction's **preset icon + name** in taller rows, from the [restrictions derived model](models/derived/CustomLobbyRestrictionsDerivedModel.lua); the icon's tooltip is the preset description). Each self-subscribes to the model and exposes `Initialize()` + `Create(parent)`. **Parked** (built, but unwired): [`CustomLobbyMapPanel`](config/CustomLobbyMapPanel.lua) (the full Map tab — preview + label/value details + Change-map), now superseded by the pinned preview. Churning a preview is safe here because the lobby shows only **one** current map and the engine caches map textures by name (the texture-leak rule only bites the *map-select dialog* — see [mapselect/CLAUDE.md](mapselect/CLAUDE.md)). | | [/lua/ui/lobby/lobby.lua](../lobby.lua) | engine entry wrapper (`CreateLobby`/`HostGame`/`JoinGame`) → CustomLobby. Old lobby preserved at `lobby-old.lua`. | diff --git a/lua/ui/lobby/customlobby/CustomLobbyController.lua b/lua/ui/lobby/customlobby/CustomLobbyController.lua index 99aefea34eb..da49a092c79 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyController.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyController.lua @@ -42,6 +42,7 @@ local CustomLobbySessionModel = import("/lua/ui/lobby/customlobby/models/customl local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/models/customlobbylocalmodel.lua") local CustomLobbySession = import("/lua/ui/lobby/customlobby/customlobbysession.lua") local CustomLobbyPresets = import("/lua/ui/lobby/customlobby/customlobbypresets.lua") +local CustomLobbyChatModel = import("/lua/ui/lobby/customlobby/social/customlobbychatmodel.lua") --- The live lobby object, set by the first engine callback. UI-triggered intents --- (RequestSetReady, …) reach the network through it without threading it everywhere. @@ -92,7 +93,24 @@ local function PlayerInSlot(slot) return CustomLobbyLaunchModel.GetSingleton().Players[slot]() or nil end ---- A plain per-slot snapshot of all players (false for empty), for the wire. +--- The display name of the peer that owns `ownerId` — its seated player's `PlayerName`, else its +--- observer entry's, else the raw peer id. The host uses this to stamp chat lines authoritatively +--- (clients don't get to spoof a name); the chat send pipeline uses it for the optimistic echo. +---@param ownerId UILobbyPeerId +---@return string +function FindNameForOwner(ownerId) + local slot = FindSlotForOwner(ownerId) + local player = slot and PlayerInSlot(slot) + if player then + return player.PlayerName + end + for _, observer in CustomLobbyLaunchModel.GetSingleton().Observers() do + if observer.OwnerID == ownerId then + return observer.PlayerName + end + end + return tostring(ownerId) +end ---@return table local function GatherPlayers() local launch = CustomLobbyLaunchModel.GetSingleton() @@ -520,6 +538,38 @@ function ProcessLaunchGame(instance, data) instance:LaunchGame(data.GameConfig) end +--- Everyone applies the host's authoritative chat line: appends it, or reconciles the sender's own +--- optimistic Pending line by the echoed Id (see CustomLobbyChatModel.Receive). +---@param instance UICustomLobbyInstance +---@param data UICustomLobbyChatMessageMessage +function ProcessChatMessage(instance, data) + CustomLobbyChatModel.Receive(CustomLobbyChatModel.GetSingleton(), { + Id = data.Id, + SenderId = data.SenderID, + SenderName = data.SenderName, + Text = data.Text, + }) +end + +--- Host relays an accepted chat line — **the single chat chokepoint**. Resolves the sender's name +--- authoritatively (clients don't get to spoof it), broadcasts the line to everyone, and shows it in +--- the host's own feed (a broadcast doesn't loop back to the sender). Future mute / rate-limit / +--- slow-mode filtering lives right here: inspect `data` and return early to drop it. +---@param instance UICustomLobbyInstance +---@param data UICustomLobbyRequestChatMessage +function ProcessRequestChat(instance, data) + local message = { + Type = 'ChatMessage', + Id = data.Id, + SenderID = data.SenderID, + SenderName = FindNameForOwner(data.SenderID), + Text = data.Text, + } + instance:BroadcastData(message) + -- the broadcast doesn't echo back to us, so display it in the host's own feed directly + ProcessChatMessage(instance, message) +end + --#endregion ------------------------------------------------------------------------------- @@ -1286,6 +1336,26 @@ function RequestSetReady(ready) end end +--- The local player sends a chat line. The host short-circuits straight to its relay chokepoint; a +--- client asks the host. `id` is the sender's client-stamped id (CustomLobbyChatModel.NextId), echoed +--- back in the broadcast so the sender's optimistic Pending line can reconcile. See +--- CustomLobbyChatController for the send pipeline that calls this. +---@param text string +---@param id string +function RequestChat(text, id) + local instance = LobbyInstance + if not instance then + return + end + + local localModel = CustomLobbyLocalModel.GetSingleton() + if localModel.IsHost() then + ProcessRequestChat(instance, { SenderID = localModel.LocalPeerId(), Text = text, Id = id }) + else + instance:SendData(localModel.HostID(), { Type = 'RequestChat', Text = text, Id = id }) + end +end + --#endregion ------------------------------------------------------------------------------- diff --git a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua index 57667a1f2d0..c235cb2daab 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua @@ -69,6 +69,7 @@ local CustomLobbySlotsInterface = import("/lua/ui/lobby/customlobby/slots/custom local CustomLobbyConfigInterface = import("/lua/ui/lobby/customlobby/config/customlobbyconfiginterface.lua") local CustomLobbyTabs = import("/lua/ui/lobby/customlobby/customlobbytabs.lua") local CustomLobbyChatPanel = import("/lua/ui/lobby/customlobby/social/customlobbychatpanel.lua") +local CustomLobbyChatModel = import("/lua/ui/lobby/customlobby/social/customlobbychatmodel.lua") local CustomLobbyObserversPanel = import("/lua/ui/lobby/customlobby/social/customlobbyobserverspanel.lua") local CustomLobbyLogsPanel = import("/lua/ui/lobby/customlobby/social/customlobbylogspanel.lua") local CustomLobbyPresetSelect = import("/lua/ui/lobby/customlobby/presetselect/customlobbypresetselect.lua") @@ -215,7 +216,13 @@ local CustomLobbyInterface = Class(Group) { --#region bottom-left: chat / observers tabs -- each tab gets a config gear (left) + a count pill (right), mirroring the config column. -- Chat's count is a dummy until the chat slice lands; Observers shows the live observer count. - self.ChatBadge = self.Trash:Add(LazyVarCreate("0")) -- TODO: real unread count with the chat slice + -- reacts to the chat feed; shows the line count (empty when none). TODO: unread-since-last-view + -- count once the chat slice tracks a last-seen marker. + self.ChatBadge = self.Trash:Add(LazyVarCreate()) + self.ChatBadge:Set(function() + local count = table.getn(CustomLobbyChatModel.GetSingleton().Entries()) + return count > 0 and tostring(count) or "" + end) self.ObserversBadge = self.Trash:Add(LazyVarCreate()) self.ObserversBadge:Set(function() return tostring(table.getn(CustomLobbyLaunchModel.GetSingleton().Observers())) diff --git a/lua/ui/lobby/customlobby/CustomLobbyMessages.lua b/lua/ui/lobby/customlobby/CustomLobbyMessages.lua index 6e833eab972..b298915ef87 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyMessages.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyMessages.lua @@ -206,6 +206,63 @@ CustomLobbyMessages = { end, }, + -- A client asks the host to broadcast a chat line. Any connected peer may ask; the host is the + -- single chokepoint that decides whether to relay it (future filtering/muting/rate-limiting lives + -- in ProcessRequestChat). The host's own lines short-circuit straight to that handler. + RequestChat = { + ---@class UICustomLobbyRequestChatMessage : UILobbyReceivedMessage + ---@field Text string + ---@field Id string # the sender's client-stamped id, echoed back so the sender can reconcile + + ---@param data UICustomLobbyRequestChatMessage + Validate = function(lobby, data) + if type(data.Text) ~= 'string' or data.Text == '' then + return false, "RequestChat has no Text" + end + if type(data.Id) ~= 'string' then + return false, "RequestChat has no Id" + end + return true + end, + Accept = function(lobby, data) + -- any connected peer may request a chat line — the host filters in ProcessRequestChat + return true + end, + ---@param data UICustomLobbyRequestChatMessage + Handler = function(lobby, data) + CustomLobbyController.ProcessRequestChat(lobby, data) + end, + }, + + -- The host's authoritative broadcast of an accepted chat line (with the sender's name resolved + -- host-side). Every peer appends it; the originating peer reconciles its optimistic Pending line by + -- the echoed Id. Sent to everyone (a whisper, slice 3, will send to a subset instead). + ChatMessage = { + ---@class UICustomLobbyChatMessageMessage : UILobbyReceivedMessage + ---@field Id string + ---@field SenderID UILobbyPeerId + ---@field SenderName string + ---@field Text string + + ---@param data UICustomLobbyChatMessageMessage + Validate = function(lobby, data) + if type(data.Text) ~= 'string' or data.Text == '' then + return false, "ChatMessage has no Text" + end + if type(data.Id) ~= 'string' then + return false, "ChatMessage has no Id" + end + return true + end, + Accept = function(lobby, data) + return RequireFromHost(lobby, data) + end, + ---@param data UICustomLobbyChatMessageMessage + Handler = function(lobby, data) + CustomLobbyController.ProcessChatMessage(lobby, data) + end, + }, + -- The host tells everyone still connected to drop their direct link to a peer -- that left, so the mesh is cleaned up (the player state follows via SetPlayers). DisconnectPeer = { diff --git a/lua/ui/lobby/customlobby/social/CustomLobbyChatController.lua b/lua/ui/lobby/customlobby/social/CustomLobbyChatController.lua new file mode 100644 index 00000000000..5530a72e92a --- /dev/null +++ b/lua/ui/lobby/customlobby/social/CustomLobbyChatController.lua @@ -0,0 +1,115 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- The lobby chat **send pipeline** — the edit box's entry point. It decides command-vs-chat and writes +-- the chat model (CustomLobbyChatModel); it never touches the wire itself. Mirrors the in-game +-- ChatController.Send split (/lua/ui/game/chat/ChatController.lua): +-- +-- Send(text) +-- ├─ '/' prefix? → a chat command — handled locally, NOTHING goes on the wire +-- │ (the command registry lands in slice 2; for now a stub system line) +-- └─ plain text → append an optimistic `Pending` echo, then hand to the controller's RequestChat +-- +-- The optimistic echo is shown immediately; when the host's authoritative `ChatMessage` echo returns +-- (carrying the same id), CustomLobbyChatModel.Receive reconciles it to `Confirmed`. The host's own +-- send reconciles in the same frame (the broadcast doesn't loop back, so ProcessRequestChat displays +-- it locally) — same mechanism, see CustomLobbyController. +-- +-- The *wire* half (RequestChat / ProcessRequestChat / ProcessChatMessage) lives in CustomLobbyController +-- for consistency with every other Request*/Process* intent and the host-authority rule. + +local CustomLobbyChatModel = import("/lua/ui/lobby/customlobby/social/customlobbychatmodel.lua") +local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") +local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/models/customlobbylocalmodel.lua") + +--- Appends a local-only system line (command feedback). Local to this peer; nothing is sent. +---@param text string +function AppendLocalSystem(text) + CustomLobbyChatModel.Append(CustomLobbyChatModel.GetSingleton(), { + Id = false, + SenderId = false, + SenderName = "System", + Text = text, + Status = 'Confirmed', + Kind = 'system', + Time = GetSystemTimeSeconds(), + }) +end + +--- Sends a chat line. A slash line is a command (handled locally, never broadcast); anything else is +--- echoed optimistically and routed to the host through the controller. +---@param text string +function Send(text) + if not text or text == '' then + return + end + + -- a slash line is a chat command, never broadcast. The command registry arrives in slice 2; for now + -- just surface a local notice so the split is visible and nothing goes on the wire. + if string.sub(text, 1, 1) == '/' then + AppendLocalSystem("Chat commands are coming soon.") + return + end + + -- drop an all-whitespace body + if not string.find(text, "%S") then + return + end + + local localModel = CustomLobbyLocalModel.GetSingleton() + local peerId = localModel.LocalPeerId() + local id = CustomLobbyChatModel.NextId(peerId) + + -- shown immediately as Pending; the host's echo reconciles it to Confirmed (see the model's Receive) + CustomLobbyChatModel.Append(CustomLobbyChatModel.GetSingleton(), { + Id = id, + SenderId = peerId, + SenderName = CustomLobbyController.FindNameForOwner(peerId), + Text = text, + Status = 'Pending', + Kind = 'chat', + Time = GetSystemTimeSeconds(), + }) + + CustomLobbyController.RequestChat(text, id) +end + +--- Sets the current send target. Reserved for whisper (slice 3); 'all' for now. +---@param recipient 'all' +function SetRecipient(recipient) + CustomLobbyChatModel.GetSingleton().Recipient:Set(recipient) +end + +------------------------------------------------------------------------------- +--#region Debugging + +--- Hot-reload hook: re-imports this module after a couple of frames (no state of its own). +function __moduleinfo.OnDirty() + ForkThread( + function() + WaitFrames(2) + import(__moduleinfo.name) + end + ) +end + +--#endregion diff --git a/lua/ui/lobby/customlobby/social/CustomLobbyChatModel.lua b/lua/ui/lobby/customlobby/social/CustomLobbyChatModel.lua new file mode 100644 index 00000000000..62b6ad88140 --- /dev/null +++ b/lua/ui/lobby/customlobby/social/CustomLobbyChatModel.lua @@ -0,0 +1,220 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- The lobby **chat feed**: a per-peer, never-synced record of the chat lines this peer has seen. Like +-- CustomLobbyLog it is a reactive `Entries` ring buffer, but it carries chat (not diagnostics) and is +-- *written by the controller* — so, unlike the Log, it is a `ClassSimple : Destroyable` registered in +-- the session trash (CustomLobbySession), freed on `Teardown()` so a session's chat doesn't leak into +-- the persistent front-end state for the whole match. +-- +-- Chat is host-authoritative: the host relays every accepted message (CustomLobbyController's +-- `ProcessRequestChat` is the single filter chokepoint), and every peer accumulates what it receives. +-- A line you send is shown to you **immediately** as `Pending`, then reconciled to `Confirmed` when the +-- host's authoritative echo (carrying the same client-stamped `Id`) comes back — see `Receive`. That is +-- why an entry has a mutable `Status` and a stable `Id`. +-- +-- This is *not* one of the three lobby models (no launch/session/local game state) and never goes on +-- the wire — it is the chat equivalent of CustomLobbyLog. The wire half (the `RequestChat` / +-- `ChatMessage` messages and their handlers) lives in CustomLobbyController; the edit-box send pipeline +-- lives in CustomLobbyChatController. Both write this model through the helpers below. + +local Create = import("/lua/lazyvar.lua").Create +local CustomLobbySession = import("/lua/ui/lobby/customlobby/customlobbysession.lua") + +--- Most recent lines kept; older ones drop off the front (matches CustomLobbyLog's cap). +local MaxEntries = 200 + +--- Monotonic counter behind `NextId`. Module-local so it survives a model teardown (ids only have to +--- be unique within this peer's session; the peer-id prefix keeps them unique across peers). +local SendCounter = 0 + +---@alias UICustomLobbyChatStatus +---| 'Pending' # an outgoing line shown optimistically, awaiting the host's echo +---| 'Confirmed' # the host broadcast it (authoritative) +---| 'Rejected' # the host dropped/filtered it (shown greyed, for the sender only) + +---@alias UICustomLobbyChatKind +---| 'chat' # a player message +---| 'system' # a local-only notice (command feedback, later: join/leave) + +---@class UICustomLobbyChatEntry +---@field Id string | false # client-stamped id (peerId:counter); false for local system lines +---@field SenderId UILobbyPeerId | false # the sender's peer id; false for system lines +---@field SenderName string # display name (resolved authoritatively by the host) +---@field Text string +---@field Status UICustomLobbyChatStatus +---@field Kind UICustomLobbyChatKind +---@field Time number # GetSystemTimeSeconds() when this entry was added + +------------------------------------------------------------------------------- +--#region Reactive model + +-- The singleton, forward-declared above the class so `Destroy` captures it as an upvalue. Assigned in +-- `SetupSingleton`, cleared in `Destroy`. +---@type UICustomLobbyChatModel | nil +local Instance = nil + +--- Reactive per-peer chat feed — a `ClassSimple` implementing `Destroyable`, registered in the session +--- trash so one `CustomLobbySession.Teardown()` frees it. Written by the controller (the wire half) and +--- the chat controller (the send pipeline) through the free-function helpers below; views only read it. +---@class UICustomLobbyChatModel : Destroyable +---@field Trash TrashBag # owns the LazyVars (freed on Destroy) +---@field Entries LazyVar # the feed, capped to MaxEntries +---@field Recipient LazyVar # current send target ('all') — reserved for whisper (slice 3) +---@field Destroyed boolean +local ChatModel = ClassSimple { + + ---@param self UICustomLobbyChatModel + __init = function(self) + self.Trash = TrashBag() + self.Entries = self.Trash:Add(Create({})) + self.Recipient = self.Trash:Add(Create('all')) + self.Destroyed = false + end, + + --- `Destroyable`: frees the LazyVars and clears the module singleton, so the next access rebuilds a + --- fresh feed and re-registers it in the next session's trash. Idempotent. Called by the session + --- trash on `Teardown()`. + ---@param self UICustomLobbyChatModel + Destroy = function(self) + if self.Destroyed then + return + end + self.Destroyed = true + self.Trash:Destroy() + if Instance == self then + Instance = nil + end + end, +} + +--- Allocates a fresh chat-model singleton and registers it in the session trash. +---@return UICustomLobbyChatModel +function SetupSingleton() + Instance = ChatModel() + CustomLobbySession.GetTrash():Add(Instance) + return Instance +end + +--- Returns the chat-model singleton, creating (and registering) it on first access — including after a +--- teardown, so it is reusable across lobby sessions. +---@return UICustomLobbyChatModel +function GetSingleton() + if not Instance then + SetupSingleton() + end + return Instance --[[@as UICustomLobbyChatModel]] +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Write helpers +-- +-- Entries is a LazyVar value, so a write builds a NEW array and `:Set`s it (mutating in place never +-- marks dependents dirty — see /lua/ui/CLAUDE.md § 2). + +--- A fresh, per-session-unique id for an outgoing line. The peer-id prefix keeps it unique across peers +--- so the host's echo reconciles only the sender's own optimistic entry. +---@param peerId UILobbyPeerId +---@return string +function NextId(peerId) + SendCounter = SendCounter + 1 + return tostring(peerId) .. ":" .. tostring(SendCounter) +end + +--- Appends one entry (copy-then-Set), trimming the oldest beyond the cap. Used for an optimistic +--- outgoing line (`Pending`) and for local system notices. +---@param model UICustomLobbyChatModel +---@param entry UICustomLobbyChatEntry +function Append(model, entry) + local entries = table.copy(model.Entries()) + table.insert(entries, entry) + while table.getn(entries) > MaxEntries do + table.remove(entries, 1) + end + model.Entries:Set(entries) +end + +--- Applies an authoritative chat message from the host. If we already hold a `Pending` entry with the +--- same `Id` (our own optimistic echo), reconcile it to `Confirmed`; otherwise append a new `Confirmed` +--- entry (someone else's message, or our own when we are not the originator). Ids are unique per peer, +--- so this never reconciles the wrong line. +---@param model UICustomLobbyChatModel +---@param fields { Id: string, SenderId: UILobbyPeerId, SenderName: string, Text: string } +function Receive(model, fields) + local entries = model.Entries() + for index, entry in entries do + if entry.Id and entry.Id == fields.Id then + -- reconcile in a fresh array (replace the entry object, then Set so dependents go dirty) + local copy = table.copy(entries) + copy[index] = { + Id = entry.Id, + SenderId = entry.SenderId, + SenderName = fields.SenderName, + Text = entry.Text, + Status = 'Confirmed', + Kind = entry.Kind, + Time = entry.Time, + } + model.Entries:Set(copy) + return + end + end + Append(model, { + Id = fields.Id, + SenderId = fields.SenderId, + SenderName = fields.SenderName, + Text = fields.Text, + Status = 'Confirmed', + Kind = 'chat', + Time = GetSystemTimeSeconds(), + }) +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Debugging + +--- Hot-reload hook: rebuilds the singleton and copies the current feed across so chat survives an edit. +--- (Maintained by hand — add a field to the model, add a copy line here too.) +---@param newModule any +function __moduleinfo.OnReload(newModule) + if Instance then + local handle = newModule.SetupSingleton() + handle.Entries:Set(Instance.Entries()) + handle.Recipient:Set(Instance.Recipient()) + end +end + +--- Hot-reload hook: re-imports this module after a couple of frames. +function __moduleinfo.OnDirty() + ForkThread( + function() + WaitFrames(2) + import(__moduleinfo.name) + end + ) +end + +--#endregion diff --git a/lua/ui/lobby/customlobby/social/CustomLobbyChatPanel.lua b/lua/ui/lobby/customlobby/social/CustomLobbyChatPanel.lua index 7ce945384f0..497fb4c98d4 100644 --- a/lua/ui/lobby/customlobby/social/CustomLobbyChatPanel.lua +++ b/lua/ui/lobby/customlobby/social/CustomLobbyChatPanel.lua @@ -20,19 +20,99 @@ --** SOFTWARE. --****************************************************************************************************** --- The Chat tab of the lobby's bottom-left tabbed panel — a placeholder until the lobby-chat slice --- lands. A bottom-left tab content component: created when its tab is selected and destroyed on --- switch (see ../CustomLobbyTabs.lua), so it's the live panel for its whole lifetime. +-- The Chat tab of the lobby's bottom-left tabbed panel: a scrollable feed of chat lines (fed by +-- CustomLobbyChatModel) over an edit box. Built to the same shape as the Logs tab +-- (../social/CustomLobbyLogsPanel.lua): a `Grid` of fixed-height rows + a vertical scrollbar that +-- sticks to the newest line unless you've scrolled up. The grid is built in `Initialize` because its +-- cell width needs the panel's concrete (post-mount) width. +-- +-- The edit box's Enter routes through CustomLobbyChatController.Send — which decides command-vs-chat, +-- echoes the line optimistically (Pending) and asks the host to broadcast it. The host's echo +-- reconciles the line to Confirmed (see CustomLobbyChatModel). A line's Status tints it: Pending dim, +-- Rejected greyed, system lines in a muted colour. +-- +-- Slice 1: each row is a single, truncated line ("Name: text"). Multi-line wrapping is a later +-- refinement (the in-game chat's ChatLinesInterface is the reference if/when we want it). +-- +-- A bottom-left tab content component: created when its tab is selected and destroyed on switch (see +-- ../CustomLobbyTabs.lua), so it's the live panel for its whole lifetime — the model observer just +-- rebuilds it (Refresh is Ready-gated). local UIUtil = import("/lua/ui/uiutil.lua") local LayoutHelpers = import("/lua/maui/layouthelpers.lua") local Group = import("/lua/maui/group.lua").Group +local Edit = import("/lua/maui/edit.lua").Edit +local Grid = import("/lua/maui/grid.lua").Grid +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap +-- flip to true to tint this panel's sections (the feed grid vs. the edit box) — see /lua/ui/CLAUDE.md § 7.1 +local Debug = true + +local CustomLobbyChatModel = import("/lua/ui/lobby/customlobby/social/customlobbychatmodel.lua") +local CustomLobbyChatController = import("/lua/ui/lobby/customlobby/social/customlobbychatcontroller.lua") + +local LazyVarDerive = import("/lua/lazyvar.lua").Derive local Layouter = LayoutHelpers.ReusedLayoutFor +-- the standard scrollbar gutter (matches the Logs panel / ModSelect): the grid reserves these 32px on +-- its right by being that much narrower, and CreateVertScrollbarFor(grid) hangs the bar on grid.Right +-- (offset 0), so it lands in the strip. Reservation lives in the content width, NOT the scrollbar call. +local ScrollGap = 32 +local RowHeight = 18 +local Pad = 4 +local EditHeight = 20 +local EditGap = 6 +local RowFont = 13 +local NameMax = 90 -- truncate the rendered line so it can't bleed past the row (no clip in MAUI) + +-- line colours by status / kind +local ChatColor = 'ffc8ccd0' -- a confirmed chat line +local PendingColor = 'ff7e848c' -- our own line, awaiting the host's echo (dimmed) +local RejectedColor = 'ff8a5a52' -- the host dropped it (greyed-red, sender only) +local SystemColor = 'ff8a909a' -- a local system notice + +--- Truncates `text` to `maxChars`, appending "…" when it had to cut. +---@param text string +---@param maxChars number +---@return string +local function Truncate(text, maxChars) + text = text or "" + if string.len(text) > maxChars then + return string.sub(text, 1, maxChars - 1) .. "…" + end + return text +end + +--- The rendered one-line label + colour for an entry, by kind and status. +---@param entry UICustomLobbyChatEntry +---@return string label +---@return string color +local function RenderEntry(entry) + if entry.Kind == 'system' then + return entry.Text, SystemColor + end + local label = (entry.SenderName or "?") .. ": " .. entry.Text + if entry.Status == 'Pending' then + return label, PendingColor + elseif entry.Status == 'Rejected' then + return label, RejectedColor + end + return label, ChatColor +end + ---@class UICustomLobbyChatPanel : Group ----@field Placeholder Text +---@field Trash TrashBag +---@field Ready boolean +---@field Grid Grid | false +---@field RowWidth number # unscaled row width (recomputed each Refresh from the panel width) +---@field Scrollbar Scrollbar | false +---@field EditBox Edit +---@field Empty Text +---@field EntriesObserver LazyVar +---@field DebugPanel? Bitmap +---@field DebugEdit? Bitmap +---@field DebugGrid? Bitmap local CustomLobbyChatPanel = ClassUI(Group) { ---@param self UICustomLobbyChatPanel @@ -40,14 +120,194 @@ local CustomLobbyChatPanel = ClassUI(Group) { __init = function(self, parent) Group.__init(self, parent, "CustomLobbyChatPanel") - self.Placeholder = UIUtil.CreateText(self, "Chat — coming soon", 14, UIUtil.bodyFont) - self.Placeholder:SetColor('ff5a606a') - self.Placeholder:DisableHitTest() + self.Trash = TrashBag() + self.Ready = false + self.Grid = false + self.Scrollbar = false + + self.Empty = UIUtil.CreateText(self, "No messages yet", 13, UIUtil.bodyFont) + self.Empty:SetColor('ff5a606a') + self.Empty:DisableHitTest() + self.Empty:Hide() + + self.EditBox = Edit(self) + -- `SetupEditStd` reads the control's bounds before `__post_init` runs; seed placeholder values + -- so it doesn't trip the default circular Left/Right/Width chain (see /lua/ui/CLAUDE.md § 1). + Layouter(self.EditBox):Left(0):Top(0):Width(200):Height(EditHeight):End() + UIUtil.SetupEditStd(self.EditBox, + UIUtil.fontColor, nil, "ffffffff", + UIUtil.highlightColor, UIUtil.bodyFont, 14, 200) + self.EditBox:ShowBackground(false) + self.EditBox:SetText('') + self.EditBox.OnEnterPressed = function(_, text) + if text and text ~= '' then + CustomLobbyChatController.Send(text) + end + self.EditBox:SetText('') + end + + -- created/destroyed with its tab, so always the live panel while it exists — the observer just + -- rebuilds (Refresh is Ready-gated until Initialize builds the grid) + self.EntriesObserver = self.Trash:Add( + LazyVarDerive(CustomLobbyChatModel.GetSingleton().Entries, function(entriesLazy) + entriesLazy() + self:Refresh() + end)) end, ---@param self UICustomLobbyChatPanel __post_init = function(self) - Layouter(self.Placeholder):AtHorizontalCenterIn(self):AtVerticalCenterIn(self):End() + -- The `__init` placeholder pinned Left/Top/Width/Height (so SetupEditStd could read bounds). + -- Override every one here, and ResetWidth/ResetTop to drop the placeholder's concrete Width(200) + -- and Top(0) — otherwise Top stays 0 (frame top) and the grid, whose bottom anchors to the edit + -- box's top, gets dragged up out of the panel. + Layouter(self.EditBox) + :AtLeftIn(self, Pad):AtRightIn(self, Pad):ResetWidth() + :AtBottomIn(self, Pad):Height(EditHeight):ResetTop() + :End() + Layouter(self.Empty):AtHorizontalCenterIn(self):AtTopIn(self, Pad):End() + + if Debug then + -- whole panel (drawn under the grid tint added in Initialize) + the edit box region + self.DebugPanel = Bitmap(self) + self.DebugPanel:SetSolidColor('303080ff') -- blue: the panel bounds + self.DebugPanel:DisableHitTest() + Layouter(self.DebugPanel):Fill(self):Over(self, 90):End() + + self.DebugEdit = Bitmap(self) + self.DebugEdit:SetSolidColor('4080ff80') -- green: the edit-box region + self.DebugEdit:DisableHitTest() + Layouter(self.DebugEdit):Fill(self.EditBox):Over(self, 110):End() + end + end, + + --- Builds the scrollable grid (its cell width needs the panel's concrete width) + scrollbar, and + --- does the first render. Three-phase init (/lua/ui/CLAUDE.md § 1). + ---@param self UICustomLobbyChatPanel + Initialize = function(self) + self.Ready = true + + -- Chat is the default tab, so this can run during the initial mount before the panel's width + -- has settled. So anchor the grid's right edge **reactively** into the panel (reserving the + -- scrollbar gutter) rather than baking a fixed width from a possibly-stale `self.Width()` — the + -- scrollbar attaches to `grid.Right`, so a stale right edge throws the bar way off. The itemWidth + -- ctor arg only drives horizontal column count (we have one column); vertical scrolling keys off + -- itemHeight, so a best-effort width is fine and the row widths are recomputed in Refresh. + self.RowWidth = self:ComputeRowWidth() + self.Grid = Grid(self, self.RowWidth, RowHeight) + Layouter(self.Grid) + :AtLeftIn(self, Pad):AtRightIn(self, ScrollGap) + :AtTopIn(self, Pad):AnchorToTop(self.EditBox, EditGap) + :End() + self.Scrollbar = UIUtil.CreateVertScrollbarFor(self.Grid) + self.Scrollbar:Hide() -- shown by UpdateScrollbar only when the feed overflows + -- let the wheel scroll the grid when the cursor is over the rows, not just over the scrollbar + UIUtil.ForwardWheelToScroll(self.Grid, self.Grid) + + if Debug then + -- the feed/grid region (its right edge shows where the scrollbar gutter is reserved) + self.DebugGrid = Bitmap(self) + self.DebugGrid:SetSolidColor('40ff8040') -- orange: the grid region + self.DebugGrid:DisableHitTest() + Layouter(self.DebugGrid):Fill(self.Grid):Over(self, 100):End() + end + + self:Refresh() + end, + + --- The current content width (panel width minus the scrollbar gutter), unscaled for row sizing. + --- Clamped to a sane minimum so an early/unsettled read can't produce a negative width. + ---@param self UICustomLobbyChatPanel + ---@return number + ComputeRowWidth = function(self) + local scale = LayoutHelpers.GetPixelScaleFactor() + return math.max(32, math.floor(self.Width() / scale) - ScrollGap) + end, + + --- Rebuilds the list from the current entries, keeping the view pinned to the newest line unless + --- the user has scrolled up. + ---@param self UICustomLobbyChatPanel + Refresh = function(self) + if not self.Ready or not self.Grid then + return + end + + local entries = CustomLobbyChatModel.GetSingleton().Entries() + local total = table.getn(entries) + + -- recompute the row width from the (now-settled) panel width so rows size correctly even if the + -- first Initialize ran before the layout settled + self.RowWidth = self:ComputeRowWidth() + + -- Bottom-align the feed (chat grows upward from the input box). A Grid renders rows top-down, so + -- a few lines would otherwise float at the top of the tall area, far from the edit box. Pin the + -- grid's bottom to the edit box (done in Initialize) and float its top so the grid is only as + -- tall as its rows — capped at the available height, beyond which it fills and scrolls. + local rowsHeight = total * LayoutHelpers.ScaleNumber(RowHeight) + self.Grid.Top:Set(function() + local availableTop = self.Top() + LayoutHelpers.ScaleNumber(Pad) + return math.max(availableTop, self.Grid.Bottom() - rowsHeight) + end) + + -- were we at (or near) the bottom before the rebuild? if so, stick to the bottom after + local _, rangeMax, _, visibleMax = self.Grid:GetScrollValues("Vert") + local stick = visibleMax >= rangeMax - 1 + + self.Grid:DeleteAndDestroyAll(true) + if total == 0 then + self.Empty:Show() + self.Grid:EndBatch() + self:UpdateScrollbar() + return + end + self.Empty:Hide() + + self.Grid:AppendCols(1, true) + self.Grid:AppendRows(total, true) + for index, entry in entries do + self.Grid:SetItem(self:CreateRow(entry), 1, index, true) + end + self.Grid:EndBatch() + + if stick then + self.Grid:ScrollSetTop("Vert", total) + end + self:UpdateScrollbar() + end, + + --- Shows the scrollbar only when the list overflows. + ---@param self UICustomLobbyChatPanel + UpdateScrollbar = function(self) + if not self.Scrollbar then + return + end + if self.Grid and self.Grid:IsScrollable("Vert") then + self.Scrollbar:Show() + else + self.Scrollbar:Hide() + end + end, + + --- Builds one chat row: a single truncated line, tinted by status/kind. Private. + ---@param self UICustomLobbyChatPanel + ---@param entry UICustomLobbyChatEntry + ---@return Group + CreateRow = function(self, entry) + local row = Group(self.Grid, "CustomLobbyChatRow") + LayoutHelpers.SetDimensions(row, self.RowWidth, RowHeight) + + local label, color = RenderEntry(entry) + local line = UIUtil.CreateText(row, Truncate(label, NameMax), RowFont, UIUtil.bodyFont) + line:SetColor(color) + line:DisableHitTest() + Layouter(line):AtLeftIn(row, Pad):AtRightIn(row, Pad):AtVerticalCenterIn(row):End() + + return row + end, + + ---@param self UICustomLobbyChatPanel + OnDestroy = function(self) + self.Trash:Destroy() end, } diff --git a/lua/ui/lobby/customlobby/social/chat-design.md b/lua/ui/lobby/customlobby/social/chat-design.md new file mode 100644 index 00000000000..d43178f05d8 --- /dev/null +++ b/lua/ui/lobby/customlobby/social/chat-design.md @@ -0,0 +1,80 @@ +# Lobby chat — design + +The custom lobby's chat. The in-game chat ([`/lua/ui/game/chat/`](/lua/ui/game/chat/)) can't be +reused — it transfers over the engine's `SessionSendChatMessage` across *armies*, and a lobby has +*peers/slots*, not armies. So chat is built on the lobby's own host-authoritative message layer. + +## Two principles + +1. **The host is authoritative for broadcasting chat.** A client never broadcasts directly; it *asks* + the host, and the host decides whether to relay. That gives one chokepoint + ([`CustomLobbyController.ProcessRequestChat`](/lua/ui/lobby/customlobby/CustomLobbyController.lua)) + where filtering — mute, rate-limit, slow-mode, profanity — will live, without touching anything else. +2. **A line you send is shown to you immediately, then its state can change.** The sender echoes the + line locally as `Pending` the instant they hit Enter; when the host's authoritative copy comes + back, it reconciles to `Confirmed` (or could be `Rejected`/dropped). This is why a chat entry has a + mutable `Status` and a stable, client-stamped `Id`. + +## Chat is a per-peer feed, not synced state + +What is host-authoritative is the *decision to broadcast a message* — a transient wire message — not +stored shared state. Each peer just accumulates the lines it receives. So chat is **not** a fourth +synced model; it is the chat equivalent of [`CustomLobbyLog`](/lua/ui/lobby/customlobby/CustomLobbyLog.lua): +a per-peer reactive `Entries` ring buffer. Unlike the Log it is *written by the controller*, so it is +a `ClassSimple : Destroyable` registered in the session trash (freed on `Teardown()`), like the +[derived models](/lua/ui/lobby/customlobby/models/derived/CLAUDE.md). + +## The flow + +``` +edit box Enter + └─ CustomLobbyChatController.Send(text) [input path: command vs chat] + ├─ '/' line → handled locally, NOTHING on the wire (registry = slice 2; stub for now) + └─ plain → ChatModel.Append(Pending) + Controller.RequestChat(text, id) + +CustomLobbyController.RequestChat(text, id) [the wire half] + ├─ host: ProcessRequestChat(self, {SenderID=me, Text, Id}) (short-circuit) + └─ client: SendData(host, { Type='RequestChat', Text, Id }) + +CustomLobbyController.ProcessRequestChat(instance, data) ★ the single chat chokepoint + ├─ (future) mute / rate-limit / slow-mode — return early to drop + ├─ SenderName = FindNameForOwner(data.SenderID) (host resolves it; clients can't spoof) + ├─ instance:BroadcastData({ Type='ChatMessage', Id, SenderID, SenderName, Text }) + └─ ProcessChatMessage(instance, message) (broadcast doesn't loop back → show locally) + +CustomLobbyController.ProcessChatMessage(instance, data) [every peer] + └─ CustomLobbyChatModel.Receive{ Id, SenderId, SenderName, Text } + ├─ a held Pending line with this Id → reconcile to Confirmed (our own echo) + └─ otherwise → append a new Confirmed line (someone else's) +``` + +Ids are `localPeerId .. ':' .. counter`, unique across peers, so the echo reconciles only the +originator's optimistic line. The host's own line reconciles in the same frame (its broadcast doesn't +loop back, so `ProcessRequestChat` displays it directly); a client's line takes a real round-trip — +the same mechanism either way. + +## Files (slice 1) + +| File | Role | +|---|---| +| [CustomLobbyChatModel.lua](CustomLobbyChatModel.lua) | per-peer reactive `Entries` ring buffer; `Append` (optimistic / system) + `Receive` (reconcile-or-append) + `NextId`. | +| [CustomLobbyChatController.lua](CustomLobbyChatController.lua) | the send pipeline: `Send` (command-vs-chat), `AppendLocalSystem`, `SetRecipient`. | +| [CustomLobbyChatPanel.lua](CustomLobbyChatPanel.lua) | the Chat tab: a scrollable feed (built to the [Logs-tab](CustomLobbyLogsPanel.lua) shape) over an edit box. | +| [`../CustomLobbyMessages.lua`](/lua/ui/lobby/customlobby/CustomLobbyMessages.lua) | `RequestChat` (Accept: any peer) + `ChatMessage` (Accept: from host). | +| [`../CustomLobbyController.lua`](/lua/ui/lobby/customlobby/CustomLobbyController.lua) | `RequestChat` / `ProcessRequestChat` (chokepoint) / `ProcessChatMessage` + `FindNameForOwner`. | + +## Roadmap (later slices) + +- **Slice 2 — commands.** A `social/commands/` registry mirroring + [`/lua/ui/game/chat/commands/`](/lua/ui/game/chat/commands/) (own `Commands` table, own resolvers + `Slot`/`Peer`, own dispatch `ctx`). Built-ins call existing controller intents (`/take`, `/close`, + `/help`). **Host-gating goes in `Accept`, not `ShouldRegister`** — `IsHost` flips only after the + connection handshake, so it must be evaluated per-invocation. `Send`'s `/` branch dispatches here. +- **Slice 3 — whisper.** `RequestChat` carries a `Recipient`; `ProcessRequestChat` `SendData`s to + sender+target instead of `BroadcastData`. Same chokepoint. +- **Slice 4 — join/leave notices.** Append `Kind='system'` lines **locally** (no wire) from + `ProcessAddPlayer` / `OnPeerDisconnected`, matching the Log's "each peer logs its own" principle. +- **Refinements.** Multi-line wrapping (slice 1 truncates one line per row — the in-game + [`ChatLinesInterface`](/lua/ui/game/chat/ChatLinesInterface.lua) is the reference); unread-since-last-view + badge count (slice 1 shows the total line count). +``` From 34a8b67ca1b7f7c88095b55632bccc5d5e8d339a Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sat, 27 Jun 2026 22:40:46 +0200 Subject: [PATCH 83/98] Fix bugs of chat panel --- .../customlobby/CustomLobbyInterface.lua | 2 +- .../social/CustomLobbyChatPanel.lua | 82 +++++++++++++++---- scripts/LaunchCustomLobby.ps1 | 7 +- 3 files changed, 71 insertions(+), 20 deletions(-) diff --git a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua index c235cb2daab..24fc91ff207 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua @@ -113,7 +113,7 @@ local function GearAction(onOpen, title, body) end -- flip to tint each layout area so the regions are visible while iterating -local Debug = true +local Debug = false -- the lobby content is designed for the 1024x768 floor; the root fills the frame (full-screen -- backdrop) but the content is centered and capped to this size, so it never stretches on a diff --git a/lua/ui/lobby/customlobby/social/CustomLobbyChatPanel.lua b/lua/ui/lobby/customlobby/social/CustomLobbyChatPanel.lua index 497fb4c98d4..8e1f7845de4 100644 --- a/lua/ui/lobby/customlobby/social/CustomLobbyChatPanel.lua +++ b/lua/ui/lobby/customlobby/social/CustomLobbyChatPanel.lua @@ -47,7 +47,7 @@ local Grid = import("/lua/maui/grid.lua").Grid local Bitmap = import("/lua/maui/bitmap.lua").Bitmap -- flip to true to tint this panel's sections (the feed grid vs. the edit box) — see /lua/ui/CLAUDE.md § 7.1 -local Debug = true +local Debug = false local CustomLobbyChatModel = import("/lua/ui/lobby/customlobby/social/customlobbychatmodel.lua") local CustomLobbyChatController = import("/lua/ui/lobby/customlobby/social/customlobbychatcontroller.lua") @@ -66,6 +66,13 @@ local EditGap = 6 local RowFont = 13 local NameMax = 90 -- truncate the rendered line so it can't bleed past the row (no clip in MAUI) +-- the input field: a bordered, filled box at the bottom so it's clear where to type +local FieldHeight = 24 +local FieldInset = 5 -- horizontal text padding inside the field +local FieldColor = 'ff10151b' -- field fill +local FieldBorderColor = 'ff2b333d' -- field 1px border +local PromptColor = 'ff5a606a' -- the dim "type a message" placeholder + -- line colours by status / kind local ChatColor = 'ffc8ccd0' -- a confirmed chat line local PendingColor = 'ff7e848c' -- our own line, awaiting the host's echo (dimmed) @@ -107,7 +114,10 @@ end ---@field Grid Grid | false ---@field RowWidth number # unscaled row width (recomputed each Refresh from the panel width) ---@field Scrollbar Scrollbar | false +---@field EditFrame Bitmap # input-field border +---@field EditField Bitmap # input-field fill (inset 1px inside the border) ---@field EditBox Edit +---@field Prompt Text # dim "type a message" placeholder, shown while the box is empty ---@field Empty Text ---@field EntriesObserver LazyVar ---@field DebugPanel? Bitmap @@ -130,6 +140,15 @@ local CustomLobbyChatPanel = ClassUI(Group) { self.Empty:DisableHitTest() self.Empty:Hide() + -- a bordered, filled input field at the bottom so it's clear where to type. Created before the + -- edit box (and prompt) so they render on top of it (sibling depth follows creation order). + self.EditFrame = Bitmap(self) + self.EditFrame:SetSolidColor(FieldBorderColor) + self.EditFrame:DisableHitTest() + self.EditField = Bitmap(self) + self.EditField:SetSolidColor(FieldColor) + self.EditField:DisableHitTest() + self.EditBox = Edit(self) -- `SetupEditStd` reads the control's bounds before `__post_init` runs; seed placeholder values -- so it doesn't trip the default circular Left/Right/Width chain (see /lua/ui/CLAUDE.md § 1). @@ -139,11 +158,25 @@ local CustomLobbyChatPanel = ClassUI(Group) { UIUtil.highlightColor, UIUtil.bodyFont, 14, 200) self.EditBox:ShowBackground(false) self.EditBox:SetText('') + + -- a dim placeholder shown while the box is empty (hidden as soon as there's text) + self.Prompt = UIUtil.CreateText(self, "Type a message…", RowFont, UIUtil.bodyFont) + self.Prompt:SetColor(PromptColor) + self.Prompt:DisableHitTest() + self.EditBox.OnEnterPressed = function(_, text) if text and text ~= '' then CustomLobbyChatController.Send(text) end self.EditBox:SetText('') + self.Prompt:Show() + end + self.EditBox.OnTextChanged = function(_, newText) + if newText and newText ~= '' then + self.Prompt:Hide() + else + self.Prompt:Show() + end end -- created/destroyed with its tab, so always the live panel while it exists — the observer just @@ -157,14 +190,25 @@ local CustomLobbyChatPanel = ClassUI(Group) { ---@param self UICustomLobbyChatPanel __post_init = function(self) + -- the input field (border + inset fill) pinned to the bottom + Layouter(self.EditFrame) + :AtLeftIn(self, Pad):AtRightIn(self, Pad) + :AtBottomIn(self, Pad):Height(FieldHeight) + :End() + Layouter(self.EditField) + :AtLeftIn(self.EditFrame, 1):AtRightIn(self.EditFrame, 1) + :AtTopIn(self.EditFrame, 1):AtBottomIn(self.EditFrame, 1) + :End() + -- The `__init` placeholder pinned Left/Top/Width/Height (so SetupEditStd could read bounds). - -- Override every one here, and ResetWidth/ResetTop to drop the placeholder's concrete Width(200) - -- and Top(0) — otherwise Top stays 0 (frame top) and the grid, whose bottom anchors to the edit - -- box's top, gets dragged up out of the panel. + -- Override every one here, and ResetWidth to drop the placeholder's concrete Width(200); + -- AtVerticalCenterIn re-sets Top (off the placeholder's 0), centring the box in the field. Layouter(self.EditBox) - :AtLeftIn(self, Pad):AtRightIn(self, Pad):ResetWidth() - :AtBottomIn(self, Pad):Height(EditHeight):ResetTop() + :AtLeftIn(self.EditField, FieldInset):AtRightIn(self.EditField, FieldInset):ResetWidth() + :Height(EditHeight):AtVerticalCenterIn(self.EditField) :End() + Layouter(self.Prompt):AtLeftIn(self.EditField, FieldInset):AtVerticalCenterIn(self.EditField):End() + Layouter(self.Empty):AtHorizontalCenterIn(self):AtTopIn(self, Pad):End() if Debug then @@ -188,16 +232,21 @@ local CustomLobbyChatPanel = ClassUI(Group) { self.Ready = true -- Chat is the default tab, so this can run during the initial mount before the panel's width - -- has settled. So anchor the grid's right edge **reactively** into the panel (reserving the - -- scrollbar gutter) rather than baking a fixed width from a possibly-stale `self.Width()` — the - -- scrollbar attaches to `grid.Right`, so a stale right edge throws the bar way off. The itemWidth - -- ctor arg only drives horizontal column count (we have one column); vertical scrolling keys off - -- itemHeight, so a best-effort width is fine and the row widths are recomputed in Refresh. + -- has settled. So anchor the grid's edges **reactively** into the panel (left+right insets, + -- reserving the scrollbar gutter) rather than baking a fixed width from a possibly-stale + -- `self.Width()` — the scrollbar attaches to `grid.Right`, so a stale right edge throws the bar + -- way off. + -- + -- The Grid hides any item outside its visible window; the visible **column** count is + -- `floor(gridWidth / itemWidth)`, so if itemWidth ever exceeds the grid's content width the count + -- is 0 and EVERY row is hidden (rows present, nothing drawn). This is a single-column list, so the + -- column stride is irrelevant — pass itemWidth = 1 to guarantee ≥ 1 visible column regardless of + -- width/timing; the rows are sized from the real content width in Refresh. self.RowWidth = self:ComputeRowWidth() - self.Grid = Grid(self, self.RowWidth, RowHeight) + self.Grid = Grid(self, 1, RowHeight) Layouter(self.Grid) :AtLeftIn(self, Pad):AtRightIn(self, ScrollGap) - :AtTopIn(self, Pad):AnchorToTop(self.EditBox, EditGap) + :AtTopIn(self, Pad):AnchorToTop(self.EditFrame, EditGap) :End() self.Scrollbar = UIUtil.CreateVertScrollbarFor(self.Grid) self.Scrollbar:Hide() -- shown by UpdateScrollbar only when the feed overflows @@ -215,13 +264,14 @@ local CustomLobbyChatPanel = ClassUI(Group) { self:Refresh() end, - --- The current content width (panel width minus the scrollbar gutter), unscaled for row sizing. - --- Clamped to a sane minimum so an early/unsettled read can't produce a negative width. + --- The grid's content width (panel width minus the left pad and the scrollbar gutter), unscaled, + --- for row sizing. Matches the grid's `:AtLeftIn(self, Pad):AtRightIn(self, ScrollGap)` insets so a + --- row spans exactly the visible feed. Clamped so an early/unsettled read can't go negative. ---@param self UICustomLobbyChatPanel ---@return number ComputeRowWidth = function(self) local scale = LayoutHelpers.GetPixelScaleFactor() - return math.max(32, math.floor(self.Width() / scale) - ScrollGap) + return math.max(32, math.floor(self.Width() / scale) - Pad - ScrollGap) end, --- Rebuilds the list from the current entries, keeping the view pinned to the newest line unless diff --git a/scripts/LaunchCustomLobby.ps1 b/scripts/LaunchCustomLobby.ps1 index 056dfbf7dc2..95d8b400b55 100644 --- a/scripts/LaunchCustomLobby.ps1 +++ b/scripts/LaunchCustomLobby.ps1 @@ -73,14 +73,15 @@ $subdivisions = @("I", "II", "III", "IV", "V") # Returns a randomised set of per-player arguments, mirroring what the FAF client passes for a # real player. The custom lobby reads these when it builds the local player (see # CustomLobbyController.CreateLocalPlayer). The host preserves them when seating a joining peer. -# * rating: the displayed rating (PL) is derived as mean - 3 * deviation; the ranges below keep -# it positive and realistic for a FAF player. +# * rating: the displayed rating (PL) is derived as mean - 3 * deviation. The deviation is kept low +# (most lobby players are established, deviation ~50-120; a fresh account would be ~500), so PL +# sits close to the mean and stays positive/realistic. # * faction: a flag arg (/uef, /aeon, …) — no value. # * team: 2 or 3 (shown as T1 / T2), so the team-aware layouts have a split to render. # * grandmaster/unlisted have no subdivision (mirrors LaunchFAInstances). function Get-PlayerArgs { $mean = Get-Random -Minimum 800 -Maximum 2400 - $deviation = Get-Random -Minimum 50 -Maximum 250 + $deviation = Get-Random -Minimum 25 -Maximum 120 $numGames = Get-Random -Minimum 0 -Maximum 2000 $team = Get-Random -Minimum 2 -Maximum 4 From 01ee2c61a8c24ffb4a7ddceb1b68db80d23ca51b Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sat, 27 Jun 2026 23:08:57 +0200 Subject: [PATCH 84/98] Extend chat and balance preview --- lua/ui/lobby/customlobby/CLAUDE.md | 6 +- .../customlobby/CustomLobbyBalancePreview.lua | 86 ++++++++++++++++--- .../customlobby/CustomLobbyController.lua | 29 +++++++ .../lobby/customlobby/CustomLobbyMessages.lua | 23 +++++ .../social/CustomLobbyChatController.lua | 10 +-- .../social/CustomLobbyChatModel.lua | 30 +++++++ .../social/CustomLobbyChatPanel.lua | 25 +++++- .../lobby/customlobby/social/chat-design.md | 13 ++- 8 files changed, 193 insertions(+), 29 deletions(-) diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index df040e39a2d..97b7eab2718 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -51,15 +51,15 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | [CustomLobbyPerformancePopover.lua](CustomLobbyPerformancePopover.lua) | hover popover over the CPU column; hand-built bitmap bar chart of a peer's `PerformanceTrackingV2` history, with a yellow recommended-unit-cap line. | | [CustomLobbyInstance.lua](CustomLobbyInstance.lua) | thin `moho.lobby_methods` shell; validates/dispatches traffic, forwards callbacks to the controller. Also feeds [`CustomLobbyLog`](CustomLobbyLog.lua) from its three network choke points (BroadcastData / SendData / DataReceived). | | [CustomLobbyLog.lua](CustomLobbyLog.lua) | the **network traffic log** — a reactive, per-peer, never-synced ring buffer (`Entries` LazyVar, capped) of every message this peer broadcasts / sends / receives, fed by the instance and rendered by the **Logs** tab. Each peer logs only its own traffic, so host and client views differ naturally. Not one of the three models (no game state); a diagnostic feed. | -| [CustomLobbyController.lua](CustomLobbyController.lua) | host-authority logic (free functions): seating, `Process*` handlers, intents (`RequestSetReady`, `RequestTakeSlot`, `RequestSwapSlots`, `RequestEject`, `RequestMoveToObserver`, `RequestSetScenario`, `RequestSetGameMods/Options`, `RequestResetGameOptions`, `RequestSetSlotsPinned`, `RequestSetSlotLocked`, `RequestApplyBalance` (applies a previewed balance), `RequestReopenClosedSlots`, `RequestLaunch`, `RequestSaveSetupPreset`/`RequestLoadSetupPreset`, `RequestChat` — all keyed by slot/bool/file/name so a chat command can call them too; permission is gated separately). **Chat relay:** `RequestChat` (host short-circuits, client sends to host) → `ProcessRequestChat` is the **single chat chokepoint** (resolves the sender name authoritatively via `FindNameForOwner`, broadcasts `ChatMessage`, and shows it locally since a broadcast doesn't loop back; future mute/rate-limit lives here) → `ProcessChatMessage` appends/reconciles into the [chat model](social/CustomLobbyChatModel.lua). The controller also shares each peer's stored CPU benchmark. **Launch:** `RequestLaunch` (host-only, readiness-validated) → auto-saves the `lastGame` preset (`BuildSetupSnapshot`) → `BuildGameConfiguration` (seed option defaults + scenario, resolve random factions, assign army numbers + push to server, stamp ratings/clan tags, resolve team-from-position for the binary AutoTeams modes, resolve sim mods via `Mods.GetGameMods`) → broadcast `LaunchGame` + `instance:LaunchGame`; clients run `ProcessLaunchGame`. **Presets:** `BuildSetupSnapshot` (read the launch state → serializable **setup-only** snapshot: scenario / options / mods / restrictions) + `ApplySetup` (host-only: write scenario/mods/restrictions, reconcile options to the current schema, one broadcast); players, observers and teams/spawn-mex are not stored (see [presetselect/](presetselect/CLAUDE.md)). | +| [CustomLobbyController.lua](CustomLobbyController.lua) | host-authority logic (free functions): seating, `Process*` handlers, intents (`RequestSetReady`, `RequestTakeSlot`, `RequestSwapSlots`, `RequestEject`, `RequestMoveToObserver`, `RequestSetScenario`, `RequestSetGameMods/Options`, `RequestResetGameOptions`, `RequestSetSlotsPinned`, `RequestSetSlotLocked`, `RequestApplyBalance` (applies a previewed balance), `RequestReopenClosedSlots`, `RequestLaunch`, `RequestSaveSetupPreset`/`RequestLoadSetupPreset`, `RequestChat` — all keyed by slot/bool/file/name so a chat command can call them too; permission is gated separately). **Chat relay:** `RequestChat` (host short-circuits, client sends to host) → `ProcessRequestChat` is the **single chat chokepoint** (resolves the sender name authoritatively via `FindNameForOwner`, broadcasts `ChatMessage`, and shows it locally since a broadcast doesn't loop back; future mute/rate-limit lives here) → `ProcessChatMessage` appends/reconciles into the [chat model](social/CustomLobbyChatModel.lua). **Join/leave notices:** the host emits a `SystemNotice` (senderless system line) via `BroadcastSystemNotice` from `ProcessAddPlayer` (joined) and `OnPeerDisconnected` (left) — host-broadcast, not per-peer roster-diffing, so every peer (incl. the host, via local loopback) shows the same line. The controller also shares each peer's stored CPU benchmark. **Launch:** `RequestLaunch` (host-only, readiness-validated) → auto-saves the `lastGame` preset (`BuildSetupSnapshot`) → `BuildGameConfiguration` (seed option defaults + scenario, resolve random factions, assign army numbers + push to server, stamp ratings/clan tags, resolve team-from-position for the binary AutoTeams modes, resolve sim mods via `Mods.GetGameMods`) → broadcast `LaunchGame` + `instance:LaunchGame`; clients run `ProcessLaunchGame`. **Presets:** `BuildSetupSnapshot` (read the launch state → serializable **setup-only** snapshot: scenario / options / mods / restrictions) + `ApplySetup` (host-only: write scenario/mods/restrictions, reconcile options to the current schema, one broadcast); players, observers and teams/spawn-mex are not stored (see [presetselect/](presetselect/CLAUDE.md)). | | [CustomLobbyRules.lua](CustomLobbyRules.lua) | the game-rule **pure kernel** (not view, not networking, **reads no models**): `RecommendedUnitCap(seatedCount, maxDimension)` (per-player cap by map size), `AutoTeamMode(gameOptions)` / `SideLabels(mode)` / `BuildSideResolver(mode, scenario)` (binary auto-team split). Every input is passed in, so the same functions serve the reactive [slots derived model](models/derived/CustomLobbySlotsDerivedModel.lua) (which applies them to publish `Side` / `Teams`) and, later, the host's launch-time team assignment — neither path makes this module read state. | | [CustomLobbyBalancer.lua](CustomLobbyBalancer.lua) | the auto-balance **pure kernel** (**reads no models**) — **positional only** (the feature is gated to a binary AutoTeams mode, so there are always exactly two sides and the **position decides the team**; the kernel never writes `Team`). Takes the [slots derived model](models/derived/CustomLobbySlotsDerivedModel.lua)'s **already-resolved** snapshot — its `Slots` table (per-seat player / side / locked / closed) + `Teams` aggregate (mode / labels / resolved) — so it never re-resolves sides. **Solve and score are split** so the preview can hand-edit a balance. **`BuildCandidates(slots, teams, arrangement, locks, count?)`** returns the top-N **distinct** team splits (best-first by match quality, mirror-equivalent splits dropped, each a complete scored plan with its own random position shuffle) — the preview browses them. **`Rebalance(slots, teams, arrangement, locks)`** is the single-best wrapper (`BuildCandidates(...)[1]`); it re-solves the *unlocked* players in three phases (a port of the legacy `PenguinAutoBalance`): **(1) Teams** — split into two even sides minimising the cheap rating heuristic; **(2) Pairs** — rank-match across the teams (strongest of A faces strongest of B; by rating, never random); **(3) Positions** — `ShufflePairsIntoSeats` (its own exported function) scatters the pairs across the free mirror rows. Locked players (and an odd roster's lowest-rated unlocked player) are **pinned at their seat in `arrangement`** — not their lobby seat — so locking a player the balance already moved keeps them put. **`ScoreArrangement(slots, teams, arrangement, locks)`** scores a concrete arrangement *without* re-solving (for a manual swap / lock toggle). Both run `ScorePlan`, which fills the plan: two rating-sorted `sides` (used for `totals` / quality), the ordered mirror **`positions`** (the k-th side-1 seat vs the k-th side-2 seat, sorted by slot so the list is stable across re-scores — this is what the preview renders + drag-reorders), per-side PL `totals`, `quality` + `currentQuality` (`trueskill.computeQuality` proposed vs. current, for a "before → after"), `winChance` (predicted win % per side — a local normal-CDF over the side mean/variance sums, the directional counterpart to the symmetric quality), and a per-player `moved` flag. `locks` (ownerId→true) is an optional **override** of the seats' own Locked flags — the preview passes its working, preview-local lock set; nil falls back to `entry.Locked`. **`BuildPlan(slots, teams, locks?, count?)`** is the convenience entry — `BuildCandidates` from the lobby's current seating (returns the candidate **list**). `lockedOwners` flags locked players; `ToArrangement(plan)` returns the lobby update — **slot → ownerId**, no team. The controller's `RequestApplyBalance` re-seats from it; the team falls out of the position, resolved at launch by `BuildGameConfiguration` (binary AutoTeams → team-from-`StartSpot`). | | [CustomLobbyPresets.lua](CustomLobbyPresets.lua) | **named setup presets** (setup-only: scenario / options / mods / restrictions — no players/observers) — pure prefs CRUD (`GetPresets`/`GetPreset`/`SavePreset`/`DeletePreset`/`RenamePreset`) over one key (`customlobby_setup_presets`), an ordered `{ Name, Setup }` array mirroring the mod presets in [`/lua/ui/modutilities.lua`](/lua/ui/modutilities.lua). Holds no models, no network. A reserved `LastGamePresetName` entry is auto-saved at launch (the rehost source). Capturing/applying a setup lives in the controller (`BuildSetupSnapshot`/`ApplySetup`); see [presetselect/](presetselect/CLAUDE.md). | | [CustomLobbySession.lua](CustomLobbySession.lua) | the lobby session's **main trash bag**. The lobby lives in the persistent front-end Lua state, which is *not* reset when the game launches in its own state — so anything left reachable (a running thread, a cache, a singleton) leaks for the whole match. This module owns one session-lifetime `TrashBag` (`GetTrash()`); every lobby-scoped `Destroyable` registers in it, and one `Teardown()` (clean-slate in `CreateLobby`, on leave, on `OnGameLaunched`) frees the lot. Works despite the bag being weak-valued because each singleton is pinned by its own module `Instance` local. **Rollout in progress** — converted: the map catalog, all five [derived models](models/derived/CLAUDE.md), and the three authoritative [models/](models/) (Launch/Session/Local, with thin `Destroy` per the design's decision #3). Still to do: the mod catalog, the interface + performance popover (pending the teardown-ordering decision), and the lobby instance. See [design/session-trashbag-teardown.md](design/session-trashbag-teardown.md). | -| [CustomLobbyMessages.lua](CustomLobbyMessages.lua) | message registry: `AddPlayer`, `SetPlayers` (launch model: players + observers), `SentLaunchInfo` (launch model: scenario / options / mods / teams / spawn mex), `SetSessionState` (session model: slot count / closed slots / locked slots / pinned), `SetReady`, `TakeSlot`, `RequestChat` (any peer → host: a chat line to relay) / `ChatMessage` (host → everyone: the accepted line), `DisconnectPeer`, `LaunchGame` (host's final game config → `instance:LaunchGame`), `ReportCpuBenchmark`, `SetCpuBenchmarks`. | +| [CustomLobbyMessages.lua](CustomLobbyMessages.lua) | message registry: `AddPlayer`, `SetPlayers` (launch model: players + observers), `SentLaunchInfo` (launch model: scenario / options / mods / teams / spawn mex), `SetSessionState` (session model: slot count / closed slots / locked slots / pinned), `SetReady`, `TakeSlot`, `RequestChat` (any peer → host: a chat line to relay) / `ChatMessage` (host → everyone: the accepted line) / `SystemNotice` (host → everyone: a senderless lobby notice — join/leave), `DisconnectPeer`, `LaunchGame` (host's final game config → `instance:LaunchGame`), `ReportCpuBenchmark`, `SetCpuBenchmarks`. | | [social/CustomLobbyChatModel.lua](social/CustomLobbyChatModel.lua) | the **chat feed** — a per-peer, never-synced reactive `Entries` ring buffer (`UICustomLobbyChatEntry`: `Id` / `SenderId` / `SenderName` / `Text` / `Status` / `Kind`), the chat equivalent of [`CustomLobbyLog`](CustomLobbyLog.lua) but **written by the controller**, so it is a `ClassSimple : Destroyable` in the session trash (freed on `Teardown()`). `Append` adds an optimistic `Pending` line / system notice; `Receive` reconciles the sender's `Pending` line to `Confirmed` by the echoed `Id` (or appends others' lines). Not one of the three models — no game state. See [social/chat-design.md](social/chat-design.md). | | [social/CustomLobbyChatController.lua](social/CustomLobbyChatController.lua) | the chat **send pipeline** (writes the chat model, never the wire): `Send` decides command-vs-chat (a `/` line is handled locally and never broadcast — registry lands in slice 2), echoes a plain line optimistically (`Pending`) and hands it to the controller's `RequestChat`; `AppendLocalSystem` for local notices. Mirrors the in-game [`ChatController.Send`](/lua/ui/game/chat/ChatController.lua) split. | -| [CustomLobbyBalancePreview.lua](CustomLobbyBalancePreview.lua) | the auto-balance **preview + tuner** (host-only `Popup`): the slots header's balance button opens it instead of re-seating directly. It owns a **working state** nothing synced sees until Apply — `Arrangement` (slot→ownerId) + `Locks` (a preview-local lock set, **seeded** from the lobby's real locks but never written back). Renders the proposal as **mirror-position rows** (row k = the k-th seat on each side, who face off, in start-position order with a position label; the right column **mirrored** so the teams face each other) — each row puts the **name on the outer edge** and the **display rating in a fixed column hugging the centre** (so the two rating columns line up across rows, flanking the gap number), with the **mean** as a muted value just outside it; **moved** players are gold, **locked** players blue. The host **browses the candidate balances** with a "< i / N >" browser (the kernel's top-N distinct splits via `BuildCandidates`); stepping adopts each. Three gestures tune the shown one: **drag a row** onto another → swap those two positions' pairs (both players move together — "D → A"; same feel as the lobby slot rows — press-vs-drag threshold, a floating pair ghost beside the cursor, and a drop-target highlight); **click two players** → swap just those two (`ScoreArrangement` re-scores, no re-solve); **click a player's lock dot** → pin/unpin them (preview-local) which **regenerates** the candidates around that lock. The **header mirrors a row**: team label (outer), average (centre column, aligned above the player ratings) + total (muted), and the **gap total** in the centre band atop the per-row gaps. The status line reports match quality **before → after** (whole percent), predicted **win** split, and any odd one out (or why it can't balance). **Apply** commits only `Arrangement` (→ `RequestApplyBalance`); **Cancel** discards everything (locks included). A transient picker, owns no synced state. | +| [CustomLobbyBalancePreview.lua](CustomLobbyBalancePreview.lua) | the auto-balance **preview + tuner** (host-only `Popup`): the slots header's balance button opens it instead of re-seating directly. It owns a **working state** nothing synced sees until Apply — `Arrangement` (slot→ownerId) + `Locks` (a preview-local lock set, **seeded** from the lobby's real locks but never written back). Renders the proposal as **mirror-position rows** (row k = the k-th seat on each side, who face off, in start-position order with a position label; the right column **mirrored** so the teams face each other) — each row puts the **name on the outer edge** and the **display rating in a fixed column hugging the centre** (so the two rating columns line up across rows, flanking the centre gap), with the **mean** as a muted value just outside it; the centre shows the pair's rating gap (magnitude) with a **`<` / `>` arrow pointing to the higher-rated side**; **moved** players are gold, **locked** players blue. The host **browses the candidate balances** with a "< i / N >" browser (the kernel's top-N distinct splits via `BuildCandidates`); stepping adopts each. Three gestures tune the shown one: **drag a row** onto another → swap those two positions' pairs (both players move together — "D → A"; same feel as the lobby slot rows — press-vs-drag threshold, a floating pair ghost beside the cursor, and a drop-target highlight); **click two players** → swap just those two (`ScoreArrangement` re-scores, no re-solve); **click a player's lock dot** → pin/unpin them (preview-local) which **regenerates** the candidates around that lock. The **header mirrors a row**: team label (outer), average (centre column, aligned above the player ratings) + total (muted), and the **gap total** (same direction arrow) in the centre band atop the per-row gaps. The status line reports match quality **before → after** (whole percent), predicted **win** split, and any odd one out (or why it can't balance). While open it **pins the lobby** (`SlotsPinned`) so the roster can't shift under the proposal (it would go stale); it remembers the prior pin state — **Cancel** restores it, **Apply** leaves it pinned so the balance holds. **Apply** commits only `Arrangement` (→ `RequestApplyBalance`); **Cancel** discards everything (locks included). A transient picker, owns no synced state (beyond toggling the shared pin while open). | | [CustomLobbyContextMenu.lua](CustomLobbyContextMenu.lua) | generic framed floating menu; `Show(entries, x, y)` renders any `{label, action, enabled}` list, dismisses on item click / click-outside / Esc. Knows nothing about the lobby. | | [CustomLobbyMenus.lua](CustomLobbyMenus.lua) | declarative menu **definitions**: entry lists with `when(ctx)`/`action(ctx)` filtered by lobby state (`BuildSlotMenu`). Adding/state-gating an item is a one-liner here. | | [slots/](slots/) | the **slot subsystem** (its own folder, a sub-folder per layout). [`CustomLobbySlotsInterface`](slots/CustomLobbySlotsInterface.lua) is the entry the composition root mounts: a "Players" header over the **active layout body**, picked by the AutoTeams mode — one-column for the non-team modes, the two-column team layout for the binary modes (left/right, top/bottom, even/odd). It observes `GameOptions` and swaps the body when the kind flips (create-on-mode / destroy-on-switch); a change *within* the binary modes is handled by the two-column body's own re-layout. It is the rows' **drag coordinator** (`UICustomLobbySlotCoordinator`: hit-test which row a point is in, drop-highlight, drag ghost → `RequestSwapSlots`) for *every* layout — it alone needs to hit-test across rows — so the layout bodies stay pure build/place/reveal and never duplicate the drag logic; it also exposes `PreferredHeight()` (computed from the mode + model, swap-order-independent) so the root sizes the slot area. The header band carries a **host-only tool strip** (right-aligned: pin seating · auto-balance · reopen closed slots) of small icon buttons (local `SlotTool`, mirroring the config column's `PreviewTool`); the pin button is lit from the synced `SlotsPinned` and the strip is hidden for clients. The **auto-balance** button is gated (`SlotTool:SetEnabled`, greyed when off) to a **resolved binary AutoTeams mode** (the derived `Teams` aggregate's `Mode` + `Resolved` — two well-defined sides, map loaded for the positional ones) and opens the [balance preview](CustomLobbyBalancePreview.lua) rather than re-seating on click. A **"Locked" notice** (lock glyph + label) sits right of the "Players" label and is shown to **everyone** while `SlotsPinned` is on, so a client can see seating is host-controlled before clicking an open slot to no effect. [`CustomLobbySlotBase`](slots/CustomLobbySlotBase.lua) holds **all slot behaviour** (one subscription to the [slots derived model](models/derived/CustomLobbySlotsDerivedModel.lua) — which already merged this seat's player / placement / closed / CPU benchmark and did the faction/CPU formatting — click/right-click/drag-to-swap + intents, the background/highlight/click overlays + a gold left-edge **lock stripe** shown when the seat's `Locked` flag is set) and the default `RenderPlayer`/`RenderCpu` that paint the standard named controls from the entry's resolved views; a presentation subclasses it and implements just `CreateContents` / `LayoutContents`. [`onecolumn/`](slots/onecolumn/): [`CustomLobbyOneColumnSlots`](slots/onecolumn/CustomLobbyOneColumnSlots.lua) (stacks thin rows, reveals 1..count) + [`CustomLobbySlotRow`](slots/onecolumn/CustomLobbySlotRow.lua) (thin one-line presentation). [`twocolumn/`](slots/twocolumn/): [`CustomLobbyTwoColumnSlots`](slots/twocolumn/CustomLobbyTwoColumnSlots.lua) (splits slots into two team columns by each seat's `Side` from the [slots derived model](models/derived/CustomLobbySlotsDerivedModel.lua) — no longer re-resolving the split itself — with a [`CustomLobbyTeamScore`](CustomLobbyTeamScore.lua) strip across the top as the Left/Right indicator, and the "two columns, unresolved" fallback — parity fill + the score self-hides until a positional map's start positions load) + [`CustomLobbySlotCard`](slots/twocolumn/CustomLobbySlotCard.lua) (fat half-width / two-line presentation; `SetMirrored` flips the right column's cards so the two teams face each other). Each body exports `HeightForCount`. | diff --git a/lua/ui/lobby/customlobby/CustomLobbyBalancePreview.lua b/lua/ui/lobby/customlobby/CustomLobbyBalancePreview.lua index 63f95682cfc..db92c7fc808 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyBalancePreview.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyBalancePreview.lua @@ -24,6 +24,10 @@ -- quality) BEFORE anything is re-seated, with Apply / Cancel — so the host commits a balance on -- purpose (USER_STORIES § R). It is also where the host *tunes* the balance with a few clicks. -- +-- While it is open the lobby is **pinned** (`SlotsPinned`), so the roster can't shift under the +-- proposal and leave it stale. The prior pin state is remembered: Cancel restores it, while Apply +-- leaves the lobby pinned so the balanced seating holds until the host unpins. +-- -- It owns a **working state** that nothing synced ever sees until Apply: -- * `Arrangement` — the proposed seating (slot -> ownerId). -- * `Locks` — a working lock set (ownerId -> true), SEEDED from the lobby's real locks but @@ -35,9 +39,10 @@ -- -- The body renders as **mirror positions** (row k = the k-th seat on each side, who face off; the -- right column is mirrored so the teams face each other): each row puts the name on the outer edge and --- the rating in a column hugging the centre (with the mean muted beside it) + the pair's gap in the --- centre. The header is the same shape — team label (outer), average (column) + total (muted), and the --- gap total in the centre band atop the per-row gaps. Three gestures tune the shown candidate: +-- the rating in a column hugging the centre (with the mean muted beside it) + the pair's rating gap in +-- the centre, a "<" / ">" arrow pointing to the higher-rated side. The header is the same shape — team +-- label (outer), average (column) + total (muted), and the gap total (with the same direction arrow) +-- in the centre band atop the per-row gaps. Three gestures tune the shown candidate: -- * **Drag a row** onto another → swap those two positions' pairs (both players move together), -- so the host arranges which pair spawns where ("D -> A"). -- * **Click a player, then another** → swap just those two (`ScoreArrangement` re-scores, no solve). @@ -57,6 +62,7 @@ local Dragger = import("/lua/maui/dragger.lua").Dragger local Popup = import("/lua/ui/controls/popups/popup.lua").Popup local CustomLobbySlotsDerivedModel = import("/lua/ui/lobby/customlobby/models/derived/customlobbyslotsderivedmodel.lua") +local CustomLobbySessionModel = import("/lua/ui/lobby/customlobby/models/customlobbysessionmodel.lua") local CustomLobbyBalancer = import("/lua/ui/lobby/customlobby/customlobbybalancer.lua") local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") @@ -76,7 +82,7 @@ local RowHeight = 24 local MaxRows = 8 -- binary AutoTeams on a 16-spawn map is at most 8 per side local LockSize = 12 local PosLabelWidth = 16 -local CenterWidth = 54 -- fixed centre band (the gap number) so the rating columns line up across rows +local CenterWidth = 60 -- fixed centre band (the gap number + direction arrow) so the rating columns line up across rows local NavSize = 22 local ButtonGap = 8 local DragThreshold = 5 -- cursor travel (screen px) before a press becomes a drag, not a click @@ -149,7 +155,9 @@ end ---@field LeftMean Text # muted mean, just outside the rating ---@field LeftRating Text # display rating, aligned in a column beside the centre ---@field CenterArea Group # fixed-width centre band (holds the gap number) ----@field Center Text # the pair's rating gap +---@field Center Text # the pair's rating gap (magnitude, centred) +---@field CenterArrowL Text # "<" shown when the left player is higher-rated +---@field CenterArrowR Text # ">" shown when the right player is higher-rated ---@field RightRating Text ---@field RightMean Text ---@field RightName Text @@ -186,7 +194,9 @@ end ---@field TeamTotalA Text # team total, muted, just outside the average (like a player mean) ---@field TeamTotalB Text ---@field HeaderCenterArea Group # centre band (holds the gap total), aligned above the rows' centre band ----@field HeaderGap Text # the total rating gap between the teams +---@field HeaderGap Text # the total rating gap between the teams (magnitude, centred) +---@field HeaderArrowL Text # "<" shown when the left team has the higher total +---@field HeaderArrowR Text # ">" shown when the right team has the higher total ---@field Rows UICustomLobbyBalanceRow[] ---@field Status Text ---@field NavPrev Bitmap # browse to the previous candidate ("<") @@ -195,6 +205,8 @@ end ---@field ApplyButton Button ---@field CancelButton Button ---@field Ready boolean +---@field Applied boolean # Apply committed — closing then leaves the lobby pinned +---@field PriorPinned boolean # SlotsPinned before the preview pinned it (restored on cancel) local CustomLobbyBalancePreview = ClassUI(Group) { ---@param self UICustomLobbyBalancePreview @@ -210,6 +222,13 @@ local CustomLobbyBalancePreview = ClassUI(Group) { self.SelectedOwner = nil self.DragGhost = false + -- pin the lobby for as long as the preview is open, so the roster can't shift under the proposal + -- (a client slot-take while balancing would make the arrangement stale). Remember the prior pin + -- state: cancelling restores it, while Apply leaves the lobby pinned so the balance holds. + self.Applied = false + self.PriorPinned = CustomLobbySessionModel.GetSingleton().SlotsPinned() and true or false + CustomLobbyController.RequestSetSlotsPinned(true) + -- seed the working state from the lobby: the candidate balances honouring the lobby's existing -- locks (locks nil -> the kernel reads each seat's own Locked flag), then keep those locks local local slots, teams = CurrentSnapshot() @@ -263,6 +282,12 @@ local CustomLobbyBalancePreview = ClassUI(Group) { self.HeaderGap = UIUtil.CreateText(self.HeaderCenterArea, "", 12, UIUtil.bodyFont) self.HeaderGap:SetColor(HeaderColor) self.HeaderGap:DisableHitTest() + self.HeaderArrowL = UIUtil.CreateText(self.HeaderCenterArea, "", 12, UIUtil.bodyFont) + self.HeaderArrowL:SetColor(HeaderColor) + self.HeaderArrowL:DisableHitTest() + self.HeaderArrowR = UIUtil.CreateText(self.HeaderCenterArea, "", 12, UIUtil.bodyFont) + self.HeaderArrowR:SetColor(HeaderColor) + self.HeaderArrowR:DisableHitTest() -- a fixed pool of interactive position rows; Render shows/hides + fills them from the proposal self.Rows = {} @@ -349,6 +374,10 @@ local CustomLobbyBalancePreview = ClassUI(Group) { row.Center = UIUtil.CreateText(row.CenterArea, "", 12, UIUtil.bodyFont) row.Center:SetColor(MutedColor) row.Center:DisableHitTest() + row.CenterArrowL = UIUtil.CreateText(row.CenterArea, "", 12, UIUtil.bodyFont) + row.CenterArrowL:DisableHitTest() + row.CenterArrowR = UIUtil.CreateText(row.CenterArea, "", 12, UIUtil.bodyFont) + row.CenterArrowR:DisableHitTest() row.RightRating = UIUtil.CreateText(row.Group, "", 14, UIUtil.bodyFont) row.RightRating:DisableHitTest() @@ -430,6 +459,8 @@ local CustomLobbyBalancePreview = ClassUI(Group) { Layouter(self.HeaderCenterArea):AtHorizontalCenterIn(self.HeaderArea):AtTopIn(self.HeaderArea) :AtBottomIn(self.HeaderArea):Width(CenterWidth):End() Layouter(self.HeaderGap):AtHorizontalCenterIn(self.HeaderCenterArea):AtVerticalCenterIn(self.HeaderCenterArea):End() + Layouter(self.HeaderArrowL):AnchorToLeft(self.HeaderGap, 3):AtVerticalCenterIn(self.HeaderCenterArea):End() + Layouter(self.HeaderArrowR):AnchorToRight(self.HeaderGap, 3):AtVerticalCenterIn(self.HeaderCenterArea):End() Layouter(self.TeamLabelA):AtLeftIn(self.HeaderArea):AtVerticalCenterIn(self.HeaderArea):End() Layouter(self.TeamAvgA):AnchorToLeft(self.HeaderCenterArea, 8):AtVerticalCenterIn(self.HeaderArea):End() @@ -460,6 +491,8 @@ local CustomLobbyBalancePreview = ClassUI(Group) { Layouter(row.CenterArea):AtHorizontalCenterIn(row.Group):AtTopIn(row.Group):AtBottomIn(row.Group) :Width(CenterWidth):End() Layouter(row.Center):AtHorizontalCenterIn(row.CenterArea):AtVerticalCenterIn(row.CenterArea):End() + Layouter(row.CenterArrowL):AnchorToLeft(row.Center, 3):AtVerticalCenterIn(row.CenterArea):End() + Layouter(row.CenterArrowR):AnchorToRight(row.Center, 3):AtVerticalCenterIn(row.CenterArea):End() -- ratings: a column hugging the centre band (left right-aligned, right left-aligned); the -- muted mean sits just outside each rating; names run from the outer edge @@ -802,16 +835,38 @@ local CustomLobbyBalancePreview = ClassUI(Group) { row.RightOwner = b and b.ownerId or nil row.RightSlot = position.slotB - -- the pair's rating gap, only when both players are rated + -- the pair's rating gap, only when both players are rated — the magnitude is centred and a + -- "<" / ">" arrow points to the higher-rated side if a and b and a.pl > 0 and b.pl > 0 then - local gap = math.abs(a.pl - b.pl) - row.Center:SetText("+" .. tostring(gap)) - row.Center:SetColor(GapColor(gap)) + self:SetDirectionalGap(row.Center, row.CenterArrowL, row.CenterArrowR, a.pl, b.pl, + GapColor(math.abs(a.pl - b.pl))) else row.Center:SetText("") + row.CenterArrowL:SetText("") + row.CenterArrowR:SetText("") end end, + --- Shows a directional rating gap: the magnitude on `numberText` (coloured `color`) with `arrowL` + --- ("<") or `arrowR` (">") lit to point at the higher of `valueA` (left) / `valueB` (right). Equal + --- values show no arrow. + ---@param self UICustomLobbyBalancePreview + ---@param numberText Text + ---@param arrowL Text + ---@param arrowR Text + ---@param valueA number + ---@param valueB number + ---@param color string + SetDirectionalGap = function(self, numberText, arrowL, arrowR, valueA, valueB, color) + local diff = valueA - valueB + numberText:SetText(tostring(math.abs(diff))) + numberText:SetColor(color) + arrowL:SetText(diff > 0 and "<" or "") + arrowL:SetColor(color) + arrowR:SetText(diff < 0 and ">" or "") + arrowR:SetColor(color) + end, + --- Paints one half of a row — name (outer), display rating (centre column) + muted mean, the lock --- dot and the selection highlight — for a player, or clears it when the half is empty. ---@param self UICustomLobbyBalancePreview @@ -858,7 +913,8 @@ local CustomLobbyBalancePreview = ClassUI(Group) { self:SetTeamHeader(self.TeamAvgA, self.TeamTotalA, 1) self:SetTeamHeader(self.TeamAvgB, self.TeamTotalB, 2) - self.HeaderGap:SetText("+" .. tostring(math.abs(result.totals[1] - result.totals[2]))) + self:SetDirectionalGap(self.HeaderGap, self.HeaderArrowL, self.HeaderArrowR, + result.totals[1], result.totals[2], HeaderColor) local positions = result.positions or {} local rowCount = table.getn(positions) @@ -935,17 +991,23 @@ local CustomLobbyBalancePreview = ClassUI(Group) { --#endregion --- Commits the working arrangement (host-authoritative) and closes. Locks toggled here are NOT - --- written back — they were only a balancing constraint for this session. + --- written back — they were only a balancing constraint for this session. The lobby stays pinned + --- (see OnDestroy) so the applied balance holds until the host unpins. ---@param self UICustomLobbyBalancePreview ApplyAndClose = function(self) if self.Result.feasible then CustomLobbyController.RequestApplyBalance(self.Arrangement) + self.Applied = true end self.OnCloseCb() end, ---@param self UICustomLobbyBalancePreview OnDestroy = function(self) + -- restore the pin state on cancel (anything but a committed Apply); Apply leaves it pinned + if not self.Applied then + CustomLobbyController.RequestSetSlotsPinned(self.PriorPinned) + end self.Trash:Destroy() end, } diff --git a/lua/ui/lobby/customlobby/CustomLobbyController.lua b/lua/ui/lobby/customlobby/CustomLobbyController.lua index da49a092c79..4ddc5e20323 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyController.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyController.lua @@ -392,6 +392,12 @@ function OnPeerDisconnected(instance, peerName, uid) -- tell the remaining peers to tear down their direct connection to the leaver instance:BroadcastData({ Type = 'DisconnectPeer', PeerID = uid }) + -- resolve the leaver's name before its slot is cleared (engine peerName as the fallback) + local name = FindNameForOwner(uid) + if name == tostring(uid) and peerName and peerName ~= '' then + name = peerName + end + local slot = FindSlotForOwner(uid) local lockChanged = false if slot then @@ -402,6 +408,9 @@ function OnPeerDisconnected(instance, peerName, uid) if lockChanged then BroadcastSessionState(instance) end + + -- announce the leave in chat (the leaver is gone; the remaining peers + host see it) + BroadcastSystemNotice(instance, name .. " left.") end --- Called when the game launches. The engine has taken over in its own Lua state, so the lobby's @@ -444,6 +453,9 @@ function ProcessAddPlayer(instance, data) -- them the map preview / slot grid have nothing to render BroadcastLaunchInfo(instance) BroadcastSessionState(instance) + + -- announce the join in chat (reaches everyone, including the peer that just joined) + BroadcastSystemNotice(instance, (player.PlayerName or "A player") .. " joined.") end --- Everyone applies the host's player + observer snapshot (launch state). @@ -551,6 +563,13 @@ function ProcessChatMessage(instance, data) }) end +--- Everyone shows the host's lobby notice as a system line in chat (a peer joined / left, …). +---@param instance UICustomLobbyInstance +---@param data UICustomLobbySystemNoticeMessage +function ProcessSystemNotice(instance, data) + CustomLobbyChatModel.AppendSystem(CustomLobbyChatModel.GetSingleton(), data.Text) +end + --- Host relays an accepted chat line — **the single chat chokepoint**. Resolves the sender's name --- authoritatively (clients don't get to spoof it), broadcasts the line to everyone, and shows it in --- the host's own feed (a broadcast doesn't loop back to the sender). Future mute / rate-limit / @@ -570,6 +589,16 @@ function ProcessRequestChat(instance, data) ProcessChatMessage(instance, message) end +--- Host emits a lobby notice (a senderless system line) to everyone and shows it locally too (the +--- broadcast doesn't loop back). Host-only; call it from the join/leave hooks. +---@param instance UICustomLobbyInstance +---@param text string +function BroadcastSystemNotice(instance, text) + local message = { Type = 'SystemNotice', Text = text } + instance:BroadcastData(message) + ProcessSystemNotice(instance, message) +end + --#endregion ------------------------------------------------------------------------------- diff --git a/lua/ui/lobby/customlobby/CustomLobbyMessages.lua b/lua/ui/lobby/customlobby/CustomLobbyMessages.lua index b298915ef87..2042913a1eb 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyMessages.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyMessages.lua @@ -263,6 +263,29 @@ CustomLobbyMessages = { end, }, + -- The host's authoritative lobby notice — a senderless system line shown in chat (a peer joined / + -- left, etc.). The host is the single source so every peer shows the same notice with no per-peer + -- roster diffing; the leaver simply isn't there to receive its own "left" line. + SystemNotice = { + ---@class UICustomLobbySystemNoticeMessage : UILobbyReceivedMessage + ---@field Text string + + ---@param data UICustomLobbySystemNoticeMessage + Validate = function(lobby, data) + if type(data.Text) ~= 'string' or data.Text == '' then + return false, "SystemNotice has no Text" + end + return true + end, + Accept = function(lobby, data) + return RequireFromHost(lobby, data) + end, + ---@param data UICustomLobbySystemNoticeMessage + Handler = function(lobby, data) + CustomLobbyController.ProcessSystemNotice(lobby, data) + end, + }, + -- The host tells everyone still connected to drop their direct link to a peer -- that left, so the mesh is cleaned up (the player state follows via SetPlayers). DisconnectPeer = { diff --git a/lua/ui/lobby/customlobby/social/CustomLobbyChatController.lua b/lua/ui/lobby/customlobby/social/CustomLobbyChatController.lua index 5530a72e92a..b28deff5e0c 100644 --- a/lua/ui/lobby/customlobby/social/CustomLobbyChatController.lua +++ b/lua/ui/lobby/customlobby/social/CustomLobbyChatController.lua @@ -44,15 +44,7 @@ local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/models/customlob --- Appends a local-only system line (command feedback). Local to this peer; nothing is sent. ---@param text string function AppendLocalSystem(text) - CustomLobbyChatModel.Append(CustomLobbyChatModel.GetSingleton(), { - Id = false, - SenderId = false, - SenderName = "System", - Text = text, - Status = 'Confirmed', - Kind = 'system', - Time = GetSystemTimeSeconds(), - }) + CustomLobbyChatModel.AppendSystem(CustomLobbyChatModel.GetSingleton(), text) end --- Sends a chat line. A slash line is a command (handled locally, never broadcast); anything else is diff --git a/lua/ui/lobby/customlobby/social/CustomLobbyChatModel.lua b/lua/ui/lobby/customlobby/social/CustomLobbyChatModel.lua index 62b6ad88140..bf9a5fabb00 100644 --- a/lua/ui/lobby/customlobby/social/CustomLobbyChatModel.lua +++ b/lua/ui/lobby/customlobby/social/CustomLobbyChatModel.lua @@ -47,6 +47,11 @@ local MaxEntries = 200 --- be unique within this peer's session; the peer-id prefix keeps them unique across peers). local SendCounter = 0 +--- The baseline for the relative clock (set when the feed is created). Entries store absolute +--- `GetSystemTimeSeconds()`; subtract this to render a `mm:ss` stamp like the traffic log. +---@type number | nil +local StartTime = nil + ---@alias UICustomLobbyChatStatus ---| 'Pending' # an outgoing line shown optimistically, awaiting the host's echo ---| 'Confirmed' # the host broadcast it (authoritative) @@ -111,10 +116,18 @@ local ChatModel = ClassSimple { ---@return UICustomLobbyChatModel function SetupSingleton() Instance = ChatModel() + StartTime = GetSystemTimeSeconds() CustomLobbySession.GetTrash():Add(Instance) return Instance end +--- The relative-clock baseline (when this feed started). Subtract from an entry's `Time` to render a +--- `mm:ss` stamp, the same way the traffic log does. +---@return number +function GetStartTime() + return StartTime or GetSystemTimeSeconds() +end + --- Returns the chat-model singleton, creating (and registering) it on first access — including after a --- teardown, so it is reusable across lobby sessions. ---@return UICustomLobbyChatModel @@ -155,6 +168,23 @@ function Append(model, entry) model.Entries:Set(entries) end +--- Appends a system notice (a senderless `Confirmed` line — command feedback, join/leave, …). The one +--- place the system-entry shape is built, so the chat controller (local notices) and the lobby +--- controller (host-broadcast join/leave) stay in step. +---@param model UICustomLobbyChatModel +---@param text string +function AppendSystem(model, text) + Append(model, { + Id = false, + SenderId = false, + SenderName = "System", + Text = text, + Status = 'Confirmed', + Kind = 'system', + Time = GetSystemTimeSeconds(), + }) +end + --- Applies an authoritative chat message from the host. If we already hold a `Pending` entry with the --- same `Id` (our own optimistic echo), reconcile it to `Confirmed`; otherwise append a new `Confirmed` --- entry (someone else's message, or our own when we are not the originator). Ids are unique per peer, diff --git a/lua/ui/lobby/customlobby/social/CustomLobbyChatPanel.lua b/lua/ui/lobby/customlobby/social/CustomLobbyChatPanel.lua index 8e1f7845de4..67e57396a20 100644 --- a/lua/ui/lobby/customlobby/social/CustomLobbyChatPanel.lua +++ b/lua/ui/lobby/customlobby/social/CustomLobbyChatPanel.lua @@ -73,12 +73,28 @@ local FieldColor = 'ff10151b' -- field fill local FieldBorderColor = 'ff2b333d' -- field 1px border local PromptColor = 'ff5a606a' -- the dim "type a message" placeholder +-- the time column (a relative mm:ss stamp at the left of each row, like the Logs tab) +local TimeFont = 11 +local TimeWidth = 30 -- fits "mm:ss" +local TimeGap = 4 +local TimeColor = 'ff6a707a' +local TimeLeft = Pad +local NameLeft = TimeLeft + TimeWidth + TimeGap -- the message text starts after the time column + -- line colours by status / kind local ChatColor = 'ffc8ccd0' -- a confirmed chat line local PendingColor = 'ff7e848c' -- our own line, awaiting the host's echo (dimmed) local RejectedColor = 'ff8a5a52' -- the host dropped it (greyed-red, sender only) local SystemColor = 'ff8a909a' -- a local system notice +--- `mm:ss` from a seconds offset (Lua 5.0 — no `%`, use `math.mod`). Mirrors the Logs tab. +---@param seconds number +---@return string +local function FormatClock(seconds) + local whole = math.floor(seconds) + return string.format("%02d:%02d", math.floor(whole / 60), math.mod(whole, 60)) +end + --- Truncates `text` to `maxChars`, appending "…" when it had to cut. ---@param text string ---@param maxChars number @@ -346,11 +362,18 @@ local CustomLobbyChatPanel = ClassUI(Group) { local row = Group(self.Grid, "CustomLobbyChatRow") LayoutHelpers.SetDimensions(row, self.RowWidth, RowHeight) + -- a relative mm:ss stamp at the left (entries store absolute time; the feed's start is the baseline) + local time = UIUtil.CreateText(row, + FormatClock(entry.Time - CustomLobbyChatModel.GetStartTime()), TimeFont, UIUtil.bodyFont) + time:SetColor(TimeColor) + time:DisableHitTest() + Layouter(time):AtLeftIn(row, TimeLeft):AtVerticalCenterIn(row):End() + local label, color = RenderEntry(entry) local line = UIUtil.CreateText(row, Truncate(label, NameMax), RowFont, UIUtil.bodyFont) line:SetColor(color) line:DisableHitTest() - Layouter(line):AtLeftIn(row, Pad):AtRightIn(row, Pad):AtVerticalCenterIn(row):End() + Layouter(line):AtLeftIn(row, NameLeft):AtRightIn(row, Pad):AtVerticalCenterIn(row):End() return row end, diff --git a/lua/ui/lobby/customlobby/social/chat-design.md b/lua/ui/lobby/customlobby/social/chat-design.md index d43178f05d8..8fa683f5722 100644 --- a/lua/ui/lobby/customlobby/social/chat-design.md +++ b/lua/ui/lobby/customlobby/social/chat-design.md @@ -60,8 +60,8 @@ the same mechanism either way. | [CustomLobbyChatModel.lua](CustomLobbyChatModel.lua) | per-peer reactive `Entries` ring buffer; `Append` (optimistic / system) + `Receive` (reconcile-or-append) + `NextId`. | | [CustomLobbyChatController.lua](CustomLobbyChatController.lua) | the send pipeline: `Send` (command-vs-chat), `AppendLocalSystem`, `SetRecipient`. | | [CustomLobbyChatPanel.lua](CustomLobbyChatPanel.lua) | the Chat tab: a scrollable feed (built to the [Logs-tab](CustomLobbyLogsPanel.lua) shape) over an edit box. | -| [`../CustomLobbyMessages.lua`](/lua/ui/lobby/customlobby/CustomLobbyMessages.lua) | `RequestChat` (Accept: any peer) + `ChatMessage` (Accept: from host). | -| [`../CustomLobbyController.lua`](/lua/ui/lobby/customlobby/CustomLobbyController.lua) | `RequestChat` / `ProcessRequestChat` (chokepoint) / `ProcessChatMessage` + `FindNameForOwner`. | +| [`../CustomLobbyMessages.lua`](/lua/ui/lobby/customlobby/CustomLobbyMessages.lua) | `RequestChat` (Accept: any peer) + `ChatMessage` (Accept: from host) + `SystemNotice` (Accept: from host — join/leave). | +| [`../CustomLobbyController.lua`](/lua/ui/lobby/customlobby/CustomLobbyController.lua) | `RequestChat` / `ProcessRequestChat` (chokepoint) / `ProcessChatMessage` + `FindNameForOwner`; `BroadcastSystemNotice` / `ProcessSystemNotice` (join/leave, hooked in `ProcessAddPlayer` / `OnPeerDisconnected`). | ## Roadmap (later slices) @@ -72,8 +72,13 @@ the same mechanism either way. connection handshake, so it must be evaluated per-invocation. `Send`'s `/` branch dispatches here. - **Slice 3 — whisper.** `RequestChat` carries a `Recipient`; `ProcessRequestChat` `SendData`s to sender+target instead of `BroadcastData`. Same chokepoint. -- **Slice 4 — join/leave notices.** Append `Kind='system'` lines **locally** (no wire) from - `ProcessAddPlayer` / `OnPeerDisconnected`, matching the Log's "each peer logs its own" principle. +- **Slice 4 — join/leave notices (done).** The host emits a `SystemNotice` (a senderless system line) + on join (`ProcessAddPlayer`) and leave (`OnPeerDisconnected`); `BroadcastSystemNotice` broadcasts it + and shows it on the host too (the broadcast doesn't loop back). Chosen over the originally-sketched + "each peer diffs the roster locally" because (a) the host doesn't run `ProcessSetPlayers` on itself, + so a local diff would skip the host, and (b) a joining peer would otherwise spam "X joined" for the + whole existing roster on its first snapshot. Host-broadcast is one symmetric path with no diff and no + baseline-flag, and the leaver simply isn't there to receive its own "left" line. - **Refinements.** Multi-line wrapping (slice 1 truncates one line per row — the in-game [`ChatLinesInterface`](/lua/ui/game/chat/ChatLinesInterface.lua) is the reference); unread-since-last-view badge count (slice 1 shows the total line count). From 52271731b4ff16e79228fe0d2f0710c9723764ae Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sat, 27 Jun 2026 23:48:00 +0200 Subject: [PATCH 85/98] Improve chat and balance dialog --- lua/ui/lobby/customlobby/CLAUDE.md | 6 +- .../customlobby/CustomLobbyBalancePreview.lua | 212 ++++++++++-------- .../lobby/customlobby/CustomLobbyBalancer.lua | 15 +- .../customlobby/CustomLobbyController.lua | 27 ++- .../lobby/customlobby/social/chat-design.md | 22 +- 5 files changed, 166 insertions(+), 116 deletions(-) diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index 97b7eab2718..c3e8de4f0c9 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -51,15 +51,15 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | [CustomLobbyPerformancePopover.lua](CustomLobbyPerformancePopover.lua) | hover popover over the CPU column; hand-built bitmap bar chart of a peer's `PerformanceTrackingV2` history, with a yellow recommended-unit-cap line. | | [CustomLobbyInstance.lua](CustomLobbyInstance.lua) | thin `moho.lobby_methods` shell; validates/dispatches traffic, forwards callbacks to the controller. Also feeds [`CustomLobbyLog`](CustomLobbyLog.lua) from its three network choke points (BroadcastData / SendData / DataReceived). | | [CustomLobbyLog.lua](CustomLobbyLog.lua) | the **network traffic log** — a reactive, per-peer, never-synced ring buffer (`Entries` LazyVar, capped) of every message this peer broadcasts / sends / receives, fed by the instance and rendered by the **Logs** tab. Each peer logs only its own traffic, so host and client views differ naturally. Not one of the three models (no game state); a diagnostic feed. | -| [CustomLobbyController.lua](CustomLobbyController.lua) | host-authority logic (free functions): seating, `Process*` handlers, intents (`RequestSetReady`, `RequestTakeSlot`, `RequestSwapSlots`, `RequestEject`, `RequestMoveToObserver`, `RequestSetScenario`, `RequestSetGameMods/Options`, `RequestResetGameOptions`, `RequestSetSlotsPinned`, `RequestSetSlotLocked`, `RequestApplyBalance` (applies a previewed balance), `RequestReopenClosedSlots`, `RequestLaunch`, `RequestSaveSetupPreset`/`RequestLoadSetupPreset`, `RequestChat` — all keyed by slot/bool/file/name so a chat command can call them too; permission is gated separately). **Chat relay:** `RequestChat` (host short-circuits, client sends to host) → `ProcessRequestChat` is the **single chat chokepoint** (resolves the sender name authoritatively via `FindNameForOwner`, broadcasts `ChatMessage`, and shows it locally since a broadcast doesn't loop back; future mute/rate-limit lives here) → `ProcessChatMessage` appends/reconciles into the [chat model](social/CustomLobbyChatModel.lua). **Join/leave notices:** the host emits a `SystemNotice` (senderless system line) via `BroadcastSystemNotice` from `ProcessAddPlayer` (joined) and `OnPeerDisconnected` (left) — host-broadcast, not per-peer roster-diffing, so every peer (incl. the host, via local loopback) shows the same line. The controller also shares each peer's stored CPU benchmark. **Launch:** `RequestLaunch` (host-only, readiness-validated) → auto-saves the `lastGame` preset (`BuildSetupSnapshot`) → `BuildGameConfiguration` (seed option defaults + scenario, resolve random factions, assign army numbers + push to server, stamp ratings/clan tags, resolve team-from-position for the binary AutoTeams modes, resolve sim mods via `Mods.GetGameMods`) → broadcast `LaunchGame` + `instance:LaunchGame`; clients run `ProcessLaunchGame`. **Presets:** `BuildSetupSnapshot` (read the launch state → serializable **setup-only** snapshot: scenario / options / mods / restrictions) + `ApplySetup` (host-only: write scenario/mods/restrictions, reconcile options to the current schema, one broadcast); players, observers and teams/spawn-mex are not stored (see [presetselect/](presetselect/CLAUDE.md)). | +| [CustomLobbyController.lua](CustomLobbyController.lua) | host-authority logic (free functions): seating, `Process*` handlers, intents (`RequestSetReady`, `RequestTakeSlot`, `RequestSwapSlots`, `RequestEject`, `RequestMoveToObserver`, `RequestSetScenario`, `RequestSetGameMods/Options`, `RequestResetGameOptions`, `RequestSetSlotsPinned`, `RequestSetSlotLocked`, `RequestApplyBalance` (applies a previewed balance), `RequestReopenClosedSlots`, `RequestLaunch`, `RequestSaveSetupPreset`/`RequestLoadSetupPreset`, `RequestChat` — all keyed by slot/bool/file/name so a chat command can call them too; permission is gated separately). **Chat relay:** `RequestChat` (host short-circuits, client sends to host) → `ProcessRequestChat` is the **single chat chokepoint** (resolves the sender name authoritatively via `FindNameForOwner`, broadcasts `ChatMessage`, and shows it locally since a broadcast doesn't loop back; future mute/rate-limit lives here) → `ProcessChatMessage` appends/reconciles into the [chat model](social/CustomLobbyChatModel.lua). **Lobby notices:** the host emits a `SystemNotice` (senderless system line shown in chat) via `BroadcastSystemNotice` — host-broadcast, not per-peer diffing, so every peer (incl. the host, via local loopback) shows the same line. Wired at the host-authoritative change sites: join (`ProcessAddPlayer`) / leave (`OnPeerDisconnected`) / kick (`RequestEject` adds "Host removed X."; a human kick also yields a "left" line from the disconnect — two lines is fine) / map / mods / options / restrictions changes / seat swap (`SwapSlots`) / move-to-observers / auto-balance applied. The controller also shares each peer's stored CPU benchmark. **Launch:** `RequestLaunch` (host-only, readiness-validated) → auto-saves the `lastGame` preset (`BuildSetupSnapshot`) → `BuildGameConfiguration` (seed option defaults + scenario, resolve random factions, assign army numbers + push to server, stamp ratings/clan tags, resolve team-from-position for the binary AutoTeams modes, resolve sim mods via `Mods.GetGameMods`) → broadcast `LaunchGame` + `instance:LaunchGame`; clients run `ProcessLaunchGame`. **Presets:** `BuildSetupSnapshot` (read the launch state → serializable **setup-only** snapshot: scenario / options / mods / restrictions) + `ApplySetup` (host-only: write scenario/mods/restrictions, reconcile options to the current schema, one broadcast); players, observers and teams/spawn-mex are not stored (see [presetselect/](presetselect/CLAUDE.md)). | | [CustomLobbyRules.lua](CustomLobbyRules.lua) | the game-rule **pure kernel** (not view, not networking, **reads no models**): `RecommendedUnitCap(seatedCount, maxDimension)` (per-player cap by map size), `AutoTeamMode(gameOptions)` / `SideLabels(mode)` / `BuildSideResolver(mode, scenario)` (binary auto-team split). Every input is passed in, so the same functions serve the reactive [slots derived model](models/derived/CustomLobbySlotsDerivedModel.lua) (which applies them to publish `Side` / `Teams`) and, later, the host's launch-time team assignment — neither path makes this module read state. | -| [CustomLobbyBalancer.lua](CustomLobbyBalancer.lua) | the auto-balance **pure kernel** (**reads no models**) — **positional only** (the feature is gated to a binary AutoTeams mode, so there are always exactly two sides and the **position decides the team**; the kernel never writes `Team`). Takes the [slots derived model](models/derived/CustomLobbySlotsDerivedModel.lua)'s **already-resolved** snapshot — its `Slots` table (per-seat player / side / locked / closed) + `Teams` aggregate (mode / labels / resolved) — so it never re-resolves sides. **Solve and score are split** so the preview can hand-edit a balance. **`BuildCandidates(slots, teams, arrangement, locks, count?)`** returns the top-N **distinct** team splits (best-first by match quality, mirror-equivalent splits dropped, each a complete scored plan with its own random position shuffle) — the preview browses them. **`Rebalance(slots, teams, arrangement, locks)`** is the single-best wrapper (`BuildCandidates(...)[1]`); it re-solves the *unlocked* players in three phases (a port of the legacy `PenguinAutoBalance`): **(1) Teams** — split into two even sides minimising the cheap rating heuristic; **(2) Pairs** — rank-match across the teams (strongest of A faces strongest of B; by rating, never random); **(3) Positions** — `ShufflePairsIntoSeats` (its own exported function) scatters the pairs across the free mirror rows. Locked players (and an odd roster's lowest-rated unlocked player) are **pinned at their seat in `arrangement`** — not their lobby seat — so locking a player the balance already moved keeps them put. **`ScoreArrangement(slots, teams, arrangement, locks)`** scores a concrete arrangement *without* re-solving (for a manual swap / lock toggle). Both run `ScorePlan`, which fills the plan: two rating-sorted `sides` (used for `totals` / quality), the ordered mirror **`positions`** (the k-th side-1 seat vs the k-th side-2 seat, sorted by slot so the list is stable across re-scores — this is what the preview renders + drag-reorders), per-side PL `totals`, `quality` + `currentQuality` (`trueskill.computeQuality` proposed vs. current, for a "before → after"), `winChance` (predicted win % per side — a local normal-CDF over the side mean/variance sums, the directional counterpart to the symmetric quality), and a per-player `moved` flag. `locks` (ownerId→true) is an optional **override** of the seats' own Locked flags — the preview passes its working, preview-local lock set; nil falls back to `entry.Locked`. **`BuildPlan(slots, teams, locks?, count?)`** is the convenience entry — `BuildCandidates` from the lobby's current seating (returns the candidate **list**). `lockedOwners` flags locked players; `ToArrangement(plan)` returns the lobby update — **slot → ownerId**, no team. The controller's `RequestApplyBalance` re-seats from it; the team falls out of the position, resolved at launch by `BuildGameConfiguration` (binary AutoTeams → team-from-`StartSpot`). | +| [CustomLobbyBalancer.lua](CustomLobbyBalancer.lua) | the auto-balance **pure kernel** (**reads no models**) — **positional only** (the feature is gated to a binary AutoTeams mode, so there are always exactly two sides and the **position decides the team**; the kernel never writes `Team`). Takes the [slots derived model](models/derived/CustomLobbySlotsDerivedModel.lua)'s **already-resolved** snapshot — its `Slots` table (per-seat player / side / locked / closed) + `Teams` aggregate (mode / labels / resolved) — so it never re-resolves sides. **Solve and score are split** so the preview can hand-edit a balance. **`BuildCandidates(slots, teams, arrangement, locks, count?)`** returns the top-N **distinct** team splits (best-first by match quality, mirror-equivalent splits dropped, each a complete scored plan with its own random position shuffle) — the preview browses them. **`Rebalance(slots, teams, arrangement, locks)`** is the single-best wrapper (`BuildCandidates(...)[1]`); it re-solves the *unlocked* players in three phases (a port of the legacy `PenguinAutoBalance`): **(1) Teams** — split into two even sides minimising the cheap rating heuristic; **(2) Pairs** — rank-match across the teams (strongest of A faces strongest of B; by rating, never random); **(3) Positions** — `ShufflePairsIntoSeats` (its own exported function) scatters the pairs across the free mirror rows. Locked players (and an odd roster's lowest-rated unlocked player) are **pinned at their seat in `arrangement`** — not their lobby seat — so locking a player the balance already moved keeps them put. **`ScoreArrangement(slots, teams, arrangement, locks)`** scores a concrete arrangement *without* re-solving (for a manual swap / lock toggle). Both run `ScorePlan`, which fills the plan: two rating-sorted `sides` (used for `totals` / quality), the ordered mirror **`positions`** (the k-th side-1 seat vs the k-th side-2 seat, sorted by slot so the list is stable across re-scores — this is what the preview renders + drag-reorders), per-side total-mean `totals`, `quality` + `currentQuality` (`trueskill.computeQuality` proposed vs. current, for a "before → after"), `winChance` (predicted win % per side — a local normal-CDF over the side mean/variance sums, the directional counterpart to the symmetric quality), and a per-player `moved` flag. `locks` (ownerId→true) is an optional **override** of the seats' own Locked flags — the preview passes its working, preview-local lock set; nil falls back to `entry.Locked`. **`BuildPlan(slots, teams, locks?, count?)`** is the convenience entry — `BuildCandidates` from the lobby's current seating (returns the candidate **list**). `lockedOwners` flags locked players; `ToArrangement(plan)` returns the lobby update — **slot → ownerId**, no team. The controller's `RequestApplyBalance` re-seats from it; the team falls out of the position, resolved at launch by `BuildGameConfiguration` (binary AutoTeams → team-from-`StartSpot`). | | [CustomLobbyPresets.lua](CustomLobbyPresets.lua) | **named setup presets** (setup-only: scenario / options / mods / restrictions — no players/observers) — pure prefs CRUD (`GetPresets`/`GetPreset`/`SavePreset`/`DeletePreset`/`RenamePreset`) over one key (`customlobby_setup_presets`), an ordered `{ Name, Setup }` array mirroring the mod presets in [`/lua/ui/modutilities.lua`](/lua/ui/modutilities.lua). Holds no models, no network. A reserved `LastGamePresetName` entry is auto-saved at launch (the rehost source). Capturing/applying a setup lives in the controller (`BuildSetupSnapshot`/`ApplySetup`); see [presetselect/](presetselect/CLAUDE.md). | | [CustomLobbySession.lua](CustomLobbySession.lua) | the lobby session's **main trash bag**. The lobby lives in the persistent front-end Lua state, which is *not* reset when the game launches in its own state — so anything left reachable (a running thread, a cache, a singleton) leaks for the whole match. This module owns one session-lifetime `TrashBag` (`GetTrash()`); every lobby-scoped `Destroyable` registers in it, and one `Teardown()` (clean-slate in `CreateLobby`, on leave, on `OnGameLaunched`) frees the lot. Works despite the bag being weak-valued because each singleton is pinned by its own module `Instance` local. **Rollout in progress** — converted: the map catalog, all five [derived models](models/derived/CLAUDE.md), and the three authoritative [models/](models/) (Launch/Session/Local, with thin `Destroy` per the design's decision #3). Still to do: the mod catalog, the interface + performance popover (pending the teardown-ordering decision), and the lobby instance. See [design/session-trashbag-teardown.md](design/session-trashbag-teardown.md). | | [CustomLobbyMessages.lua](CustomLobbyMessages.lua) | message registry: `AddPlayer`, `SetPlayers` (launch model: players + observers), `SentLaunchInfo` (launch model: scenario / options / mods / teams / spawn mex), `SetSessionState` (session model: slot count / closed slots / locked slots / pinned), `SetReady`, `TakeSlot`, `RequestChat` (any peer → host: a chat line to relay) / `ChatMessage` (host → everyone: the accepted line) / `SystemNotice` (host → everyone: a senderless lobby notice — join/leave), `DisconnectPeer`, `LaunchGame` (host's final game config → `instance:LaunchGame`), `ReportCpuBenchmark`, `SetCpuBenchmarks`. | | [social/CustomLobbyChatModel.lua](social/CustomLobbyChatModel.lua) | the **chat feed** — a per-peer, never-synced reactive `Entries` ring buffer (`UICustomLobbyChatEntry`: `Id` / `SenderId` / `SenderName` / `Text` / `Status` / `Kind`), the chat equivalent of [`CustomLobbyLog`](CustomLobbyLog.lua) but **written by the controller**, so it is a `ClassSimple : Destroyable` in the session trash (freed on `Teardown()`). `Append` adds an optimistic `Pending` line / system notice; `Receive` reconciles the sender's `Pending` line to `Confirmed` by the echoed `Id` (or appends others' lines). Not one of the three models — no game state. See [social/chat-design.md](social/chat-design.md). | | [social/CustomLobbyChatController.lua](social/CustomLobbyChatController.lua) | the chat **send pipeline** (writes the chat model, never the wire): `Send` decides command-vs-chat (a `/` line is handled locally and never broadcast — registry lands in slice 2), echoes a plain line optimistically (`Pending`) and hands it to the controller's `RequestChat`; `AppendLocalSystem` for local notices. Mirrors the in-game [`ChatController.Send`](/lua/ui/game/chat/ChatController.lua) split. | -| [CustomLobbyBalancePreview.lua](CustomLobbyBalancePreview.lua) | the auto-balance **preview + tuner** (host-only `Popup`): the slots header's balance button opens it instead of re-seating directly. It owns a **working state** nothing synced sees until Apply — `Arrangement` (slot→ownerId) + `Locks` (a preview-local lock set, **seeded** from the lobby's real locks but never written back). Renders the proposal as **mirror-position rows** (row k = the k-th seat on each side, who face off, in start-position order with a position label; the right column **mirrored** so the teams face each other) — each row puts the **name on the outer edge** and the **display rating in a fixed column hugging the centre** (so the two rating columns line up across rows, flanking the centre gap), with the **mean** as a muted value just outside it; the centre shows the pair's rating gap (magnitude) with a **`<` / `>` arrow pointing to the higher-rated side**; **moved** players are gold, **locked** players blue. The host **browses the candidate balances** with a "< i / N >" browser (the kernel's top-N distinct splits via `BuildCandidates`); stepping adopts each. Three gestures tune the shown one: **drag a row** onto another → swap those two positions' pairs (both players move together — "D → A"; same feel as the lobby slot rows — press-vs-drag threshold, a floating pair ghost beside the cursor, and a drop-target highlight); **click two players** → swap just those two (`ScoreArrangement` re-scores, no re-solve); **click a player's lock dot** → pin/unpin them (preview-local) which **regenerates** the candidates around that lock. The **header mirrors a row**: team label (outer), average (centre column, aligned above the player ratings) + total (muted), and the **gap total** (same direction arrow) in the centre band atop the per-row gaps. The status line reports match quality **before → after** (whole percent), predicted **win** split, and any odd one out (or why it can't balance). While open it **pins the lobby** (`SlotsPinned`) so the roster can't shift under the proposal (it would go stale); it remembers the prior pin state — **Cancel** restores it, **Apply** leaves it pinned so the balance holds. **Apply** commits only `Arrangement` (→ `RequestApplyBalance`); **Cancel** discards everything (locks included). A transient picker, owns no synced state (beyond toggling the shared pin while open). | +| [CustomLobbyBalancePreview.lua](CustomLobbyBalancePreview.lua) | the auto-balance **preview + tuner** (host-only `Popup`): the slots header's balance button opens it instead of re-seating directly. It owns a **working state** nothing synced sees until Apply — `Arrangement` (slot→ownerId) + `Locks` (a preview-local lock set, **seeded** from the lobby's real locks but never written back). Renders the proposal as **mirror-position rows** (row k = the k-th seat on each side, who face off, in start-position order with a position label; the right column **mirrored** so the teams face each other) — each row puts the **name on the outer edge** and the **mean rating in a fixed column hugging the centre** (so the two columns line up across rows, flanking the centre gap), with the **deviation** as a muted `(±N)` just outside it; the centre shows the pair's mean gap (magnitude) with a **`<` / `>` arrow pointing to the higher-rated side**; **moved** players are gold, **locked** players blue. The host **browses the candidate balances** with a "< i / N >" browser (the kernel's top-N distinct splits via `BuildCandidates`); stepping adopts each. Three gestures tune the shown one: **drag a row** onto another → swap those two positions' pairs (both players move together — "D → A"; same feel as the lobby slot rows — press-vs-drag threshold, a floating pair ghost beside the cursor, and a drop-target highlight); **click two players** → swap just those two (`ScoreArrangement` re-scores, no re-solve); **click a player's lock dot** → pin/unpin them (preview-local) which **regenerates** the candidates around that lock. The **header is a two-line summary** on the same columns as the rows: top line — team label (outer) + each team's predicted **win %** (column) + the match **quality** % in the centre; bottom line — total mean (column) with the summed deviation muted `(±N)`, and the **gap total** (direction arrow) in the centre. The status line carries only situational notes (the odd one out, or why it can't balance). While open it **pins the lobby** (`SlotsPinned`) so the roster can't shift under the proposal (it would go stale); it remembers the prior pin state — **Cancel** restores it, **Apply** leaves it pinned so the balance holds. **Apply** commits only `Arrangement` (→ `RequestApplyBalance`); **Cancel** discards everything (locks included). A transient picker, owns no synced state (beyond toggling the shared pin while open). | | [CustomLobbyContextMenu.lua](CustomLobbyContextMenu.lua) | generic framed floating menu; `Show(entries, x, y)` renders any `{label, action, enabled}` list, dismisses on item click / click-outside / Esc. Knows nothing about the lobby. | | [CustomLobbyMenus.lua](CustomLobbyMenus.lua) | declarative menu **definitions**: entry lists with `when(ctx)`/`action(ctx)` filtered by lobby state (`BuildSlotMenu`). Adding/state-gating an item is a one-liner here. | | [slots/](slots/) | the **slot subsystem** (its own folder, a sub-folder per layout). [`CustomLobbySlotsInterface`](slots/CustomLobbySlotsInterface.lua) is the entry the composition root mounts: a "Players" header over the **active layout body**, picked by the AutoTeams mode — one-column for the non-team modes, the two-column team layout for the binary modes (left/right, top/bottom, even/odd). It observes `GameOptions` and swaps the body when the kind flips (create-on-mode / destroy-on-switch); a change *within* the binary modes is handled by the two-column body's own re-layout. It is the rows' **drag coordinator** (`UICustomLobbySlotCoordinator`: hit-test which row a point is in, drop-highlight, drag ghost → `RequestSwapSlots`) for *every* layout — it alone needs to hit-test across rows — so the layout bodies stay pure build/place/reveal and never duplicate the drag logic; it also exposes `PreferredHeight()` (computed from the mode + model, swap-order-independent) so the root sizes the slot area. The header band carries a **host-only tool strip** (right-aligned: pin seating · auto-balance · reopen closed slots) of small icon buttons (local `SlotTool`, mirroring the config column's `PreviewTool`); the pin button is lit from the synced `SlotsPinned` and the strip is hidden for clients. The **auto-balance** button is gated (`SlotTool:SetEnabled`, greyed when off) to a **resolved binary AutoTeams mode** (the derived `Teams` aggregate's `Mode` + `Resolved` — two well-defined sides, map loaded for the positional ones) and opens the [balance preview](CustomLobbyBalancePreview.lua) rather than re-seating on click. A **"Locked" notice** (lock glyph + label) sits right of the "Players" label and is shown to **everyone** while `SlotsPinned` is on, so a client can see seating is host-controlled before clicking an open slot to no effect. [`CustomLobbySlotBase`](slots/CustomLobbySlotBase.lua) holds **all slot behaviour** (one subscription to the [slots derived model](models/derived/CustomLobbySlotsDerivedModel.lua) — which already merged this seat's player / placement / closed / CPU benchmark and did the faction/CPU formatting — click/right-click/drag-to-swap + intents, the background/highlight/click overlays + a gold left-edge **lock stripe** shown when the seat's `Locked` flag is set) and the default `RenderPlayer`/`RenderCpu` that paint the standard named controls from the entry's resolved views; a presentation subclasses it and implements just `CreateContents` / `LayoutContents`. [`onecolumn/`](slots/onecolumn/): [`CustomLobbyOneColumnSlots`](slots/onecolumn/CustomLobbyOneColumnSlots.lua) (stacks thin rows, reveals 1..count) + [`CustomLobbySlotRow`](slots/onecolumn/CustomLobbySlotRow.lua) (thin one-line presentation). [`twocolumn/`](slots/twocolumn/): [`CustomLobbyTwoColumnSlots`](slots/twocolumn/CustomLobbyTwoColumnSlots.lua) (splits slots into two team columns by each seat's `Side` from the [slots derived model](models/derived/CustomLobbySlotsDerivedModel.lua) — no longer re-resolving the split itself — with a [`CustomLobbyTeamScore`](CustomLobbyTeamScore.lua) strip across the top as the Left/Right indicator, and the "two columns, unresolved" fallback — parity fill + the score self-hides until a positional map's start positions load) + [`CustomLobbySlotCard`](slots/twocolumn/CustomLobbySlotCard.lua) (fat half-width / two-line presentation; `SetMirrored` flips the right column's cards so the two teams face each other). Each body exports `HeightForCount`. | diff --git a/lua/ui/lobby/customlobby/CustomLobbyBalancePreview.lua b/lua/ui/lobby/customlobby/CustomLobbyBalancePreview.lua index db92c7fc808..de6e15b865e 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyBalancePreview.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyBalancePreview.lua @@ -39,17 +39,18 @@ -- -- The body renders as **mirror positions** (row k = the k-th seat on each side, who face off; the -- right column is mirrored so the teams face each other): each row puts the name on the outer edge and --- the rating in a column hugging the centre (with the mean muted beside it) + the pair's rating gap in --- the centre, a "<" / ">" arrow pointing to the higher-rated side. The header is the same shape — team --- label (outer), average (column) + total (muted), and the gap total (with the same direction arrow) --- in the centre band atop the per-row gaps. Three gestures tune the shown candidate: +-- the **mean** rating in a column hugging the centre (with the deviation muted as "(±N)" beside it) + +-- the pair's mean gap in the centre, a "<" / ">" arrow pointing to the higher-rated side. The header is +-- a two-line summary on the same columns: TOP — team label (outer) + each team's predicted win % (column) +-- + the match quality in the centre; BOTTOM — total mean (column, "(±N)" summed deviation muted) + the +-- gap total (direction arrow) in the centre. Three gestures tune the shown candidate: -- * **Drag a row** onto another → swap those two positions' pairs (both players move together), -- so the host arranges which pair spawns where ("D -> A"). -- * **Click a player, then another** → swap just those two (`ScoreArrangement` re-scores, no solve). -- * **Click a player's lock** → pin / unpin them, which **regenerates** the candidates around that -- constraint (the locked player held at its seat, the rest re-balanced). --- Moved players are gold, locked players blue. The status line reports quality before -> after and the --- predicted win split. +-- Moved players are gold, locked players blue. The status line carries only situational notes (the odd +-- one left in place, or why it can't balance). -- -- Built to the preset dialog's shape (areas layout, three-phase init, Popup singleton). @@ -71,11 +72,11 @@ local Layouter = LayoutHelpers.ReusedLayoutFor local Debug = false local DialogWidth = 540 -local DialogHeight = 452 +local DialogHeight = 470 local Pad = 12 local TitleHeight = 28 local HintHeight = 16 -local HeaderHeight = 22 +local HeaderHeight = 40 -- two lines: total (+dev) / win% per team, gap / quality in the centre local StatusHeight = 24 local ActionHeight = 52 local RowHeight = 24 @@ -152,14 +153,14 @@ end ---@field RightSelect Bitmap ---@field PosLabel Text # the position index (1..N) ---@field LeftName Text # outer edge ----@field LeftMean Text # muted mean, just outside the rating ----@field LeftRating Text # display rating, aligned in a column beside the centre +---@field LeftDev Text # muted deviation "(±N)", just outside the rating +---@field LeftRating Text # mean rating, aligned in a column beside the centre ---@field CenterArea Group # fixed-width centre band (holds the gap number) ---@field Center Text # the pair's rating gap (magnitude, centred) ---@field CenterArrowL Text # "<" shown when the left player is higher-rated ---@field CenterArrowR Text # ">" shown when the right player is higher-rated ---@field RightRating Text ----@field RightMean Text +---@field RightDev Text ---@field RightName Text ---@field LeftLock Bitmap # left player's lock toggle ---@field RightLock Bitmap @@ -182,6 +183,8 @@ end ---@field TitleArea Group ---@field HintArea Group ---@field HeaderArea Group +---@field HeaderTop Group # top line of the header (win % + quality) — vertical reference +---@field HeaderBottom Group # bottom line of the header (totals + gap) — vertical reference ---@field RowsArea Group ---@field StatusArea Group ---@field ActionArea Group @@ -189,14 +192,17 @@ end ---@field Hint Text ---@field TeamLabelA Text # team label, outer edge (like a player name) ---@field TeamLabelB Text ----@field TeamAvgA Text # team average, centre column (aligned above the player ratings) ----@field TeamAvgB Text ----@field TeamTotalA Text # team total, muted, just outside the average (like a player mean) +---@field TeamTotalA Text # team total (sum of means), centre column (aligned above the player ratings) ---@field TeamTotalB Text ----@field HeaderCenterArea Group # centre band (holds the gap total), aligned above the rows' centre band ----@field HeaderGap Text # the total rating gap between the teams (magnitude, centred) +---@field TeamDevA Text # team total deviation "(±N)", muted, just outside the total +---@field TeamDevB Text +---@field TeamWinA Text # team A predicted win %, top line of its column +---@field TeamWinB Text +---@field HeaderCenterArea Group # centre band (holds the gap total + quality), aligned above the rows' centre band +---@field HeaderGap Text # the total rating gap between the teams (magnitude, centred, bottom line) ---@field HeaderArrowL Text # "<" shown when the left team has the higher total ---@field HeaderArrowR Text # ">" shown when the right team has the higher total +---@field HeaderQuality Text # match quality %, centred on the top line (above the gap) ---@field Rows UICustomLobbyBalanceRow[] ---@field Status Text ---@field NavPrev Bitmap # browse to the previous candidate ("<") @@ -254,8 +260,8 @@ local CustomLobbyBalancePreview = ClassUI(Group) { self.Hint:SetColor(MutedColor) self.Hint:DisableHitTest() - -- the team summary mirrors a player row: label (outer), total (muted) + average (centre column, - -- above the player ratings), and the gap total in the centre band above the per-row gaps + -- the team summary mirrors a player row: label (outer) + total (centre column, above the player + -- ratings), and the gap total in the centre band above the per-row gaps local labels = self.Result.labels or { "Team 1", "Team 2" } self.TeamLabelA = UIUtil.CreateText(self.HeaderArea, labels[1], 14, UIUtil.titleFont) self.TeamLabelA:SetColor(HeaderColor) @@ -264,20 +270,20 @@ local CustomLobbyBalancePreview = ClassUI(Group) { self.TeamLabelB:SetColor(HeaderColor) self.TeamLabelB:DisableHitTest() - self.TeamAvgA = UIUtil.CreateText(self.HeaderArea, "", 14, UIUtil.titleFont) - self.TeamAvgA:SetColor(HeaderColor) - self.TeamAvgA:DisableHitTest() - self.TeamAvgB = UIUtil.CreateText(self.HeaderArea, "", 14, UIUtil.titleFont) - self.TeamAvgB:SetColor(HeaderColor) - self.TeamAvgB:DisableHitTest() - - self.TeamTotalA = UIUtil.CreateText(self.HeaderArea, "", 11, UIUtil.bodyFont) - self.TeamTotalA:SetColor(MutedColor) + self.TeamTotalA = UIUtil.CreateText(self.HeaderArea, "", 14, UIUtil.titleFont) + self.TeamTotalA:SetColor(HeaderColor) self.TeamTotalA:DisableHitTest() - self.TeamTotalB = UIUtil.CreateText(self.HeaderArea, "", 11, UIUtil.bodyFont) - self.TeamTotalB:SetColor(MutedColor) + self.TeamTotalB = UIUtil.CreateText(self.HeaderArea, "", 14, UIUtil.titleFont) + self.TeamTotalB:SetColor(HeaderColor) self.TeamTotalB:DisableHitTest() + self.TeamDevA = UIUtil.CreateText(self.HeaderArea, "", 11, UIUtil.bodyFont) + self.TeamDevA:SetColor(MutedColor) + self.TeamDevA:DisableHitTest() + self.TeamDevB = UIUtil.CreateText(self.HeaderArea, "", 11, UIUtil.bodyFont) + self.TeamDevB:SetColor(MutedColor) + self.TeamDevB:DisableHitTest() + self.HeaderCenterArea = Group(self.HeaderArea, "BalanceHeaderCenter") self.HeaderGap = UIUtil.CreateText(self.HeaderCenterArea, "", 12, UIUtil.bodyFont) self.HeaderGap:SetColor(HeaderColor) @@ -289,6 +295,20 @@ local CustomLobbyBalancePreview = ClassUI(Group) { self.HeaderArrowR:SetColor(HeaderColor) self.HeaderArrowR:DisableHitTest() + -- the bottom line of the header: each team's predicted win % (under its total) + the match + -- quality (under the gap). HeaderTop / HeaderBottom are vertical-reference bands only. + self.HeaderTop = Group(self.HeaderArea, "BalanceHeaderTop") + self.HeaderBottom = Group(self.HeaderArea, "BalanceHeaderBottom") + self.TeamWinA = UIUtil.CreateText(self.HeaderArea, "", 14, UIUtil.titleFont) + self.TeamWinA:SetColor(HeaderColor) + self.TeamWinA:DisableHitTest() + self.TeamWinB = UIUtil.CreateText(self.HeaderArea, "", 14, UIUtil.titleFont) + self.TeamWinB:SetColor(HeaderColor) + self.TeamWinB:DisableHitTest() + self.HeaderQuality = UIUtil.CreateText(self.HeaderCenterArea, "", 12, UIUtil.bodyFont) + self.HeaderQuality:SetColor(HeaderColor) + self.HeaderQuality:DisableHitTest() + -- a fixed pool of interactive position rows; Render shows/hides + fills them from the proposal self.Rows = {} for i = 1, MaxRows do @@ -364,9 +384,9 @@ local CustomLobbyBalancePreview = ClassUI(Group) { -- beside the centre band; the gap number lives in the centre band so the rating columns line up row.LeftName = UIUtil.CreateText(row.Group, "", 14, UIUtil.bodyFont) row.LeftName:DisableHitTest() - row.LeftMean = UIUtil.CreateText(row.Group, "", 11, UIUtil.bodyFont) - row.LeftMean:SetColor(MutedColor) - row.LeftMean:DisableHitTest() + row.LeftDev = UIUtil.CreateText(row.Group, "", 11, UIUtil.bodyFont) + row.LeftDev:SetColor(MutedColor) + row.LeftDev:DisableHitTest() row.LeftRating = UIUtil.CreateText(row.Group, "", 14, UIUtil.bodyFont) row.LeftRating:DisableHitTest() @@ -381,9 +401,9 @@ local CustomLobbyBalancePreview = ClassUI(Group) { row.RightRating = UIUtil.CreateText(row.Group, "", 14, UIUtil.bodyFont) row.RightRating:DisableHitTest() - row.RightMean = UIUtil.CreateText(row.Group, "", 11, UIUtil.bodyFont) - row.RightMean:SetColor(MutedColor) - row.RightMean:DisableHitTest() + row.RightDev = UIUtil.CreateText(row.Group, "", 11, UIUtil.bodyFont) + row.RightDev:SetColor(MutedColor) + row.RightDev:DisableHitTest() row.RightName = UIUtil.CreateText(row.Group, "", 14, UIUtil.bodyFont) row.RightName:DisableHitTest() @@ -455,19 +475,30 @@ local CustomLobbyBalancePreview = ClassUI(Group) { Layouter(self.Title):AtHorizontalCenterIn(self.TitleArea):AtVerticalCenterIn(self.TitleArea):End() Layouter(self.Hint):AtHorizontalCenterIn(self.HintArea):AtVerticalCenterIn(self.HintArea):End() - -- team summary, laid out on the same columns as the player rows below it + -- team summary, on the same columns as the player rows: two stacked lines (HeaderTop = totals + + -- gap, HeaderBottom = win % + quality). The centre band spans both lines. + Layouter(self.HeaderTop):AtLeftIn(self.HeaderArea):AtRightIn(self.HeaderArea) + :AtTopIn(self.HeaderArea):Height(HeaderHeight / 2):End() + Layouter(self.HeaderBottom):AtLeftIn(self.HeaderArea):AtRightIn(self.HeaderArea) + :AnchorToBottom(self.HeaderTop, 0):AtBottomIn(self.HeaderArea):End() Layouter(self.HeaderCenterArea):AtHorizontalCenterIn(self.HeaderArea):AtTopIn(self.HeaderArea) :AtBottomIn(self.HeaderArea):Width(CenterWidth):End() - Layouter(self.HeaderGap):AtHorizontalCenterIn(self.HeaderCenterArea):AtVerticalCenterIn(self.HeaderCenterArea):End() - Layouter(self.HeaderArrowL):AnchorToLeft(self.HeaderGap, 3):AtVerticalCenterIn(self.HeaderCenterArea):End() - Layouter(self.HeaderArrowR):AnchorToRight(self.HeaderGap, 3):AtVerticalCenterIn(self.HeaderCenterArea):End() - Layouter(self.TeamLabelA):AtLeftIn(self.HeaderArea):AtVerticalCenterIn(self.HeaderArea):End() - Layouter(self.TeamAvgA):AnchorToLeft(self.HeaderCenterArea, 8):AtVerticalCenterIn(self.HeaderArea):End() - Layouter(self.TeamTotalA):AnchorToLeft(self.TeamAvgA, 5):AtVerticalCenterIn(self.HeaderArea):End() - Layouter(self.TeamLabelB):AtRightIn(self.HeaderArea):AtVerticalCenterIn(self.HeaderArea):End() - Layouter(self.TeamAvgB):AnchorToRight(self.HeaderCenterArea, 8):AtVerticalCenterIn(self.HeaderArea):End() - Layouter(self.TeamTotalB):AnchorToRight(self.TeamAvgB, 5):AtVerticalCenterIn(self.HeaderArea):End() + -- top line: team label (outer) + per-team win % (column) and the match quality in the centre + Layouter(self.HeaderQuality):AtHorizontalCenterIn(self.HeaderCenterArea):AtVerticalCenterIn(self.HeaderTop):End() + Layouter(self.TeamLabelA):AtLeftIn(self.HeaderArea):AtVerticalCenterIn(self.HeaderTop):End() + Layouter(self.TeamWinA):AnchorToLeft(self.HeaderCenterArea, 8):AtVerticalCenterIn(self.HeaderTop):End() + Layouter(self.TeamLabelB):AtRightIn(self.HeaderArea):AtVerticalCenterIn(self.HeaderTop):End() + Layouter(self.TeamWinB):AnchorToRight(self.HeaderCenterArea, 8):AtVerticalCenterIn(self.HeaderTop):End() + + -- bottom line: per-team total (+dev, under the win %) and the gap total in the centre + Layouter(self.HeaderGap):AtHorizontalCenterIn(self.HeaderCenterArea):AtVerticalCenterIn(self.HeaderBottom):End() + Layouter(self.HeaderArrowL):AnchorToLeft(self.HeaderGap, 3):AtVerticalCenterIn(self.HeaderBottom):End() + Layouter(self.HeaderArrowR):AnchorToRight(self.HeaderGap, 3):AtVerticalCenterIn(self.HeaderBottom):End() + Layouter(self.TeamTotalA):AnchorToLeft(self.HeaderCenterArea, 8):AtVerticalCenterIn(self.HeaderBottom):End() + Layouter(self.TeamDevA):AnchorToLeft(self.TeamTotalA, 5):AtVerticalCenterIn(self.HeaderBottom):End() + Layouter(self.TeamTotalB):AnchorToRight(self.HeaderCenterArea, 8):AtVerticalCenterIn(self.HeaderBottom):End() + Layouter(self.TeamDevB):AnchorToRight(self.TeamTotalB, 5):AtVerticalCenterIn(self.HeaderBottom):End() -- stack the position rows from the top of the rows area for i = 1, MaxRows do @@ -497,10 +528,10 @@ local CustomLobbyBalancePreview = ClassUI(Group) { -- ratings: a column hugging the centre band (left right-aligned, right left-aligned); the -- muted mean sits just outside each rating; names run from the outer edge Layouter(row.LeftRating):AnchorToLeft(row.CenterArea, 8):AtVerticalCenterIn(row.Group):End() - Layouter(row.LeftMean):AnchorToLeft(row.LeftRating, 5):AtVerticalCenterIn(row.Group):End() + Layouter(row.LeftDev):AnchorToLeft(row.LeftRating, 5):AtVerticalCenterIn(row.Group):End() Layouter(row.LeftName):AnchorToRight(row.LeftLock, 6):AtVerticalCenterIn(row.Group):End() Layouter(row.RightRating):AnchorToRight(row.CenterArea, 8):AtVerticalCenterIn(row.Group):End() - Layouter(row.RightMean):AnchorToRight(row.RightRating, 5):AtVerticalCenterIn(row.Group):End() + Layouter(row.RightDev):AnchorToRight(row.RightRating, 5):AtVerticalCenterIn(row.Group):End() Layouter(row.RightName):AnchorToLeft(row.RightLock, 6):AtVerticalCenterIn(row.Group):End() -- the swap-click / drag halves sit above the drop tint but below the texts (which ignore @@ -804,20 +835,6 @@ local CustomLobbyBalancePreview = ClassUI(Group) { --------------------------------------------------------------------------- --#region Rendering - --- Sets one team's header column — average (centre column) + total (muted) — from the plan. - ---@param self UICustomLobbyBalancePreview - ---@param avgText Text - ---@param totalText Text - ---@param side 1 | 2 - SetTeamHeader = function(self, avgText, totalText, side) - local result = self.Result - local count = table.getn(result.sides[side]) - local total = result.totals[side] - local avg = count > 0 and math.floor(total / count + 0.5) or 0 - avgText:SetText(tostring(avg)) - totalText:SetText("(" .. tostring(total) .. ")") - end, - --- Fills one position row from a `UICustomLobbyBalancePosition` (either player may be absent on an --- uneven split), and records its slots/owners for the click + drag handlers. ---@param self UICustomLobbyBalancePreview @@ -828,18 +845,20 @@ local CustomLobbyBalancePreview = ClassUI(Group) { row.Group:Show() row.PosLabel:SetText(tostring(index)) local a, b = position.a, position.b - self:FillHalf(row.LeftName, row.LeftRating, row.LeftMean, row.LeftLock, row.LeftSelect, a, position.slotA) - self:FillHalf(row.RightName, row.RightRating, row.RightMean, row.RightLock, row.RightSelect, b, position.slotB) + self:FillHalf(row.LeftName, row.LeftRating, row.LeftDev, row.LeftLock, row.LeftSelect, a, position.slotA) + self:FillHalf(row.RightName, row.RightRating, row.RightDev, row.RightLock, row.RightSelect, b, position.slotB) row.LeftOwner = a and a.ownerId or nil row.LeftSlot = position.slotA row.RightOwner = b and b.ownerId or nil row.RightSlot = position.slotB - -- the pair's rating gap, only when both players are rated — the magnitude is centred and a + -- the pair's mean gap, only when both players are rated — the magnitude is centred and a -- "<" / ">" arrow points to the higher-rated side if a and b and a.pl > 0 and b.pl > 0 then - self:SetDirectionalGap(row.Center, row.CenterArrowL, row.CenterArrowR, a.pl, b.pl, - GapColor(math.abs(a.pl - b.pl))) + local meanA = math.floor(a.mean + 0.5) + local meanB = math.floor(b.mean + 0.5) + self:SetDirectionalGap(row.Center, row.CenterArrowL, row.CenterArrowR, meanA, meanB, + GapColor(math.abs(meanA - meanB))) else row.Center:SetText("") row.CenterArrowL:SetText("") @@ -867,21 +886,21 @@ local CustomLobbyBalancePreview = ClassUI(Group) { arrowR:SetColor(color) end, - --- Paints one half of a row — name (outer), display rating (centre column) + muted mean, the lock - --- dot and the selection highlight — for a player, or clears it when the half is empty. + --- Paints one half of a row — name (outer), mean rating (centre column) + muted deviation "(±N)", + --- the lock dot and the selection highlight — for a player, or clears it when the half is empty. ---@param self UICustomLobbyBalancePreview ---@param name Text ---@param rating Text - ---@param mean Text + ---@param dev Text ---@param lock Bitmap ---@param select Bitmap ---@param player UICustomLobbyBalancePlayer | nil ---@param slot number | nil - FillHalf = function(self, name, rating, mean, lock, select, player, slot) + FillHalf = function(self, name, rating, dev, lock, select, player, slot) if not player then name:SetText("") rating:SetText("") - mean:SetText("") + dev:SetText("") lock:SetAlpha(0.0) select:SetAlpha(0.0) return @@ -889,14 +908,14 @@ local CustomLobbyBalancePreview = ClassUI(Group) { local color = PlayerColor(player) name:SetText(player.name) name:SetColor(color) - -- rating + mean only for rated players (an unrated / AI player's mean is a placeholder) + -- mean + deviation only for rated players (an unrated / AI player's mean is a placeholder) if player.pl > 0 then - rating:SetText(tostring(player.pl)) + rating:SetText(tostring(math.floor(player.mean + 0.5))) rating:SetColor(color) - mean:SetText("(" .. tostring(math.floor(player.mean + 0.5)) .. ")") + dev:SetText("(±" .. tostring(math.floor(player.dev + 0.5)) .. ")") else rating:SetText("") - mean:SetText("") + dev:SetText("") end -- the lock dot: solid blue when pinned, faint when not (a click toggles it) lock:SetSolidColor(player.locked and LockColor or SelectColor) @@ -911,10 +930,22 @@ local CustomLobbyBalancePreview = ClassUI(Group) { Render = function(self) local result = self.Result - self:SetTeamHeader(self.TeamAvgA, self.TeamTotalA, 1) - self:SetTeamHeader(self.TeamAvgB, self.TeamTotalB, 2) + self.TeamTotalA:SetText(tostring(math.floor(result.totals[1] + 0.5))) + self.TeamTotalB:SetText(tostring(math.floor(result.totals[2] + 0.5))) + self.TeamDevA:SetText("(±" .. tostring(math.floor(result.devTotals[1] + 0.5)) .. ")") + self.TeamDevB:SetText("(±" .. tostring(math.floor(result.devTotals[2] + 0.5)) .. ")") self:SetDirectionalGap(self.HeaderGap, self.HeaderArrowL, self.HeaderArrowR, - result.totals[1], result.totals[2], HeaderColor) + math.floor(result.totals[1] + 0.5), math.floor(result.totals[2] + 0.5), HeaderColor) + + -- bottom line: each team's predicted win % (under its total) + the match quality (under the gap) + if result.winChance then + self.TeamWinA:SetText(tostring(result.winChance[1]) .. "%") + self.TeamWinB:SetText(tostring(result.winChance[2]) .. "%") + else + self.TeamWinA:SetText("") + self.TeamWinB:SetText("") + end + self.HeaderQuality:SetText(result.quality and (tostring(math.floor(result.quality + 0.5)) .. "%") or "n/a") local positions = result.positions or {} local rowCount = table.getn(positions) @@ -956,8 +987,8 @@ local CustomLobbyBalancePreview = ClassUI(Group) { self:SetNavArrow(self.NavNext, multiple and self.CandidateIndex < count) end, - --- The summary: the reason it can't balance, else "Quality before -> after · Win a/b · Gap g", - --- plus any odd one left out. + --- The status line now carries only situational notes — why it can't balance, or the odd one left + --- in place. Quality / win / totals live in the header. ---@param self UICustomLobbyBalancePreview ---@return string StatusLine = function(self) @@ -965,27 +996,10 @@ local CustomLobbyBalancePreview = ClassUI(Group) { if result.reason then return result.reason end - - -- match quality, as "current -> proposed" when both are known (whole percent — the trueskill - -- value carries two decimals we don't need on screen) - local quality - if result.quality and result.currentQuality then - quality = "Quality " .. tostring(math.floor(result.currentQuality + 0.5)) - .. "% -> " .. tostring(math.floor(result.quality + 0.5)) .. "%" - elseif result.quality then - quality = "Quality " .. tostring(math.floor(result.quality + 0.5)) .. "%" - else - quality = "Quality n/a" - end - - local status = quality - if result.winChance then - status = status .. " · Win " .. tostring(result.winChance[1]) .. "% / " .. tostring(result.winChance[2]) .. "%" - end if result.unassigned then - status = status .. " · " .. result.unassigned.name .. " stays put (odd count)" + return result.unassigned.name .. " stays put (odd count)" end - return status + return "" end, --#endregion diff --git a/lua/ui/lobby/customlobby/CustomLobbyBalancer.lua b/lua/ui/lobby/customlobby/CustomLobbyBalancer.lua index 370ebd5d765..9989f465151 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyBalancer.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyBalancer.lua @@ -41,8 +41,8 @@ -- pair's *position* is random while the *pairing* stays fixed. Locked players keep their seat. -- Then the chosen split is scored once with trueskill `computeQuality` for the preview. -- --- Ratings: the search + quality use MEAN / DEV; the per-side display totals use PL (matching the --- team-score strip). +-- Ratings: the search + quality use MEAN / DEV; the preview shows MEAN +/- DEV per player, and the +-- per-side display `totals` are the sum of the players' MEAN. local Trueskill = import("/lua/ui/lobby/trueskill.lua") @@ -75,7 +75,8 @@ local MaxBalanceEvaluations = 20000 ---@field labels string[] # the two side labels, for the preview ---@field sides UICustomLobbyBalancePlayer[][] # the two teams, rank-sorted (totals / quality) ---@field positions UICustomLobbyBalancePosition[] # ordered mirror rows (k-th seat each side), for the preview ----@field totals number[] # per-side PL totals +---@field totals number[] # per-side total mean (sum of the players' means) +---@field devTotals number[] # per-side total deviation (sum of the players' deviations) ---@field lockedOwners table # user-locked players (the preview marks them) ---@field unassigned UICustomLobbyBalancePlayer | false # the odd one left in place, if any ---@field feasible boolean # false = nothing to apply (Apply disabled) @@ -282,6 +283,7 @@ local function NewPlan(teams) sides = { {}, {} }, positions = {}, totals = { 0, 0 }, + devTotals = { 0, 0 }, lockedOwners = {}, unassigned = false, feasible = false, @@ -294,7 +296,7 @@ local function NewPlan(teams) end --- Scores a concrete `arrangement` (slot -> ownerId) into `plan`: the two rank-sorted sides, the ---- per-side PL totals, the moved flags (vs. each player's current lobby seat), the current-seating +--- per-side total mean, the moved flags (vs. each player's current lobby seat), the current-seating --- quality, and the proposed quality + win split. Pure display computation — it moves no one. --- `players` / `seatSide` come from Project. ---@param plan UICustomLobbyBalancePlan @@ -331,10 +333,13 @@ local function ScorePlan(plan, players, seatSide, arrangement) table.sort(plan.sides[2], ByRatingDesc) for side = 1, 2 do local total = 0 + local devTotal = 0 for _, bp in ipairs(plan.sides[side]) do - total = total + bp.pl + total = total + bp.mean + devTotal = devTotal + bp.dev end plan.totals[side] = total + plan.devTotals[side] = devTotal end plan.quality = ComputeQuality(plan.sides) diff --git a/lua/ui/lobby/customlobby/CustomLobbyController.lua b/lua/ui/lobby/customlobby/CustomLobbyController.lua index 4ddc5e20323..8fdc40e2f05 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyController.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyController.lua @@ -244,6 +244,17 @@ local function SwapSlots(instance, slotA, slotB) if lockChanged then BroadcastSessionState(instance) end + + -- announce the swap/move (the host performs every swap, whether it started the drag or a client did) + local aName = a and a.PlayerName + local bName = b and b.PlayerName + if aName and bName then + BroadcastSystemNotice(instance, aName .. " and " .. bName .. " swapped seats.") + elseif aName then + BroadcastSystemNotice(instance, aName .. " moved to seat " .. slotB .. ".") + elseif bName then + BroadcastSystemNotice(instance, bName .. " moved to seat " .. slotA .. ".") + end end --- Reads a numeric command-line argument (e.g. `/mean 1500`), falling back to the default @@ -409,7 +420,8 @@ function OnPeerDisconnected(instance, peerName, uid) BroadcastSessionState(instance) end - -- announce the leave in chat (the leaver is gone; the remaining peers + host see it) + -- announce the leave in chat (the leaver is gone; the remaining peers + host see it). A host kick + -- also produces a separate "Host removed X." line from RequestEject — two lines for a kick is fine. BroadcastSystemNotice(instance, name .. " left.") end @@ -757,6 +769,10 @@ function RequestSetScenario(scenarioFile) end BroadcastLaunchInfo(instance) + + -- the scenario derived model resolved the new map synchronously (its observer fired on the Set above) + local scenario = import("/lua/ui/lobby/customlobby/models/derived/customlobbyscenarioderivedmodel.lua").GetScenario() + BroadcastSystemNotice(instance, "Host changed the map to " .. ((scenario and scenario.Name) or "a new map") .. ".") end --- The host sets the active sim mods. Host-only — backs the mod-select dialog. Sets `GameMods` @@ -776,6 +792,7 @@ function RequestSetGameMods(gameMods) CustomLobbyLaunchModel.SetGameMods(CustomLobbyLaunchModel.GetSingleton(), gameMods) BroadcastLaunchInfo(instance) + BroadcastSystemNotice(instance, "Host changed the game mods (" .. table.getsize(gameMods) .. " active).") end --- The host sets the unit restrictions. Host-only — backs the unit-select dialog and a @@ -799,6 +816,7 @@ function RequestSetRestrictions(keys) CustomLobbyLaunchModel.SetRestrictions(CustomLobbyLaunchModel.GetSingleton(), keys) LOG("CustomLobby: restrictions set (" .. table.getn(keys) .. ")") BroadcastLaunchInfo(instance) + BroadcastSystemNotice(instance, "Host changed the unit restrictions (" .. table.getn(keys) .. ").") end --- The host sets the game options. Host-only — backs the options dialog. Replaces the whole @@ -818,6 +836,7 @@ function RequestSetGameOptions(options) CustomLobbyLaunchModel.SetGameOptions(CustomLobbyLaunchModel.GetSingleton(), options) BroadcastLaunchInfo(instance) + BroadcastSystemNotice(instance, "Host changed the game options.") end --- The host resets every game option to its default. Host-only — backs the lobby's "Reset @@ -1239,6 +1258,7 @@ function RequestApplyBalance(arrangement) end end BroadcastPlayers(instance) + BroadcastSystemNotice(instance, "Host balanced the teams.") end --- Re-broadcasts the closed slots, then after a short delay opens every one of them. The @@ -1303,8 +1323,10 @@ function RequestEject(slot) if not player then return end + BroadcastSystemNotice(instance, "Host removed " .. (player.PlayerName or "a player") .. ".") if player.Human then - -- the resulting PeerDisconnected clears the slot (and its lock) and re-broadcasts + -- the resulting PeerDisconnected clears the slot (and its lock), re-broadcasts, and adds its own + -- "X left." line — two lines for a kick is fine instance:EjectPeer(player.OwnerID, "KickedByHost") else CustomLobbyLaunchModel.ClearPlayer(CustomLobbyLaunchModel.GetSingleton(), slot) @@ -1342,6 +1364,7 @@ function RequestMoveToObserver(slot) if ClearSlotLock(slot) then BroadcastSessionState(instance) end + BroadcastSystemNotice(instance, (player.PlayerName or "A player") .. " moved to observers.") end --- The local player toggles their ready flag. Host applies + broadcasts; a client diff --git a/lua/ui/lobby/customlobby/social/chat-design.md b/lua/ui/lobby/customlobby/social/chat-design.md index 8fa683f5722..4201c1c3c17 100644 --- a/lua/ui/lobby/customlobby/social/chat-design.md +++ b/lua/ui/lobby/customlobby/social/chat-design.md @@ -72,13 +72,21 @@ the same mechanism either way. connection handshake, so it must be evaluated per-invocation. `Send`'s `/` branch dispatches here. - **Slice 3 — whisper.** `RequestChat` carries a `Recipient`; `ProcessRequestChat` `SendData`s to sender+target instead of `BroadcastData`. Same chokepoint. -- **Slice 4 — join/leave notices (done).** The host emits a `SystemNotice` (a senderless system line) - on join (`ProcessAddPlayer`) and leave (`OnPeerDisconnected`); `BroadcastSystemNotice` broadcasts it - and shows it on the host too (the broadcast doesn't loop back). Chosen over the originally-sketched - "each peer diffs the roster locally" because (a) the host doesn't run `ProcessSetPlayers` on itself, - so a local diff would skip the host, and (b) a joining peer would otherwise spam "X joined" for the - whole existing roster on its first snapshot. Host-broadcast is one symmetric path with no diff and no - baseline-flag, and the leaver simply isn't there to receive its own "left" line. +- **Slice 4 — system notices (done).** The host emits a `SystemNotice` (a senderless system line) at + its authoritative change sites; `BroadcastSystemNotice` broadcasts it **and** shows it on the host too + (the broadcast doesn't loop back). Chosen over the originally-sketched "each peer diffs the roster + locally" because (a) the host doesn't run `ProcessSetPlayers` on itself, so a local diff would skip the + host, and (b) a joining peer would otherwise spam "X joined" for the whole existing roster on its first + snapshot. Host-broadcast is one symmetric path with no diff and no baseline-flag. + - Wired: **join** (`ProcessAddPlayer`), **leave** (`OnPeerDisconnected`), **kick** (`RequestEject` adds + "Host removed X."; a human kick also produces the disconnect's "left" line — two lines is fine), + **map / mods / options / restrictions** changes (the `RequestSet*` intents, which fire + once per action — *not* on snapshot rebroadcasts, so no spam), **seat swap / move** (`SwapSlots`, + covering host-initiated and any client-requested swap), **move-to-observers** + (`RequestMoveToObserver`), and **auto-balance applied** (`RequestApplyBalance`). + - Not yet emitted (deliberately, as noise-prone): ready toggles, slot takes, faction/colour picks. + Per-option diffs ("changed Unit Cap to 1000") would read better than the generic "changed the game + options" but need an old-vs-new compare in the intent. - **Refinements.** Multi-line wrapping (slice 1 truncates one line per row — the in-game [`ChatLinesInterface`](/lua/ui/game/chat/ChatLinesInterface.lua) is the reference); unread-since-last-view badge count (slice 1 shows the total line count). From 31d5b6104f0ef2a1f8a5e61bb764fc8de7d956de Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 28 Jun 2026 01:27:34 +0200 Subject: [PATCH 86/98] Setup teams when the game starts --- lua/ui/lobby/customlobby/CLAUDE.md | 4 +- .../customlobby/CustomLobbyBalancePreview.lua | 56 ++++++++++++++++++- .../customlobby/CustomLobbyController.lua | 54 ++++++++++++++---- .../slots/CustomLobbySlotsInterface.lua | 55 +++++++++++++++++- .../lobby/customlobby/social/chat-design.md | 12 ++-- 5 files changed, 160 insertions(+), 21 deletions(-) diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index c3e8de4f0c9..2716859ebbf 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -59,10 +59,10 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | [CustomLobbyMessages.lua](CustomLobbyMessages.lua) | message registry: `AddPlayer`, `SetPlayers` (launch model: players + observers), `SentLaunchInfo` (launch model: scenario / options / mods / teams / spawn mex), `SetSessionState` (session model: slot count / closed slots / locked slots / pinned), `SetReady`, `TakeSlot`, `RequestChat` (any peer → host: a chat line to relay) / `ChatMessage` (host → everyone: the accepted line) / `SystemNotice` (host → everyone: a senderless lobby notice — join/leave), `DisconnectPeer`, `LaunchGame` (host's final game config → `instance:LaunchGame`), `ReportCpuBenchmark`, `SetCpuBenchmarks`. | | [social/CustomLobbyChatModel.lua](social/CustomLobbyChatModel.lua) | the **chat feed** — a per-peer, never-synced reactive `Entries` ring buffer (`UICustomLobbyChatEntry`: `Id` / `SenderId` / `SenderName` / `Text` / `Status` / `Kind`), the chat equivalent of [`CustomLobbyLog`](CustomLobbyLog.lua) but **written by the controller**, so it is a `ClassSimple : Destroyable` in the session trash (freed on `Teardown()`). `Append` adds an optimistic `Pending` line / system notice; `Receive` reconciles the sender's `Pending` line to `Confirmed` by the echoed `Id` (or appends others' lines). Not one of the three models — no game state. See [social/chat-design.md](social/chat-design.md). | | [social/CustomLobbyChatController.lua](social/CustomLobbyChatController.lua) | the chat **send pipeline** (writes the chat model, never the wire): `Send` decides command-vs-chat (a `/` line is handled locally and never broadcast — registry lands in slice 2), echoes a plain line optimistically (`Pending`) and hands it to the controller's `RequestChat`; `AppendLocalSystem` for local notices. Mirrors the in-game [`ChatController.Send`](/lua/ui/game/chat/ChatController.lua) split. | -| [CustomLobbyBalancePreview.lua](CustomLobbyBalancePreview.lua) | the auto-balance **preview + tuner** (host-only `Popup`): the slots header's balance button opens it instead of re-seating directly. It owns a **working state** nothing synced sees until Apply — `Arrangement` (slot→ownerId) + `Locks` (a preview-local lock set, **seeded** from the lobby's real locks but never written back). Renders the proposal as **mirror-position rows** (row k = the k-th seat on each side, who face off, in start-position order with a position label; the right column **mirrored** so the teams face each other) — each row puts the **name on the outer edge** and the **mean rating in a fixed column hugging the centre** (so the two columns line up across rows, flanking the centre gap), with the **deviation** as a muted `(±N)` just outside it; the centre shows the pair's mean gap (magnitude) with a **`<` / `>` arrow pointing to the higher-rated side**; **moved** players are gold, **locked** players blue. The host **browses the candidate balances** with a "< i / N >" browser (the kernel's top-N distinct splits via `BuildCandidates`); stepping adopts each. Three gestures tune the shown one: **drag a row** onto another → swap those two positions' pairs (both players move together — "D → A"; same feel as the lobby slot rows — press-vs-drag threshold, a floating pair ghost beside the cursor, and a drop-target highlight); **click two players** → swap just those two (`ScoreArrangement` re-scores, no re-solve); **click a player's lock dot** → pin/unpin them (preview-local) which **regenerates** the candidates around that lock. The **header is a two-line summary** on the same columns as the rows: top line — team label (outer) + each team's predicted **win %** (column) + the match **quality** % in the centre; bottom line — total mean (column) with the summed deviation muted `(±N)`, and the **gap total** (direction arrow) in the centre. The status line carries only situational notes (the odd one out, or why it can't balance). While open it **pins the lobby** (`SlotsPinned`) so the roster can't shift under the proposal (it would go stale); it remembers the prior pin state — **Cancel** restores it, **Apply** leaves it pinned so the balance holds. **Apply** commits only `Arrangement` (→ `RequestApplyBalance`); **Cancel** discards everything (locks included). A transient picker, owns no synced state (beyond toggling the shared pin while open). | +| [CustomLobbyBalancePreview.lua](CustomLobbyBalancePreview.lua) | the auto-balance **preview + tuner** (host-only `Popup`): the slots header's balance button opens it instead of re-seating directly. It owns a **working state** nothing synced sees until Apply — `Arrangement` (slot→ownerId) + `Locks` (a preview-local lock set, **seeded** from the lobby's real locks but never written back). Renders the proposal as **mirror-position rows** (row k = the k-th seat on each side, who face off, in start-position order with a position label; the right column **mirrored** so the teams face each other) — each row puts the **name on the outer edge** and the **mean rating in a fixed column hugging the centre** (so the two columns line up across rows, flanking the centre gap), with the **deviation** as a muted `(±N)` just outside it; the centre shows the pair's mean gap (magnitude) with a **`<` / `>` arrow pointing to the higher-rated side**; **moved** players are gold, **locked** players blue. The host **browses the candidate balances** with a "< i / N >" browser (the kernel's top-N distinct splits via `BuildCandidates`); stepping adopts each. Three gestures tune the shown one: **drag a row** onto another → swap those two positions' pairs (both players move together — "D → A"; same feel as the lobby slot rows — press-vs-drag threshold, a floating pair ghost beside the cursor, and a drop-target highlight); **click two players** → swap just those two (`ScoreArrangement` re-scores, no re-solve); **click a player's lock dot** → pin/unpin them (preview-local) which **regenerates** the candidates around that lock. The **header is a two-line summary** on the same columns as the rows: top line — team label (outer) + each team's predicted **win %** (column) + the match **quality** % in the centre; bottom line — total mean (column) with the summed deviation muted `(±N)`, and the **gap total** (direction arrow) in the centre. The status line carries only situational notes (the odd one out, or why it can't balance). Every value + control has a hover **tooltip** explaining it (win / quality / gap / total / deviation / player / lock / candidate browser). While open it **pins the lobby** (`SlotsPinned`) so the roster can't shift under the proposal (it would go stale); it remembers the prior pin state — **Cancel** restores it, **Apply** leaves it pinned so the balance holds. **Apply** commits only `Arrangement` (→ `RequestApplyBalance`); **Cancel** discards everything (locks included). A transient picker, owns no synced state (beyond toggling the shared pin while open). | | [CustomLobbyContextMenu.lua](CustomLobbyContextMenu.lua) | generic framed floating menu; `Show(entries, x, y)` renders any `{label, action, enabled}` list, dismisses on item click / click-outside / Esc. Knows nothing about the lobby. | | [CustomLobbyMenus.lua](CustomLobbyMenus.lua) | declarative menu **definitions**: entry lists with `when(ctx)`/`action(ctx)` filtered by lobby state (`BuildSlotMenu`). Adding/state-gating an item is a one-liner here. | -| [slots/](slots/) | the **slot subsystem** (its own folder, a sub-folder per layout). [`CustomLobbySlotsInterface`](slots/CustomLobbySlotsInterface.lua) is the entry the composition root mounts: a "Players" header over the **active layout body**, picked by the AutoTeams mode — one-column for the non-team modes, the two-column team layout for the binary modes (left/right, top/bottom, even/odd). It observes `GameOptions` and swaps the body when the kind flips (create-on-mode / destroy-on-switch); a change *within* the binary modes is handled by the two-column body's own re-layout. It is the rows' **drag coordinator** (`UICustomLobbySlotCoordinator`: hit-test which row a point is in, drop-highlight, drag ghost → `RequestSwapSlots`) for *every* layout — it alone needs to hit-test across rows — so the layout bodies stay pure build/place/reveal and never duplicate the drag logic; it also exposes `PreferredHeight()` (computed from the mode + model, swap-order-independent) so the root sizes the slot area. The header band carries a **host-only tool strip** (right-aligned: pin seating · auto-balance · reopen closed slots) of small icon buttons (local `SlotTool`, mirroring the config column's `PreviewTool`); the pin button is lit from the synced `SlotsPinned` and the strip is hidden for clients. The **auto-balance** button is gated (`SlotTool:SetEnabled`, greyed when off) to a **resolved binary AutoTeams mode** (the derived `Teams` aggregate's `Mode` + `Resolved` — two well-defined sides, map loaded for the positional ones) and opens the [balance preview](CustomLobbyBalancePreview.lua) rather than re-seating on click. A **"Locked" notice** (lock glyph + label) sits right of the "Players" label and is shown to **everyone** while `SlotsPinned` is on, so a client can see seating is host-controlled before clicking an open slot to no effect. [`CustomLobbySlotBase`](slots/CustomLobbySlotBase.lua) holds **all slot behaviour** (one subscription to the [slots derived model](models/derived/CustomLobbySlotsDerivedModel.lua) — which already merged this seat's player / placement / closed / CPU benchmark and did the faction/CPU formatting — click/right-click/drag-to-swap + intents, the background/highlight/click overlays + a gold left-edge **lock stripe** shown when the seat's `Locked` flag is set) and the default `RenderPlayer`/`RenderCpu` that paint the standard named controls from the entry's resolved views; a presentation subclasses it and implements just `CreateContents` / `LayoutContents`. [`onecolumn/`](slots/onecolumn/): [`CustomLobbyOneColumnSlots`](slots/onecolumn/CustomLobbyOneColumnSlots.lua) (stacks thin rows, reveals 1..count) + [`CustomLobbySlotRow`](slots/onecolumn/CustomLobbySlotRow.lua) (thin one-line presentation). [`twocolumn/`](slots/twocolumn/): [`CustomLobbyTwoColumnSlots`](slots/twocolumn/CustomLobbyTwoColumnSlots.lua) (splits slots into two team columns by each seat's `Side` from the [slots derived model](models/derived/CustomLobbySlotsDerivedModel.lua) — no longer re-resolving the split itself — with a [`CustomLobbyTeamScore`](CustomLobbyTeamScore.lua) strip across the top as the Left/Right indicator, and the "two columns, unresolved" fallback — parity fill + the score self-hides until a positional map's start positions load) + [`CustomLobbySlotCard`](slots/twocolumn/CustomLobbySlotCard.lua) (fat half-width / two-line presentation; `SetMirrored` flips the right column's cards so the two teams face each other). Each body exports `HeightForCount`. | +| [slots/](slots/) | the **slot subsystem** (its own folder, a sub-folder per layout). [`CustomLobbySlotsInterface`](slots/CustomLobbySlotsInterface.lua) is the entry the composition root mounts: a "Players" header over the **active layout body**, picked by the AutoTeams mode — one-column for the non-team modes, the two-column team layout for the binary modes (left/right, top/bottom, even/odd). It observes `GameOptions` and swaps the body when the kind flips (create-on-mode / destroy-on-switch); a change *within* the binary modes is handled by the two-column body's own re-layout. It is the rows' **drag coordinator** (`UICustomLobbySlotCoordinator`: hit-test which row a point is in, drop-highlight, drag ghost → `RequestSwapSlots`) for *every* layout — it alone needs to hit-test across rows — so the layout bodies stay pure build/place/reveal and never duplicate the drag logic; it also exposes `PreferredHeight()` (computed from the mode + model, swap-order-independent) so the root sizes the slot area. The header band carries a **host-only tool strip** (right-aligned: pin seating · auto teams · auto-balance · reopen closed slots) of small icon buttons (local `SlotTool`, mirroring the config column's `PreviewTool`); the pin button is lit from the synced `SlotsPinned` and the strip is hidden for clients. The **auto-teams** button is a quick-cycle that steps `AutoTeams` through none → top/bottom → left/right → odd/even → manual (`RequestSetGameOptions`); its glyph is the per-mode `/BUTTON/autoteam//` art (kept in sync from the `GameOptions` observer), so it shows the current mode and the slot layout follows. The **auto-balance** button is gated (`SlotTool:SetEnabled`, greyed when off) to a **resolved binary AutoTeams mode** (the derived `Teams` aggregate's `Mode` + `Resolved` — two well-defined sides, map loaded for the positional ones) and opens the [balance preview](CustomLobbyBalancePreview.lua) rather than re-seating on click. A **"Locked" notice** (lock glyph + label) sits right of the "Players" label and is shown to **everyone** while `SlotsPinned` is on, so a client can see seating is host-controlled before clicking an open slot to no effect. [`CustomLobbySlotBase`](slots/CustomLobbySlotBase.lua) holds **all slot behaviour** (one subscription to the [slots derived model](models/derived/CustomLobbySlotsDerivedModel.lua) — which already merged this seat's player / placement / closed / CPU benchmark and did the faction/CPU formatting — click/right-click/drag-to-swap + intents, the background/highlight/click overlays + a gold left-edge **lock stripe** shown when the seat's `Locked` flag is set) and the default `RenderPlayer`/`RenderCpu` that paint the standard named controls from the entry's resolved views; a presentation subclasses it and implements just `CreateContents` / `LayoutContents`. [`onecolumn/`](slots/onecolumn/): [`CustomLobbyOneColumnSlots`](slots/onecolumn/CustomLobbyOneColumnSlots.lua) (stacks thin rows, reveals 1..count) + [`CustomLobbySlotRow`](slots/onecolumn/CustomLobbySlotRow.lua) (thin one-line presentation). [`twocolumn/`](slots/twocolumn/): [`CustomLobbyTwoColumnSlots`](slots/twocolumn/CustomLobbyTwoColumnSlots.lua) (splits slots into two team columns by each seat's `Side` from the [slots derived model](models/derived/CustomLobbySlotsDerivedModel.lua) — no longer re-resolving the split itself — with a [`CustomLobbyTeamScore`](CustomLobbyTeamScore.lua) strip across the top as the Left/Right indicator, and the "two columns, unresolved" fallback — parity fill + the score self-hides until a positional map's start positions load) + [`CustomLobbySlotCard`](slots/twocolumn/CustomLobbySlotCard.lua) (fat half-width / two-line presentation; `SetMirrored` flips the right column's cards so the two teams face each other). Each body exports `HeightForCount`. | | [CustomLobbyObserversInterface.lua](CustomLobbyObserversInterface.lua) | observer strip; subscribes to the model's `Observers` list and shows the count + names (read-only). | | [CustomLobbyScenarioPreview.lua](CustomLobbyScenarioPreview.lua) | **shared** map-preview *surface*: the scenario's map texture + overlays (start spots, resource/wreck markers, plus a **dummy translucent `WaterMask`** placeholder until a real mask exists) with aspect-correct positioning, texture-leak-safe icon sharing, per-group visibility (`SetOverlayVisible('spawns'\|'resources'\|'wrecks'\|'water', …)`), and three-phase init. A **pure renderer**: `SetScenario(info, markers)` takes already-extracted markers (`UICustomLobbyScenarioMarkers`) and never touches the raw save. Chrome-free; `CustomLobbyMapPreview` wraps it with the frame. Spawn appearance is the owner's via a `CreateSpawnIcon` factory. | | [CustomLobbyMapPreview.lua](CustomLobbyMapPreview.lua) | the map preview **as one whole** — the chrome (glow border on top + dark backdrop, surface inset by `Padding`), the surface, and the faction spawn icon (local `MapPreviewSpawn`). Used by **both** consumers: created `Bound = true` it subscribes to [`CustomLobbyScenarioDerivedModel`](models/derived/CustomLobbyScenarioDerivedModel.lua) and renders the resolved scenario with per-slot faction spawns (no reload on take/swap, and no reload on a same-map rebroadcast — the model dedups); created unbound (the map-select dialog) it does no model wiring and the owner drives `preview.Surface` directly (numbered-dot spawns), feeding it markers from the catalog's `LoadMarkers`. Exposes `.Surface` for owners to drive / anchor overlays to. | diff --git a/lua/ui/lobby/customlobby/CustomLobbyBalancePreview.lua b/lua/ui/lobby/customlobby/CustomLobbyBalancePreview.lua index de6e15b865e..ccb768d2aff 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyBalancePreview.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyBalancePreview.lua @@ -50,7 +50,7 @@ -- * **Click a player's lock** → pin / unpin them, which **regenerates** the candidates around that -- constraint (the locked player held at its seat, the rest re-balanced). -- Moved players are gold, locked players blue. The status line carries only situational notes (the odd --- one left in place, or why it can't balance). +-- one left in place, or why it can't balance). Every value / control has a hover tooltip (see `Help`). -- -- Built to the preset dialog's shape (areas layout, three-phase init, Popup singleton). @@ -61,6 +61,7 @@ local Group = import("/lua/maui/group.lua").Group local Bitmap = import("/lua/maui/bitmap.lua").Bitmap local Dragger = import("/lua/maui/dragger.lua").Dragger local Popup = import("/lua/ui/controls/popups/popup.lua").Popup +local Tooltip = import("/lua/ui/game/tooltip.lua") local CustomLobbySlotsDerivedModel = import("/lua/ui/lobby/customlobby/models/derived/customlobbyslotsderivedmodel.lua") local CustomLobbySessionModel = import("/lua/ui/lobby/customlobby/models/customlobbysessionmodel.lua") @@ -99,6 +100,35 @@ local GapLowColor = 'ff66bb66' -- a close pair local GapMidColor = 'ffd9c97a' -- a noticeable gap local GapHighColor = 'ffcc6666' -- a lopsided pair +-- hover tooltips explaining each value / control { title, body } +local Help = { + Win = { "Win chance", + "The chance this team wins. It comes from the player ratings. The two teams add up to 100%." }, + Quality = { "Match quality", + "How even the two teams are. A higher number means a closer match." }, + Gap = { "Rating gap", + "How far apart the two teams are in rating. The arrow points to the stronger team. A smaller number is more balanced." }, + Total = { "Team rating", + "The team's total rating. It is the sum of the players' ratings." }, + Deviation = { "Rating uncertainty", + "How unsure we are about the team's rating. It is the sum of the players' deviations. A higher number means the ratings are less certain." }, + Player = { "Player rating", + "The player's rating. The number in brackets is the deviation, which is how unsure we are about that rating. Click two players to swap them. Drag the row to move the pair to another spot." }, + Lock = { "Lock in place", + "Pin this player to their seat. Auto-balance keeps locked players where they are. Only the other players get moved." }, + Candidate = { "Other balances", + "Step through the other balanced line-ups. The best one is shown first." }, +} + +--- Attaches an explanatory hover tooltip to a control (re-enabling hit testing, since the value texts +--- are hit-disabled so the row's click / drag passes through to the catcher beneath them). +---@param control Control +---@param help string[] # { title, body } +local function AddHelp(control, help) + control:EnableHitTest() + Tooltip.AddControlTooltipManual(control, help[1], help[2]) +end + --- Creates an invisible layout area (tinted while Debug is on). ---@param parent Control ---@param name string @@ -298,7 +328,9 @@ local CustomLobbyBalancePreview = ClassUI(Group) { -- the bottom line of the header: each team's predicted win % (under its total) + the match -- quality (under the gap). HeaderTop / HeaderBottom are vertical-reference bands only. self.HeaderTop = Group(self.HeaderArea, "BalanceHeaderTop") + self.HeaderTop:DisableHitTest() self.HeaderBottom = Group(self.HeaderArea, "BalanceHeaderBottom") + self.HeaderBottom:DisableHitTest() self.TeamWinA = UIUtil.CreateText(self.HeaderArea, "", 14, UIUtil.titleFont) self.TeamWinA:SetColor(HeaderColor) self.TeamWinA:DisableHitTest() @@ -309,6 +341,16 @@ local CustomLobbyBalancePreview = ClassUI(Group) { self.HeaderQuality:SetColor(HeaderColor) self.HeaderQuality:DisableHitTest() + -- explanatory hover tooltips on the header values + AddHelp(self.TeamWinA, Help.Win) + AddHelp(self.TeamWinB, Help.Win) + AddHelp(self.HeaderQuality, Help.Quality) + AddHelp(self.HeaderGap, Help.Gap) + AddHelp(self.TeamTotalA, Help.Total) + AddHelp(self.TeamTotalB, Help.Total) + AddHelp(self.TeamDevA, Help.Deviation) + AddHelp(self.TeamDevB, Help.Deviation) + -- a fixed pool of interactive position rows; Render shows/hides + fills them from the proposal self.Rows = {} for i = 1, MaxRows do @@ -429,6 +471,13 @@ local CustomLobbyBalancePreview = ClassUI(Group) { return false end + -- hover tooltips: the click/drag halves explain the player values + interaction, the dots the lock + -- (AddControlTooltipManual chains the existing handlers, so click / drag / toggle still fire) + AddHelp(row.LeftSelect, Help.Player) + AddHelp(row.RightSelect, Help.Player) + AddHelp(row.LeftLock, Help.Lock) + AddHelp(row.RightLock, Help.Lock) + return row end, @@ -454,6 +503,7 @@ local CustomLobbyBalancePreview = ClassUI(Group) { label:DisableHitTest() Layouter(label):AtHorizontalCenterIn(arrow):AtVerticalCenterIn(arrow):End() arrow.Label = label + AddHelp(arrow, Help.Candidate) return arrow end, @@ -551,8 +601,8 @@ local CustomLobbyBalancePreview = ClassUI(Group) { Layouter(self.NavNext):AnchorToRight(self.NavLabel, 6):AtVerticalCenterIn(self.ActionArea) :Width(NavSize):Height(NavSize):End() - Layouter(self.CancelButton):AtRightIn(self.ActionArea):AtVerticalCenterIn(self.ActionArea):End() - Layouter(self.ApplyButton):AnchorToLeft(self.CancelButton, ButtonGap):AtVerticalCenterIn(self.ActionArea):End() + Layouter(self.ApplyButton):AtRightIn(self.ActionArea):AtVerticalCenterIn(self.ActionArea):End() + Layouter(self.CancelButton):AnchorToLeft(self.ApplyButton, ButtonGap):AtVerticalCenterIn(self.ActionArea):End() end, --- Post-mount render (the opener calls this after Popup centres the dialog, so the rows read diff --git a/lua/ui/lobby/customlobby/CustomLobbyController.lua b/lua/ui/lobby/customlobby/CustomLobbyController.lua index 8fdc40e2f05..3f6f70afcfd 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyController.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyController.lua @@ -111,6 +111,18 @@ function FindNameForOwner(ownerId) end return tostring(ownerId) end + +--- A set (`key -> true`) from a list of keys, so an order-independent `table.equal` can compare two +--- key lists (the restrictions list is order-independent, like the mods/options keyed tables already are). +---@param keys string[] +---@return table +local function KeySet(keys) + local set = {} + for _, key in keys do + set[key] = true + end + return set +end ---@return table local function GatherPlayers() local launch = CustomLobbyLaunchModel.GetSingleton() @@ -758,7 +770,9 @@ function RequestSetScenario(scenarioFile) return end - CustomLobbyLaunchModel.SetScenario(CustomLobbyLaunchModel.GetSingleton(), scenarioFile) + local launch = CustomLobbyLaunchModel.GetSingleton() + local changed = scenarioFile ~= launch.ScenarioFile() + CustomLobbyLaunchModel.SetScenario(launch, scenarioFile) -- size the lobby room to the map's start spots, so all of its slots show (capped at MaxSlots) local count = ScenarioSlotCount(scenarioFile) @@ -770,9 +784,12 @@ function RequestSetScenario(scenarioFile) BroadcastLaunchInfo(instance) - -- the scenario derived model resolved the new map synchronously (its observer fired on the Set above) - local scenario = import("/lua/ui/lobby/customlobby/models/derived/customlobbyscenarioderivedmodel.lua").GetScenario() - BroadcastSystemNotice(instance, "Host changed the map to " .. ((scenario and scenario.Name) or "a new map") .. ".") + -- announce only a real map change (re-selecting the same map is a no-op for the derived state) + if changed then + -- the scenario derived model resolved the new map synchronously (its observer fired on the Set) + local scenario = import("/lua/ui/lobby/customlobby/models/derived/customlobbyscenarioderivedmodel.lua").GetScenario() + BroadcastSystemNotice(instance, "Host changed the map to " .. ((scenario and scenario.Name) or "a new map") .. ".") + end end --- The host sets the active sim mods. Host-only — backs the mod-select dialog. Sets `GameMods` @@ -790,9 +807,14 @@ function RequestSetGameMods(gameMods) return end - CustomLobbyLaunchModel.SetGameMods(CustomLobbyLaunchModel.GetSingleton(), gameMods) + local launch = CustomLobbyLaunchModel.GetSingleton() + -- a set (uuid -> true): table.equal is order-independent, matching the mods derived model's dedup + local changed = not table.equal(gameMods, launch.GameMods()) + CustomLobbyLaunchModel.SetGameMods(launch, gameMods) BroadcastLaunchInfo(instance) - BroadcastSystemNotice(instance, "Host changed the game mods (" .. table.getsize(gameMods) .. " active).") + if changed then + BroadcastSystemNotice(instance, "Host changed the game mods (" .. table.getsize(gameMods) .. " active).") + end end --- The host sets the unit restrictions. Host-only — backs the unit-select dialog and a @@ -813,10 +835,16 @@ function RequestSetRestrictions(keys) return end - CustomLobbyLaunchModel.SetRestrictions(CustomLobbyLaunchModel.GetSingleton(), keys) + local launch = CustomLobbyLaunchModel.GetSingleton() + -- key the lists into sets so table.equal is order-independent (restrictions is a list, unlike the + -- mods/options keyed tables) — a mere reorder of the same keys is not a change + local changed = not table.equal(KeySet(keys), KeySet(launch.Restrictions())) + CustomLobbyLaunchModel.SetRestrictions(launch, keys) LOG("CustomLobby: restrictions set (" .. table.getn(keys) .. ")") BroadcastLaunchInfo(instance) - BroadcastSystemNotice(instance, "Host changed the unit restrictions (" .. table.getn(keys) .. ").") + if changed then + BroadcastSystemNotice(instance, "Host changed the unit restrictions (" .. table.getn(keys) .. ").") + end end --- The host sets the game options. Host-only — backs the options dialog. Replaces the whole @@ -834,9 +862,15 @@ function RequestSetGameOptions(options) return end - CustomLobbyLaunchModel.SetGameOptions(CustomLobbyLaunchModel.GetSingleton(), options) + local launch = CustomLobbyLaunchModel.GetSingleton() + -- compare the value tables (matching the options derived model's publish dedup); a no-op apply + -- (the host opened the dialog and clicked OK without changing anything) announces nothing + local changed = not table.equal(options, launch.GameOptions()) + CustomLobbyLaunchModel.SetGameOptions(launch, options) BroadcastLaunchInfo(instance) - BroadcastSystemNotice(instance, "Host changed the game options.") + if changed then + BroadcastSystemNotice(instance, "Host changed the game options.") + end end --- The host resets every game option to its default. Host-only — backs the lobby's "Reset diff --git a/lua/ui/lobby/customlobby/slots/CustomLobbySlotsInterface.lua b/lua/ui/lobby/customlobby/slots/CustomLobbySlotsInterface.lua index 866a9b416d8..53436b85c0f 100644 --- a/lua/ui/lobby/customlobby/slots/CustomLobbySlotsInterface.lua +++ b/lua/ui/lobby/customlobby/slots/CustomLobbySlotsInterface.lua @@ -74,6 +74,31 @@ local PinnedIcon = '/game/menu-btns/pinned_btn_up.dds' -- pinned (toggle on / local BalanceIcon = '/BUTTON/autobalance/_btn_up.dds' local ReopenIcon = '/game/recall-panel/icon-recall_bmp.dds' +-- the auto-teams quick-cycle button steps through these modes in order (matches the options dropdown); +-- its glyph is the faction-skinned per-mode art the legacy lobby used (/BUTTON/autoteam//). +local AutoTeamOrder = { 'none', 'tvsb', 'lvsr', 'pvsi', 'manual' } + +--- The button glyph for an auto-teams mode. +---@param mode string +---@return FileName +local function AutoTeamIcon(mode) + return '/BUTTON/autoteam/' .. mode .. '/_btn_up.dds' +end + +--- The next mode in the cycle after `current` (wraps). +---@param current string +---@return string +local function NextAutoTeam(current) + local index = 1 + for i, mode in ipairs(AutoTeamOrder) do + if mode == current then + index = i + break + end + end + return AutoTeamOrder[math.mod(index, table.getn(AutoTeamOrder)) + 1] +end + --- A small square icon button for the header tool strip: a solid background that lights on hover, --- plus an `Active` state (driven externally for the pin toggle) that lights it the "on" colour. A --- toggle can pass a second `activeTexture` so the icon glyph swaps while active (the pin shows the @@ -171,6 +196,15 @@ local SlotTool = Class(Group) { self.Enabled = enabled and true or false self:ApplyVisual() end, + + --- Swaps the icon glyph (for a cycling action like the auto-teams button, whose glyph reflects the + --- current mode). Plain actions don't paint the icon in ApplyVisual, so this is safe to drive externally. + ---@param self UICustomLobbySlotTool + ---@param texture FileName + SetIcon = function(self, texture) + self.IdleTexture = texture + self.Icon:SetTexture(UIUtil.UIFile(texture)) + end, } ---@alias UICustomLobbySlotsBody UICustomLobbyOneColumnSlots | UICustomLobbyTwoColumnSlots @@ -182,6 +216,7 @@ local SlotTool = Class(Group) { ---@field LockLabel Text # "Locked" notice, visible to everyone while pinned ---@field Tools Group # host-only tool strip (right of the header) ---@field PinButton UICustomLobbySlotTool +---@field AutoTeamsButton UICustomLobbySlotTool ---@field BalanceButton UICustomLobbySlotTool ---@field ReopenButton UICustomLobbySlotTool ---@field Body UICustomLobbySlotsBody | false # the active layout body @@ -233,6 +268,17 @@ local CustomLobbySlotsInterface = Class(Group) { Tooltip.AddControlTooltipManual(self.PinButton.Bg, "Pin slots", "Lock seating so only you (the host) can move players between slots.") + -- cycles the AutoTeams mode; its glyph shows the current mode and the slot layout follows + self.AutoTeamsButton = SlotTool(self.Tools, AutoTeamIcon('none')) + self.AutoTeamsButton.OnPress = function() + local launch = CustomLobbyLaunchModel.GetSingleton() + local options = table.copy(launch.GameOptions()) + options.AutoTeams = NextAutoTeam(options.AutoTeams or 'none') + CustomLobbyController.RequestSetGameOptions(options) + end + Tooltip.AddControlTooltipManual(self.AutoTeamsButton.Bg, "Auto teams", + "Choose how teams form from the start spots. Click to step through: None, Top vs Bottom, Left vs Right, Odd vs Even, Manual.") + self.BalanceButton = SlotTool(self.Tools, BalanceIcon) self.BalanceButton.OnPress = function() CustomLobbyBalancePreview.Open() @@ -306,14 +352,16 @@ local CustomLobbySlotsInterface = Class(Group) { Layouter(self.Tools) :AtRightIn(self, 4) :AtVerticalCenterIn(self.Header) - :Width(3 * ToolSize + 2 * ToolGap):Height(ToolSize) + :Width(4 * ToolSize + 3 * ToolGap):Height(ToolSize) :End() local function placeTool(tool) return Layouter(tool):AtTopIn(self.Tools):Width(ToolSize):Height(ToolSize) end + -- reading left to right: Pin · Auto teams · Balance · Reopen placeTool(self.ReopenButton):AtRightIn(self.Tools):End() placeTool(self.BalanceButton):LeftOf(self.ReopenButton, ToolGap):End() - placeTool(self.PinButton):LeftOf(self.BalanceButton, ToolGap):End() + placeTool(self.AutoTeamsButton):LeftOf(self.BalanceButton, ToolGap):End() + placeTool(self.PinButton):LeftOf(self.AutoTeamsButton, ToolGap):End() self.Mounted = true self:LayoutBody() @@ -378,6 +426,9 @@ local CustomLobbySlotsInterface = Class(Group) { --- within the same kind (e.g. lvsr→tvsb) is handled by the body's own re-layout. ---@param self UICustomLobbySlotsInterface OnAutoTeamsChanged = function(self) + -- keep the quick-cycle button's glyph in sync with the mode (also catches changes from the + -- options dialog or a reset, not just the button itself) + self.AutoTeamsButton:SetIcon(AutoTeamIcon(CustomLobbyLaunchModel.GetSingleton().GameOptions().AutoTeams or 'none')) if self:KindForMode() ~= self.LayoutKind then self:RebuildBody() end diff --git a/lua/ui/lobby/customlobby/social/chat-design.md b/lua/ui/lobby/customlobby/social/chat-design.md index 4201c1c3c17..446d57c8c39 100644 --- a/lua/ui/lobby/customlobby/social/chat-design.md +++ b/lua/ui/lobby/customlobby/social/chat-design.md @@ -80,14 +80,18 @@ the same mechanism either way. snapshot. Host-broadcast is one symmetric path with no diff and no baseline-flag. - Wired: **join** (`ProcessAddPlayer`), **leave** (`OnPeerDisconnected`), **kick** (`RequestEject` adds "Host removed X."; a human kick also produces the disconnect's "left" line — two lines is fine), - **map / mods / options / restrictions** changes (the `RequestSet*` intents, which fire + **map / mods / options / restrictions** changes — each `RequestSet*` intent compares the incoming + value against the current launch-model value (the same inputs the derived models dedup on: file / + mod set / option values / order-independent restriction keys) and announces **only on a real + change**, so a no-op apply (open the dialog, click OK, nothing changed) is silent. They fire once per action — *not* on snapshot rebroadcasts, so no spam), **seat swap / move** (`SwapSlots`, covering host-initiated and any client-requested swap), **move-to-observers** (`RequestMoveToObserver`), and **auto-balance applied** (`RequestApplyBalance`). - Not yet emitted (deliberately, as noise-prone): ready toggles, slot takes, faction/colour picks. Per-option diffs ("changed Unit Cap to 1000") would read better than the generic "changed the game options" but need an old-vs-new compare in the intent. -- **Refinements.** Multi-line wrapping (slice 1 truncates one line per row — the in-game - [`ChatLinesInterface`](/lua/ui/game/chat/ChatLinesInterface.lua) is the reference); unread-since-last-view - badge count (slice 1 shows the total line count). +- **Refinements.** Multi-line wrapping is **done** — `CustomLobbyChatPanel.BuildLines`/`WrapLine` wrap + each entry's label to the message-column width (via [`/lua/maui/text.lua`](/lua/maui/text.lua) + `WrapText`, measured by a hidden font-matched text) and emit one fixed-height grid row per wrapped line. + Still open: unread-since-last-view badge count (the badge shows the total line count). ``` From 582c7ea01758347a15d0a870a20753e2a8ee359a Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 28 Jun 2026 01:27:42 +0200 Subject: [PATCH 87/98] Add support for wrapping long chat messages --- .../social/CustomLobbyChatPanel.lua | 113 ++++++++++++------ 1 file changed, 77 insertions(+), 36 deletions(-) diff --git a/lua/ui/lobby/customlobby/social/CustomLobbyChatPanel.lua b/lua/ui/lobby/customlobby/social/CustomLobbyChatPanel.lua index 67e57396a20..79cf4753f8c 100644 --- a/lua/ui/lobby/customlobby/social/CustomLobbyChatPanel.lua +++ b/lua/ui/lobby/customlobby/social/CustomLobbyChatPanel.lua @@ -31,8 +31,9 @@ -- reconciles the line to Confirmed (see CustomLobbyChatModel). A line's Status tints it: Pending dim, -- Rejected greyed, system lines in a muted colour. -- --- Slice 1: each row is a single, truncated line ("Name: text"). Multi-line wrapping is a later --- refinement (the in-game chat's ChatLinesInterface is the reference if/when we want it). +-- Long messages **wrap**: each entry's "Name: text" label is word-wrapped to the message-column width +-- (BuildLines + WrapLine, via /lua/maui/text.lua WrapText measured by a hidden font-matched text), and +-- each wrapped line becomes one fixed-height grid row — the time stamp rides only the entry's first line. -- -- A bottom-left tab content component: created when its tab is selected and destroyed on switch (see -- ../CustomLobbyTabs.lua), so it's the live panel for its whole lifetime — the model observer just @@ -52,6 +53,7 @@ local Debug = false local CustomLobbyChatModel = import("/lua/ui/lobby/customlobby/social/customlobbychatmodel.lua") local CustomLobbyChatController = import("/lua/ui/lobby/customlobby/social/customlobbychatcontroller.lua") +local WrapText = import("/lua/maui/text.lua").WrapText local LazyVarDerive = import("/lua/lazyvar.lua").Derive local Layouter = LayoutHelpers.ReusedLayoutFor @@ -64,7 +66,6 @@ local Pad = 4 local EditHeight = 20 local EditGap = 6 local RowFont = 13 -local NameMax = 90 -- truncate the rendered line so it can't bleed past the row (no clip in MAUI) -- the input field: a bordered, filled box at the bottom so it's clear where to type local FieldHeight = 24 @@ -95,19 +96,8 @@ local function FormatClock(seconds) return string.format("%02d:%02d", math.floor(whole / 60), math.mod(whole, 60)) end ---- Truncates `text` to `maxChars`, appending "…" when it had to cut. ----@param text string ----@param maxChars number ----@return string -local function Truncate(text, maxChars) - text = text or "" - if string.len(text) > maxChars then - return string.sub(text, 1, maxChars - 1) .. "…" - end - return text -end - ---- The rendered one-line label + colour for an entry, by kind and status. +--- The rendered label + colour for an entry, by kind and status. The label is wrapped to the message +--- column width (see WrapLines), so the whole "Name: text" string flows across as many rows as it needs. ---@param entry UICustomLobbyChatEntry ---@return string label ---@return string color @@ -135,6 +125,7 @@ end ---@field EditBox Edit ---@field Prompt Text # dim "type a message" placeholder, shown while the box is empty ---@field Empty Text +---@field Measure Text # hidden, font-matched text used only to measure widths for wrapping ---@field EntriesObserver LazyVar ---@field DebugPanel? Bitmap ---@field DebugEdit? Bitmap @@ -156,6 +147,12 @@ local CustomLobbyChatPanel = ClassUI(Group) { self.Empty:DisableHitTest() self.Empty:Hide() + -- a hidden, font-matched text whose :GetStringAdvance measures wrap widths (same font/size as a + -- row's message text, so the measurement matches what's rendered) + self.Measure = UIUtil.CreateText(self, "", RowFont, UIUtil.bodyFont) + self.Measure:DisableHitTest() + self.Measure:Hide() + -- a bordered, filled input field at the bottom so it's clear where to type. Created before the -- edit box (and prompt) so they render on top of it (sibling depth follows creation order). self.EditFrame = Bitmap(self) @@ -226,6 +223,7 @@ local CustomLobbyChatPanel = ClassUI(Group) { Layouter(self.Prompt):AtLeftIn(self.EditField, FieldInset):AtVerticalCenterIn(self.EditField):End() Layouter(self.Empty):AtHorizontalCenterIn(self):AtTopIn(self, Pad):End() + Layouter(self.Measure):AtLeftTopIn(self, 0):End() -- hidden; only its font metrics are used if Debug then -- whole panel (drawn under the grid tint added in Initialize) + the edit box region @@ -298,13 +296,14 @@ local CustomLobbyChatPanel = ClassUI(Group) { return end - local entries = CustomLobbyChatModel.GetSingleton().Entries() - local total = table.getn(entries) - -- recompute the row width from the (now-settled) panel width so rows size correctly even if the -- first Initialize ran before the layout settled self.RowWidth = self:ComputeRowWidth() + -- flatten entries into visual lines (one per wrapped line); a long message spans several rows + local lines = self:BuildLines(CustomLobbyChatModel.GetSingleton().Entries()) + local total = table.getn(lines) + -- Bottom-align the feed (chat grows upward from the input box). A Grid renders rows top-down, so -- a few lines would otherwise float at the top of the tall area, far from the edit box. Pin the -- grid's bottom to the edit box (done in Initialize) and float its top so the grid is only as @@ -330,8 +329,8 @@ local CustomLobbyChatPanel = ClassUI(Group) { self.Grid:AppendCols(1, true) self.Grid:AppendRows(total, true) - for index, entry in entries do - self.Grid:SetItem(self:CreateRow(entry), 1, index, true) + for index, line in lines do + self.Grid:SetItem(self:CreateRow(line), 1, index, true) end self.Grid:EndBatch() @@ -341,6 +340,48 @@ local CustomLobbyChatPanel = ClassUI(Group) { self:UpdateScrollbar() end, + --- Flattens the entries into renderable lines: each entry's "Name: text" label is wrapped to the + --- message-column width, yielding one descriptor per wrapped line. The time stamp rides the first + --- line of each entry; continuation lines align under the message column with no stamp. + ---@param self UICustomLobbyChatPanel + ---@param entries UICustomLobbyChatEntry[] + ---@return { Stamp: string | false, Text: string, Color: string }[] + BuildLines = function(self, entries) + local width = LayoutHelpers.ScaleNumber(self.RowWidth - NameLeft - Pad) + local start = CustomLobbyChatModel.GetStartTime() + local lines = {} + for _, entry in entries do + local label, color = RenderEntry(entry) + local stamp = FormatClock(entry.Time - start) + local wrapped = self:WrapLine(label, width) + for i, text in wrapped do + table.insert(lines, { + Stamp = (i == 1) and stamp or false, + Text = text, + Color = color, + }) + end + end + return lines + end, + + --- Wraps `label` to `width` (actual pixels) via the hidden measuring text. Never returns empty. + ---@param self UICustomLobbyChatPanel + ---@param label string + ---@param width number + ---@return string[] + WrapLine = function(self, label, width) + if width < 1 then + return { label } + end + local measure = self.Measure + local lines = WrapText(label, width, function(chunk) return measure:GetStringAdvance(chunk) end) + if table.empty(lines) then + lines = { label } + end + return lines + end, + --- Shows the scrollbar only when the list overflows. ---@param self UICustomLobbyChatPanel UpdateScrollbar = function(self) @@ -354,26 +395,26 @@ local CustomLobbyChatPanel = ClassUI(Group) { end end, - --- Builds one chat row: a single truncated line, tinted by status/kind. Private. + --- Builds one rendered row: the wrapped text in the message column, tinted by status/kind, with the + --- mm:ss stamp at the left only on an entry's first line (continuation lines have no stamp). Private. ---@param self UICustomLobbyChatPanel - ---@param entry UICustomLobbyChatEntry + ---@param line { Stamp: string | false, Text: string, Color: string } ---@return Group - CreateRow = function(self, entry) + CreateRow = function(self, line) local row = Group(self.Grid, "CustomLobbyChatRow") LayoutHelpers.SetDimensions(row, self.RowWidth, RowHeight) - -- a relative mm:ss stamp at the left (entries store absolute time; the feed's start is the baseline) - local time = UIUtil.CreateText(row, - FormatClock(entry.Time - CustomLobbyChatModel.GetStartTime()), TimeFont, UIUtil.bodyFont) - time:SetColor(TimeColor) - time:DisableHitTest() - Layouter(time):AtLeftIn(row, TimeLeft):AtVerticalCenterIn(row):End() - - local label, color = RenderEntry(entry) - local line = UIUtil.CreateText(row, Truncate(label, NameMax), RowFont, UIUtil.bodyFont) - line:SetColor(color) - line:DisableHitTest() - Layouter(line):AtLeftIn(row, NameLeft):AtRightIn(row, Pad):AtVerticalCenterIn(row):End() + if line.Stamp then + local time = UIUtil.CreateText(row, line.Stamp, TimeFont, UIUtil.bodyFont) + time:SetColor(TimeColor) + time:DisableHitTest() + Layouter(time):AtLeftIn(row, TimeLeft):AtVerticalCenterIn(row):End() + end + + local text = UIUtil.CreateText(row, line.Text, RowFont, UIUtil.bodyFont) + text:SetColor(line.Color) + text:DisableHitTest() + Layouter(text):AtLeftIn(row, NameLeft):AtRightIn(row, Pad):AtVerticalCenterIn(row):End() return row end, From 646b6c8b7bb56f8b18f2478e15838da61bf03137 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 28 Jun 2026 10:17:37 +0200 Subject: [PATCH 88/98] Add basic faction selection --- lua/ui/lobby/USER_STORIES.md | 12 +- lua/ui/lobby/customlobby/CLAUDE.md | 11 +- .../customlobby/CustomLobbyController.lua | 225 +++++++++- .../customlobby/CustomLobbyInterface.lua | 9 +- .../lobby/customlobby/CustomLobbyMessages.lua | 66 +++ .../models/CustomLobbyLaunchModel.lua | 63 ++- .../derived/CustomLobbySlotsDerivedModel.lua | 48 ++- .../customlobby/slots/CustomLobbySlotBase.lua | 285 ++++++++++++- .../slots/CustomLobbySlotPickers.lua | 402 ++++++++++++++++++ .../slots/CustomLobbySlotsInterface.lua | 17 +- .../slots/onecolumn/CustomLobbySlotRow.lua | 59 ++- .../slots/twocolumn/CustomLobbySlotCard.lua | 80 +++- .../social/CustomLobbyChatModel.lua | 24 +- .../social/CustomLobbyChatPanel.lua | 4 + .../lobby/customlobby/social/chat-design.md | 4 +- 15 files changed, 1258 insertions(+), 51 deletions(-) create mode 100644 lua/ui/lobby/customlobby/slots/CustomLobbySlotPickers.lua diff --git a/lua/ui/lobby/USER_STORIES.md b/lua/ui/lobby/USER_STORIES.md index f214bcfe39c..f07c0788334 100644 --- a/lua/ui/lobby/USER_STORIES.md +++ b/lua/ui/lobby/USER_STORIES.md @@ -26,19 +26,19 @@ Roles: **Host**, **Player** (human in a slot), **Observer**, **Joining client** ## B. Slots & players -- 🟡 As a **host**, I want to open or close an empty slot, so that I control how many players can join. *(Closed-slot state is modelled + synced; the open/close control isn't wired.)* +- ✅ As a **host**, I want to open or close an empty slot, so that I control how many players can join. *(Per-seat **Close**/**Open** button on each empty/closed slot + a header **Close empty slots** button → `RequestSetSlotClosed` / `RequestCloseEmptySlots`; closing is rejected on an occupied seat.)* - ⬜ As a **host on an adaptive map**, I want to close a slot as "spawn-mex", so that the position yields mass instead of an ACU. - ✅ As a **player**, I want to occupy an empty open slot, so that I can join the game; *when* I'm not already marked ready. - ✅ As a **host**, I want to move a player to another slot or swap two players, so that I can arrange start positions and teams. *(Drag a row onto another → `RequestSwapSlots`.)* - 🟡 As a **host**, I want to kick a player with an optional message, so that I can remove someone; *and* the message is remembered for next time. *(Eject works; no message / recall.)* -- 🟡 As a **player**, I want each slot row to show name, rating, country, faction, colour, team, ping, CPU and ready state, so that I can assess the lobby at a glance. *(Name, CPU/headroom, ready shown; rating/country/faction/colour/team/ping not yet.)* +- 🟡 As a **player**, I want each slot row to show name, rating, country, faction, colour, team, ping, CPU and ready state, so that I can assess the lobby at a glance. *(Name, colour, faction, team, rating, games, country flag, CPU/headroom, ready shown + a reserved avatar box; ping not yet.)* - ✅ As a **player**, I want a slot's controls to reflect who owns it (me / another player / host / AI) via colour and an appropriate right-click menu, so that I only see actions I'm allowed to take. ## C. Per-player settings -- ⬜ As a **player**, I want to choose my faction (including Random), so that I play what I want; *given* the map allows it and I'm not ready. -- ⬜ As a **player**, I want to pick a colour, so that I'm visually distinct; *when* the colour is free — taken colours are hidden and the host reverts conflicts. -- ⬜ As a **player**, I want to set my team, so that I'm allied correctly; *when* AutoTeams is off and I'm not ready. +- 🟡 As a **player**, I want to choose my faction (including Random), so that I play what I want; *given* the map allows it and I'm not ready. *(Click the faction → a **multi-toggle** picker: tick a subset of factions, more than one = random among them, resolved to one at launch. Gated to your own seat while not ready, or any AI seat for the host. Per-map faction availability isn't enforced yet.)* +- 🟡 As a **player**, I want to pick a colour, so that I'm visually distinct; *when* the colour is free — taken colours are hidden and the host reverts conflicts. *(Click the swatch → a colour grid; colours taken by another seated player are greyed, and the host rejects a taken colour if a race slips through → `RequestSetColor`.)* +- 🟡 As a **player**, I want to set my team, so that I'm allied correctly; *when* AutoTeams is off and I'm not ready. *(Click the team tag → a team picker (No team / Team 1..8); hidden under a binary AutoTeams mode, where the start position decides the team → `RequestSetTeam`.)* - ✅ As a **player**, I want to toggle Ready, so that I signal I'm set; *and when* ready, my own controls lock until I unready. - ⬜ As a **host**, I want to force a player not-ready, so that I can change settings that affect them. - ⬜ As a **player**, I want to pick my start position on the map preview, so that I control where I spawn; *when* spawn is fixed (host gets swap/assign tools otherwise). @@ -161,7 +161,7 @@ Roles: **Host**, **Player** (human in a slot), **Observer**, **Joining client** - ✅ As a **host**, I want the ability to lock players in-place when autobalance is applied so that players that want to play together stay in the same team. *(Slot context menu "Lock in slot"; a locked seat is pinned (synced session state) and the balancer rearranges only the unlocked players around it — see `CustomLobbyBalancer`.)* - ⬜ As a **player**, I want the observer bug that can freeze/crash the lobby fixed — or at least a warning when it's hit — so that observing doesn't break the lobby. *(Reliable repro needs a debuggable lobby setup.)* -- ⬜ As a **player**, I want to randomise among a chosen subset of factions (multi-choice), so that I get variety without pure random. +- ✅ As a **player**, I want to randomise among a chosen subset of factions (multi-choice), so that I get variety without pure random. *(The faction picker is a multi-toggle; the allowed set is stored as `player.Factions` and `BuildGameConfiguration` resolves it to one concrete faction at launch.)* - ⬜ As a **player**, I want my FAF avatar shown in the lobby, so that players are recognisable. - ⬜ As a **host**, I want a "players vs AI" AutoTeams option, so that all humans are teamed against the AIs automatically. - ⬜ As a **host**, I want closing a slot to auto-move its player to a free slot (in opti), so that rearranging doesn't drop anyone. diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md index 2716859ebbf..ad9371df7c0 100644 --- a/lua/ui/lobby/customlobby/CLAUDE.md +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -25,7 +25,7 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` - **[CustomLobbyLaunchModel.lua](models/CustomLobbyLaunchModel.lua)** — shared **and** launched: the launch payload (players, observers, scenario, game options, mods, auto-teams, spawn - mex, unit restrictions) + `MaxSlots` + the `UICustomLobbyPlayer` shape. `Players` is an **array of per-slot + mex, unit restrictions) + `MaxSlots` + the `UICustomLobbyPlayer` shape (incl. the faction multi-select `Factions` + its representative `Faction`; `RealFactionCount` / `RepresentativeFaction` / `NormalizeFactions` / the multi-field `SetPlayerFields` helpers). `Players` is an **array of per-slot LazyVars** so one slot's change re-fires only that row. Write helpers (`SetPlayer`, `SetPlayerField`, `AddObserver`, …) keep the copy-then-`Set` discipline. - **[CustomLobbySessionModel.lua](models/CustomLobbySessionModel.lua)** — shared but **not** @@ -51,18 +51,19 @@ it get launched (becomes part of the game)?* See the `customlobby-model-choice` | [CustomLobbyPerformancePopover.lua](CustomLobbyPerformancePopover.lua) | hover popover over the CPU column; hand-built bitmap bar chart of a peer's `PerformanceTrackingV2` history, with a yellow recommended-unit-cap line. | | [CustomLobbyInstance.lua](CustomLobbyInstance.lua) | thin `moho.lobby_methods` shell; validates/dispatches traffic, forwards callbacks to the controller. Also feeds [`CustomLobbyLog`](CustomLobbyLog.lua) from its three network choke points (BroadcastData / SendData / DataReceived). | | [CustomLobbyLog.lua](CustomLobbyLog.lua) | the **network traffic log** — a reactive, per-peer, never-synced ring buffer (`Entries` LazyVar, capped) of every message this peer broadcasts / sends / receives, fed by the instance and rendered by the **Logs** tab. Each peer logs only its own traffic, so host and client views differ naturally. Not one of the three models (no game state); a diagnostic feed. | -| [CustomLobbyController.lua](CustomLobbyController.lua) | host-authority logic (free functions): seating, `Process*` handlers, intents (`RequestSetReady`, `RequestTakeSlot`, `RequestSwapSlots`, `RequestEject`, `RequestMoveToObserver`, `RequestSetScenario`, `RequestSetGameMods/Options`, `RequestResetGameOptions`, `RequestSetSlotsPinned`, `RequestSetSlotLocked`, `RequestApplyBalance` (applies a previewed balance), `RequestReopenClosedSlots`, `RequestLaunch`, `RequestSaveSetupPreset`/`RequestLoadSetupPreset`, `RequestChat` — all keyed by slot/bool/file/name so a chat command can call them too; permission is gated separately). **Chat relay:** `RequestChat` (host short-circuits, client sends to host) → `ProcessRequestChat` is the **single chat chokepoint** (resolves the sender name authoritatively via `FindNameForOwner`, broadcasts `ChatMessage`, and shows it locally since a broadcast doesn't loop back; future mute/rate-limit lives here) → `ProcessChatMessage` appends/reconciles into the [chat model](social/CustomLobbyChatModel.lua). **Lobby notices:** the host emits a `SystemNotice` (senderless system line shown in chat) via `BroadcastSystemNotice` — host-broadcast, not per-peer diffing, so every peer (incl. the host, via local loopback) shows the same line. Wired at the host-authoritative change sites: join (`ProcessAddPlayer`) / leave (`OnPeerDisconnected`) / kick (`RequestEject` adds "Host removed X."; a human kick also yields a "left" line from the disconnect — two lines is fine) / map / mods / options / restrictions changes / seat swap (`SwapSlots`) / move-to-observers / auto-balance applied. The controller also shares each peer's stored CPU benchmark. **Launch:** `RequestLaunch` (host-only, readiness-validated) → auto-saves the `lastGame` preset (`BuildSetupSnapshot`) → `BuildGameConfiguration` (seed option defaults + scenario, resolve random factions, assign army numbers + push to server, stamp ratings/clan tags, resolve team-from-position for the binary AutoTeams modes, resolve sim mods via `Mods.GetGameMods`) → broadcast `LaunchGame` + `instance:LaunchGame`; clients run `ProcessLaunchGame`. **Presets:** `BuildSetupSnapshot` (read the launch state → serializable **setup-only** snapshot: scenario / options / mods / restrictions) + `ApplySetup` (host-only: write scenario/mods/restrictions, reconcile options to the current schema, one broadcast); players, observers and teams/spawn-mex are not stored (see [presetselect/](presetselect/CLAUDE.md)). | +| [CustomLobbyController.lua](CustomLobbyController.lua) | host-authority logic (free functions): seating, `Process*` handlers, intents (`RequestSetReady`, `RequestTakeSlot`, `RequestSwapSlots`, `RequestEject`, `RequestMoveToObserver`, `RequestSetFactions`/`RequestSetColor`/`RequestSetTeam` (the seated player's own faction multi-select / colour / team — host applies to the slot, a client asks the host for its own seat; colour is scarcity-validated, faction normalised + a representative `Faction` kept), `RequestSetSlotClosed` (open/close one seat — closing rejected on an occupied seat) / `RequestCloseEmptySlots` (close every open empty seat), `RequestSetScenario`, `RequestSetGameMods/Options`, `RequestResetGameOptions`, `RequestSetSlotsPinned`, `RequestSetSlotLocked`, `RequestApplyBalance` (applies a previewed balance), `RequestReopenClosedSlots`, `RequestLaunch`, `RequestSaveSetupPreset`/`RequestLoadSetupPreset`, `RequestChat` — all keyed by slot/bool/file/name so a chat command can call them too; permission is gated separately). The **faction multi-select**: `player.Factions` (allowed real-faction indices) is the source of truth, `player.Faction` its representative (one index, or the Random sentinel when >1); `BuildGameConfiguration` resolves it to one concrete faction at launch (uniform pick from the set). **Chat relay:** `RequestChat` (host short-circuits, client sends to host) → `ProcessRequestChat` is the **single chat chokepoint** (resolves the sender name authoritatively via `FindNameForOwner`, broadcasts `ChatMessage`, and shows it locally since a broadcast doesn't loop back; future mute/rate-limit lives here) → `ProcessChatMessage` appends/reconciles into the [chat model](social/CustomLobbyChatModel.lua). **Lobby notices:** the host emits a `SystemNotice` (senderless system line shown in chat) via `BroadcastSystemNotice` — host-broadcast, not per-peer diffing, so every peer (incl. the host, via local loopback) shows the same line. Wired at the host-authoritative change sites: join (`ProcessAddPlayer`) / leave (`OnPeerDisconnected`) / kick (`RequestEject` adds "Host removed X."; a human kick also yields a "left" line from the disconnect — two lines is fine) / map / mods / options / restrictions changes / seat swap (`SwapSlots`) / move-to-observers / auto-balance applied. The controller also shares each peer's stored CPU benchmark. **Launch:** `RequestLaunch` (host-only, readiness-validated) → auto-saves the `lastGame` preset (`BuildSetupSnapshot`) → `BuildGameConfiguration` (seed option defaults + scenario, resolve random factions, assign army numbers + push to server, stamp ratings/clan tags, resolve team-from-position for the binary AutoTeams modes, resolve sim mods via `Mods.GetGameMods`) → broadcast `LaunchGame` + `instance:LaunchGame`; clients run `ProcessLaunchGame`. **Presets:** `BuildSetupSnapshot` (read the launch state → serializable **setup-only** snapshot: scenario / options / mods / restrictions) + `ApplySetup` (host-only: write scenario/mods/restrictions, reconcile options to the current schema, one broadcast); players, observers and teams/spawn-mex are not stored (see [presetselect/](presetselect/CLAUDE.md)). | | [CustomLobbyRules.lua](CustomLobbyRules.lua) | the game-rule **pure kernel** (not view, not networking, **reads no models**): `RecommendedUnitCap(seatedCount, maxDimension)` (per-player cap by map size), `AutoTeamMode(gameOptions)` / `SideLabels(mode)` / `BuildSideResolver(mode, scenario)` (binary auto-team split). Every input is passed in, so the same functions serve the reactive [slots derived model](models/derived/CustomLobbySlotsDerivedModel.lua) (which applies them to publish `Side` / `Teams`) and, later, the host's launch-time team assignment — neither path makes this module read state. | | [CustomLobbyBalancer.lua](CustomLobbyBalancer.lua) | the auto-balance **pure kernel** (**reads no models**) — **positional only** (the feature is gated to a binary AutoTeams mode, so there are always exactly two sides and the **position decides the team**; the kernel never writes `Team`). Takes the [slots derived model](models/derived/CustomLobbySlotsDerivedModel.lua)'s **already-resolved** snapshot — its `Slots` table (per-seat player / side / locked / closed) + `Teams` aggregate (mode / labels / resolved) — so it never re-resolves sides. **Solve and score are split** so the preview can hand-edit a balance. **`BuildCandidates(slots, teams, arrangement, locks, count?)`** returns the top-N **distinct** team splits (best-first by match quality, mirror-equivalent splits dropped, each a complete scored plan with its own random position shuffle) — the preview browses them. **`Rebalance(slots, teams, arrangement, locks)`** is the single-best wrapper (`BuildCandidates(...)[1]`); it re-solves the *unlocked* players in three phases (a port of the legacy `PenguinAutoBalance`): **(1) Teams** — split into two even sides minimising the cheap rating heuristic; **(2) Pairs** — rank-match across the teams (strongest of A faces strongest of B; by rating, never random); **(3) Positions** — `ShufflePairsIntoSeats` (its own exported function) scatters the pairs across the free mirror rows. Locked players (and an odd roster's lowest-rated unlocked player) are **pinned at their seat in `arrangement`** — not their lobby seat — so locking a player the balance already moved keeps them put. **`ScoreArrangement(slots, teams, arrangement, locks)`** scores a concrete arrangement *without* re-solving (for a manual swap / lock toggle). Both run `ScorePlan`, which fills the plan: two rating-sorted `sides` (used for `totals` / quality), the ordered mirror **`positions`** (the k-th side-1 seat vs the k-th side-2 seat, sorted by slot so the list is stable across re-scores — this is what the preview renders + drag-reorders), per-side total-mean `totals`, `quality` + `currentQuality` (`trueskill.computeQuality` proposed vs. current, for a "before → after"), `winChance` (predicted win % per side — a local normal-CDF over the side mean/variance sums, the directional counterpart to the symmetric quality), and a per-player `moved` flag. `locks` (ownerId→true) is an optional **override** of the seats' own Locked flags — the preview passes its working, preview-local lock set; nil falls back to `entry.Locked`. **`BuildPlan(slots, teams, locks?, count?)`** is the convenience entry — `BuildCandidates` from the lobby's current seating (returns the candidate **list**). `lockedOwners` flags locked players; `ToArrangement(plan)` returns the lobby update — **slot → ownerId**, no team. The controller's `RequestApplyBalance` re-seats from it; the team falls out of the position, resolved at launch by `BuildGameConfiguration` (binary AutoTeams → team-from-`StartSpot`). | | [CustomLobbyPresets.lua](CustomLobbyPresets.lua) | **named setup presets** (setup-only: scenario / options / mods / restrictions — no players/observers) — pure prefs CRUD (`GetPresets`/`GetPreset`/`SavePreset`/`DeletePreset`/`RenamePreset`) over one key (`customlobby_setup_presets`), an ordered `{ Name, Setup }` array mirroring the mod presets in [`/lua/ui/modutilities.lua`](/lua/ui/modutilities.lua). Holds no models, no network. A reserved `LastGamePresetName` entry is auto-saved at launch (the rehost source). Capturing/applying a setup lives in the controller (`BuildSetupSnapshot`/`ApplySetup`); see [presetselect/](presetselect/CLAUDE.md). | | [CustomLobbySession.lua](CustomLobbySession.lua) | the lobby session's **main trash bag**. The lobby lives in the persistent front-end Lua state, which is *not* reset when the game launches in its own state — so anything left reachable (a running thread, a cache, a singleton) leaks for the whole match. This module owns one session-lifetime `TrashBag` (`GetTrash()`); every lobby-scoped `Destroyable` registers in it, and one `Teardown()` (clean-slate in `CreateLobby`, on leave, on `OnGameLaunched`) frees the lot. Works despite the bag being weak-valued because each singleton is pinned by its own module `Instance` local. **Rollout in progress** — converted: the map catalog, all five [derived models](models/derived/CLAUDE.md), and the three authoritative [models/](models/) (Launch/Session/Local, with thin `Destroy` per the design's decision #3). Still to do: the mod catalog, the interface + performance popover (pending the teardown-ordering decision), and the lobby instance. See [design/session-trashbag-teardown.md](design/session-trashbag-teardown.md). | -| [CustomLobbyMessages.lua](CustomLobbyMessages.lua) | message registry: `AddPlayer`, `SetPlayers` (launch model: players + observers), `SentLaunchInfo` (launch model: scenario / options / mods / teams / spawn mex), `SetSessionState` (session model: slot count / closed slots / locked slots / pinned), `SetReady`, `TakeSlot`, `RequestChat` (any peer → host: a chat line to relay) / `ChatMessage` (host → everyone: the accepted line) / `SystemNotice` (host → everyone: a senderless lobby notice — join/leave), `DisconnectPeer`, `LaunchGame` (host's final game config → `instance:LaunchGame`), `ReportCpuBenchmark`, `SetCpuBenchmarks`. | +| [CustomLobbyMessages.lua](CustomLobbyMessages.lua) | message registry: `AddPlayer`, `SetPlayers` (launch model: players + observers), `SentLaunchInfo` (launch model: scenario / options / mods / teams / spawn mex), `SetSessionState` (session model: slot count / closed slots / locked slots / pinned), `SetReady`, `SetFactions`/`SetColor`/`SetTeam` (a client → host: change its own seat's faction set / colour / team), `TakeSlot`, `RequestChat` (any peer → host: a chat line to relay) / `ChatMessage` (host → everyone: the accepted line) / `SystemNotice` (host → everyone: a senderless lobby notice — join/leave), `DisconnectPeer`, `LaunchGame` (host's final game config → `instance:LaunchGame`), `ReportCpuBenchmark`, `SetCpuBenchmarks`. | | [social/CustomLobbyChatModel.lua](social/CustomLobbyChatModel.lua) | the **chat feed** — a per-peer, never-synced reactive `Entries` ring buffer (`UICustomLobbyChatEntry`: `Id` / `SenderId` / `SenderName` / `Text` / `Status` / `Kind`), the chat equivalent of [`CustomLobbyLog`](CustomLobbyLog.lua) but **written by the controller**, so it is a `ClassSimple : Destroyable` in the session trash (freed on `Teardown()`). `Append` adds an optimistic `Pending` line / system notice; `Receive` reconciles the sender's `Pending` line to `Confirmed` by the echoed `Id` (or appends others' lines). Not one of the three models — no game state. See [social/chat-design.md](social/chat-design.md). | | [social/CustomLobbyChatController.lua](social/CustomLobbyChatController.lua) | the chat **send pipeline** (writes the chat model, never the wire): `Send` decides command-vs-chat (a `/` line is handled locally and never broadcast — registry lands in slice 2), echoes a plain line optimistically (`Pending`) and hands it to the controller's `RequestChat`; `AppendLocalSystem` for local notices. Mirrors the in-game [`ChatController.Send`](/lua/ui/game/chat/ChatController.lua) split. | | [CustomLobbyBalancePreview.lua](CustomLobbyBalancePreview.lua) | the auto-balance **preview + tuner** (host-only `Popup`): the slots header's balance button opens it instead of re-seating directly. It owns a **working state** nothing synced sees until Apply — `Arrangement` (slot→ownerId) + `Locks` (a preview-local lock set, **seeded** from the lobby's real locks but never written back). Renders the proposal as **mirror-position rows** (row k = the k-th seat on each side, who face off, in start-position order with a position label; the right column **mirrored** so the teams face each other) — each row puts the **name on the outer edge** and the **mean rating in a fixed column hugging the centre** (so the two columns line up across rows, flanking the centre gap), with the **deviation** as a muted `(±N)` just outside it; the centre shows the pair's mean gap (magnitude) with a **`<` / `>` arrow pointing to the higher-rated side**; **moved** players are gold, **locked** players blue. The host **browses the candidate balances** with a "< i / N >" browser (the kernel's top-N distinct splits via `BuildCandidates`); stepping adopts each. Three gestures tune the shown one: **drag a row** onto another → swap those two positions' pairs (both players move together — "D → A"; same feel as the lobby slot rows — press-vs-drag threshold, a floating pair ghost beside the cursor, and a drop-target highlight); **click two players** → swap just those two (`ScoreArrangement` re-scores, no re-solve); **click a player's lock dot** → pin/unpin them (preview-local) which **regenerates** the candidates around that lock. The **header is a two-line summary** on the same columns as the rows: top line — team label (outer) + each team's predicted **win %** (column) + the match **quality** % in the centre; bottom line — total mean (column) with the summed deviation muted `(±N)`, and the **gap total** (direction arrow) in the centre. The status line carries only situational notes (the odd one out, or why it can't balance). Every value + control has a hover **tooltip** explaining it (win / quality / gap / total / deviation / player / lock / candidate browser). While open it **pins the lobby** (`SlotsPinned`) so the roster can't shift under the proposal (it would go stale); it remembers the prior pin state — **Cancel** restores it, **Apply** leaves it pinned so the balance holds. **Apply** commits only `Arrangement` (→ `RequestApplyBalance`); **Cancel** discards everything (locks included). A transient picker, owns no synced state (beyond toggling the shared pin while open). | | [CustomLobbyContextMenu.lua](CustomLobbyContextMenu.lua) | generic framed floating menu; `Show(entries, x, y)` renders any `{label, action, enabled}` list, dismisses on item click / click-outside / Esc. Knows nothing about the lobby. | +| [slots/CustomLobbySlotPickers.lua](slots/CustomLobbySlotPickers.lua) | the three per-player edit **pickers** a card opens (same singleton + click-outside-cover + Esc framing as the context menu): `ShowFactionPicker` (a faction **multi-toggle** — tick a subset, applies live, never empties the set, >1 = random), `ShowColorPicker` (a colour grid; colours taken by another seated player greyed + inert, current colour framed), `ShowTeamPicker` (No team / Team 1..8). Each only proposes — it calls `RequestSetFactions` / `RequestSetColor` / `RequestSetTeam` (host-authoritative) and the seat re-renders from the synced model; it writes no model itself. | | [CustomLobbyMenus.lua](CustomLobbyMenus.lua) | declarative menu **definitions**: entry lists with `when(ctx)`/`action(ctx)` filtered by lobby state (`BuildSlotMenu`). Adding/state-gating an item is a one-liner here. | -| [slots/](slots/) | the **slot subsystem** (its own folder, a sub-folder per layout). [`CustomLobbySlotsInterface`](slots/CustomLobbySlotsInterface.lua) is the entry the composition root mounts: a "Players" header over the **active layout body**, picked by the AutoTeams mode — one-column for the non-team modes, the two-column team layout for the binary modes (left/right, top/bottom, even/odd). It observes `GameOptions` and swaps the body when the kind flips (create-on-mode / destroy-on-switch); a change *within* the binary modes is handled by the two-column body's own re-layout. It is the rows' **drag coordinator** (`UICustomLobbySlotCoordinator`: hit-test which row a point is in, drop-highlight, drag ghost → `RequestSwapSlots`) for *every* layout — it alone needs to hit-test across rows — so the layout bodies stay pure build/place/reveal and never duplicate the drag logic; it also exposes `PreferredHeight()` (computed from the mode + model, swap-order-independent) so the root sizes the slot area. The header band carries a **host-only tool strip** (right-aligned: pin seating · auto teams · auto-balance · reopen closed slots) of small icon buttons (local `SlotTool`, mirroring the config column's `PreviewTool`); the pin button is lit from the synced `SlotsPinned` and the strip is hidden for clients. The **auto-teams** button is a quick-cycle that steps `AutoTeams` through none → top/bottom → left/right → odd/even → manual (`RequestSetGameOptions`); its glyph is the per-mode `/BUTTON/autoteam//` art (kept in sync from the `GameOptions` observer), so it shows the current mode and the slot layout follows. The **auto-balance** button is gated (`SlotTool:SetEnabled`, greyed when off) to a **resolved binary AutoTeams mode** (the derived `Teams` aggregate's `Mode` + `Resolved` — two well-defined sides, map loaded for the positional ones) and opens the [balance preview](CustomLobbyBalancePreview.lua) rather than re-seating on click. A **"Locked" notice** (lock glyph + label) sits right of the "Players" label and is shown to **everyone** while `SlotsPinned` is on, so a client can see seating is host-controlled before clicking an open slot to no effect. [`CustomLobbySlotBase`](slots/CustomLobbySlotBase.lua) holds **all slot behaviour** (one subscription to the [slots derived model](models/derived/CustomLobbySlotsDerivedModel.lua) — which already merged this seat's player / placement / closed / CPU benchmark and did the faction/CPU formatting — click/right-click/drag-to-swap + intents, the background/highlight/click overlays + a gold left-edge **lock stripe** shown when the seat's `Locked` flag is set) and the default `RenderPlayer`/`RenderCpu` that paint the standard named controls from the entry's resolved views; a presentation subclasses it and implements just `CreateContents` / `LayoutContents`. [`onecolumn/`](slots/onecolumn/): [`CustomLobbyOneColumnSlots`](slots/onecolumn/CustomLobbyOneColumnSlots.lua) (stacks thin rows, reveals 1..count) + [`CustomLobbySlotRow`](slots/onecolumn/CustomLobbySlotRow.lua) (thin one-line presentation). [`twocolumn/`](slots/twocolumn/): [`CustomLobbyTwoColumnSlots`](slots/twocolumn/CustomLobbyTwoColumnSlots.lua) (splits slots into two team columns by each seat's `Side` from the [slots derived model](models/derived/CustomLobbySlotsDerivedModel.lua) — no longer re-resolving the split itself — with a [`CustomLobbyTeamScore`](CustomLobbyTeamScore.lua) strip across the top as the Left/Right indicator, and the "two columns, unresolved" fallback — parity fill + the score self-hides until a positional map's start positions load) + [`CustomLobbySlotCard`](slots/twocolumn/CustomLobbySlotCard.lua) (fat half-width / two-line presentation; `SetMirrored` flips the right column's cards so the two teams face each other). Each body exports `HeightForCount`. | +| [slots/](slots/) | the **slot subsystem** (its own folder, a sub-folder per layout). [`CustomLobbySlotsInterface`](slots/CustomLobbySlotsInterface.lua) is the entry the composition root mounts: a "Players" header over the **active layout body**, picked by the AutoTeams mode — one-column for the non-team modes, the two-column team layout for the binary modes (left/right, top/bottom, even/odd). It observes `GameOptions` and swaps the body when the kind flips (create-on-mode / destroy-on-switch); a change *within* the binary modes is handled by the two-column body's own re-layout. It is the rows' **drag coordinator** (`UICustomLobbySlotCoordinator`: hit-test which row a point is in, drop-highlight, drag ghost → `RequestSwapSlots`) for *every* layout — it alone needs to hit-test across rows — so the layout bodies stay pure build/place/reveal and never duplicate the drag logic; it also exposes `PreferredHeight()` (computed from the mode + model, swap-order-independent) so the root sizes the slot area. The header band carries a **host-only tool strip** (right-aligned: pin seating · auto teams · auto-balance · close empty slots · reopen closed slots) of small icon buttons (local `SlotTool`, mirroring the config column's `PreviewTool`); the **close empty slots** button (`RequestCloseEmptySlots`) closes every open seat with no player in one go (pairs with reopen); the pin button is lit from the synced `SlotsPinned` and the strip is hidden for clients. The **auto-teams** button is a quick-cycle that steps `AutoTeams` through none → top/bottom → left/right → odd/even → manual (`RequestSetGameOptions`); its glyph is the per-mode `/BUTTON/autoteam//` art (kept in sync from the `GameOptions` observer), so it shows the current mode and the slot layout follows. The **auto-balance** button is gated (`SlotTool:SetEnabled`, greyed when off) to a **resolved binary AutoTeams mode** (the derived `Teams` aggregate's `Mode` + `Resolved` — two well-defined sides, map loaded for the positional ones) and opens the [balance preview](CustomLobbyBalancePreview.lua) rather than re-seating on click. A **"Locked" notice** (lock glyph + label) sits right of the "Players" label and is shown to **everyone** while `SlotsPinned` is on, so a client can see seating is host-controlled before clicking an open slot to no effect. [`CustomLobbySlotBase`](slots/CustomLobbySlotBase.lua) holds **all slot behaviour** (one subscription to the [slots derived model](models/derived/CustomLobbySlotsDerivedModel.lua) — which already merged this seat's player / placement / closed / CPU benchmark and did the faction/CPU formatting — click/right-click/drag-to-swap + intents, the background/highlight/click overlays + a gold left-edge **lock stripe** shown when the seat's `Locked` flag is set) and the default `RenderPlayer`/`RenderCpu`/`RenderExtras` that paint the standard + optional named controls (colour, name, team, ready, CPU + the optional rating / games / country flag / reserved avatar box + a **faction icon strip** — `CreateFactionIcons`/`RenderFactionIcons` show the selected factions as small icons, a single Random icon when the whole set is allowed) from the entry's resolved views; a presentation subclasses it and implements just `CreateContents` / `LayoutContents`. The base also owns the **per-player editing**: `CreateEditZone(kind)` builds a click zone (laid over the colour swatch / faction / team, above the ClickArea) that — when the seat is editable (your own seat while not ready, or any AI seat for the host; team gated off under a binary AutoTeams mode) — opens the matching picker from [`CustomLobbySlotPickers`](slots/CustomLobbySlotPickers.lua), else falls through to the normal take/ready/drag press. And a host-only per-seat **close/open button** (`CreateSlotButton` — shown by `RenderSlotButton` only on an empty seat → "Close", or a closed seat → "Open" → `RequestSetSlotClosed`; a closed empty seat reads "- closed -"). [`onecolumn/`](slots/onecolumn/): [`CustomLobbyOneColumnSlots`](slots/onecolumn/CustomLobbyOneColumnSlots.lua) (stacks thin rows, reveals 1..count) + [`CustomLobbySlotRow`](slots/onecolumn/CustomLobbySlotRow.lua) (thin one-line presentation). [`twocolumn/`](slots/twocolumn/): [`CustomLobbyTwoColumnSlots`](slots/twocolumn/CustomLobbyTwoColumnSlots.lua) (splits slots into two team columns by each seat's `Side` from the [slots derived model](models/derived/CustomLobbySlotsDerivedModel.lua) — no longer re-resolving the split itself — with a [`CustomLobbyTeamScore`](CustomLobbyTeamScore.lua) strip across the top as the Left/Right indicator, and the "two columns, unresolved" fallback — parity fill + the score self-hides until a positional map's start positions load) + [`CustomLobbySlotCard`](slots/twocolumn/CustomLobbySlotCard.lua) (fat half-width / two-line presentation; `SetMirrored` flips the right column's cards so the two teams face each other). Each body exports `HeightForCount`. | | [CustomLobbyObserversInterface.lua](CustomLobbyObserversInterface.lua) | observer strip; subscribes to the model's `Observers` list and shows the count + names (read-only). | | [CustomLobbyScenarioPreview.lua](CustomLobbyScenarioPreview.lua) | **shared** map-preview *surface*: the scenario's map texture + overlays (start spots, resource/wreck markers, plus a **dummy translucent `WaterMask`** placeholder until a real mask exists) with aspect-correct positioning, texture-leak-safe icon sharing, per-group visibility (`SetOverlayVisible('spawns'\|'resources'\|'wrecks'\|'water', …)`), and three-phase init. A **pure renderer**: `SetScenario(info, markers)` takes already-extracted markers (`UICustomLobbyScenarioMarkers`) and never touches the raw save. Chrome-free; `CustomLobbyMapPreview` wraps it with the frame. Spawn appearance is the owner's via a `CreateSpawnIcon` factory. | | [CustomLobbyMapPreview.lua](CustomLobbyMapPreview.lua) | the map preview **as one whole** — the chrome (glow border on top + dark backdrop, surface inset by `Padding`), the surface, and the faction spawn icon (local `MapPreviewSpawn`). Used by **both** consumers: created `Bound = true` it subscribes to [`CustomLobbyScenarioDerivedModel`](models/derived/CustomLobbyScenarioDerivedModel.lua) and renders the resolved scenario with per-slot faction spawns (no reload on take/swap, and no reload on a same-map rebroadcast — the model dedups); created unbound (the map-select dialog) it does no model wiring and the owner drives `preview.Surface` directly (numbered-dot spawns), feeding it markers from the catalog's `LoadMarkers`. Exposes `.Surface` for owners to drive / anchor overlays to. | @@ -116,7 +117,7 @@ later slice that needs its own roster capture — see [presetselect/](presetsele 2. Remaining sub-dialogs (units, prefs) as mini-MVC, following the map-select shape. **Mods** and **options** are done — see [modselect/](modselect/CLAUDE.md) (carries its own presets, replacing the legacy per-mod "favorites") and [optionselect/](optionselect/CLAUDE.md). -3. Make slot controls interactive (faction/colour/team → controller **intents**). +3. ~~Make slot controls interactive (faction/colour/team → controller **intents**).~~ **Done** — click the swatch / faction / team on a card → a picker → `RequestSetColor` / `RequestSetFactions` (multi-select) / `RequestSetTeam`; the host also opens/closes seats (per-seat button + header "close empty"). Cards also show rating / games / country flag + a reserved avatar box. 4. Map-derived `SlotCount`; the GPGNet (`localPort == -1`) FAF-client path. ## Rules (same as autolobby) diff --git a/lua/ui/lobby/customlobby/CustomLobbyController.lua b/lua/ui/lobby/customlobby/CustomLobbyController.lua index 3f6f70afcfd..9bd59d40d6e 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyController.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyController.lua @@ -269,6 +269,75 @@ local function SwapSlots(instance, slotA, slotB) end end +--- Whether `color` (a PlayerColor index) is already used by a seated player other than `exceptSlot`. +--- Colours are scarce — two players can't share one — so a colour change is rejected if taken. +---@param color number +---@param exceptSlot number +---@return boolean +local function IsColorTaken(color, exceptSlot) + local launch = CustomLobbyLaunchModel.GetSingleton() + for slot = 1, CustomLobbyLaunchModel.MaxSlots do + if slot ~= exceptSlot then + local player = launch.Players[slot]() + if player and player.PlayerColor == color then + return true + end + end + end + return false +end + +--- Host-side: applies a faction multi-select to the player in `slot` (normalised) and re-broadcasts. +--- `Factions` is the source of truth; `Faction` is kept as its representative. A no-op on an empty slot. +---@param instance UICustomLobbyInstance +---@param slot number | nil +---@param factions number[] +local function ApplyFactions(instance, slot, factions) + local launch = CustomLobbyLaunchModel.GetSingleton() + if not (slot and launch.Players[slot]()) then + return + end + local normalized = CustomLobbyLaunchModel.NormalizeFactions(factions) + CustomLobbyLaunchModel.SetPlayerFields(launch, slot, { + Factions = normalized, + Faction = CustomLobbyLaunchModel.RepresentativeFaction(normalized), + }) + BroadcastPlayers(instance) +end + +--- Host-side: applies a colour to the player in `slot` (both PlayerColor + ArmyColor) and re-broadcasts. +--- Rejected (logged) if the colour is already taken by another seated player, or the slot is empty. +---@param instance UICustomLobbyInstance +---@param slot number | nil +---@param color number +local function ApplyColor(instance, slot, color) + local launch = CustomLobbyLaunchModel.GetSingleton() + if not (slot and launch.Players[slot]() and type(color) == 'number') then + return + end + if IsColorTaken(color, slot) then + WARN("CustomLobby: colour " .. tostring(color) .. " is already taken; ignoring") + return + end + CustomLobbyLaunchModel.SetPlayerFields(launch, slot, { PlayerColor = color, ArmyColor = color }) + BroadcastPlayers(instance) +end + +--- Host-side: applies a team to the player in `slot` (backend numbering: 1 = no team, 2..9 = teams +--- 1..8) and re-broadcasts. A no-op on an empty slot. (Binary AutoTeams modes still override the team +--- from the start position at launch — see `BuildGameConfiguration`.) +---@param instance UICustomLobbyInstance +---@param slot number | nil +---@param team number +local function ApplyTeam(instance, slot, team) + local launch = CustomLobbyLaunchModel.GetSingleton() + if not (slot and launch.Players[slot]() and type(team) == 'number') then + return + end + CustomLobbyLaunchModel.SetPlayerField(launch, slot, 'Team', team) + BroadcastPlayers(instance) +end + --- Reads a numeric command-line argument (e.g. `/mean 1500`), falling back to the default --- when it is absent or unparseable. ---@param key string @@ -326,8 +395,16 @@ function CreateLocalPlayer(instance) player.NG = GetCommandLineNumber("/numgames", 0) player.PL = math.floor(player.MEAN - 3 * player.DEV) - -- faction + team + -- faction + team. The faction multi-select (`Factions`) is the source of truth: seed it from the + -- single command-line / default faction — a concrete faction is a one-element set, the Random + -- sentinel becomes "all factions" — and keep `Faction` as its representative. player.Faction = GetCommandLineFaction(player.Faction) + if player.Faction and player.Faction <= CustomLobbyLaunchModel.RealFactionCount then + player.Factions = { player.Faction } + else + player.Factions = CustomLobbyLaunchModel.NormalizeFactions(nil) -- all factions = random + end + player.Faction = CustomLobbyLaunchModel.RepresentativeFaction(player.Factions) player.Team = GetCommandLineNumber("/team", player.Team) -- identity + league standing @@ -532,6 +609,27 @@ function ProcessSetReady(instance, data) end end +--- Host applies a client's faction multi-select to that client's own slot and re-broadcasts. +---@param instance UICustomLobbyInstance +---@param data UICustomLobbySetFactionsMessage +function ProcessSetFactions(instance, data) + ApplyFactions(instance, FindSlotForOwner(data.SenderID), data.Factions) +end + +--- Host applies a client's colour choice to that client's own slot (scarcity-checked) and re-broadcasts. +---@param instance UICustomLobbyInstance +---@param data UICustomLobbySetColorMessage +function ProcessSetColor(instance, data) + ApplyColor(instance, FindSlotForOwner(data.SenderID), data.Color) +end + +--- Host applies a client's team choice to that client's own slot and re-broadcasts. +---@param instance UICustomLobbyInstance +---@param data UICustomLobbySetTeamMessage +function ProcessSetTeam(instance, data) + ApplyTeam(instance, FindSlotForOwner(data.SenderID), data.Team) +end + --- Host moves a requesting client into the open slot it asked for. Ignored while seating --- is pinned — only the host may change slots then (the host's own take goes through --- RequestTakeSlot, which is exempt). @@ -969,9 +1067,16 @@ local function BuildGameConfiguration(instance) local player = launch.Players[slot]() if player then local options = table.copy(player) - if (options.Faction or factionCount + 1) > factionCount then + -- resolve the faction multi-select to one concrete faction: pick uniformly from the + -- allowed set (`Factions`); a one-element set is that faction, a multi-element set is the + -- random pick. Fall back to the legacy single-Faction-or-random when no set is present. + local allowed = options.Factions + if allowed and table.getn(allowed) > 0 then + options.Faction = allowed[Random(1, table.getn(allowed))] + elseif (options.Faction or factionCount + 1) > factionCount then options.Faction = Random(1, factionCount) end + options.Factions = nil -- launch payload carries the resolved single faction only options.StartSpot = options.StartSpot or slot playerOptions[slot] = options if options.PL then ratings[options.PlayerName] = options.PL end @@ -1337,6 +1442,65 @@ function RequestReopenClosedSlots() end)) end +--- The host opens or closes a single slot. Host-only — backs the per-slot close/open button and its +--- context-menu entries. A closed slot has no army at launch (session state, not launched). Closing is +--- only allowed on an empty seat (we don't evict a player by closing under them); opening always works. +---@param slot number +---@param closed boolean +function RequestSetSlotClosed(slot, closed) + local instance = LobbyInstance + if not instance then + return + end + + local session = CustomLobbySessionModel.GetSingleton() + if not CustomLobbyLocalModel.GetSingleton().IsHost() then + WARN("CustomLobby: only the host can open or close slots") + return + end + if type(slot) ~= 'number' or slot < 1 or slot > session.SlotCount() then + return + end + -- closing an occupied seat would silently drop its player; require it empty first + if closed and CustomLobbyLaunchModel.GetSingleton().Players[slot]() then + WARN("CustomLobby: cannot close an occupied slot " .. tostring(slot)) + return + end + + CustomLobbySessionModel.SetClosed(session, slot, closed and true or false) + BroadcastSessionState(instance) +end + +--- The host closes every currently-open empty slot in one go (the header "close empty slots" button). +--- Occupied and already-closed slots are left as-is; one broadcast for the batch. +function RequestCloseEmptySlots() + local instance = LobbyInstance + if not instance then + return + end + + local session = CustomLobbySessionModel.GetSingleton() + if not CustomLobbyLocalModel.GetSingleton().IsHost() then + WARN("CustomLobby: only the host can close slots") + return + end + + local launch = CustomLobbyLaunchModel.GetSingleton() + local closedSlots = table.copy(session.ClosedSlots()) + local changed = false + for slot = 1, session.SlotCount() do + if not launch.Players[slot]() and not closedSlots[slot] then + closedSlots[slot] = true + changed = true + end + end + if not changed then + return + end + session.ClosedSlots:Set(closedSlots) + BroadcastSessionState(instance) +end + --- Ejects the player in `slot`: a human is dropped from the network (the resulting --- PeerDisconnected clears the slot + re-broadcasts), an AI is just cleared. Host-only. --- Slot-keyed so a chat command (`/eject `) can call it too; whether the caller @@ -1422,6 +1586,63 @@ function RequestSetReady(ready) end end +--- Sets the faction multi-select for a seat. The host applies directly to `slot` (it may edit any seat +--- — its own or an AI); a client can only change its own seat, so it asks the host (which applies to the +--- sender's slot, ignoring `slot`). `Factions` is a list of allowed real-faction indices — more than one +--- means random among them. Mirrors RequestSetReady's host/client split. +---@param slot number +---@param factions number[] +function RequestSetFactions(slot, factions) + local instance = LobbyInstance + if not instance then + return + end + + local localModel = CustomLobbyLocalModel.GetSingleton() + if localModel.IsHost() then + ApplyFactions(instance, slot, factions) + else + instance:SendData(localModel.HostID(), { Type = 'SetFactions', Factions = factions }) + end +end + +--- Sets the colour for a seat (host applies to `slot`; a client asks the host for its own seat). The +--- host rejects a colour already taken by another seated player — colours are scarce. +---@param slot number +---@param color number +function RequestSetColor(slot, color) + local instance = LobbyInstance + if not instance then + return + end + + local localModel = CustomLobbyLocalModel.GetSingleton() + if localModel.IsHost() then + ApplyColor(instance, slot, color) + else + instance:SendData(localModel.HostID(), { Type = 'SetColor', Color = color }) + end +end + +--- Sets the team for a seat (backend numbering: 1 = no team, 2..9 = teams 1..8). Host applies to +--- `slot`; a client asks the host for its own seat. (Binary AutoTeams modes still decide the team from +--- the start position at launch.) +---@param slot number +---@param team number +function RequestSetTeam(slot, team) + local instance = LobbyInstance + if not instance then + return + end + + local localModel = CustomLobbyLocalModel.GetSingleton() + if localModel.IsHost() then + ApplyTeam(instance, slot, team) + else + instance:SendData(localModel.HostID(), { Type = 'SetTeam', Team = team }) + end +end + --- The local player sends a chat line. The host short-circuits straight to its relay chokepoint; a --- client asks the host. `id` is the sender's client-stamped id (CustomLobbyChatModel.NextId), echoed --- back in the broadcast so the sender's optimistic Pending line can reconcile. See diff --git a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua index 24fc91ff207..ecee86484be 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua @@ -216,12 +216,13 @@ local CustomLobbyInterface = Class(Group) { --#region bottom-left: chat / observers tabs -- each tab gets a config gear (left) + a count pill (right), mirroring the config column. -- Chat's count is a dummy until the chat slice lands; Observers shows the live observer count. - -- reacts to the chat feed; shows the line count (empty when none). TODO: unread-since-last-view - -- count once the chat slice tracks a last-seen marker. + -- unread chat count (lines since the Chat tab was last viewed; the panel marks the feed seen + -- while it's open). Empty when nothing is unread or while the Chat tab is active. self.ChatBadge = self.Trash:Add(LazyVarCreate()) self.ChatBadge:Set(function() - local count = table.getn(CustomLobbyChatModel.GetSingleton().Entries()) - return count > 0 and tostring(count) or "" + local model = CustomLobbyChatModel.GetSingleton() + local unread = model.TotalCount() - model.SeenTotal() + return unread > 0 and tostring(unread) or "" end) self.ObserversBadge = self.Trash:Add(LazyVarCreate()) self.ObserversBadge:Set(function() diff --git a/lua/ui/lobby/customlobby/CustomLobbyMessages.lua b/lua/ui/lobby/customlobby/CustomLobbyMessages.lua index 2042913a1eb..6b4035d0e35 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyMessages.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyMessages.lua @@ -184,6 +184,72 @@ CustomLobbyMessages = { end, }, + -- A client asks the host to set its own faction multi-select (the allowed real-faction indices; + -- more than one = random among them). The host applies it to the sender's slot. + SetFactions = { + ---@class UICustomLobbySetFactionsMessage : UILobbyReceivedMessage + ---@field Factions number[] + + ---@param data UICustomLobbySetFactionsMessage + Validate = function(lobby, data) + if type(data.Factions) ~= 'table' then + return false, "SetFactions has no Factions list" + end + return true + end, + Accept = function(lobby, data) + return RequireHost(lobby) + end, + ---@param data UICustomLobbySetFactionsMessage + Handler = function(lobby, data) + CustomLobbyController.ProcessSetFactions(lobby, data) + end, + }, + + -- A client asks the host to set its own colour (a PlayerColor index). The host applies it to the + -- sender's slot, rejecting a colour already taken by another seated player. + SetColor = { + ---@class UICustomLobbySetColorMessage : UILobbyReceivedMessage + ---@field Color number + + ---@param data UICustomLobbySetColorMessage + Validate = function(lobby, data) + if type(data.Color) ~= 'number' then + return false, "SetColor has no Color index" + end + return true + end, + Accept = function(lobby, data) + return RequireHost(lobby) + end, + ---@param data UICustomLobbySetColorMessage + Handler = function(lobby, data) + CustomLobbyController.ProcessSetColor(lobby, data) + end, + }, + + -- A client asks the host to set its own team (backend numbering: 1 = no team, 2..9 = teams 1..8). + -- The host applies it to the sender's slot. + SetTeam = { + ---@class UICustomLobbySetTeamMessage : UILobbyReceivedMessage + ---@field Team number + + ---@param data UICustomLobbySetTeamMessage + Validate = function(lobby, data) + if type(data.Team) ~= 'number' then + return false, "SetTeam has no Team number" + end + return true + end, + Accept = function(lobby, data) + return RequireHost(lobby) + end, + ---@param data UICustomLobbySetTeamMessage + Handler = function(lobby, data) + CustomLobbyController.ProcessSetTeam(lobby, data) + end, + }, + -- A client asks the host to move it into an open slot (also reachable via a -- `/take ` chat command). The host validates the seat and re-broadcasts. TakeSlot = { diff --git a/lua/ui/lobby/customlobby/models/CustomLobbyLaunchModel.lua b/lua/ui/lobby/customlobby/models/CustomLobbyLaunchModel.lua index d3aa9ee9942..846aa632815 100644 --- a/lua/ui/lobby/customlobby/models/CustomLobbyLaunchModel.lua +++ b/lua/ui/lobby/customlobby/models/CustomLobbyLaunchModel.lua @@ -40,6 +40,10 @@ local CustomLobbySession = import("/lua/ui/lobby/customlobby/customlobbysession. --- Maximum number of player slots the engine supports. MaxSlots = 16 +--- The number of real (non-random) factions — the upper bound of the faction multi-select, and the +--- index just below the Random sentinel. Reads factions.lua so custom factions are counted too. +RealFactionCount = table.getn(import("/lua/factions.lua").Factions) + ------------------------------------------------------------------------------- --#region Shapes @@ -49,7 +53,8 @@ MaxSlots = 16 ---@field PlayerName string ---@field OwnerID UILobbyPeerId ---@field Human boolean ----@field Faction number # 1=UEF 2=Aeon 3=Cybran 4=Seraphim 5=Random +---@field Faction number # 1=UEF 2=Aeon 3=Cybran 4=Seraphim 5=Random — the representative (see Factions) +---@field Factions number[] # the multi-select: the real-faction indices the player allows; >1 = random among them ---@field PlayerColor number ---@field ArmyColor number ---@field Team number # 1 = no team (FFA), 2..9 = teams 1..8 @@ -182,6 +187,62 @@ function SetPlayerField(model, slot, key, value) model.Players[slot]:Set(player) end +--- Merges several fields onto the player in a slot in one copy-then-Set (one re-render, not N). +--- Use when a change touches more than one field at once (e.g. the faction multi-select updates +--- both `Factions` and the representative `Faction`). +---@param model UICustomLobbyLaunchModel +---@param slot number +---@param fields table +function SetPlayerFields(model, slot, fields) + local current = model.Players[slot]() + if not current then + return + end + local player = table.copy(current) + for key, value in fields do + player[key] = value + end + model.Players[slot]:Set(player) +end + +--- The single representative faction for a multi-select: the chosen one when exactly one faction is +--- picked, else the Random sentinel (`RealFactionCount + 1`). Keeps `player.Faction` coherent for the +--- readers that want one value (skin, the launch fallback) while `player.Factions` stays the source of +--- truth for the choice. An empty / nil set reads as full random. +---@param factions number[] | nil +---@return number +function RepresentativeFaction(factions) + if factions and table.getn(factions) == 1 then + return factions[1] + end + return RealFactionCount + 1 +end + +--- Normalises a faction multi-select: a sorted list of the in-range real-faction indices, de-duped. +--- An empty / nil / all-invalid set falls back to "all factions" (full random), so a player always +--- has at least one allowed faction. +---@param factions number[] | nil +---@return number[] +function NormalizeFactions(factions) + local seen, out = {}, {} + if factions then + for _, index in factions do + if type(index) == 'number' and index >= 1 and index <= RealFactionCount and not seen[index] then + seen[index] = true + table.insert(out, index) + end + end + end + if table.empty(out) then + for index = 1, RealFactionCount do + table.insert(out, index) + end + return out + end + table.sort(out) + return out +end + --- Sets a single game option (copy-then-Set). ---@param model UICustomLobbyLaunchModel ---@param key string diff --git a/lua/ui/lobby/customlobby/models/derived/CustomLobbySlotsDerivedModel.lua b/lua/ui/lobby/customlobby/models/derived/CustomLobbySlotsDerivedModel.lua index 5a3539dc577..318a798dc2b 100644 --- a/lua/ui/lobby/customlobby/models/derived/CustomLobbySlotsDerivedModel.lua +++ b/lua/ui/lobby/customlobby/models/derived/CustomLobbySlotsDerivedModel.lua @@ -93,6 +93,10 @@ local MaxSlots = CustomLobbyLaunchModel.MaxSlots ---@field team string ---@field ready string ---@field readyColor string +---@field rating string # the player's displayed rating (PL), "" when unrated +---@field games string # number of games played, "" when none / unknown +---@field flag FileName | false # country flag texture, false when no country +---@field factionIcons FileName[] # the selected factions as icons (single icon = Random when the full set is allowed) --- The CPU column's resolved presentation (false when there is no player / no benchmark to show). ---@class UICustomLobbySlotCpuView @@ -141,6 +145,33 @@ local function FactionLabel(faction) return "Random" end +--- The small "any faction" icon, shown when the whole faction set is allowed (pure random). +local RandomFactionIcon = '/faction_icon-sm/random_ico.dds' + +--- The selected factions as a list of small icons: the full set (or none) reads as pure random and +--- shows a single Random icon; a subset shows that subset's icons; one shows one. So a partial random +--- (e.g. UEF + Cybran) is legible as exactly those two icons. +---@param player UICustomLobbyPlayer +---@return FileName[] +local function FactionIcons(player) + local factions = player.Factions + local count = factions and table.getn(factions) or 0 + if count == 0 or count >= CustomLobbyLaunchModel.RealFactionCount then + return { RandomFactionIcon } + end + local icons = {} + for _, index in factions do + local data = Factions[index] + if data and data.SmallIcon then + table.insert(icons, data.SmallIcon) + end + end + if table.empty(icons) then + return { RandomFactionIcon } + end + return icons +end + --- The sim-rate categories tracked in PerformanceTrackingV2, ordered for the "most-played" pick. local PerformanceCategories = { 'Skirmish', 'SkirmishWithAI', 'Campaign' } @@ -196,6 +227,17 @@ end ---@param player UICustomLobbyPlayer ---@return UICustomLobbySlotPlayerView local function BuildPlayerView(player) + -- rating: the displayed (conservative) rating PL; blank for unrated / AI with no rating + local rating = "" + if player.PL and player.PL > 0 then + rating = tostring(math.floor(player.PL + 0.5)) + end + -- games played; blank when none / unknown (e.g. AI) + local games = "" + if player.NG and player.NG > 0 then + games = tostring(player.NG) + end + return { colorHex = GameColors.PlayerColors[player.PlayerColor] or 'ffffffff', name = player.PlayerName or "?", @@ -204,6 +246,10 @@ local function BuildPlayerView(player) team = (player.Team and player.Team > 1) and ("T" .. (player.Team - 1)) or "-", ready = player.Ready and "ready" or "", readyColor = player.Ready and 'ff7ad97a' or 'ff888888', + rating = rating, + games = games, + flag = (player.Country and player.Country ~= "") and ('/countries/' .. player.Country .. '.dds') or false, + factionIcons = FactionIcons(player), } end @@ -307,7 +353,7 @@ local function Signature(slots) parts[slot] = tostring(slot) .. "|" .. (entry.Closed and "C" or "_") .. (entry.Locked and "L" or "_") - .. "|" .. (pv and (pv.colorHex .. pv.name .. pv.nameColor .. pv.faction .. pv.team .. pv.ready .. pv.readyColor) or "-") + .. "|" .. (pv and (pv.colorHex .. pv.name .. pv.nameColor .. pv.faction .. pv.team .. pv.ready .. pv.readyColor .. pv.rating .. pv.games .. tostring(pv.flag) .. table.concat(pv.factionIcons, ",")) or "-") .. "|" .. (cv and (cv.text .. cv.textColor .. (cv.showIndicator and tostring(cv.indicatorColor) or "0")) or "-") .. "|" .. tostring(entry.StartSpot) .. "|" .. (pos and (tostring(pos[1]) .. ":" .. tostring(pos[2])) or "-") diff --git a/lua/ui/lobby/customlobby/slots/CustomLobbySlotBase.lua b/lua/ui/lobby/customlobby/slots/CustomLobbySlotBase.lua index 400e7948e16..f2de3a8487d 100644 --- a/lua/ui/lobby/customlobby/slots/CustomLobbySlotBase.lua +++ b/lua/ui/lobby/customlobby/slots/CustomLobbySlotBase.lua @@ -45,24 +45,33 @@ -- This keeps the drag/intent logic single-sourced; the presentations are pure arrangement. local LayoutHelpers = import("/lua/maui/layouthelpers.lua") +local UIUtil = import("/lua/ui/uiutil.lua") local Group = import("/lua/maui/group.lua").Group local Bitmap = import("/lua/maui/bitmap.lua").Bitmap local Dragger = import("/lua/maui/dragger.lua").Dragger local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/models/customlobbylocalmodel.lua") +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/models/customlobbylaunchmodel.lua") +local CustomLobbyRules = import("/lua/ui/lobby/customlobby/customlobbyrules.lua") local CustomLobbyPerformancePopover = import("/lua/ui/lobby/customlobby/customlobbyperformancepopover.lua") local CustomLobbyContextMenu = import("/lua/ui/lobby/customlobby/customlobbycontextmenu.lua") local CustomLobbyMenus = import("/lua/ui/lobby/customlobby/customlobbymenus.lua") +local CustomLobbySlotPickers = import("/lua/ui/lobby/customlobby/slots/customlobbyslotpickers.lua") local CustomLobbySlotsDerivedModel = import("/lua/ui/lobby/customlobby/models/derived/customlobbyslotsderivedmodel.lua") local LazyVarDerive = import("/lua/lazyvar.lua").Derive local Layouter = LayoutHelpers.ReusedLayoutFor +local scaled = LayoutHelpers.ScaleNumber -- cursor travel (screen px) before a press becomes a drag instead of a click local DragThreshold = 5 +-- the faction display is a small left-to-right strip of the selected factions' icons +local FactionIconSize = 14 +local FactionIconGap = 2 + --- The container that owns the slot rows and coordinates drag-to-swap. The row only --- starts the gesture; the coordinator hit-tests across all rows (it's the only thing --- that knows their rects) and resolves a drop to a controller intent. @@ -82,6 +91,9 @@ local DragThreshold = 5 ---@field team string ---@field ready string ---@field readyColor string +---@field rating string +---@field games string +---@field flag FileName | false --- A presentation-supplied view of the CPU column (nil = no data, clear it). ---@class UICustomLobbySlotCpuView @@ -90,6 +102,11 @@ local DragThreshold = 5 ---@field indicatorColor? Color ---@field showIndicator boolean +--- The host-only per-seat close/open button (a labelled surface). +---@class UICustomLobbySlotButton : Group +---@field Bg Bitmap +---@field Label Text + ---@class UICustomLobbySlotBase : Group ---@field Trash TrashBag ---@field SlotIndex number @@ -104,11 +121,20 @@ local DragThreshold = 5 -- the standard named controls a presentation must provide (the base's Render* paint these): ---@field ColorSwatch Bitmap ---@field Name Text ----@field Faction Text ---@field Team Text ---@field Ready Text ---@field Cpu Text ---@field CpuIndicator Bitmap +-- optional controls a presentation may provide (the base paints these when present): +---@field Faction? Text # legacy text faction label (presentations now use FactionIcons) +---@field FactionIcons? Group # the selected factions as a strip of small icons +---@field FactionIconBitmaps? Bitmap[] # the icon slots inside FactionIcons +---@field Rating? Text +---@field Games? Text +---@field Flag? Bitmap +---@field Avatar? Bitmap +---@field SlotButton? UICustomLobbySlotButton # host-only per-seat close/open button +---@field SlotButtonMode? "close" | "open" | false local CustomLobbySlotBase = Class(Group) { ---@param self UICustomLobbySlotBase @@ -203,19 +229,76 @@ local CustomLobbySlotBase = Class(Group) { self.ColorSwatch:SetSolidColor('00000000') self.Name:SetText("- open -") self.Name:SetColor('ff888888') - self.Faction:SetText("") + if self.Faction then self.Faction:SetText("") end self.Team:SetText("") self.Ready:SetText("") + self:RenderExtras(nil) return end self.ColorSwatch:SetSolidColor(view.colorHex) self.Name:SetText(view.name) self.Name:SetColor(view.nameColor) - self.Faction:SetText(view.faction) + if self.Faction then self.Faction:SetText(view.faction) end self.Team:SetText(view.team) self.Ready:SetText(view.ready) self.Ready:SetColor(view.readyColor) + self:RenderExtras(view) + end, + + --- Paints the optional controls a presentation may provide (rating / games / country flag / avatar + --- placeholder) — guarded so a presentation that omits one is unaffected. `view` is nil on an empty + --- seat (everything clears / hides). + ---@param self UICustomLobbySlotBase + ---@param view UICustomLobbySlotPlayerView | nil + RenderExtras = function(self, view) + if self.Rating then + self.Rating:SetText((view and view.rating) or "") + end + if self.Games then + local games = (view and view.games) or "" + self.Games:SetText(games ~= "" and ("G:" .. games) or "") + end + if self.Flag then + local flag = view and view.flag + if flag then + self.Flag:SetTexture(UIUtil.UIFile(flag)) + self.Flag:SetAlpha(1.0) + else + self.Flag:SetAlpha(0.0) + end + end + -- the avatar is a reserved placeholder for now: a dim box while the seat is occupied + if self.Avatar then + self.Avatar:SetAlpha(view and 1.0 or 0.0) + end + self:RenderFactionIcons(view) + end, + + --- Paints the faction icon strip from `view.factionIcons` (one icon per allowed faction; a single + --- Random icon when the full set is allowed). Sizes the strip to the shown count so neighbours + --- reflow, and hides it entirely on an empty seat. + ---@param self UICustomLobbySlotBase + ---@param view UICustomLobbySlotPlayerView | nil + RenderFactionIcons = function(self, view) + local bitmaps = self.FactionIconBitmaps + if not (self.FactionIcons and bitmaps) then + return + end + local icons = (view and view.factionIcons) or {} + local count = table.getn(icons) + local slots = table.getn(bitmaps) + local shown = math.min(count, slots) + for k = 1, slots do + if k <= shown then + bitmaps[k]:SetTexture(UIUtil.UIFile(icons[k])) + bitmaps[k]:SetAlpha(1.0) + else + bitmaps[k]:SetAlpha(0.0) + end + end + local width = (shown > 0) and (shown * FactionIconSize + (shown - 1) * FactionIconGap) or 0 + self.FactionIcons.Width:Set(scaled(width)) end, --- Paints the CPU column (or clears it when `view` is nil) onto the standard named controls. @@ -252,8 +335,15 @@ local CustomLobbySlotBase = Class(Group) { self.CurrentPlayer = entry.Player self:RenderPlayer(entry.PlayerView or nil) self:RenderCpu(entry.CpuView or nil) + -- a closed empty seat reads "- closed -" instead of "- open -" + if entry.Closed and not entry.Player then + self.Name:SetText("- closed -") + self.Name:SetColor('ff6a7078') + end -- a locked seat (only meaningful when occupied) shows the gold left-edge accent self.LockStripe:SetAlpha((entry.Locked and entry.Player) and 1.0 or 0.0) + -- the host-only per-seat close/open button (shown only on an empty or closed seat) + self:RenderSlotButton(entry) end, --#endregion @@ -388,6 +478,195 @@ local CustomLobbySlotBase = Class(Group) { end end, + --- Whether the local peer may edit this seat's per-player settings (faction / colour / team): your + --- own seat while you're not ready, or any AI seat if you're the host. (Another human's seat is not + --- editable.) A per-`kind` gate refines this — the team picker is hidden under a binary AutoTeams + --- mode (the team is decided by start position there). + ---@param self UICustomLobbySlotBase + ---@param kind "color" | "faction" | "team" + ---@return boolean + CanEdit = function(self, kind) + local player = self.CurrentPlayer + if not player then + return false + end + local localModel = CustomLobbyLocalModel.GetSingleton() + local mine = player.Human and player.OwnerID == localModel.LocalPeerId() and not player.Ready + local hostAi = localModel.IsHost() and not player.Human + if not (mine or hostAi) then + return false + end + if kind == "team" and CustomLobbyRules.AutoTeamMode(CustomLobbyLaunchModel.GetSingleton().GameOptions()) then + return false -- binary AutoTeams decides the team from the start position + end + return true + end, + + --- Opens the picker for an editable element, anchored at the cursor. + ---@param self UICustomLobbySlotBase + ---@param kind "color" | "faction" | "team" + ---@param event KeyEvent + OpenPicker = function(self, kind, event) + local player = self.CurrentPlayer + if not player then + return + end + local x, y = event.MouseX, event.MouseY + if kind == "color" then + CustomLobbySlotPickers.ShowColorPicker(self.SlotIndex, player, x, y) + elseif kind == "faction" then + CustomLobbySlotPickers.ShowFactionPicker(self.SlotIndex, player, x, y) + elseif kind == "team" then + CustomLobbySlotPickers.ShowTeamPicker(self.SlotIndex, player, x, y) + end + end, + + --- Routes an edit-zone press: a left press on an editable element opens its picker; otherwise it + --- behaves like a normal row press (take / ready / host-drag), and a right press opens the context + --- menu — so the zone never swallows the row's default interactions when it isn't editable. + ---@param self UICustomLobbySlotBase + ---@param kind "color" | "faction" | "team" + ---@param event KeyEvent + ---@return boolean + HandleEditZoneEvent = function(self, kind, event) + if event.Type ~= 'ButtonPress' then + return false + end + if event.Modifiers.Right then + self:OnRowContext(event) + elseif self:CanEdit(kind) then + self:OpenPicker(kind, event) + else + self:OnRowPress(event) + end + return true + end, + + --- Builds a transparent hit zone over an editable element (the presentation lays it out above the + --- ClickArea, like the CPU hover zone). Routes presses through `HandleEditZoneEvent`. + ---@param self UICustomLobbySlotBase + ---@param kind "color" | "faction" | "team" + ---@return Bitmap + CreateEditZone = function(self, kind) + local zone = Bitmap(self) + zone:SetSolidColor('00000000') + zone.HandleEvent = function(control, event) + return self:HandleEditZoneEvent(kind, event) + end + return zone + end, + + --- Builds the faction icon strip: a Group with one icon slot per real faction (hidden until + --- `RenderFactionIcons` fills them). The presentation positions the outer group and calls + --- `LayoutFactionIcons` to lay the slots inside it; the edit zone overlays the group. + ---@param self UICustomLobbySlotBase + ---@return Group + CreateFactionIcons = function(self) + local group = Group(self) + local bitmaps = {} + for k = 1, CustomLobbyLaunchModel.RealFactionCount do + local icon = Bitmap(group) + icon:DisableHitTest() + icon:SetAlpha(0.0) + bitmaps[k] = icon + end + self.FactionIcons = group + self.FactionIconBitmaps = bitmaps + return group + end, + + --- Lays the icon slots left-to-right inside the faction-icon group (called from the presentation's + --- LayoutContents after it has positioned the outer group). The slots track the group's left edge, + --- so the strip follows the group regardless of column orientation. + ---@param self UICustomLobbySlotBase + LayoutFactionIcons = function(self) + if not self.FactionIconBitmaps then + return + end + self.FactionIcons.Height:Set(scaled(FactionIconSize)) + for k, icon in self.FactionIconBitmaps do + local offset = (k - 1) * (FactionIconSize + FactionIconGap) + Layouter(icon):Width(FactionIconSize):Height(FactionIconSize):AtVerticalCenterIn(self.FactionIcons) + :Left(function() return self.FactionIcons.Left() + scaled(offset) end):End() + end + end, + + --- Builds the host-only per-seat close/open button (hidden until `RenderSlotButton` shows it on an + --- empty / closed seat). A small labelled surface; the presentation positions the outer group and + --- calls `LayoutSlotButton` to bind its internals. + ---@param self UICustomLobbySlotBase + ---@return Group + CreateSlotButton = function(self) + ---@type UICustomLobbySlotButton + local btn = Group(self) + btn.Bg = Bitmap(btn) + btn.Bg:SetSolidColor('ff2a333c') + btn.Label = UIUtil.CreateText(btn, "", 12, UIUtil.bodyFont) + btn.Label:SetColor('ffd0d4d8') + btn.Label:DisableHitTest() + btn.Bg.HandleEvent = function(control, event) + if event.Type == 'ButtonPress' then + self:OnSlotButtonPress() + return true + elseif event.Type == 'MouseEnter' then + control:SetSolidColor('ff37424d') + return true + elseif event.Type == 'MouseExit' then + control:SetSolidColor('ff2a333c') + return true + end + return false + end + btn:Hide() + self.SlotButton = btn + return btn + end, + + --- Binds the slot button's internals (called from the presentation's LayoutContents after it has + --- positioned the outer group). + ---@param self UICustomLobbySlotBase + LayoutSlotButton = function(self) + if not self.SlotButton then + return + end + Layouter(self.SlotButton.Bg):Fill(self.SlotButton):End() + Layouter(self.SlotButton.Label):AtCenterIn(self.SlotButton):End() + end, + + --- Updates the close/open button for the resolved entry: the host sees "Close" on an empty open + --- seat and "Open" on a closed seat; it is hidden otherwise (occupied seat, or a non-host peer). + ---@param self UICustomLobbySlotBase + ---@param entry UICustomLobbySlot + RenderSlotButton = function(self, entry) + local btn = self.SlotButton + if not btn then + return + end + local isHost = CustomLobbyLocalModel.GetSingleton().IsHost() + if isHost and not entry.Player and not entry.Closed then + self.SlotButtonMode = "close" + btn.Label:SetText("Close") + btn:Show() + elseif isHost and entry.Closed then + self.SlotButtonMode = "open" + btn.Label:SetText("Open") + btn:Show() + else + self.SlotButtonMode = false + btn:Hide() + end + end, + + --- Click on the close/open button: opens or closes this seat (host-authoritative). + ---@param self UICustomLobbySlotBase + OnSlotButtonPress = function(self) + if self.SlotButtonMode == "close" then + CustomLobbyController.RequestSetSlotClosed(self.SlotIndex, true) + elseif self.SlotButtonMode == "open" then + CustomLobbyController.RequestSetSlotClosed(self.SlotIndex, false) + end + end, + --#endregion ---@param self UICustomLobbySlotBase diff --git a/lua/ui/lobby/customlobby/slots/CustomLobbySlotPickers.lua b/lua/ui/lobby/customlobby/slots/CustomLobbySlotPickers.lua new file mode 100644 index 00000000000..c5d64b7cfb3 --- /dev/null +++ b/lua/ui/lobby/customlobby/slots/CustomLobbySlotPickers.lua @@ -0,0 +1,402 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** 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. +--****************************************************************************************************** + +-- The three small floating pickers a player opens by clicking a card's editable element: the **faction +-- multi-toggle** (tick a subset of factions — more than one means random among them), the **colour +-- grid** (taken colours greyed; colours are scarce), and the **team list**. Each is a framed panel +-- anchored at a screen point, dismissed on click-outside / Esc — the same singleton + full-screen-cover +-- pattern as CustomLobbyContextMenu, lifted above the slot rows' raised hit area. +-- +-- A picker only proposes a change: it calls the matching host-authoritative controller intent +-- (`RequestSetFactions` / `RequestSetColor` / `RequestSetTeam`, keyed by slot) and the seat re-renders +-- from the synced model. It never writes a model itself. The faction picker applies live on each toggle +-- (and stays open); the colour / team pickers apply and close (a single choice). + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") +local EscapeHandler = import("/lua/ui/dialogs/eschandler.lua") + +local Group = import("/lua/maui/group.lua").Group +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap +local GameColors = import("/lua/gamecolors.lua").GameColors +local Factions = import("/lua/factions.lua").Factions +local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/models/customlobbylaunchmodel.lua") + +local Layouter = LayoutHelpers.ReusedLayoutFor +local scaled = LayoutHelpers.ScaleNumber + +local Pad = 6 +local RowHeight = 24 +local LabelPadX = 8 +local RowWidth = 150 -- faction / team rows +local Swatch = 22 -- colour grid cell +local SwatchGap = 4 +local SwatchesPerRow = 8 + +local BorderColor = 'ff415055' +local FillColor = 'f0101418' +local HoverColor = '22ffffff' +local CheckOn = 'ff7ad97a' +local CheckOff = '00000000' +local CheckBorder = 'ff5a6470' +local MaxTeams = 8 -- "No team" + Team 1..MaxTeams + +------------------------------------------------------------------------------- +--#region Singleton + framing (mirrors CustomLobbyContextMenu) + +local ModuleTrash = TrashBag() +---@type Group | false +local Instance = false +---@type Bitmap | false +local Cover = false + +--- Closes the open picker (if any). Idempotent. +function Hide() + if not Instance then + return + end + EscapeHandler.PopEscapeHandler() + Instance:Destroy() + Instance = false + if Cover then + Cover:Destroy() + Cover = false + end +end + +--- A full-screen invisible catcher: a click off the panel dismisses it. +---@return Bitmap +local function CreateCover() + local cover = Bitmap(GetFrame(0)) + cover:SetSolidColor('00000000') + cover.HandleEvent = function(control, event) + if event.Type == 'ButtonPress' then + Hide() + return true + end + return false + end + Layouter(cover):Fill(GetFrame(0)):End() + return cover +end + +--- Builds an empty framed panel (border + dark fill) of the given unscaled size, parented to the +--- frame. Children anchor into it; the caller positions it via `Mount`. +---@param width number +---@param height number +---@return Group +local function CreatePanel(width, height) + local panel = Group(GetFrame(0), "CustomLobbySlotPicker") + panel.Width:Set(scaled(width)) + panel.Height:Set(scaled(height)) + + local border = Bitmap(panel) + border:SetSolidColor(BorderColor) + border:DisableHitTest() + local fill = Bitmap(panel) + fill:SetSolidColor(FillColor) + fill:DisableHitTest() + Layouter(border):Fill(panel):End() + Layouter(fill):AtLeftIn(panel, 1):AtRightIn(panel, 1):AtTopIn(panel, 1):AtBottomIn(panel, 1):End() + return panel +end + +--- Shows `panel` at the screen point, kept on-screen, above the slot rows' raised hit area, with the +--- click-outside cover and an Esc handler. Replaces any picker already open. +---@param panel Group +---@param x number +---@param y number +local function Mount(panel, x, y) + local frame = GetFrame(0) + local baseDepth = frame:GetTopmostDepth() + + Cover = CreateCover() + Cover.Depth:Set(baseDepth + 10) + + panel.Depth:Set(baseDepth + 20) + ModuleTrash:Add(panel) + Instance = panel + + local left = math.min(x, frame.Right() - panel.Width()) + local top = math.min(y, frame.Bottom() - panel.Height()) + panel.Left:Set(math.max(0, left)) + panel.Top:Set(math.max(0, top)) + + EscapeHandler.PushEscapeHandler(function() Hide() end) +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Faction multi-toggle + +--- The sorted list of ticked faction indices in `selected`. +---@param selected table +---@return number[] +local function SelectedFactions(selected) + local out = {} + for index = 1, CustomLobbyLaunchModel.RealFactionCount do + if selected[index] then + table.insert(out, index) + end + end + return out +end + +--- Opens the faction multi-toggle for `slot`'s player at the screen point. Each row is a faction with a +--- tick box; clicking toggles it and applies live (more than one ticked = random among them). The last +--- ticked faction can't be un-ticked, so a player always has at least one allowed faction. +---@param slot number +---@param player UICustomLobbyPlayer +---@param x number +---@param y number +function ShowFactionPicker(slot, player, x, y) + Hide() + local count = CustomLobbyLaunchModel.RealFactionCount + + local selected = {} + for _, index in (player.Factions or { player.Faction }) do + if type(index) == 'number' and index >= 1 and index <= count then + selected[index] = true + end + end + + local panel = CreatePanel(RowWidth, Pad * 2 + count * RowHeight) + + for i = 1, count do + local faction = Factions[i] + local top = Pad + (i - 1) * RowHeight + + local row = Group(panel) + local surface = Bitmap(row) + surface:SetSolidColor(HoverColor) + surface:SetAlpha(0.0) + + -- the frame first (behind), then the fill on top so the tick shows over it + local checkBorder = Bitmap(row) + checkBorder:SetSolidColor(CheckBorder) + checkBorder:DisableHitTest() + local check = Bitmap(row) + check:DisableHitTest() + check:SetSolidColor(selected[i] and CheckOn or CheckOff) + + local icon = Bitmap(row) + icon:DisableHitTest() + if faction.SmallIcon then + icon:SetTexture(UIUtil.UIFile(faction.SmallIcon)) + end + + local label = UIUtil.CreateText(row, faction.DisplayName or faction.Key or tostring(i), 13, UIUtil.bodyFont) + label:DisableHitTest() + + surface.HandleEvent = function(control, event) + if event.Type == 'MouseEnter' then + control:SetAlpha(1.0) + return true + elseif event.Type == 'MouseExit' then + control:SetAlpha(0.0) + return true + elseif event.Type == 'ButtonPress' then + -- toggle, but never empty the set (the last allowed faction stays ticked) + if selected[i] and table.getn(SelectedFactions(selected)) <= 1 then + return true + end + selected[i] = not selected[i] + check:SetSolidColor(selected[i] and CheckOn or CheckOff) + CustomLobbyController.RequestSetFactions(slot, SelectedFactions(selected)) + return true + end + return false + end + + Layouter(row):AtLeftIn(panel, Pad):AtRightIn(panel, Pad):Height(RowHeight) + :Top(function() return panel.Top() + scaled(top) end):End() + Layouter(surface):Fill(row):End() + Layouter(checkBorder):AtLeftIn(row):AtVerticalCenterIn(row):Width(14):Height(14):End() + Layouter(check):AtLeftIn(checkBorder, 1):AtTopIn(checkBorder, 1):AtRightIn(checkBorder, 1):AtBottomIn(checkBorder, 1):End() + Layouter(icon):AnchorToRight(checkBorder, LabelPadX):AtVerticalCenterIn(row):Width(14):Height(14):End() + Layouter(label):AnchorToRight(icon, 6):AtVerticalCenterIn(row):End() + end + + Mount(panel, x, y) +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Colour grid + +--- The set of PlayerColor indices already used by a seated player other than `exceptSlot` (greyed in +--- the grid — colours are scarce). Mirrors the host-side scarcity check, for UX. +---@param exceptSlot number +---@return table +local function TakenColors(exceptSlot) + local launch = CustomLobbyLaunchModel.GetSingleton() + local taken = {} + for slot = 1, CustomLobbyLaunchModel.MaxSlots do + if slot ~= exceptSlot then + local player = launch.Players[slot]() + if player and player.PlayerColor then + taken[player.PlayerColor] = true + end + end + end + return taken +end + +--- Opens the colour grid for `slot`'s player. Free colours are pickable (click applies + closes); +--- colours taken by another seated player are greyed and inert; the player's current colour is framed. +---@param slot number +---@param player UICustomLobbyPlayer +---@param x number +---@param y number +function ShowColorPicker(slot, player, x, y) + Hide() + local colors = GameColors.PlayerColors + local total = table.getn(colors) + local rows = math.ceil(total / SwatchesPerRow) + local taken = TakenColors(slot) + + local width = Pad * 2 + SwatchesPerRow * Swatch + (SwatchesPerRow - 1) * SwatchGap + local height = Pad * 2 + rows * Swatch + (rows - 1) * SwatchGap + local panel = CreatePanel(width, height) + + for i = 1, total do + local col = math.mod(i - 1, SwatchesPerRow) + local rowIdx = math.floor((i - 1) / SwatchesPerRow) + local left = Pad + col * (Swatch + SwatchGap) + local top = Pad + rowIdx * (Swatch + SwatchGap) + local isTaken = taken[i] + local isCurrent = player.PlayerColor == i + + -- a white frame behind the current colour's swatch + if isCurrent then + local frame = Bitmap(panel) + frame:SetSolidColor('ffffffff') + frame:DisableHitTest() + Layouter(frame):Width(Swatch + 4):Height(Swatch + 4) + :Left(function() return panel.Left() + scaled(left - 2) end) + :Top(function() return panel.Top() + scaled(top - 2) end):End() + end + + local swatch = Bitmap(panel) + swatch:SetSolidColor(colors[i]) + swatch:SetAlpha(isTaken and 0.25 or 1.0) + if not isTaken and not isCurrent then + swatch.HandleEvent = function(control, event) + if event.Type == 'MouseEnter' then + control:SetAlpha(0.7) + return true + elseif event.Type == 'MouseExit' then + control:SetAlpha(1.0) + return true + elseif event.Type == 'ButtonPress' then + Hide() + CustomLobbyController.RequestSetColor(slot, i) + return true + end + return false + end + else + swatch:DisableHitTest() + end + + Layouter(swatch):Width(Swatch):Height(Swatch) + :Left(function() return panel.Left() + scaled(left) end) + :Top(function() return panel.Top() + scaled(top) end):End() + end + + Mount(panel, x, y) +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Team list + +--- Opens the team list for `slot`'s player: "No team" + Team 1..MaxTeams (backend numbering 1, 2..9). +--- Clicking applies + closes. The current team is highlighted. +---@param slot number +---@param player UICustomLobbyPlayer +---@param x number +---@param y number +function ShowTeamPicker(slot, player, x, y) + Hide() + local entries = {} + table.insert(entries, { label = "No team", team = 1 }) + for t = 1, MaxTeams do + table.insert(entries, { label = "Team " .. tostring(t), team = t + 1 }) + end + local count = table.getn(entries) + + local panel = CreatePanel(RowWidth, Pad * 2 + count * RowHeight) + + for i = 1, count do + local entry = entries[i] + local top = Pad + (i - 1) * RowHeight + local isCurrent = (player.Team or 1) == entry.team + + local row = Group(panel) + local surface = Bitmap(row) + surface:SetSolidColor(HoverColor) + surface:SetAlpha(isCurrent and 0.12 or 0.0) + + local label = UIUtil.CreateText(row, entry.label, 13, UIUtil.bodyFont) + label:SetColor(isCurrent and CheckOn or 'ffffffff') + label:DisableHitTest() + + surface.HandleEvent = function(control, event) + if event.Type == 'MouseEnter' then + control:SetAlpha(0.12) + return true + elseif event.Type == 'MouseExit' then + control:SetAlpha(isCurrent and 0.12 or 0.0) + return true + elseif event.Type == 'ButtonPress' then + Hide() + CustomLobbyController.RequestSetTeam(slot, entry.team) + return true + end + return false + end + + Layouter(row):AtLeftIn(panel, Pad):AtRightIn(panel, Pad):Height(RowHeight) + :Top(function() return panel.Top() + scaled(top) end):End() + Layouter(surface):Fill(row):End() + Layouter(label):AtLeftIn(row, LabelPadX):AtVerticalCenterIn(row):End() + end + + Mount(panel, x, y) +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Debugging + +function __moduleinfo.OnDirty() + Hide() + ModuleTrash:Destroy() +end + +--#endregion diff --git a/lua/ui/lobby/customlobby/slots/CustomLobbySlotsInterface.lua b/lua/ui/lobby/customlobby/slots/CustomLobbySlotsInterface.lua index 53436b85c0f..76971c48e68 100644 --- a/lua/ui/lobby/customlobby/slots/CustomLobbySlotsInterface.lua +++ b/lua/ui/lobby/customlobby/slots/CustomLobbySlotsInterface.lua @@ -73,6 +73,7 @@ local PinIcon = '/game/menu-btns/pin_btn_up.dds' -- unpinned (toggle off local PinnedIcon = '/game/menu-btns/pinned_btn_up.dds' -- pinned (toggle on / the lock-notice glyph) local BalanceIcon = '/BUTTON/autobalance/_btn_up.dds' local ReopenIcon = '/game/recall-panel/icon-recall_bmp.dds' +local CloseEmptyIcon = '/dialogs/close_btn/close.dds' -- close all empty open slots (pairs with reopen) -- the auto-teams quick-cycle button steps through these modes in order (matches the options dropdown); -- its glyph is the faction-skinned per-mode art the legacy lobby used (/BUTTON/autoteam//). @@ -218,6 +219,7 @@ local SlotTool = Class(Group) { ---@field PinButton UICustomLobbySlotTool ---@field AutoTeamsButton UICustomLobbySlotTool ---@field BalanceButton UICustomLobbySlotTool +---@field CloseEmptyButton UICustomLobbySlotTool ---@field ReopenButton UICustomLobbySlotTool ---@field Body UICustomLobbySlotsBody | false # the active layout body ---@field LayoutKind "one" | "two" | false # which layout Body currently is @@ -286,6 +288,14 @@ local CustomLobbySlotsInterface = Class(Group) { Tooltip.AddControlTooltipManual(self.BalanceButton.Bg, "Auto balance", "Preview a balanced two-team split before applying it. Locked players stay put.") + -- closes every currently-open empty slot in one go (pairs with the reopen button) + self.CloseEmptyButton = SlotTool(self.Tools, CloseEmptyIcon) + self.CloseEmptyButton.OnPress = function() + CustomLobbyController.RequestCloseEmptySlots() + end + Tooltip.AddControlTooltipManual(self.CloseEmptyButton.Bg, "Close empty slots", + "Close every open slot that has no player, so no more players can join those seats.") + self.ReopenButton = SlotTool(self.Tools, ReopenIcon) self.ReopenButton.OnPress = function() CustomLobbyController.RequestReopenClosedSlots() @@ -352,14 +362,15 @@ local CustomLobbySlotsInterface = Class(Group) { Layouter(self.Tools) :AtRightIn(self, 4) :AtVerticalCenterIn(self.Header) - :Width(4 * ToolSize + 3 * ToolGap):Height(ToolSize) + :Width(5 * ToolSize + 4 * ToolGap):Height(ToolSize) :End() local function placeTool(tool) return Layouter(tool):AtTopIn(self.Tools):Width(ToolSize):Height(ToolSize) end - -- reading left to right: Pin · Auto teams · Balance · Reopen + -- reading left to right: Pin · Auto teams · Balance · Close empty · Reopen placeTool(self.ReopenButton):AtRightIn(self.Tools):End() - placeTool(self.BalanceButton):LeftOf(self.ReopenButton, ToolGap):End() + placeTool(self.CloseEmptyButton):LeftOf(self.ReopenButton, ToolGap):End() + placeTool(self.BalanceButton):LeftOf(self.CloseEmptyButton, ToolGap):End() placeTool(self.AutoTeamsButton):LeftOf(self.BalanceButton, ToolGap):End() placeTool(self.PinButton):LeftOf(self.AutoTeamsButton, ToolGap):End() diff --git a/lua/ui/lobby/customlobby/slots/onecolumn/CustomLobbySlotRow.lua b/lua/ui/lobby/customlobby/slots/onecolumn/CustomLobbySlotRow.lua index a799368c8c1..d2cb1542d9b 100644 --- a/lua/ui/lobby/customlobby/slots/onecolumn/CustomLobbySlotRow.lua +++ b/lua/ui/lobby/customlobby/slots/onecolumn/CustomLobbySlotRow.lua @@ -38,23 +38,51 @@ local Layouter = LayoutHelpers.ReusedLayoutFor ---@class UICustomLobbySlotRow : UICustomLobbySlotBase ---@field SlotNumber Text +---@field Avatar Bitmap +---@field Flag Bitmap ---@field ColorSwatch Bitmap ---@field Name Text ----@field Faction Text +---@field Rating Text +---@field Games Text +---@field FactionIcons Group ---@field Cpu Text ---@field CpuIndicator Bitmap ---@field Team Text ---@field Ready Text ---@field CpuHover Bitmap +---@field ColorZone Bitmap +---@field FactionZone Bitmap +---@field TeamZone Bitmap local CustomLobbySlotRow = Class(CustomLobbySlotBase) { ---@param self UICustomLobbySlotRow CreateContents = function(self) self.SlotNumber = UIUtil.CreateText(self, tostring(self.SlotIndex), 14, UIUtil.bodyFont) + + -- a reserved avatar placeholder (a dim box for now; a real FAF avatar lands later) + self.Avatar = Bitmap(self) + self.Avatar:SetSolidColor('33ffffff') + self.Avatar:SetAlpha(0.0) + self.Avatar:DisableHitTest() + + -- the player's country flag (hidden until a country resolves) + self.Flag = Bitmap(self) + self.Flag:SetAlpha(0.0) + self.Flag:DisableHitTest() + self.ColorSwatch = Bitmap(self) self.ColorSwatch:SetSolidColor('00000000') self.Name = UIUtil.CreateText(self, "", 14, UIUtil.bodyFont) - self.Faction = UIUtil.CreateText(self, "", 14, UIUtil.bodyFont) + + -- rating (PL) + games played, dim, in the right cluster + self.Rating = UIUtil.CreateText(self, "", 14, UIUtil.bodyFont) + self.Rating:SetColor('ffc8ccd0') + self.Games = UIUtil.CreateText(self, "", 12, UIUtil.bodyFont) + self.Games:SetColor('ff9aa0a8') + + -- the selected factions as a small icon strip (Random icon when the full set is allowed) + self:CreateFactionIcons() + self.Cpu = UIUtil.CreateText(self, "", 14, UIUtil.bodyFont) -- a small square left of the CPU label: green when the machine sustains the -- recommended unit cap at full speed, fading to red the more the sim must slow @@ -71,20 +99,43 @@ local CustomLobbySlotRow = Class(CustomLobbySlotBase) { self.CpuHover.HandleEvent = function(control, event) return self:HandleCpuHoverEvent(event) end + + -- click zones over the editable elements (colour / faction / team); the base opens the matching + -- picker when the seat is editable, else falls through to the normal row press + self.ColorZone = self:CreateEditZone("color") + self.FactionZone = self:CreateEditZone("faction") + self.TeamZone = self:CreateEditZone("team") + + -- the host-only close/open button (shown only on an empty / closed seat) + self:CreateSlotButton() end, ---@param self UICustomLobbySlotRow LayoutContents = function(self) + -- left: # · swatch · avatar · flag · name Layouter(self.SlotNumber):AtLeftIn(self, 6):AtVerticalCenterIn(self):End() Layouter(self.ColorSwatch):AnchorToRight(self.SlotNumber, 8):AtVerticalCenterIn(self):Width(14):Height(14):End() - Layouter(self.Name):AnchorToRight(self.ColorSwatch, 8):AtVerticalCenterIn(self):End() + Layouter(self.Avatar):AnchorToRight(self.ColorSwatch, 6):AtVerticalCenterIn(self):Width(16):Height(16):End() + Layouter(self.Flag):AnchorToRight(self.Avatar, 6):AtVerticalCenterIn(self):Width(18):Height(12):End() + Layouter(self.Name):AnchorToRight(self.Flag, 8):AtVerticalCenterIn(self):End() + + -- right (laid out right-to-left): ready · team · cpu · faction · games · rating Layouter(self.Ready):AtRightIn(self, 8):AtVerticalCenterIn(self):End() Layouter(self.Team):AnchorToLeft(self.Ready, 12):AtVerticalCenterIn(self):End() Layouter(self.Cpu):AnchorToLeft(self.Team, 12):AtVerticalCenterIn(self):End() Layouter(self.CpuIndicator):AnchorToLeft(self.Cpu, 5):AtVerticalCenterIn(self):Width(8):Height(12):End() - Layouter(self.Faction):AnchorToLeft(self.CpuIndicator, 10):AtVerticalCenterIn(self):End() + Layouter(self.FactionIcons):AnchorToLeft(self.CpuIndicator, 10):AtVerticalCenterIn(self):End() + self:LayoutFactionIcons() + Layouter(self.Games):AnchorToLeft(self.FactionIcons, 12):AtVerticalCenterIn(self):End() + Layouter(self.Rating):AnchorToLeft(self.Games, 8):AtVerticalCenterIn(self):End() Layouter(self.CpuHover):Fill(self.Cpu):Over(self, 20):End() + Layouter(self.ColorZone):Fill(self.ColorSwatch):Over(self, 20):End() + Layouter(self.FactionZone):Fill(self.FactionIcons):Over(self, 20):End() + Layouter(self.TeamZone):Fill(self.Team):Over(self, 20):End() + + Layouter(self.SlotButton):AtRightIn(self, 8):AtVerticalCenterIn(self):Width(54):Height(18):Over(self, 20):End() + self:LayoutSlotButton() end, } diff --git a/lua/ui/lobby/customlobby/slots/twocolumn/CustomLobbySlotCard.lua b/lua/ui/lobby/customlobby/slots/twocolumn/CustomLobbySlotCard.lua index 381a088ecbd..ab84d8aa4c6 100644 --- a/lua/ui/lobby/customlobby/slots/twocolumn/CustomLobbySlotCard.lua +++ b/lua/ui/lobby/customlobby/slots/twocolumn/CustomLobbySlotCard.lua @@ -55,14 +55,20 @@ local function ResetHorizontalEdges(control) end ---@class UICustomLobbySlotCard : UICustomLobbySlotBase +---@field Avatar Bitmap ---@field ColorSwatch Bitmap ---@field Name Text +---@field Flag Bitmap ---@field Ready Text ----@field Faction Text +---@field FactionIcons Group ---@field Team Text +---@field Rating Text ---@field Cpu Text ---@field CpuIndicator Bitmap ---@field CpuHover Bitmap +---@field ColorZone Bitmap +---@field FactionZone Bitmap +---@field TeamZone Bitmap ---@field Mirrored boolean # right-column cards lay out mirrored so the two teams face each other local CustomLobbySlotCard = Class(CustomLobbySlotBase) { @@ -70,12 +76,29 @@ local CustomLobbySlotCard = Class(CustomLobbySlotBase) { CreateContents = function(self) self.Mirrored = false + -- a reserved avatar placeholder (a dim box for now; a real FAF avatar lands later) + self.Avatar = Bitmap(self) + self.Avatar:SetSolidColor('33ffffff') + self.Avatar:SetAlpha(0.0) + self.Avatar:DisableHitTest() + self.ColorSwatch = Bitmap(self) self.ColorSwatch:SetSolidColor('00000000') self.Name = UIUtil.CreateText(self, "", 14, UIUtil.bodyFont) + + -- the player's country flag (hidden until a country resolves) + self.Flag = Bitmap(self) + self.Flag:SetAlpha(0.0) + self.Flag:DisableHitTest() + self.Ready = UIUtil.CreateText(self, "", 12, UIUtil.bodyFont) - self.Faction = UIUtil.CreateText(self, "", 12, UIUtil.bodyFont) + -- the selected factions as a small icon strip (Random icon when the full set is allowed) + self:CreateFactionIcons() self.Team = UIUtil.CreateText(self, "", 12, UIUtil.bodyFont) + + self.Rating = UIUtil.CreateText(self, "", 12, UIUtil.bodyFont) + self.Rating:SetColor('ffc8ccd0') + self.Cpu = UIUtil.CreateText(self, "", 12, UIUtil.bodyFont) self.CpuIndicator = Bitmap(self) self.CpuIndicator:SetSolidColor('ff7ad97a') @@ -88,6 +111,15 @@ local CustomLobbySlotCard = Class(CustomLobbySlotBase) { self.CpuHover.HandleEvent = function(control, event) return self:HandleCpuHoverEvent(event) end + + -- click zones over the editable elements (colour / faction / team); the base opens the matching + -- picker when the seat is editable, else falls through to the normal row press + self.ColorZone = self:CreateEditZone("color") + self.FactionZone = self:CreateEditZone("faction") + self.TeamZone = self:CreateEditZone("team") + + -- the host-only close/open button (shown only on an empty / closed seat) + self:CreateSlotButton() end, --- Lays the card out, mirrored for a right-column card so the leading edge (swatch + name, @@ -97,35 +129,49 @@ local CustomLobbySlotCard = Class(CustomLobbySlotBase) { LayoutContents = function(self) -- clear any horizontal edge a previous (opposite-orientation) pass pinned, so only the new -- anchor binds each control (the vertical anchors don't change between orientations) - for _, control in { self.ColorSwatch, self.Name, self.Ready, self.Faction, self.Team, self.Cpu, self.CpuIndicator } do + for _, control in { self.Avatar, self.ColorSwatch, self.Name, self.Flag, self.Ready, self.FactionIcons, self.Team, self.Rating, self.Cpu, self.CpuIndicator } do ResetHorizontalEdges(control) end if self.Mirrored then - -- line 1 (top): ready … name · swatch - Layouter(self.ColorSwatch):AtRightIn(self, 6):AtTopIn(self, 7):Width(14):Height(14):End() + -- line 1 (top): ready … name · swatch · avatar + Layouter(self.Avatar):AtRightIn(self, 6):AtTopIn(self, 6):Width(16):Height(16):End() + Layouter(self.ColorSwatch):AnchorToLeft(self.Avatar, 6):AtVerticalCenterIn(self.Avatar):Width(14):Height(14):End() Layouter(self.Name):AnchorToLeft(self.ColorSwatch, 6):AtVerticalCenterIn(self.ColorSwatch):End() Layouter(self.Ready):AtLeftIn(self, 6):AtVerticalCenterIn(self.ColorSwatch):End() - -- line 2 (bottom): cpu [indicator] … team · faction - Layouter(self.Faction):AtRightIn(self, 6):AtBottomIn(self, 7):End() - Layouter(self.Team):AnchorToLeft(self.Faction, 8):AtVerticalCenterIn(self.Faction):End() - Layouter(self.Cpu):AtLeftIn(self, 6):AtVerticalCenterIn(self.Faction):End() - Layouter(self.CpuIndicator):AnchorToRight(self.Cpu, 5):AtVerticalCenterIn(self.Faction):Width(8):Height(12):End() + -- line 2 (bottom): cpu [indicator] · rating … team · faction · flag + Layouter(self.Flag):AtRightIn(self, 6):AtBottomIn(self, 8):Width(18):Height(12):End() + Layouter(self.FactionIcons):AnchorToLeft(self.Flag, 6):AtVerticalCenterIn(self.Flag):End() + Layouter(self.Team):AnchorToLeft(self.FactionIcons, 8):AtVerticalCenterIn(self.Flag):End() + Layouter(self.Cpu):AtLeftIn(self, 6):AtVerticalCenterIn(self.Flag):End() + Layouter(self.CpuIndicator):AnchorToRight(self.Cpu, 5):AtVerticalCenterIn(self.Flag):Width(8):Height(12):End() + Layouter(self.Rating):AnchorToRight(self.CpuIndicator, 8):AtVerticalCenterIn(self.Flag):End() else - -- line 1 (top): swatch · name … ready - Layouter(self.ColorSwatch):AtLeftIn(self, 6):AtTopIn(self, 7):Width(14):Height(14):End() + -- line 1 (top): avatar · swatch · name … ready + Layouter(self.Avatar):AtLeftIn(self, 6):AtTopIn(self, 6):Width(16):Height(16):End() + Layouter(self.ColorSwatch):AnchorToRight(self.Avatar, 6):AtVerticalCenterIn(self.Avatar):Width(14):Height(14):End() Layouter(self.Name):AnchorToRight(self.ColorSwatch, 6):AtVerticalCenterIn(self.ColorSwatch):End() Layouter(self.Ready):AtRightIn(self, 6):AtVerticalCenterIn(self.ColorSwatch):End() - -- line 2 (bottom): faction · team … [indicator] cpu - Layouter(self.Faction):AtLeftIn(self, 6):AtBottomIn(self, 7):End() - Layouter(self.Team):AnchorToRight(self.Faction, 8):AtVerticalCenterIn(self.Faction):End() - Layouter(self.Cpu):AtRightIn(self, 6):AtVerticalCenterIn(self.Faction):End() - Layouter(self.CpuIndicator):AnchorToLeft(self.Cpu, 5):AtVerticalCenterIn(self.Faction):Width(8):Height(12):End() + -- line 2 (bottom): flag · faction · team … rating · [indicator] cpu + Layouter(self.Flag):AtLeftIn(self, 6):AtBottomIn(self, 8):Width(18):Height(12):End() + Layouter(self.FactionIcons):AnchorToRight(self.Flag, 6):AtVerticalCenterIn(self.Flag):End() + Layouter(self.Team):AnchorToRight(self.FactionIcons, 8):AtVerticalCenterIn(self.Flag):End() + Layouter(self.Cpu):AtRightIn(self, 6):AtVerticalCenterIn(self.Flag):End() + Layouter(self.CpuIndicator):AnchorToLeft(self.Cpu, 5):AtVerticalCenterIn(self.Flag):Width(8):Height(12):End() + Layouter(self.Rating):AnchorToLeft(self.CpuIndicator, 8):AtVerticalCenterIn(self.Flag):End() end + self:LayoutFactionIcons() Layouter(self.CpuHover):Fill(self.Cpu):Over(self, 20):End() + Layouter(self.ColorZone):Fill(self.ColorSwatch):Over(self, 20):End() + Layouter(self.FactionZone):Fill(self.FactionIcons):Over(self, 20):End() + Layouter(self.TeamZone):Fill(self.Team):Over(self, 20):End() + + -- centred so it reads cleanly on an empty / closed card, regardless of mirror + Layouter(self.SlotButton):AtCenterIn(self):Width(60):Height(18):Over(self, 20):End() + self:LayoutSlotButton() end, --- Sets the mirror state (the two-column layout calls this per the card's column) and re-lays the diff --git a/lua/ui/lobby/customlobby/social/CustomLobbyChatModel.lua b/lua/ui/lobby/customlobby/social/CustomLobbyChatModel.lua index bf9a5fabb00..de840e8d130 100644 --- a/lua/ui/lobby/customlobby/social/CustomLobbyChatModel.lua +++ b/lua/ui/lobby/customlobby/social/CustomLobbyChatModel.lua @@ -82,10 +82,12 @@ local Instance = nil --- trash so one `CustomLobbySession.Teardown()` frees it. Written by the controller (the wire half) and --- the chat controller (the send pipeline) through the free-function helpers below; views only read it. ---@class UICustomLobbyChatModel : Destroyable ----@field Trash TrashBag # owns the LazyVars (freed on Destroy) ----@field Entries LazyVar # the feed, capped to MaxEntries ----@field Recipient LazyVar # current send target ('all') — reserved for whisper (slice 3) ----@field Destroyed boolean +---@field Trash TrashBag # owns the LazyVars (freed on Destroy) +---@field Entries LazyVar # the feed, capped to MaxEntries +---@field Recipient LazyVar # current send target ('all') — reserved for whisper (slice 3) +---@field TotalCount LazyVar # monotonic count of lines ever appended (survives the ring-buffer trim) — drives the unread badge +---@field SeenTotal LazyVar # `TotalCount` as of the last time the feed was viewed; unread = TotalCount - SeenTotal +---@field Destroyed boolean local ChatModel = ClassSimple { ---@param self UICustomLobbyChatModel @@ -93,6 +95,8 @@ local ChatModel = ClassSimple { self.Trash = TrashBag() self.Entries = self.Trash:Add(Create({})) self.Recipient = self.Trash:Add(Create('all')) + self.TotalCount = self.Trash:Add(Create(0)) + self.SeenTotal = self.Trash:Add(Create(0)) self.Destroyed = false end, @@ -165,9 +169,19 @@ function Append(model, entry) while table.getn(entries) > MaxEntries do table.remove(entries, 1) end + -- bump the monotonic total BEFORE publishing Entries: a panel observing Entries refreshes + -- synchronously and marks the feed seen, so it must read the new total (else it lags by one) + model.TotalCount:Set(model.TotalCount() + 1) model.Entries:Set(entries) end +--- Marks the feed seen up to the current total (resets the unread count to 0). The chat panel calls +--- this whenever it renders — it only exists while the Chat tab is open, so "rendered" means "viewed". +---@param model UICustomLobbyChatModel +function MarkSeen(model) + model.SeenTotal:Set(model.TotalCount()) +end + --- Appends a system notice (a senderless `Confirmed` line — command feedback, join/leave, …). The one --- place the system-entry shape is built, so the chat controller (local notices) and the lobby --- controller (host-broadcast join/leave) stay in step. @@ -234,6 +248,8 @@ function __moduleinfo.OnReload(newModule) local handle = newModule.SetupSingleton() handle.Entries:Set(Instance.Entries()) handle.Recipient:Set(Instance.Recipient()) + handle.TotalCount:Set(Instance.TotalCount()) + handle.SeenTotal:Set(Instance.SeenTotal()) end end diff --git a/lua/ui/lobby/customlobby/social/CustomLobbyChatPanel.lua b/lua/ui/lobby/customlobby/social/CustomLobbyChatPanel.lua index 79cf4753f8c..ec8dfbeeb78 100644 --- a/lua/ui/lobby/customlobby/social/CustomLobbyChatPanel.lua +++ b/lua/ui/lobby/customlobby/social/CustomLobbyChatPanel.lua @@ -296,6 +296,10 @@ local CustomLobbyChatPanel = ClassUI(Group) { return end + -- the panel only exists while the Chat tab is open, so a render means the feed is being viewed: + -- clear the unread count (the badge in CustomLobbyInterface reads TotalCount - SeenTotal) + CustomLobbyChatModel.MarkSeen(CustomLobbyChatModel.GetSingleton()) + -- recompute the row width from the (now-settled) panel width so rows size correctly even if the -- first Initialize ran before the layout settled self.RowWidth = self:ComputeRowWidth() diff --git a/lua/ui/lobby/customlobby/social/chat-design.md b/lua/ui/lobby/customlobby/social/chat-design.md index 446d57c8c39..ccd72410a0d 100644 --- a/lua/ui/lobby/customlobby/social/chat-design.md +++ b/lua/ui/lobby/customlobby/social/chat-design.md @@ -93,5 +93,7 @@ the same mechanism either way. - **Refinements.** Multi-line wrapping is **done** — `CustomLobbyChatPanel.BuildLines`/`WrapLine` wrap each entry's label to the message-column width (via [`/lua/maui/text.lua`](/lua/maui/text.lua) `WrapText`, measured by a hidden font-matched text) and emit one fixed-height grid row per wrapped line. - Still open: unread-since-last-view badge count (the badge shows the total line count). + Unread badge is **done** — the model keeps a monotonic `TotalCount` (bumped per append, survives the + ring-buffer trim) and a `SeenTotal` marker; the panel calls `MarkSeen` whenever it renders (it only + exists while the Chat tab is open), and the tab badge shows `TotalCount - SeenTotal`. ``` From 0fd1f487c79792c8391677df1ab7097b32f28a77 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 28 Jun 2026 10:17:45 +0200 Subject: [PATCH 89/98] Highlight your personal slot --- .../models/derived/CustomLobbySlotsDerivedModel.lua | 13 ++++++++++--- .../lobby/customlobby/slots/CustomLobbySlotBase.lua | 8 ++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/lua/ui/lobby/customlobby/models/derived/CustomLobbySlotsDerivedModel.lua b/lua/ui/lobby/customlobby/models/derived/CustomLobbySlotsDerivedModel.lua index 318a798dc2b..983887c3b0a 100644 --- a/lua/ui/lobby/customlobby/models/derived/CustomLobbySlotsDerivedModel.lua +++ b/lua/ui/lobby/customlobby/models/derived/CustomLobbySlotsDerivedModel.lua @@ -108,6 +108,7 @@ local MaxSlots = CustomLobbyLaunchModel.MaxSlots --- One fully-resolved slot: the merge of player (launch) + placement (scenario) + closed (session) + --- CPU benchmark (local). Carries both the ready-to-paint views and the raw refs interaction needs. ---@class UICustomLobbySlot +---@field IsLocalPeer boolean # true when the seated player is the local peer ---@field Slot number # slot index 1..MaxSlots ---@field Player UICustomLobbyPlayer | false # the seated player (raw), false when empty — for intents/drag ---@field Closed boolean # session: seat closed (no army at launch) @@ -311,12 +312,13 @@ local Instance = nil ---@param cap number | nil # recommended unit cap (seated-count × per-player tier) ---@param side 1 | 2 | false # resolved binary auto-team side for this seat ---@return UICustomLobbySlot -local function BuildSlot(slot, player, closed, locked, benchmarks, spawns, cap, side) +local function BuildSlot(slot, player, closed, locked, benchmarks, spawns, cap, side, IsLocalPeer) if not player then return { Slot = slot, Player = false, Closed = closed, Locked = locked, StartSpot = false, Position = false, Side = side, PlayerView = false, CpuView = false, Benchmark = false, UnitCap = false, + IsLocalPeer = false, } end @@ -334,6 +336,7 @@ local function BuildSlot(slot, player, closed, locked, benchmarks, spawns, cap, CpuView = BuildCpuView(benchmark or nil, cap), Benchmark = benchmark, UnitCap = cap or false, + IsLocalPeer = IsLocalPeer } end @@ -425,11 +428,14 @@ local SlotsModel = ClassSimple { --- scenario state and publishes each — but only when its own signature changed (the de-dup). ---@param self UICustomLobbySlotsDerivedModel Recompute = function(self) + LOG("Recompute") local launch = CustomLobbyLaunchModel.GetSingleton() local session = CustomLobbySessionModel.GetSingleton() local closedSlots = session.ClosedSlots() local lockedSlots = session.LockedSlots() - local benchmarks = CustomLobbyLocalModel.GetSingleton().CpuBenchmarks() + local peerModel = CustomLobbyLocalModel.GetSingleton() + local benchmarks = peerModel.CpuBenchmarks() + local peerId = peerModel.LocalPeerId() local scenario = CustomLobbyScenarioDerivedModel.GetScenario() local spawns = scenario and scenario.Markers and scenario.Markers.Spawns or nil @@ -458,8 +464,9 @@ local SlotsModel = ClassSimple { local player = launch.Players[slot]() local spot = (player and player.StartSpot) or slot local side = (resolved and resolver and resolver(spot)) or false + local isLocalPeer = player and player.OwnerID == peerId or false slots[slot] = BuildSlot(slot, player, closedSlots[slot] and true or false, - lockedSlots[slot] and true or false, benchmarks, spawns, cap, side) + lockedSlots[slot] and true or false, benchmarks, spawns, cap, side, isLocalPeer) if player then if side == 1 then totalA = totalA + (player.PL or 0) diff --git a/lua/ui/lobby/customlobby/slots/CustomLobbySlotBase.lua b/lua/ui/lobby/customlobby/slots/CustomLobbySlotBase.lua index f2de3a8487d..1492c5f66d8 100644 --- a/lua/ui/lobby/customlobby/slots/CustomLobbySlotBase.lua +++ b/lua/ui/lobby/customlobby/slots/CustomLobbySlotBase.lua @@ -335,6 +335,14 @@ local CustomLobbySlotBase = Class(Group) { self.CurrentPlayer = entry.Player self:RenderPlayer(entry.PlayerView or nil) self:RenderCpu(entry.CpuView or nil) + + LOG(entry.IsLocalPeer) + if entry.IsLocalPeer then + self.Background:SetSolidColor('44ffffff') + else + self.Background:SetSolidColor('22ffffff') + end + -- a closed empty seat reads "- closed -" instead of "- open -" if entry.Closed and not entry.Player then self.Name:SetText("- closed -") From 92ab1f7f86e2ca747c4374e89fd61f60adfac6b7 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 28 Jun 2026 10:27:38 +0200 Subject: [PATCH 90/98] Fix not being initialized with lazy var --- lua/ui/lobby/customlobby/CustomLobbyTeamScore.lua | 12 ++++++------ .../slots/twocolumn/CustomLobbyTwoColumnSlots.lua | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/lua/ui/lobby/customlobby/CustomLobbyTeamScore.lua b/lua/ui/lobby/customlobby/CustomLobbyTeamScore.lua index fced699cd95..cc3b0d5cdc3 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyTeamScore.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyTeamScore.lua @@ -86,18 +86,18 @@ local CustomLobbyTeamScore = ClassUI(Group) { ---@param self UICustomLobbyTeamScore __post_init = function(self) - -- centred row: LabelA ScoreA · ScoreB LabelB - Layouter(self.Sep):AtHorizontalCenterIn(self):AtVerticalCenterIn(self):End() - Layouter(self.ScoreA):AnchorToLeft(self.Sep, 10):AtVerticalCenterIn(self):End() - Layouter(self.LabelA):AnchorToLeft(self.ScoreA, 8):AtVerticalCenterIn(self):End() - Layouter(self.ScoreB):AnchorToRight(self.Sep, 10):AtVerticalCenterIn(self):End() - Layouter(self.LabelB):AnchorToRight(self.ScoreB, 8):AtVerticalCenterIn(self):End() end, --- Builds the score after the parent has placed the widget (kept symmetric with the panels). ---@param self UICustomLobbyTeamScore Initialize = function(self) self.Ready = true + -- centred row: LabelA ScoreA · ScoreB LabelB + Layouter(self.Sep):AtHorizontalCenterIn(self):AtVerticalCenterIn(self):End() + Layouter(self.ScoreA):AnchorToLeft(self.Sep, 10):AtVerticalCenterIn(self):End() + Layouter(self.LabelA):AnchorToLeft(self.ScoreA, 8):AtVerticalCenterIn(self):End() + Layouter(self.ScoreB):AnchorToRight(self.Sep, 10):AtVerticalCenterIn(self):End() + Layouter(self.LabelB):AnchorToRight(self.ScoreB, 8):AtVerticalCenterIn(self):End() self:Refresh() end, diff --git a/lua/ui/lobby/customlobby/slots/twocolumn/CustomLobbyTwoColumnSlots.lua b/lua/ui/lobby/customlobby/slots/twocolumn/CustomLobbyTwoColumnSlots.lua index 6be05480186..9b8bed79ec7 100644 --- a/lua/ui/lobby/customlobby/slots/twocolumn/CustomLobbyTwoColumnSlots.lua +++ b/lua/ui/lobby/customlobby/slots/twocolumn/CustomLobbyTwoColumnSlots.lua @@ -148,7 +148,6 @@ local CustomLobbyTwoColumnSlots = Class(Group) { local prev = nil for _, slot in cols[column] do local card = self.Rows[slot] - card:SetMirrored(column == 2) -- the right column faces inward (mirrored) local builder = Layouter(card):AtLeftIn(self.Columns[column]):AtRightIn(self.Columns[column]):Height(CardHeight) if not prev then builder:AtTopIn(self.Columns[column]) @@ -156,6 +155,7 @@ local CustomLobbyTwoColumnSlots = Class(Group) { builder:AnchorToBottom(prev, CardGap) end builder:End() + card:SetMirrored(column == 2) -- the right column faces inward (mirrored) card:Show() prev = card end From cc9b1324a292bc76e70041a5a75c1b9f9276a306 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 28 Jun 2026 10:28:09 +0200 Subject: [PATCH 91/98] Documnent highlight feature --- .../lobby/customlobby/slots/CustomLobbySlotBase.lua | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/lua/ui/lobby/customlobby/slots/CustomLobbySlotBase.lua b/lua/ui/lobby/customlobby/slots/CustomLobbySlotBase.lua index 1492c5f66d8..c9dec8f44ad 100644 --- a/lua/ui/lobby/customlobby/slots/CustomLobbySlotBase.lua +++ b/lua/ui/lobby/customlobby/slots/CustomLobbySlotBase.lua @@ -336,7 +336,7 @@ local CustomLobbySlotBase = Class(Group) { self:RenderPlayer(entry.PlayerView or nil) self:RenderCpu(entry.CpuView or nil) - LOG(entry.IsLocalPeer) + -- feature: highlight the seat of the local peer if entry.IsLocalPeer then self.Background:SetSolidColor('44ffffff') else @@ -348,7 +348,7 @@ local CustomLobbySlotBase = Class(Group) { self.Name:SetText("- closed -") self.Name:SetColor('ff6a7078') end - -- a locked seat (only meaningful when occupied) shows the gold left-edge accent + -- a locked seat (only meaningful when occupied) shows the geold left-edge accent self.LockStripe:SetAlpha((entry.Locked and entry.Player) and 1.0 or 0.0) -- the host-only per-seat close/open button (shown only on an empty or closed seat) self:RenderSlotButton(entry) @@ -625,6 +625,11 @@ local CustomLobbySlotBase = Class(Group) { end return false end + local oldShow = btn.Show + btn.Show = function(self, ...) + oldShow(self, unpack(arg)) + LOG(debug.traceback()) + end btn:Hide() self.SlotButton = btn return btn @@ -650,6 +655,8 @@ local CustomLobbySlotBase = Class(Group) { if not btn then return end + + btn:Hide() local isHost = CustomLobbyLocalModel.GetSingleton().IsHost() if isHost and not entry.Player and not entry.Closed then self.SlotButtonMode = "close" @@ -660,6 +667,7 @@ local CustomLobbySlotBase = Class(Group) { btn.Label:SetText("Open") btn:Show() else + btn.Label:SetText("HELP") self.SlotButtonMode = false btn:Hide() end From 04623d409bb8de65781383a63e3b63f944811f67 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 28 Jun 2026 10:39:03 +0200 Subject: [PATCH 92/98] Fix type/edit area of chat panel --- lua/ui/lobby/customlobby/social/CustomLobbyChatPanel.lua | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lua/ui/lobby/customlobby/social/CustomLobbyChatPanel.lua b/lua/ui/lobby/customlobby/social/CustomLobbyChatPanel.lua index ec8dfbeeb78..a8119d2c806 100644 --- a/lua/ui/lobby/customlobby/social/CustomLobbyChatPanel.lua +++ b/lua/ui/lobby/customlobby/social/CustomLobbyChatPanel.lua @@ -218,9 +218,9 @@ local CustomLobbyChatPanel = ClassUI(Group) { -- AtVerticalCenterIn re-sets Top (off the placeholder's 0), centring the box in the field. Layouter(self.EditBox) :AtLeftIn(self.EditField, FieldInset):AtRightIn(self.EditField, FieldInset):ResetWidth() - :Height(EditHeight):AtVerticalCenterIn(self.EditField) + :Height(EditHeight):AtTopIn(self.EditField, Pad) :End() - Layouter(self.Prompt):AtLeftIn(self.EditField, FieldInset):AtVerticalCenterIn(self.EditField):End() + Layouter(self.Prompt):AtLeftIn(self.EditField, FieldInset):AtTopIn(self.EditField, Pad):End() Layouter(self.Empty):AtHorizontalCenterIn(self):AtTopIn(self, Pad):End() Layouter(self.Measure):AtLeftTopIn(self, 0):End() -- hidden; only its font metrics are used @@ -235,7 +235,7 @@ local CustomLobbyChatPanel = ClassUI(Group) { self.DebugEdit = Bitmap(self) self.DebugEdit:SetSolidColor('4080ff80') -- green: the edit-box region self.DebugEdit:DisableHitTest() - Layouter(self.DebugEdit):Fill(self.EditBox):Over(self, 110):End() + Layouter(self.DebugEdit):Fill(self.EditField):Over(self, 110):End() end end, From a9436c4c0f7a09b10a991ccf7c0ad56b287d959c Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 28 Jun 2026 10:39:24 +0200 Subject: [PATCH 93/98] Remove debug code --- lua/ui/lobby/customlobby/slots/CustomLobbySlotBase.lua | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/lua/ui/lobby/customlobby/slots/CustomLobbySlotBase.lua b/lua/ui/lobby/customlobby/slots/CustomLobbySlotBase.lua index c9dec8f44ad..2a0447f6f86 100644 --- a/lua/ui/lobby/customlobby/slots/CustomLobbySlotBase.lua +++ b/lua/ui/lobby/customlobby/slots/CustomLobbySlotBase.lua @@ -625,11 +625,7 @@ local CustomLobbySlotBase = Class(Group) { end return false end - local oldShow = btn.Show - btn.Show = function(self, ...) - oldShow(self, unpack(arg)) - LOG(debug.traceback()) - end + btn:Hide() self.SlotButton = btn return btn From d8d10fe915d28f47b98364927f0b5fc7d9752154 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 28 Jun 2026 12:31:32 +0200 Subject: [PATCH 94/98] Rework the lobby options panel to be standalone and easier to debug --- .../config/CustomLobbyOptionsPanel.lua | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/lua/ui/lobby/customlobby/config/CustomLobbyOptionsPanel.lua b/lua/ui/lobby/customlobby/config/CustomLobbyOptionsPanel.lua index 195228acfb0..032b6e001e6 100644 --- a/lua/ui/lobby/customlobby/config/CustomLobbyOptionsPanel.lua +++ b/lua/ui/lobby/customlobby/config/CustomLobbyOptionsPanel.lua @@ -54,6 +54,7 @@ local ScrollGap = 32 -- standard lobby scrollbar gutter (see ModSelect) local GridContentWidth = 360 - 6 - ScrollGap local LabelMaxChars = 22 local ValueMaxChars = 22 +local Debug = false local SpecialColor = 'ffd0a24c' -- marker + label tint for a map/mod option local NormalColor = 'ffc8ccd0' @@ -66,12 +67,13 @@ local ValueColor = 'ff9aa0a8' local function Truncate(text, maxChars) text = text or "" if string.len(text) > maxChars then - return string.sub(text, 1, maxChars - 1) .. "…" + local limit = math.max(1, math.floor(maxChars)) + return string.sub(text, 1, limit - 1) .. "…" end return text end ----@class UICustomLobbyOptionsPanel : Group +---@class UICustomLobbyOptionsPanel : Bitmap ---@field Trash TrashBag ---@field Ready boolean ---@field HideDefaults boolean @@ -80,12 +82,14 @@ end ---@field Scrollbar Scrollbar | false ---@field Empty Text ---@field OptionsObserver LazyVar -local CustomLobbyOptionsPanel = ClassUI(Group) { +local CustomLobbyOptionsPanel = ClassUI(Bitmap) { ---@param self UICustomLobbyOptionsPanel ---@param parent Control __init = function(self, parent) - Group.__init(self, parent, "CustomLobbyOptionsPanel") + Bitmap.__init(self, parent) + self:SetSolidColor(Debug and '303080ff' or '00000000') + self:DisableHitTest() self.Trash = TrashBag() self.Ready = false @@ -115,7 +119,8 @@ local CustomLobbyOptionsPanel = ClassUI(Group) { end, ---@param self UICustomLobbyOptionsPanel - __post_init = function(self) + __post_init = function(self, parent) + Layouter(self):Fill(parent):End() Layouter(self.HideDefaultsToggle):AtLeftIn(self, 6):AtTopIn(self, 4):End() Layouter(self.OptionsGrid) :AtLeftIn(self, 6):Width(GridContentWidth) From 633ffc7e981d40a7e21dfc031412380d76755e5d Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 28 Jun 2026 12:46:26 +0200 Subject: [PATCH 95/98] Rework the debug feature of the lobby chat panel --- .../social/CustomLobbyChatPanel.lua | 43 +++++-------------- 1 file changed, 11 insertions(+), 32 deletions(-) diff --git a/lua/ui/lobby/customlobby/social/CustomLobbyChatPanel.lua b/lua/ui/lobby/customlobby/social/CustomLobbyChatPanel.lua index a8119d2c806..cee1ec71aa4 100644 --- a/lua/ui/lobby/customlobby/social/CustomLobbyChatPanel.lua +++ b/lua/ui/lobby/customlobby/social/CustomLobbyChatPanel.lua @@ -127,15 +127,16 @@ end ---@field Empty Text ---@field Measure Text # hidden, font-matched text used only to measure widths for wrapping ---@field EntriesObserver LazyVar ----@field DebugPanel? Bitmap ----@field DebugEdit? Bitmap ----@field DebugGrid? Bitmap -local CustomLobbyChatPanel = ClassUI(Group) { +---@diagnostic disable-next-line +local CustomLobbyChatPanel = ClassUI(Bitmap) { ---@param self UICustomLobbyChatPanel ---@param parent Control __init = function(self, parent) - Group.__init(self, parent, "CustomLobbyChatPanel") + ---@diagnostic disable-next-line: param-type-mismatch + Bitmap.__init(self, parent) + self:SetSolidColor(Debug and '303080ff' or '00000000') + self:DisableHitTest() self.Trash = TrashBag() self.Ready = false @@ -167,7 +168,7 @@ local CustomLobbyChatPanel = ClassUI(Group) { -- so it doesn't trip the default circular Left/Right/Width chain (see /lua/ui/CLAUDE.md § 1). Layouter(self.EditBox):Left(0):Top(0):Width(200):Height(EditHeight):End() UIUtil.SetupEditStd(self.EditBox, - UIUtil.fontColor, nil, "ffffffff", + UIUtil.fontColor, nil, UIUtil.highlightColor, UIUtil.highlightColor, UIUtil.bodyFont, 14, 200) self.EditBox:ShowBackground(false) self.EditBox:SetText('') @@ -202,7 +203,8 @@ local CustomLobbyChatPanel = ClassUI(Group) { end, ---@param self UICustomLobbyChatPanel - __post_init = function(self) + __post_init = function(self, parent) + Layouter(self):Fill(parent):End() -- the input field (border + inset fill) pinned to the bottom Layouter(self.EditFrame) :AtLeftIn(self, Pad):AtRightIn(self, Pad) @@ -224,19 +226,6 @@ local CustomLobbyChatPanel = ClassUI(Group) { Layouter(self.Empty):AtHorizontalCenterIn(self):AtTopIn(self, Pad):End() Layouter(self.Measure):AtLeftTopIn(self, 0):End() -- hidden; only its font metrics are used - - if Debug then - -- whole panel (drawn under the grid tint added in Initialize) + the edit box region - self.DebugPanel = Bitmap(self) - self.DebugPanel:SetSolidColor('303080ff') -- blue: the panel bounds - self.DebugPanel:DisableHitTest() - Layouter(self.DebugPanel):Fill(self):Over(self, 90):End() - - self.DebugEdit = Bitmap(self) - self.DebugEdit:SetSolidColor('4080ff80') -- green: the edit-box region - self.DebugEdit:DisableHitTest() - Layouter(self.DebugEdit):Fill(self.EditField):Over(self, 110):End() - end end, --- Builds the scrollable grid (its cell width needs the panel's concrete width) + scrollbar, and @@ -267,14 +256,6 @@ local CustomLobbyChatPanel = ClassUI(Group) { -- let the wheel scroll the grid when the cursor is over the rows, not just over the scrollbar UIUtil.ForwardWheelToScroll(self.Grid, self.Grid) - if Debug then - -- the feed/grid region (its right edge shows where the scrollbar gutter is reserved) - self.DebugGrid = Bitmap(self) - self.DebugGrid:SetSolidColor('40ff8040') -- orange: the grid region - self.DebugGrid:DisableHitTest() - Layouter(self.DebugGrid):Fill(self.Grid):Over(self, 100):End() - end - self:Refresh() end, @@ -313,10 +294,8 @@ local CustomLobbyChatPanel = ClassUI(Group) { -- grid's bottom to the edit box (done in Initialize) and float its top so the grid is only as -- tall as its rows — capped at the available height, beyond which it fills and scrolls. local rowsHeight = total * LayoutHelpers.ScaleNumber(RowHeight) - self.Grid.Top:Set(function() - local availableTop = self.Top() + LayoutHelpers.ScaleNumber(Pad) - return math.max(availableTop, self.Grid.Bottom() - rowsHeight) - end) + local availableHeight = self.EditFrame.Top() - self.Top() - LayoutHelpers.ScaleNumber(Pad + EditGap) + LayoutHelpers.SetHeight(self.Grid, math.max(0, math.min(rowsHeight, availableHeight))) -- were we at (or near) the bottom before the rebuild? if so, stick to the bottom after local _, rangeMax, _, visibleMax = self.Grid:GetScrollValues("Vert") From 7300f11ceed057500444e91581a573a0784b1f73 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 28 Jun 2026 13:11:57 +0200 Subject: [PATCH 96/98] Refinement of the logs panel --- .../social/CustomLobbyLogsPanel.lua | 79 ++++++++++++------- 1 file changed, 51 insertions(+), 28 deletions(-) diff --git a/lua/ui/lobby/customlobby/social/CustomLobbyLogsPanel.lua b/lua/ui/lobby/customlobby/social/CustomLobbyLogsPanel.lua index e55d7a766dd..c7ac4662526 100644 --- a/lua/ui/lobby/customlobby/social/CustomLobbyLogsPanel.lua +++ b/lua/ui/lobby/customlobby/social/CustomLobbyLogsPanel.lua @@ -26,9 +26,9 @@ -- **scrollable list** of rows. -- -- Each row is laid out in columns, **fixed-width columns first then the flexible name** so the names --- line up: `time · kind · ⚠ · name`. A malformed / unauthorised message (its Validate or Accept --- returned a reason) tints the name and fills the ⚠ slot with a warning icon whose tooltip is the --- reason. +-- line up: `time · kind · peer · ⚠ · name`. A malformed / unauthorised message (its Validate or +-- Accept returned a reason) tints the name and fills the ⚠ slot with a warning icon whose tooltip is +-- the reason. -- -- The list is a `Grid` (one column, a row Group per cell) + a vertical scrollbar — the same -- scrollable-rows pattern the config column's option/mod panels use; the Grid hides off-window rows @@ -70,23 +70,28 @@ local ButtonIdle = 'ff141a20' local ButtonHover = 'ff1f262e' local ButtonIconInset = 4 local ButtonIconColor = 'ffc8ccd0' -- TODO: temporary 1-colour placeholder until a copy/clipboard icon exists +local Debug = true -- font sizes per column (the message type is the one you read, so it's the largest) local TimeFont = 12 -local KindFont = 14 +local KindFont = 16 +local PeerFont = 14 local NameFont = 14 local TitleFont = 14 -- fixed column widths (left → right); the name column is flexible and fills whatever is left local TimeWidth = 30 -local KindWidth = 16 +local KindWidth = 24 +local PeerWidth = 54 local IconSize = 13 local NameMax = 40 +local PeerMax = 16 -- left edges of each column, accumulated so every row's name starts at the same x (aligned) local TimeLeft = Pad local KindLeft = TimeLeft + TimeWidth + ColGap -local WarnLeft = KindLeft + KindWidth + ColGap +local PeerLeft = KindLeft + KindWidth + ColGap +local WarnLeft = PeerLeft + PeerWidth + ColGap local NameLeft = WarnLeft + IconSize + ColGap local TimeColor = 'ff6a707a' @@ -99,9 +104,9 @@ local WarnIcon = '/MODS/mod_type_warning.dds' -- the kind glyph (its own column, so the name aligns regardless of direction) local KindGlyph = { - broadcast = "»»", - send = "»", - recv = "«", + broadcast = "→→", + send = "→", + recv = "←", } -- plain-text kind label for the clipboard copy (glyphs don't paste usefully) @@ -126,7 +131,8 @@ end local function Truncate(text, maxChars) text = text or "" if string.len(text) > maxChars then - return string.sub(text, 1, maxChars - 1) .. "…" + local limit = math.max(1, math.floor(maxChars)) + return string.sub(text, 1, limit - 1) .. "…" end return text end @@ -145,7 +151,7 @@ local function EntryToText(entry) return line end ----@class UICustomLobbyLogsPanel : Group +---@class UICustomLobbyLogsPanel : Bitmap ---@field Trash TrashBag ---@field Ready boolean ---@field CopyButton Group # icon-button (Bg + Icon); copies the log to the clipboard @@ -157,12 +163,14 @@ end ---@field Scrollbar Scrollbar | false ---@field Empty Text ---@field EntriesObserver LazyVar -local CustomLobbyLogsPanel = ClassUI(Group) { +local CustomLobbyLogsPanel = ClassUI(Bitmap) { ---@param self UICustomLobbyLogsPanel ---@param parent Control __init = function(self, parent) - Group.__init(self, parent, "CustomLobbyLogsPanel") + Bitmap.__init(self, parent) + self:SetSolidColor(Debug and '303080ff' or '00000000') + self:DisableHitTest() self.Trash = TrashBag() self.Ready = false @@ -211,7 +219,8 @@ local CustomLobbyLogsPanel = ClassUI(Group) { end, ---@param self UICustomLobbyLogsPanel - __post_init = function(self) + __post_init = function(self, parent) + Layouter(self):Fill(parent):End() Layouter(self.CopyButton):AtLeftIn(self, Pad):AtTopIn(self, Pad):Width(ButtonSize):Height(ButtonSize):End() Layouter(self.CopyBg):Fill(self.CopyButton):End() Layouter(self.CopyIcon) @@ -222,19 +231,29 @@ local CustomLobbyLogsPanel = ClassUI(Group) { Layouter(self.Empty):AtHorizontalCenterIn(self):AtTopIn(self, ToolbarHeight + 8):End() end, + --- The grid's content width (panel width minus the left pad and the scrollbar gutter), unscaled, + --- for row sizing. Matches the grid's `:AtLeftIn(self, Pad):AtRightIn(self, ScrollGap)` insets + --- so a row spans exactly the visible feed. Clamped so an early/unsettled read can't go negative. + ---@param self UICustomLobbyLogsPanel + ---@return number + ComputeRowWidth = function(self) + local scale = LayoutHelpers.GetPixelScaleFactor() + return math.max(1, math.floor(self.Width() / scale) - Pad - ScrollGap) + end, + --- Builds the scrollable grid (its cell width needs the panel's concrete width) + scrollbar, and --- does the first render. Three-phase init (/lua/ui/CLAUDE.md § 1). ---@param self UICustomLobbyLogsPanel Initialize = function(self) self.Ready = true - -- Grid itemWidth is unscaled (Grid scales it); the panel width is concrete/scaled, so divide - -- back out the ui scale. Reserve the scrollbar gap on the right. - local scale = LayoutHelpers.GetPixelScaleFactor() - self.RowWidth = math.floor(self.Width() / scale) - ScrollGap - self.Grid = Grid(self, self.RowWidth, RowHeight) + -- The Grid hides any item outside its visible window; the visible column count is + -- floor(gridWidth / itemWidth), so if itemWidth exceeds the visible content width every row is + -- hidden. Use a dummy item width of 1 and size each row independently from the real width. + self.RowWidth = self:ComputeRowWidth() + self.Grid = Grid(self, 1, RowHeight) Layouter(self.Grid) - :AtLeftIn(self, 0):Width(self.RowWidth) + :AtLeftIn(self, Pad):AtRightIn(self, ScrollGap) :AnchorToBottom(self.CopyButton, 6):AtBottomIn(self, Pad) :End() self.Scrollbar = UIUtil.CreateVertScrollbarFor(self.Grid) @@ -252,6 +271,9 @@ local CustomLobbyLogsPanel = ClassUI(Group) { return end + -- Recompute after mount so rows size correctly even if Initialize ran before layout settled. + self.RowWidth = self:ComputeRowWidth() + local entries = CustomLobbyLog.GetSingleton().Entries() local total = table.getn(entries) self.Title:SetText("Logs (" .. total .. ")") @@ -306,9 +328,9 @@ local CustomLobbyLogsPanel = ClassUI(Group) { CopyToClipboard(table.concat(lines, "\n")) end, - --- Builds one log row: time · kind · (warn) · name. The warn icon only appears for a bad - --- message; its column slot is still reserved (a fixed width before the flexible name) so names - --- stay aligned. Private. + --- Builds one log row: time · kind · peer · (warn) · name. The peer is kept as separate network + --- metadata so the description stays to the right of all transport details. The warn icon only + --- appears for a bad message; its column slot is still reserved so names stay aligned. Private. ---@param self UICustomLobbyLogsPanel ---@param entry UICustomLobbyLogEntry ---@return Group @@ -326,11 +348,12 @@ local CustomLobbyLogsPanel = ClassUI(Group) { kind:DisableHitTest() Layouter(kind):AtLeftIn(row, KindLeft):AtVerticalCenterIn(row):End() - local label = entry.Type - if entry.Peer then - label = label .. (entry.Kind == 'recv' and " ← " or " → ") .. tostring(entry.Peer) - end - local name = UIUtil.CreateText(row, Truncate(label, NameMax), NameFont, UIUtil.bodyFont) + local peer = UIUtil.CreateText(row, Truncate(entry.Peer and tostring(entry.Peer) or "", PeerMax), PeerFont, UIUtil.bodyFont) + peer:SetColor(TimeColor) + peer:DisableHitTest() + Layouter(peer):AtLeftIn(row, PeerLeft):AtVerticalCenterIn(row):Width(PeerWidth):End() + + local name = UIUtil.CreateText(row, Truncate(entry.Type, NameMax), NameFont, UIUtil.bodyFont) name:SetColor(entry.Error and ErrorColor or NameColor) name:DisableHitTest() Layouter(name):AtLeftIn(row, NameLeft):AtRightIn(row, Pad):AtVerticalCenterIn(row):End() From 39ddd6105edf1f7bee338c6e033a937c46af4821 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 28 Jun 2026 16:22:45 +0200 Subject: [PATCH 97/98] Add a toggle to make debugging UI elements easier --- .../customlobby/CustomLobbyController.lua | 57 +++++++++++++++++++ .../slots/CustomLobbySlotsInterface.lua | 53 +++++++++++++++-- scripts/LaunchCustomLobby.ps1 | 1 + 3 files changed, 105 insertions(+), 6 deletions(-) diff --git a/lua/ui/lobby/customlobby/CustomLobbyController.lua b/lua/ui/lobby/customlobby/CustomLobbyController.lua index 9bd59d40d6e..e02c8b1954c 100644 --- a/lua/ui/lobby/customlobby/CustomLobbyController.lua +++ b/lua/ui/lobby/customlobby/CustomLobbyController.lua @@ -1665,6 +1665,63 @@ end --#endregion +------------------------------------------------------------------------------- +--#region UI inspection (debug) +-- +-- A local, per-peer developer aid — never synced, no host authority. Toggles the engine's +-- "show control under mouse" overlay and, while it's on, binds a key to the one-shot "dump +-- controls under cursor" debug action so hovering + that key dumps the control beneath the +-- cursor (a button can't drive that command on its own — clicking one parks the cursor on the +-- button). Lives here so any lobby view (or a chat command) can drive the same toggle. Bound +-- straight into the engine key map (not the user's prefs), so it's transient and clears off. + +--- the key bound to the dump action while the overlay is on, and the debug key action it fires +local InspectDumpKey = 'q' +local InspectDumpAction = 'debug_dump_focus_ui_control' + +--- whether the inspect overlay is on (controller-owned so every caller reflects the same state) +local UiInspectOverlayEnabled = false + +--- The transient `{ key → action }` map bound while the overlay is on, resolved from +--- `debugKeyActions` so the console command isn't duplicated here. +---@return table +local function InspectDumpKeyMap() + local debugKeyActions = import("/lua/keymap/debugkeyactions.lua").debugKeyActions + return { [InspectDumpKey] = debugKeyActions[InspectDumpAction] } +end + +--- Turns the "show control under mouse" overlay on or off, binding/unbinding the dump key +--- alongside it. Idempotent — a no-op when already in the requested state. +---@param enabled boolean +function SetUiInspectOverlay(enabled) + enabled = enabled and true or false + if enabled == UiInspectOverlayEnabled then + return + end + UiInspectOverlayEnabled = enabled + ConExecute('UI_ShowControlUnderMouse ' .. (enabled and 'true' or 'false')) + if enabled then + IN_AddKeyMapTable(InspectDumpKeyMap()) + else + IN_RemoveKeyMapTable(InspectDumpKeyMap()) + end +end + +--- Flips the inspect overlay and returns the new state (so a toggle button can reflect it). +---@return boolean +function ToggleUiInspectOverlay() + SetUiInspectOverlay(not UiInspectOverlayEnabled) + return UiInspectOverlayEnabled +end + +--- Whether the inspect overlay is currently on (lets a view sync its toggle button on creation). +---@return boolean +function IsUiInspectOverlayEnabled() + return UiInspectOverlayEnabled +end + +--#endregion + ------------------------------------------------------------------------------- --#region Debugging diff --git a/lua/ui/lobby/customlobby/slots/CustomLobbySlotsInterface.lua b/lua/ui/lobby/customlobby/slots/CustomLobbySlotsInterface.lua index 76971c48e68..44cc3ede7fa 100644 --- a/lua/ui/lobby/customlobby/slots/CustomLobbySlotsInterface.lua +++ b/lua/ui/lobby/customlobby/slots/CustomLobbySlotsInterface.lua @@ -74,6 +74,7 @@ local PinnedIcon = '/game/menu-btns/pinned_btn_up.dds' -- pinned (toggle on / local BalanceIcon = '/BUTTON/autobalance/_btn_up.dds' local ReopenIcon = '/game/recall-panel/icon-recall_bmp.dds' local CloseEmptyIcon = '/dialogs/close_btn/close.dds' -- close all empty open slots (pairs with reopen) +local InspectIcon = '/dialogs/zoom_btn/zoom_btn_up.dds' -- /debug-only: toggle the "show control under mouse" overlay -- the auto-teams quick-cycle button steps through these modes in order (matches the options dropdown); -- its glyph is the faction-skinned per-mode art the legacy lobby used (/BUTTON/autoteam//). @@ -221,6 +222,7 @@ local SlotTool = Class(Group) { ---@field BalanceButton UICustomLobbySlotTool ---@field CloseEmptyButton UICustomLobbySlotTool ---@field ReopenButton UICustomLobbySlotTool +---@field DebugButton? UICustomLobbySlotTool # /debug-only host UI-inspection toggle ---@field Body UICustomLobbySlotsBody | false # the active layout body ---@field LayoutKind "one" | "two" | false # which layout Body currently is ---@field Mounted boolean # true once __post_init has laid us out @@ -303,14 +305,39 @@ local CustomLobbySlotsInterface = Class(Group) { Tooltip.AddControlTooltipManual(self.ReopenButton.Bg, "Reopen closed slots", "Close, then re-open every closed slot to refresh the lobby for everyone.") - -- the tool strip is a host action set; hide it for clients + -- a UI-inspection toggle, created only when the game was launched with /debug: lights the + -- "show control under mouse" overlay so any control can be inspected by hovering it (the + -- overlay + dump-key wiring lives in the controller, see ToggleUiInspectOverlay). Unlike the + -- host action buttons it sits on the far left, directly right of the "Players" label (a + -- developer aid, not a lobby action) — so it's parented to the header band rather than the + -- right-aligned Tools strip, and the IsHost observer below gates its visibility separately + -- (creation is gated on /debug, visibility on IsHost). + if HasCommandLineArg('/debug') then + self.DebugButton = SlotTool(self, InspectIcon) + self.DebugButton:SetActive(CustomLobbyController.IsUiInspectOverlayEnabled()) + self.DebugButton.OnPress = function() + self.DebugButton:SetActive(CustomLobbyController.ToggleUiInspectOverlay()) + end + Tooltip.AddControlTooltipManual(self.DebugButton.Bg, "Inspect controls", + "Toggle the 'show control under mouse' overlay, then hover any UI control to inspect it. (/debug only)") + end + + -- the tool strip (and the far-left /debug inspect button) are host-only; hide for clients self.IsHostObserver = self.Trash:Add( LazyVarDerive(CustomLobbyLocalModel.GetSingleton().IsHost, function(isHostLazy) - if isHostLazy() then + local isHost = isHostLazy() + if isHost then self.Tools:Show() else self.Tools:Hide() end + if self.DebugButton then + if isHost then + self.DebugButton:Show() + else + self.DebugButton:Hide() + end + end end)) -- light the host's pin button and show the (everyone-visible) lock notice while seating is @@ -353,12 +380,21 @@ local CustomLobbySlotsInterface = Class(Group) { __post_init = function(self) Layouter(self.Header):AtLeftIn(self, 4):AtTopIn(self):End() - -- the lock notice, just right of the "Players" label (hidden unless seating is pinned) - Layouter(self.LockIcon):CenteredRightOf(self.Header, 8):Width(ToolSize):Height(ToolSize):End() + -- the /debug inspect toggle sits on the far left, directly right of the "Players" label + if self.DebugButton then + Layouter(self.DebugButton) + :CenteredRightOf(self.Header, 6):Width(ToolSize):Height(ToolSize) + :End() + end + + -- the lock notice, just right of the "Players" label (right of the inspect button when it's + -- present), hidden unless seating is pinned + local lockAnchor = self.DebugButton or self.Header + Layouter(self.LockIcon):CenteredRightOf(lockAnchor, 8):Width(ToolSize):Height(ToolSize):End() Layouter(self.LockLabel):CenteredRightOf(self.LockIcon, 3):End() - -- the tool strip: a fixed-width band pinned top-right, the three square buttons inside it - -- laid out right-to-left (Pin · Balance · Reopen, reading left-to-right), centred on the header + -- the tool strip: a fixed-width band pinned top-right, the five square buttons inside it laid + -- out right-to-left, centred on the header Layouter(self.Tools) :AtRightIn(self, 4) :AtVerticalCenterIn(self.Header) @@ -575,6 +611,11 @@ local CustomLobbySlotsInterface = Class(Group) { ---@param self UICustomLobbySlotsInterface OnDestroy = function(self) + -- if the inspect overlay was left on, turn it off so it doesn't outlive the lobby + -- (idempotent in the controller, so it's safe even when the button never existed) + if self.DebugButton then + CustomLobbyController.SetUiInspectOverlay(false) + end self.Trash:Destroy() end, } diff --git a/scripts/LaunchCustomLobby.ps1 b/scripts/LaunchCustomLobby.ps1 index 95d8b400b55..abe1c91a15e 100644 --- a/scripts/LaunchCustomLobby.ps1 +++ b/scripts/LaunchCustomLobby.ps1 @@ -142,6 +142,7 @@ function Launch-LobbyInstance { # Host instance (top-left). Positional order for /hostgame is protocol, port, name, gameName, map. $hostArgs = @( "/log", "host_lobby_1.log", + "/debug", "/hostgame", $hostProtocol, $port, $hostPlayerName, $gameName, $map ) + $commonArgs + (Get-PlayerArgs) Launch-LobbyInstance -instanceNumber 1 -xPos 0 -yPos 0 -arguments $hostArgs From b11b1be0a3dbdd03647d07c744a30cec607197ea Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 28 Jun 2026 17:33:14 +0200 Subject: [PATCH 98/98] Simplify the observers panel --- .../CustomLobbyObserversInterface.lua | 98 ------------------- .../social/CustomLobbyObserversPanel.lua | 68 ++++++++++--- 2 files changed, 54 insertions(+), 112 deletions(-) delete mode 100644 lua/ui/lobby/customlobby/CustomLobbyObserversInterface.lua diff --git a/lua/ui/lobby/customlobby/CustomLobbyObserversInterface.lua b/lua/ui/lobby/customlobby/CustomLobbyObserversInterface.lua deleted file mode 100644 index 710dfcd56cc..00000000000 --- a/lua/ui/lobby/customlobby/CustomLobbyObserversInterface.lua +++ /dev/null @@ -1,98 +0,0 @@ ---****************************************************************************************************** ---** Copyright (c) 2026 FAForever ---** ---** 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. ---****************************************************************************************************** - --- The observer strip: subscribes to the model's `Observers` list and renders a header --- with the count plus the names. Read-only for now — a player becomes an observer via --- the host's "Move to observers", and rejoins by right-clicking an open slot ("Play --- this slot"); per-observer host actions can be a later slice. - -local UIUtil = import("/lua/ui/uiutil.lua") -local LayoutHelpers = import("/lua/maui/layouthelpers.lua") - -local Group = import("/lua/maui/group.lua").Group -local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/models/customlobbylaunchmodel.lua") - -local LazyVarDerive = import("/lua/lazyvar.lua").Derive - -local Layouter = LayoutHelpers.ReusedLayoutFor - ----@class UICustomLobbyObserversInterface : Group ----@field Trash TrashBag ----@field Header Text ----@field Names Text ----@field ObserversObserver LazyVar -local CustomLobbyObserversInterface = Class(Group) { - - ---@param self UICustomLobbyObserversInterface - ---@param parent Control - __init = function(self, parent) - Group.__init(self, parent, "CustomLobbyObservers") - - self.Trash = TrashBag() - - self.Header = UIUtil.CreateText(self, "Observers (0)", 14, UIUtil.titleFont) - self.Names = UIUtil.CreateText(self, "—", 12, UIUtil.bodyFont) - self.Names:SetColor('ff9aa0a8') - - local model = CustomLobbyLaunchModel.GetSingleton() - self.ObserversObserver = self.Trash:Add( - LazyVarDerive(model.Observers, function(observersLazy) - self:OnObserversChanged(observersLazy()) - end)) - end, - - ---@param self UICustomLobbyObserversInterface - __post_init = function(self) - Layouter(self.Header):AtLeftTopIn(self):End() - Layouter(self.Names):AtLeftIn(self):AnchorToBottom(self.Header, 4):End() - end, - - --- Renders the observer count + names. - ---@param self UICustomLobbyObserversInterface - ---@param observers UICustomLobbyPlayer[] - OnObserversChanged = function(self, observers) - local count = table.getn(observers) - self.Header:SetText("Observers (" .. count .. ")") - - if count == 0 then - self.Names:SetText("—") - return - end - - local names = {} - for i = 1, count do - names[i] = observers[i].PlayerName or "?" - end - self.Names:SetText(table.concat(names, ", ")) - end, - - ---@param self UICustomLobbyObserversInterface - OnDestroy = function(self) - self.Trash:Destroy() - end, -} - ----@param parent Control ----@return UICustomLobbyObserversInterface -Create = function(parent) - return CustomLobbyObserversInterface(parent) -end diff --git a/lua/ui/lobby/customlobby/social/CustomLobbyObserversPanel.lua b/lua/ui/lobby/customlobby/social/CustomLobbyObserversPanel.lua index e2c6df8899e..fedfb984cc0 100644 --- a/lua/ui/lobby/customlobby/social/CustomLobbyObserversPanel.lua +++ b/lua/ui/lobby/customlobby/social/CustomLobbyObserversPanel.lua @@ -20,8 +20,7 @@ --** SOFTWARE. --****************************************************************************************************** --- The Observers tab of the lobby's bottom-left tabbed panel: the observer list (the shared --- CustomLobbyObserversInterface, which self-subscribes to the model's `Observers`) plus a "Become +-- The Observers tab of the lobby's bottom-left tabbed panel: the observer list plus a "Become -- observer" button. Everyone may drop to observers; the move is host-authoritative — a client's -- click asks the host through the `RequestMoveToObserver` intent. -- @@ -33,12 +32,15 @@ local LayoutHelpers = import("/lua/maui/layouthelpers.lua") local Tooltip = import("/lua/ui/game/tooltip.lua") local Group = import("/lua/maui/group.lua").Group -local CustomLobbyObserversInterface = import("/lua/ui/lobby/customlobby/customlobbyobserversinterface.lua") +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/models/customlobbylaunchmodel.lua") local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/models/customlobbylocalmodel.lua") +local LazyVarDerive = import("/lua/lazyvar.lua").Derive + local Layouter = LayoutHelpers.ReusedLayoutFor +local Debug = true --- The local player's slot (the one this peer owns), or nil if they're an observer / unseated. ---@return number | nil @@ -54,15 +56,26 @@ local function FindLocalSlot() return nil end ----@class UICustomLobbyObserversPanel : Group ----@field List UICustomLobbyObserversInterface +---@class UICustomLobbyObserversPanel : Bitmap ---@field ObserveButton Button -local CustomLobbyObserversPanel = ClassUI(Group) { +---@field Header Text +---@field Names Text +---@field Trash TrashBag +---@field ObserversObserver LazyVar +local CustomLobbyObserversPanel = ClassUI(Bitmap) { ---@param self UICustomLobbyObserversPanel ---@param parent Control __init = function(self, parent) - Group.__init(self, parent, "CustomLobbyObserversPanel") + Bitmap.__init(self, parent) + self:SetSolidColor(Debug and '303080ff' or '00000000') + self:DisableHitTest() + + self.Trash = TrashBag() + + self.Header = UIUtil.CreateText(self, "Observers (0)", 14, UIUtil.titleFont) + self.Names = UIUtil.CreateText(self, "—", 12, UIUtil.bodyFont) + self.Names:SetColor('ff9aa0a8') self.ObserveButton = UIUtil.CreateButtonWithDropshadow(self, '/BUTTON/medium/', "Become observer") self.ObserveButton.OnClick = function(button, modifiers) @@ -73,16 +86,43 @@ local CustomLobbyObserversPanel = ClassUI(Group) { end Tooltip.AddControlTooltipManual(self.ObserveButton, "Become observer", "Leave your slot and watch as an observer.") - self.List = CustomLobbyObserversInterface.Create(self) + local model = CustomLobbyLaunchModel.GetSingleton() + self.ObserversObserver = self.Trash:Add( + LazyVarDerive(model.Observers, function(observersLazy) + self:OnObserversChanged(observersLazy()) + end)) + end, + + ---@param self UICustomLobbyObserversPanel + __post_init = function(self, parent) + Layouter(self):Fill(parent):End() + Layouter(self.Header):AtLeftTopIn(self):End() + Layouter(self.Names):AtLeftIn(self):AnchorToBottom(self.Header, 4):End() + Layouter(self.ObserveButton):AtHorizontalCenterIn(self):AnchorToBottom(self.Names, 8):End() + end, + + --- Renders the observer count + names. + ---@param self UICustomLobbyObserversPanel + ---@param observers UICustomLobbyPlayer[] + OnObserversChanged = function(self, observers) + local count = table.getn(observers) + self.Header:SetText("Observers (" .. count .. ")") + + if count == 0 then + self.Names:SetText("—") + return + end + + local names = {} + for i = 1, count do + names[i] = observers[i].PlayerName or "?" + end + self.Names:SetText(table.concat(names, ", ")) end, ---@param self UICustomLobbyObserversPanel - __post_init = function(self) - Layouter(self.ObserveButton):AtHorizontalCenterIn(self):AtBottomIn(self, 4):End() - Layouter(self.List) - :AtLeftIn(self):AtRightIn(self):AtTopIn(self) - :AnchorToTop(self.ObserveButton, 6) - :End() + OnDestroy = function(self) + self.Trash:Destroy() end, }