diff --git a/GeneralsMD/Code/GameEngine/CMakeLists.txt b/GeneralsMD/Code/GameEngine/CMakeLists.txt index a84c0935e98..343f6652a15 100644 --- a/GeneralsMD/Code/GameEngine/CMakeLists.txt +++ b/GeneralsMD/Code/GameEngine/CMakeLists.txt @@ -1115,6 +1115,7 @@ set(GAMEENGINE_SRC # Source/GameNetwork/GameSpy/Thread/ThreadUtils.cpp # Source/GameNetwork/GameSpyOverlay.cpp Source/GameNetwork/GUIUtil.cpp + Source/GameNetwork/RandomAssign.cpp # Source/GameNetwork/IPEnumeration.cpp # Source/GameNetwork/LANAPI.cpp # Source/GameNetwork/LANAPICallbacks.cpp diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.h index 5e89ed8d313..51a627bddad 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.h @@ -1,5 +1,7 @@ #pragma once +#include "GameNetwork/RandomAssign.h" + #include "NGMP_include.h" #include "OnlineServices_RoomsInterface.h" #include "GameNetwork/GameInfo.h" @@ -226,6 +228,10 @@ class NGMP_OnlineServices_LobbyInterface void UpdateCurrentLobby_AITeam(int slot, int team); void UpdateCurrentLobby_AIStartPos(int slot, int startpos); + // Randomize: send the slots a roll changed (only the fields it + // changed, with the occupant's user id so the service can skip a slot that changed hands) and report the outcome. + void UpdateCurrentLobby_BulkSlotUpdate(const std::vector& changes, std::function onDone); + void UpdateCurrentLobbyMaxCameraHeight(uint16_t maxCameraHeight); void SetJoinability(ELobbyJoinability joinabilityFlag); diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/RandomAssign.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/RandomAssign.h new file mode 100644 index 00000000000..b445a889db1 --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/RandomAssign.h @@ -0,0 +1,48 @@ +/* +** Command & Conquer Generals Zero Hour(tm) +** Copyright 2025 Electronic Arts Inc. +** +** This program is free software: you can redistribute it and/or modify +** it under the terms of the GNU General Public License as published by +** the Free Software Foundation, either version 3 of the License, or +** (at your option) any later version. +** +** This program is distributed in the hope that it will be useful, +** but WITHOUT ANY WARRANTY; without even the implied warranty of +** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +** GNU General Public License for more details. +** +** You should have received a copy of the GNU General Public License +** along with this program. If not, see . +*/ + +// Lobby "Randomize" button: resolves every slot still set +// to Random (faction, color, start position) in the lobby, so a group no longer has +// to start and abandon a match just to learn the engine's random draw. + +#pragma once + +#include "Lib/BaseType.h" + +#include + +class GameInfo; + +// One slot the roll changed. A field left at -1 was not touched by the roll. +struct RandomSlotAssignment +{ + Int slotIndex = -1; + Int side = -1; + Int color = -1; + Int startPos = -1; +}; + +// Resolve random factions, colors and start positions on every occupied player +// slot of the lobby that still has them unset. Slots with explicit choices and +// observer slots are left alone (observers are resolved at match start, as in +// retail). Draws from a private RNG seeded from the clock, deliberately NOT from +// the game-logic RNG, so the result says nothing about the draw the engine will +// make from the lobby seed at match start. outChanges receives the slots that +// changed and what changed, for callers that must push the roll through an +// authoritative service instead of applying it locally. +void performRandomAssign(GameInfo *game, std::vector &outChanges); diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/LanGameOptionsMenu.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/LanGameOptionsMenu.cpp index 53cda5a2a2e..c4f7d06147a 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/LanGameOptionsMenu.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/LanGameOptionsMenu.cpp @@ -59,6 +59,7 @@ #include "Common/MultiplayerSettings.h" #include "GameClient/GameText.h" #include "GameNetwork/GUIUtil.h" +#include "GameNetwork/RandomAssign.h" extern char *LANnextScreen; @@ -110,6 +111,7 @@ static NameKeyType textEntryChatID = NAMEKEY_INVALID; static NameKeyType textEntryMapDisplayID = NAMEKEY_INVALID; static NameKeyType buttonBackID = NAMEKEY_INVALID; static NameKeyType buttonStartID = NAMEKEY_INVALID; +static NameKeyType buttonRandomizeID = NAMEKEY_INVALID; static NameKeyType buttonEmoteID = NAMEKEY_INVALID; static NameKeyType buttonSelectMapID = NAMEKEY_INVALID; static NameKeyType checkboxLimitSuperweaponsID = NAMEKEY_INVALID; @@ -119,6 +121,10 @@ static NameKeyType windowMapID = NAMEKEY_INVALID; static GameWindow *parentLanGameOptions = nullptr; static GameWindow *buttonBack = nullptr; static GameWindow *buttonStart = nullptr; +static GameWindow *buttonRandomize = nullptr; +// How many times the host pressed Randomize in this +// lobby; announced with every roll so re-rolling is visible to everyone present. +static Int s_randomizeCount = 0; static GameWindow *buttonSelectMap = nullptr; static GameWindow *buttonEmote = nullptr; static GameWindow *textEntryChat = nullptr; @@ -674,6 +680,7 @@ void InitLanGameGadgets() parentLanGameOptionsID = TheNameKeyGenerator->nameToKey( "LanGameOptionsMenu.wnd:LanGameOptionsMenuParent" ); buttonBackID = TheNameKeyGenerator->nameToKey( "LanGameOptionsMenu.wnd:ButtonBack" ); buttonStartID = TheNameKeyGenerator->nameToKey( "LanGameOptionsMenu.wnd:ButtonStart" ); + buttonRandomizeID = TheNameKeyGenerator->nameToKey( "LanGameOptionsMenu.wnd:ButtonRandomize" ); textEntryChatID = TheNameKeyGenerator->nameToKey( "LanGameOptionsMenu.wnd:TextEntryChat" ); textEntryMapDisplayID = TheNameKeyGenerator->nameToKey( "LanGameOptionsMenu.wnd:TextEntryMapDisplay" ); listboxChatWindowLanGameID = TheNameKeyGenerator->nameToKey( "LanGameOptionsMenu.wnd:ListboxChatWindowLanGame" ); @@ -692,6 +699,9 @@ void InitLanGameGadgets() DEBUG_ASSERTCRASH(buttonSelectMap, ("Could not find the buttonSelectMap")); buttonStart = TheWindowManager->winGetWindowFromId( parentLanGameOptions,buttonStartID ); DEBUG_ASSERTCRASH(buttonStart, ("Could not find the buttonStart")); + buttonRandomize = TheWindowManager->winGetWindowFromId( parentLanGameOptions, buttonRandomizeID ); // absent from older .wnd layouts + if (buttonRandomize) + buttonRandomize->winSetText(TheGameText->FETCH_OR_SUBSTITUTE("GUI:Randomize", L"Randomize")); // no .csf change needed buttonBack = TheWindowManager->winGetWindowFromId( parentLanGameOptions, buttonBackID); DEBUG_ASSERTCRASH(buttonBack, ("Could not find the buttonBack")); listboxChatWindowLanGame = TheWindowManager->winGetWindowFromId( parentLanGameOptions, listboxChatWindowLanGameID ); @@ -795,6 +805,7 @@ void DeinitLanGameGadgets() buttonSelectMap = nullptr; buttonStart = nullptr; buttonBack = nullptr; + buttonRandomize = nullptr; listboxChatWindowLanGame = nullptr; textEntryChat = nullptr; textEntryMapDisplay = nullptr; @@ -822,6 +833,7 @@ void DeinitLanGameGadgets() //------------------------------------------------------------------------------------------------- void LanGameOptionsMenuInit( WindowLayout *layout, void *userData ) { + s_randomizeCount = 0; if (TheLAN->GetMyGame() && TheLAN->GetMyGame()->isGameInProgress()) { // If we init while the game is in progress, we are really returning to the menu @@ -885,6 +897,8 @@ void LanGameOptionsMenuInit( WindowLayout *layout, void *userData ) //DEBUG_LOG(("LanGameOptionsMenuInit(): map is %s", TheLAN->GetMyGame()->getMap().str())); buttonStart->winSetText(TheGameText->fetch("GUI:Accept")); buttonSelectMap->winEnable( FALSE ); + if (buttonRandomize) + buttonRandomize->winEnable( FALSE ); checkboxLimitSuperweapons->winEnable( FALSE ); // Can look but only host can touch comboBoxStartingCash->winEnable( FALSE ); // Ditto TheLAN->GetMyGame()->setMapCRC( TheLAN->GetMyGame()->getMapCRC() ); // force a recheck @@ -1275,6 +1289,31 @@ WindowMsgHandledType LanGameOptionsMenuSystem( GameWindow *window, UnsignedInt m } } + else if ( controlID == buttonRandomizeID ) + { + // Host-only Randomize: resolve the random slots, + // broadcast the new options and tell the room it happened. + if (TheLAN->AmIHost()) + { + std::vector changes; + performRandomAssign(TheLAN->GetMyGame(), changes); + if (changes.empty()) + { + TheLAN->RequestChat(TheGameText->FETCH_OR_SUBSTITUTE("GUI:RandomizeNothingToDo", L"Randomize: every slot already has a faction, color and start position"), LANAPIInterface::LANCHAT_SYSTEM); + } + else + { + TheLAN->GetMyGame()->resetAccepted(); // settings changed, so everyone accepts again + TheLAN->RequestGameOptions(GenerateGameOptionsString(), true); + lanUpdateSlotList(); + + ++s_randomizeCount; + UnicodeString strInform; + strInform.format(TheGameText->FETCH_OR_SUBSTITUTE("GUI:HostRandomizedSlots", L"Randomize: the host resolved the random factions, colors and start positions (roll %d)"), s_randomizeCount); + TheLAN->RequestChat(strInform, LANAPIInterface::LANCHAT_SYSTEM); + } + } + } else if ( controlID == checkboxLimitSuperweaponsID ) { handleLimitSuperweaponsClick(); diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLGameSetupMenu.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLGameSetupMenu.cpp index d8c0b2c2eba..696167b20fe 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLGameSetupMenu.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLGameSetupMenu.cpp @@ -68,6 +68,7 @@ #include "GameNetwork/GameSpy/GSConfig.h" #include "GameNetwork/GeneralsOnline/NGMP_interfaces.h" +#include "GameNetwork/RandomAssign.h" #include #include #include @@ -203,6 +204,7 @@ static NameKeyType buttonBackID = NAMEKEY_INVALID; static NameKeyType buttonStartID = NAMEKEY_INVALID; static NameKeyType buttonEmoteID = NAMEKEY_INVALID; static NameKeyType buttonSelectMapID = NAMEKEY_INVALID; +static NameKeyType buttonRandomizeID = NAMEKEY_INVALID; static NameKeyType windowMapID = NAMEKEY_INVALID; #if defined(GENERALS_ONLINE_ENABLE_MATCH_START_COUNTDOWN) @@ -220,6 +222,19 @@ static GameWindow *parentWOLGameSetup = NULL; static GameWindow *buttonBack = NULL; static GameWindow *buttonStart = NULL; static GameWindow *buttonSelectMap = NULL; +static GameWindow *buttonRandomize = NULL; +// How many times the host pressed Randomize in this +// lobby; announced with every roll so re-rolling is visible to everyone present. +static Int s_randomizeCount = 0; +// The lobby remembers a faction and colour as the player's +// preference only when the player picked them here. A value that arrived from the service, because +// the host pressed Randomize or moved this slot, is not a choice and must not be stored. +// Colour and faction are tracked apart, so picking one does not make a rolled value of the other a choice. +// s_applyingLobbyUpdate marks the repaint that a service push drives: it fires the same combo +// callbacks as a real click, so without it the push that delivers a roll looks like a choice. +static Bool s_localColorChosen = FALSE; +static Bool s_localFactionChosen = FALSE; +static Bool s_applyingLobbyUpdate = FALSE; static GameWindow *buttonEmote = NULL; static GameWindow *textEntryChat = NULL; static GameWindow *textEntryMapDisplay = NULL; @@ -360,8 +375,10 @@ static void savePlayerInfo() { // save off some prefs CustomMatchPreferences pref; - pref.setPreferredColor(slot->getColor()); - pref.setPreferredFaction(slot->getPlayerTemplate()); + if (s_localColorChosen) + pref.setPreferredColor(slot->getColor()); + if (s_localFactionChosen) + pref.setPreferredFaction(slot->getPlayerTemplate()); if (TheNGMPGame->amIHost()) { pref.setPreferredMap(TheNGMPGame->getMap()); @@ -686,6 +703,8 @@ NameKeyType listboxGameSetupChatID = NAMEKEY_INVALID; static void handleColorSelection(int index) { + if (!s_applyingLobbyUpdate && TheNGMPGame && index == TheNGMPGame->getLocalSlotNum()) + s_localColorChosen = TRUE; // picked here, so it is worth remembering GameWindow *combo = comboBoxColor[index]; Int color, selIndex; GadgetComboBoxGetSelectedPos(combo, &selIndex); @@ -747,6 +766,8 @@ static void handleColorSelection(int index) static void handlePlayerTemplateSelection(int index, bool bInitialSetup = false) { + if (!bInitialSetup && !s_applyingLobbyUpdate && TheNGMPGame && index == TheNGMPGame->getLocalSlotNum()) + s_localFactionChosen = TRUE; // picked here, so it is worth remembering GameWindow *combo = comboBoxPlayerTemplate[index]; Int playerTemplate, selIndex; GadgetComboBoxGetSelectedPos(combo, &selIndex); @@ -1559,10 +1580,12 @@ void WOLDisplaySlotList(void) DEBUG_ASSERTCRASH(!game->getConstSlot(0)->isOpen(), ("Open host!")); + s_applyingLobbyUpdate = TRUE; UpdateSlotList(game, comboBoxPlayer, comboBoxColor, comboBoxPlayerTemplate, comboBoxTeam, buttonAccept, buttonStart, buttonMapStartPosition); - WOLDisplayGameOptions(); + WOLDisplayGameOptions(); // repopulates the army combos too, so still part of the push + s_applyingLobbyUpdate = FALSE; for (Int i = 0; i < MAX_SLOTS; ++i) { @@ -1620,6 +1643,7 @@ void InitWOLGameGadgets() listboxGameSetupChatID = TheNameKeyGenerator->nameToKey( "GameSpyGameOptionsMenu.wnd:ListboxChatWindowGameSpyGameSetup" ); buttonEmoteID = TheNameKeyGenerator->nameToKey( "GameSpyGameOptionsMenu.wnd:ButtonEmote" ); buttonSelectMapID = TheNameKeyGenerator->nameToKey( "GameSpyGameOptionsMenu.wnd:ButtonSelectMap" ); + buttonRandomizeID = TheNameKeyGenerator->nameToKey( "GameSpyGameOptionsMenu.wnd:ButtonRandomize" ); checkBoxUseStatsID = TheNameKeyGenerator->nameToKey( "GameSpyGameOptionsMenu.wnd:CheckBoxUseStats" ); windowMapID = TheNameKeyGenerator->nameToKey( "GameSpyGameOptionsMenu.wnd:MapWindow" ); checkBoxLimitSuperweaponsID = TheNameKeyGenerator->nameToKey("GameSpyGameOptionsMenu.wnd:CheckboxLimitSuperweapons"); @@ -1633,6 +1657,9 @@ void InitWOLGameGadgets() parentWOLGameSetup = TheWindowManager->winGetWindowFromId( NULL, parentWOLGameSetupID ); buttonEmote = TheWindowManager->winGetWindowFromId( parentWOLGameSetup,buttonEmoteID ); buttonSelectMap = TheWindowManager->winGetWindowFromId( parentWOLGameSetup,buttonSelectMapID ); + buttonRandomize = TheWindowManager->winGetWindowFromId( parentWOLGameSetup, buttonRandomizeID ); // absent from older .wnd layouts + if (buttonRandomize) + buttonRandomize->winSetText(TheGameText->FETCH_OR_SUBSTITUTE("GUI:Randomize", L"Randomize")); // no .csf change needed checkBoxUseStats = TheWindowManager->winGetWindowFromId( parentWOLGameSetup, checkBoxUseStatsID ); buttonStart = TheWindowManager->winGetWindowFromId( parentWOLGameSetup,buttonStartID ); buttonBack = TheWindowManager->winGetWindowFromId( parentWOLGameSetup, buttonBackID); @@ -1679,6 +1706,7 @@ void InitWOLGameGadgets() { checkBoxLimitSuperweapons->winEnable( false ); comboBoxStartingCash->winEnable( false ); + if (buttonRandomize) buttonRandomize->winEnable( false ); NameKeyType labelID = TheNameKeyGenerator->nameToKey("GameSpyGameOptionsMenu.wnd:StartingCashLabel"); TheWindowManager->winGetWindowFromId(parentWOLGameSetup, labelID)->winEnable( FALSE ); } @@ -1687,6 +1715,7 @@ void InitWOLGameGadgets() { checkBoxLimitSuperweapons->winEnable(true); comboBoxStartingCash->winEnable(true); + if (buttonRandomize) buttonRandomize->winEnable(true); } #endif @@ -1699,6 +1728,7 @@ void InitWOLGameGadgets() checkBoxLimitSuperweapons->winEnable( FALSE ); comboBoxStartingCash->winEnable( FALSE ); checkBoxLimitArmies->winEnable( FALSE ); + if (buttonRandomize) buttonRandomize->winEnable( FALSE ); NameKeyType labelID = TheNameKeyGenerator->nameToKey("GameSpyGameOptionsMenu.wnd:StartingCashLabel"); TheWindowManager->winGetWindowFromId(parentWOLGameSetup, labelID)->winEnable( FALSE ); } @@ -1840,6 +1870,7 @@ void DeinitWOLGameGadgets() parentWOLGameSetup = NULL; buttonEmote = NULL; buttonSelectMap = NULL; + buttonRandomize = NULL; buttonStart = NULL; buttonBack = NULL; listboxGameSetupChat = NULL; @@ -1878,6 +1909,10 @@ Bool initialAcceptEnable = FALSE; //------------------------------------------------------------------------------------------------- void WOLGameSetupMenuInit( WindowLayout *layout, void *userData ) { + s_randomizeCount = 0; + s_localColorChosen = FALSE; + s_localFactionChosen = FALSE; + s_applyingLobbyUpdate = FALSE; NGMP_OnlineServices_LobbyInterface* pLobbyInterface = NGMP_OnlineServicesManager::GetInterface(); if (pLobbyInterface == nullptr) { @@ -2240,6 +2275,7 @@ void WOLGameSetupMenuInit( WindowLayout *layout, void *userData ) buttonStart->winSetText(TheGameText->fetch("GUI:Accept")); buttonStart->winEnable( FALSE ); buttonSelectMap->winEnable( FALSE ); + if (buttonRandomize) buttonRandomize->winEnable( FALSE ); initialAcceptEnable = FALSE; WOLDisplaySlotList(); @@ -2531,6 +2567,7 @@ void WOLGameSetupMenuUpdate( WindowLayout * layout, void *userData) buttonStart->winSetText(TheGameText->fetch("GUI:Start")); buttonStart->winEnable(TRUE); buttonSelectMap->winEnable(TRUE); + if (buttonRandomize) buttonRandomize->winEnable(TRUE); initialAcceptEnable = TRUE; comboBoxStartingCash->winEnable(TRUE); @@ -4137,6 +4174,61 @@ WindowMsgHandledType WOLGameSetupMenuSystem( GameWindow *window, UnsignedInt msg WOLMapSelectLayout->hide( FALSE ); WOLMapSelectLayout->bringForward(); } + else if ( controlID == buttonRandomizeID ) + { + // Host-only Randomize. The lobby service owns the + // slots, so the roll is computed on a scratch copy, only the slots it changed are sent, and + // the room is told once the service has accepted them; the next lobby push shows the result. + NGMP_OnlineServices_LobbyInterface* pLobbyInterface = NGMP_OnlineServicesManager::GetInterface(); + if (pLobbyInterface != nullptr && pLobbyInterface->IsHost()) + { + NGMPGame* game = pLobbyInterface->GetCurrentGame(); + if (game) + { + GameSlot saved[MAX_SLOTS]; + Int i; + for (i = 0; i < MAX_SLOTS; ++i) + { + if (game->getSlot(i)) + saved[i] = *game->getSlot(i); + } + std::vector changes; + performRandomAssign(game, changes); + for (i = 0; i < MAX_SLOTS; ++i) + { + if (game->getSlot(i)) + *game->getSlot(i) = saved[i]; + } + + if (changes.empty()) + { + UnicodeString strNothing = TheGameText->FETCH_OR_SUBSTITUTE("GUI:RandomizeNothingToDo", L"Randomize: every slot already has a faction, color and start position"); + pLobbyInterface->SendAnnouncementMessageToCurrentLobby(strNothing, true); + } + else + { + // The button is held down only while the request is in flight, and goes back to + // whatever it was when the roll started: a stats match or a guest must not end + // up with it enabled. + const Bool bWasEnabled = (buttonRandomize != NULL) && BitIsSet(buttonRandomize->winGetStatus(), WIN_STATUS_ENABLED); + if (buttonRandomize) + buttonRandomize->winEnable(FALSE); + pLobbyInterface->UpdateCurrentLobby_BulkSlotUpdate(changes, [pLobbyInterface, bWasEnabled](bool bSuccess) + { + if (bSuccess) + { + ++s_randomizeCount; + UnicodeString strInform; + strInform.format(TheGameText->FETCH_OR_SUBSTITUTE("GUI:HostRandomizedSlots", L"Randomize: the host resolved the random factions, colors and start positions (roll %d)"), s_randomizeCount); + pLobbyInterface->SendAnnouncementMessageToCurrentLobby(strInform, true); + } + if (buttonRandomize) + buttonRandomize->winEnable(bWasEnabled); + }); + } + } + } + } else if ( controlID == buttonStartID ) { savePlayerInfo(); diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GUIUtil.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GUIUtil.cpp index 7e3231fcee1..a04f23cdc75 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GUIUtil.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GUIUtil.cpp @@ -507,17 +507,29 @@ void UpdateSlotList( GameInfo *myGame, GameWindow *comboPlayer[], //if( i == myGame->getLocalSlotNum()) if((comboColor[i] != nullptr) && BitIsSet(comboColor[i]->winGetStatus(), WIN_STATUS_ENABLED)) PopulateColorComboBox(i, comboColor, myGame, myGame->getConstSlot(i)->getPlayerTemplate() == PLAYERTEMPLATE_OBSERVER); - Int max, idx; + Int max, idx, attempt; if (comboColor[i] != nullptr) { - max = GadgetComboBoxGetLength(comboColor[i]); - for (idx=0; idxgetColor()) + max = GadgetComboBoxGetLength(comboColor[i]); + for (idx=0; idxgetColor()) + { + GadgetComboBoxSetSelectedPos(comboColor[i], idx, TRUE); + bFound = TRUE; + break; + } } + // A slot whose colour combo is disabled (every + // remote slot, on the host) is skipped by the populate above, so a colour it has not + // listed before is missing and the cell keeps showing whatever it showed last. Rebuild + // it once and look again, rather than leaving the host's lobby disagreeing with the + // service and with every other client. + if (!bFound && attempt == 0) + PopulateColorComboBox(i, comboColor, myGame, myGame->getConstSlot(i)->getPlayerTemplate() == PLAYERTEMPLATE_OBSERVER); } } diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp index 4b5b9da9cbc..7bae5d54291 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp @@ -74,7 +74,8 @@ enum class ELobbyUpdateField AI_TEAM = 15, AI_START_POS = 16, MAX_CAMERA_HEIGHT = 17, - JOINABILITY = 18 + JOINABILITY = 18, + HOST_ACTION_BULK_SLOT_UPDATE = 19 }; void NGMP_OnlineServices_LobbyInterface::UpdateCurrentLobby_Map(UnicodeString strMap, AsciiString strMapPath, bool bIsOfficial, int newMaxPlayers) @@ -493,6 +494,61 @@ void NGMP_OnlineServices_LobbyInterface::UpdateCurrentLobby_ForceReady() }); } +void NGMP_OnlineServices_LobbyInterface::UpdateCurrentLobby_BulkSlotUpdate(const std::vector& changes, std::function onDone) +{ + // Randomize: one host action for all rolled slots. + // reset autostart if host changes anything (because ready flag will reset too) +#if !defined(GENERALS_ONLINE_DISABLE_AUTO_ACCEPT) + ClearAutoReadyCountdown(); +#endif + if (TheNGMPGame && TheNGMPGame->IsCountdownStarted()) + TheNGMPGame->StopCountdown(); + + std::string strURI = std::format("{}/{}", NGMP_OnlineServicesManager::GetAPIEndpoint("Lobby"), m_CurrentLobby.lobbyID); + std::map mapHeaders; + + nlohmann::json j; + j["field"] = ELobbyUpdateField::HOST_ACTION_BULK_SLOT_UPDATE; + + nlohmann::json slotsArray = nlohmann::json::array(); + for (const RandomSlotAssignment& change : changes) + { + // AI slots are members too (with the service's placeholder user id), so match on + // the slot rather than on a real user id. + const LobbyMemberEntry* occupant = nullptr; + for (const LobbyMemberEntry& member : m_CurrentLobby.members) + { + if (member.m_SlotIndex == change.slotIndex) + occupant = &member; + } + if (occupant == nullptr) + continue; // nobody in that slot any more + nlohmann::json slotEntry; + slotEntry["slot_index"] = change.slotIndex; + slotEntry["user_id"] = occupant->user_id; + if (change.side != -1) + slotEntry["side"] = change.side; + if (change.color != -1) + slotEntry["color"] = change.color; + if (change.startPos != -1) + slotEntry["start_pos"] = change.startPos; + slotsArray.push_back(slotEntry); + } + j["slots"] = slotsArray; + std::string strPostData = j.dump(); + + NGMP_OnlineServicesManager::GetInstance()->GetHTTPManager()->SendPOSTRequest(strURI.c_str(), EIPProtocolVersion::DONT_CARE, mapHeaders, strPostData.c_str(), [onDone](bool bSuccess, int statusCode, std::string strBody, HTTPRequest* pReq) + { + const bool bOK = bSuccess && statusCode >= 200 && statusCode < 300; + if (!bOK) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "UpdateCurrentLobby_BulkSlotUpdate failed: success=%d, status=%d", bSuccess, statusCode); + } + if (onDone) + onDone(bOK); + }); +} + void NGMP_OnlineServices_LobbyInterface::SendChatMessageToCurrentLobby(UnicodeString& strChatMsgUnicode, bool bIsAction) { std::shared_ptr pWS = NGMP_OnlineServicesManager::GetWebSocket();; diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/RandomAssign.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/RandomAssign.cpp new file mode 100644 index 00000000000..6c6d814667f --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/RandomAssign.cpp @@ -0,0 +1,322 @@ +/* +** Command & Conquer Generals Zero Hour(tm) +** Copyright 2025 Electronic Arts Inc. +** +** This program is free software: you can redistribute it and/or modify +** it under the terms of the GNU General Public License as published by +** the Free Software Foundation, either version 3 of the License, or +** (at your option) any later version. +** +** This program is distributed in the hope that it will be useful, +** but WITHOUT ANY WARRANTY; without even the implied warranty of +** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +** GNU General Public License for more details. +** +** You should have received a copy of the GNU General Public License +** along with this program. If not, see . +*/ + +// Lobby "Randomize" button. The placement mirrors +// populateRandomSideAndColor / populateRandomStartPosition in GameLogic.cpp so the +// lobby result looks like what a match start would have produced, but it is drawn +// from an independent, clock-seeded RNG: nothing here consumes or predicts the +// game-logic RNG that match start seeds from the lobby seed. + +#include "PreRTS.h" + +#include "GameNetwork/RandomAssign.h" +#include "GameNetwork/GameInfo.h" +#include "Common/PlayerTemplate.h" +#include "Common/MultiplayerSettings.h" +#include "GameClient/MapUtil.h" +#include "GameClient/ChallengeGenerals.h" + +#include +#include +#include + +// Private RNG for the roll: seeded from the clock, never the game-logic RNG, and +// not the process CRT RNG either so the roll has no side effect on anything else. +static Int rollBelow(Int n) +{ + static std::mt19937 s_rng((unsigned int)std::random_device{}() ^ (unsigned int)timeGetTime()); + return (n > 0) ? (Int)(s_rng() % (unsigned int)n) : 0; +} + +// Build the list of valid template indices for random assignment. +// Same filtering as populateRandomSideAndColor (GameLogic.cpp). +static void buildValidTemplates(const GameInfo *game, std::vector &out) +{ + Int count = ThePlayerTemplateStore->getPlayerTemplateCount(); + for (Int c = 0; c < count; ++c) + { + const PlayerTemplate *fac = ThePlayerTemplateStore->getNthPlayerTemplate(c); + if (!fac) + continue; + + // Must have a starting building (filters out civilian etc) + if (fac->getStartingBuilding().isEmpty()) + continue; + + // Respect old factions only mode + if (game->oldFactionsOnly() && !fac->isOldFaction()) + continue; + + // Skip generals that are not unlocked on this client, the same test the + // lobby faction combo applies (GUIUtil.cpp) and match start enforces. + const GeneralPersona *general = TheChallengeGenerals->getGeneralByTemplateName(fac->getName()); + if (general && !general->isStartingEnabled()) + continue; + + out.push_back(c); + } +} + +// Phase 1: Assign random factions and colors. +// Mirrors populateRandomSideAndColor (GameLogic.cpp:691-781). +// Only touches slots that have PLAYERTEMPLATE_RANDOM / color == -1. +static void assignRandomFactions(GameInfo *game, const std::vector &validTemplates) +{ + for (Int i = 0; i < MAX_SLOTS; ++i) + { + GameSlot *slot = game->getSlot(i); + if (!slot || !slot->isOccupied() || slot->getPlayerTemplate() == PLAYERTEMPLATE_OBSERVER) + continue; + + // Assign faction if random + Int playerTemplateIdx = slot->getPlayerTemplate(); + if (playerTemplateIdx == PLAYERTEMPLATE_RANDOM && !validTemplates.empty()) + { + playerTemplateIdx = validTemplates[rollBelow((Int)validTemplates.size())]; + slot->setPlayerTemplate(playerTemplateIdx); + } + + // Assign color if random (-1) + Int colorIdx = slot->getColor(); + if (colorIdx < 0 || colorIdx >= TheMultiplayerSettings->getNumColors()) + { + // Pick uniformly among the colors nobody holds yet, so a full lobby + // never ends up with a slot left at -1 because a blind retry loop + // kept landing on taken colors. + std::vector freeColors; + Int numColors = TheMultiplayerSettings->getNumColors(); + for (Int c = 0; c < numColors; ++c) + { + if (!game->isColorTaken(c)) + freeColors.push_back(c); + } + if (!freeColors.empty()) + slot->setColor(freeColors[rollBelow((Int)freeColors.size())]); + } + } +} + +// Phase 2: Assign start positions using distance-based placement. +// Mirrors populateRandomStartPosition (GameLogic.cpp:787-1053). +// Different teams are placed far apart, teammates close together. +// Only touches slots that have startPos == -1. +static void assignRandomPositions(GameInfo *game) +{ + Int i; + const MapMetaData *md = TheMapCache ? TheMapCache->findMap(game->getMap()) : nullptr; + if (!md) + return; // unknown map: no start spots to hand out; match start will do it + Int numPlayers = md->m_numPlayers; + + if (numPlayers <= 0) + return; + // The per-spot tables below are MAX_SLOTS wide; never index past them. + if (numPlayers > MAX_SLOTS) + numPlayers = MAX_SLOTS; + + // Build distance matrix between all start positions using map waypoints + const WaypointMap &waypoints = md->m_waypoints; + Real startSpotDistance[MAX_SLOTS][MAX_SLOTS]; + for (i = 0; i < MAX_SLOTS; ++i) + { + for (Int j = 0; j < MAX_SLOTS; ++j) + { + if (i != j && i < numPlayers && j < numPlayers) + { + AsciiString w1, w2; + w1.format("Player_%d_Start", i + 1); + w2.format("Player_%d_Start", j + 1); + WaypointMap::const_iterator c1 = waypoints.find(w1); + WaypointMap::const_iterator c2 = waypoints.find(w2); + if (c1 == waypoints.end() || c2 == waypoints.end()) + { + startSpotDistance[i][j] = 1000000.0f; + } + else + { + Coord3D p1 = c1->second; + Coord3D p2 = c2->second; + startSpotDistance[i][j] = sqrt(sqr(p1.x - p2.x) + sqr(p1.y - p2.y)); + } + } + else + { + startSpotDistance[i][j] = 0.0f; + } + } + } + + // Track which positions are already taken (deliberately assigned) + Bool taken[MAX_SLOTS]; + for (i = 0; i < MAX_SLOTS; ++i) + taken[i] = (i < numPlayers) ? FALSE : TRUE; + + Bool hasStartSpotBeenPicked = FALSE; + for (i = 0; i < MAX_SLOTS; ++i) + { + GameSlot *slot = game->getSlot(i); + if (!slot || !slot->isOccupied() || slot->getPlayerTemplate() == PLAYERTEMPLATE_OBSERVER) + continue; + + Int posIdx = slot->getStartPos(); + if (posIdx >= 0 && posIdx < numPlayers) + { + hasStartSpotBeenPicked = TRUE; + taken[posIdx] = TRUE; + } + } + + // Track first position per team for teammate clustering + Int teamPosIdx[MAX_SLOTS]; + for (i = 0; i < MAX_SLOTS; ++i) + teamPosIdx[i] = -1; + + // Seed teamPosIdx from already-assigned slots + for (i = 0; i < MAX_SLOTS; ++i) + { + const GameSlot *slot = game->getConstSlot(i); + if (!slot || !slot->isOccupied() || slot->getPlayerTemplate() == PLAYERTEMPLATE_OBSERVER) + continue; + Int posIdx = slot->getStartPos(); + if (posIdx >= 0 && posIdx < numPlayers) + { + Int team = slot->getTeamNumber(); + if (team >= 0 && teamPosIdx[team] == -1) + teamPosIdx[team] = posIdx; + } + } + + // Assign positions for non-observer slots that don't have one yet + for (i = 0; i < MAX_SLOTS; ++i) + { + GameSlot *slot = game->getSlot(i); + if (!slot || !slot->isOccupied() || slot->getPlayerTemplate() == PLAYERTEMPLATE_OBSERVER) + continue; + + Int posIdx = slot->getStartPos(); + if (posIdx >= 0 && posIdx < numPlayers) + continue; // already assigned + + Int team = slot->getTeamNumber(); + + if (!hasStartSpotBeenPicked) + { + // First player: nothing is taken yet, so any spot is free + posIdx = rollBelow(numPlayers); + hasStartSpotBeenPicked = TRUE; + slot->setStartPos(posIdx); + taken[posIdx] = TRUE; + if (team >= 0) + teamPosIdx[team] = posIdx; + } + else if (team < 0 || teamPosIdx[team] == -1) + { + // New team or no team: pick position farthest from all taken positions + Real farthestDistance = 0.0f; + Int farthestIndex = -1; + for (posIdx = 0; posIdx < numPlayers; ++posIdx) + { + if (taken[posIdx]) + continue; + + Real dist = 0.0f; + for (Int n = 0; n < numPlayers; ++n) + { + if (taken[n] && n != posIdx) + dist += startSpotDistance[posIdx][n]; + } + if (farthestIndex < 0 || dist > farthestDistance) + { + farthestDistance = dist; + farthestIndex = posIdx; + } + } + + if (farthestIndex >= 0) + { + slot->setStartPos(farthestIndex); + taken[farthestIndex] = TRUE; + if (team >= 0) + teamPosIdx[team] = farthestIndex; + } + } + else + { + // Teammate: pick position closest to team's existing position + Real closestDist = FLT_MAX; + Int closestIdx = -1; + for (Int n = 0; n < numPlayers; ++n) + { + if (!taken[n] && startSpotDistance[teamPosIdx[team]][n] < closestDist) + { + closestDist = startSpotDistance[teamPosIdx[team]][n]; + closestIdx = n; + } + } + if (closestIdx >= 0) + { + slot->setStartPos(closestIdx); + taken[closestIdx] = TRUE; + } + } + } + + // Observer slots are left alone: they take a player's spot at match start. +} + +void performRandomAssign(GameInfo *game, std::vector &outChanges) +{ + if (!game) + return; + + Int before[MAX_SLOTS][3]; + Int i; + for (i = 0; i < MAX_SLOTS; ++i) + { + const GameSlot *slot = game->getConstSlot(i); + before[i][0] = slot ? slot->getPlayerTemplate() : -1; + before[i][1] = slot ? slot->getColor() : -1; + before[i][2] = slot ? slot->getStartPos() : -1; + } + + // Phase 1: Assign factions and colors for random slots + std::vector validTemplates; + buildValidTemplates(game, validTemplates); + assignRandomFactions(game, validTemplates); + + // Phase 2: Assign start positions using distance-based placement + assignRandomPositions(game); + + outChanges.clear(); + for (i = 0; i < MAX_SLOTS; ++i) + { + const GameSlot *slot = game->getConstSlot(i); + if (!slot) + continue; + RandomSlotAssignment change; + change.slotIndex = i; + if (slot->getPlayerTemplate() != before[i][0]) + change.side = slot->getPlayerTemplate(); + if (slot->getColor() != before[i][1]) + change.color = slot->getColor(); + if (slot->getStartPos() != before[i][2]) + change.startPos = slot->getStartPos(); + if (change.side != -1 || change.color != -1 || change.startPos != -1) + outChanges.push_back(change); + } +}