diff --git a/lib/netplay/netplay.cpp b/lib/netplay/netplay.cpp index cbde1bc076c..e27fd86cf4e 100644 --- a/lib/netplay/netplay.cpp +++ b/lib/netplay/netplay.cpp @@ -703,10 +703,7 @@ void NET_InitPlayers(bool initTeams, bool initSpectator) static void NETSendNPlayerInfoTo(uint32_t *index, uint32_t indexLen, unsigned to) { - if (NetPlay.bComms && ingame.localJoiningInProgress) - { - ASSERT_HOST_ONLY(return); - } + ASSERT_HOST_ONLY(return); NETbeginEncode(NETnetQueue(to), NET_PLAYER_INFO); NETuint32_t(&indexLen); for (unsigned n = 0; n < indexLen; ++n) @@ -751,12 +748,14 @@ static void NETSendAllPlayerInfoTo(unsigned to) void NETBroadcastTwoPlayerInfo(uint32_t index1, uint32_t index2) { + ASSERT_HOST_ONLY(return); uint32_t indices[2] = {index1, index2}; NETSendNPlayerInfoTo(indices, 2, NET_ALL_PLAYERS); } void NETBroadcastPlayerInfo(uint32_t index) { + ASSERT_HOST_ONLY(return); NETSendPlayerInfoTo(index, NET_ALL_PLAYERS); } diff --git a/src/hci/quickchat.cpp b/src/hci/quickchat.cpp index 570a4c094cc..57ff13c097f 100644 --- a/src/hci/quickchat.cpp +++ b/src/hci/quickchat.cpp @@ -2618,6 +2618,8 @@ const char* to_display_string(WzQuickChatMessage msg) // WZ-generated internal messages - not for users to deliberately send case WzQuickChatMessage::INTERNAL_MSG_DELIVERY_FAILURE_TRY_AGAIN: return _("Message delivery failure - try again"); + case WzQuickChatMessage::INTERNAL_LOBBY_NOTICE_MAP_DOWNLOADED: + return _("Map Downloaded"); // not a valid message case WzQuickChatMessage::MESSAGE_COUNT: @@ -2817,6 +2819,7 @@ void sendQuickChat(WzQuickChatMessage message, uint32_t fromPlayer, WzQuickChatT bool isInGame = (GetGameMode() == GS_NORMAL); auto senderTeam = checkedGetPlayerTeam(fromPlayer); bool senderIsSpectator = NetPlay.players[fromPlayer].isSpectator; + bool senderCanUseSecuredMessages = realSelectedPlayer == NetPlay.hostPlayer || realSelectedPlayer < MAX_PLAYERS; // non-host spectator slots don't currently support / send secured messages auto isPotentiallyValidTarget = [&](uint32_t playerIdx) -> bool { if (playerIdx == fromPlayer) @@ -2919,7 +2922,7 @@ void sendQuickChat(WzQuickChatMessage message, uint32_t fromPlayer, WzQuickChatT } NETQUEUE queue = NETnetQueue((recipient < MAX_PLAYERS) ? whosResponsible(recipient) : recipient); - bool sendSecured = isInGame && (queue.index == NetPlay.hostPlayer || queue.index < MAX_PLAYERS); + bool sendSecured = isInGame && (queue.index == NetPlay.hostPlayer || queue.index < MAX_PLAYERS) && senderCanUseSecuredMessages; if (sendSecured) { if (!NETbeginEncodeSecured(queue, NET_QUICK_CHAT_MSG)) @@ -2970,7 +2973,8 @@ void sendQuickChat(WzQuickChatMessage message, uint32_t fromPlayer, WzQuickChatT bool recvQuickChat(NETQUEUE queue) { bool isInGame = (GetGameMode() == GS_NORMAL); - bool expectingSecuredMessage = isInGame && (realSelectedPlayer == NetPlay.hostPlayer || realSelectedPlayer < MAX_PLAYERS); // spectators do not expect secured messages + bool senderCanUseSecuredMessages = queue.index == NetPlay.hostPlayer || queue.index < MAX_PLAYERS; + bool expectingSecuredMessage = isInGame && (realSelectedPlayer == NetPlay.hostPlayer || realSelectedPlayer < MAX_PLAYERS) && senderCanUseSecuredMessages; // spectator slots do not expect secured messages uint32_t sender = MAX_CONNECTED_PLAYERS; uint32_t recipient = MAX_CONNECTED_PLAYERS; diff --git a/src/hci/quickchat.h b/src/hci/quickchat.h index 9c5d71ff5c3..bce20f2edc2 100644 --- a/src/hci/quickchat.h +++ b/src/hci/quickchat.h @@ -104,7 +104,8 @@ \ /* FROM THIS POINT ON - ONLY INTERNAL MESSAGES! */ \ /* WZ-generated internal messages - not for users to deliberately send */ \ - MSG(INTERNAL_MSG_DELIVERY_FAILURE_TRY_AGAIN) // This should always be the first internal message! + MSG(INTERNAL_MSG_DELIVERY_FAILURE_TRY_AGAIN) /* This should always be the first internal message! */ \ + MSG(INTERNAL_LOBBY_NOTICE_MAP_DOWNLOADED) #define GENERATE_ENUM(ENUM) ENUM, diff --git a/src/multiint.cpp b/src/multiint.cpp index bf30a301f48..a6e930fbb43 100644 --- a/src/multiint.cpp +++ b/src/multiint.cpp @@ -116,6 +116,8 @@ #include "stdinreader.h" #include "urlhelpers.h" #include "hci/quickchat.h" +#include "hci/teamstrategy.h" +#include "multivote.h" #include "activity.h" #include @@ -123,7 +125,6 @@ #define MAP_PREVIEW_DISPLAY_TIME 2500 // number of milliseconds to show map in preview #define LOBBY_DISABLED_TAG "lobbyDisabled" -#define VOTE_TAG "voting" #define KICK_REASON_TAG "kickReason" #define SLOTTYPE_TAG_PREFIX "slotType" #define SLOTTYPE_REQUEST_TAG SLOTTYPE_TAG_PREFIX "::request" @@ -179,7 +180,6 @@ char sPlayer[128] = {'\0'}; // player name (to be used) bool multiintDisableLobbyRefresh = false; // if we allow lobby to be refreshed or not. static UDWORD hideTime = 0; -static uint8_t playerVotes[MAX_PLAYERS]; LOBBY_ERROR_TYPES LobbyError = ERROR_NOERROR; static bool bInActualHostedLobby = false; static bool bRequestedSelfMoveToPlayers = false; @@ -1279,114 +1279,14 @@ WzString formatGameName(WzString name) return withoutTechlevel + " (T" + WzString::number(game.techLevel) + " " + WzString::number(game.maxPlayers) + "P)"; } -void resetVoteData() -{ - for (unsigned int i = 0; i < MAX_PLAYERS; ++i) - { - playerVotes[i] = 0; - } -} - -static void sendVoteData(uint8_t currentVote) -{ - NETbeginEncode(NETbroadcastQueue(), NET_VOTE); - NETuint32_t(&selectedPlayer); - NETuint8_t(¤tVote); - NETend(); -} - -static uint8_t getVoteTotal() -{ - ASSERT_HOST_ONLY(return true); - - uint8_t total = 0; - - for (unsigned i = 0; i < MAX_PLAYERS; ++i) - { - if (isHumanPlayer(i)) - { - if (selectedPlayer == i) - { - // always count the host as a "yes" vote. - playerVotes[i] = 1; - } - total += playerVotes[i]; - } - else - { - playerVotes[i] = 0; - } - } - - return total; -} - -static bool recvVote(NETQUEUE queue) -{ - ASSERT_HOST_ONLY(return true); - - uint8_t newVote; - uint32_t player; - - NETbeginDecode(queue, NET_VOTE); - NETuint32_t(&player); // TODO: check that NETQUEUE belongs to that player :wink: - NETuint8_t(&newVote); - NETend(); - - if (player >= MAX_PLAYERS) - { - debug(LOG_ERROR, "Invalid NET_VOTE from player %d: player id = %d", queue.index, static_cast(player)); - return false; - } - - playerVotes[player] = (newVote == 1) ? 1 : 0; - - debug(LOG_NET, "total votes: %d/%d", static_cast(getVoteTotal()), static_cast(NET_numHumanPlayers())); - - // there is no "votes" that disallows map change so assume they are all allowing - if(newVote == 1) { - char msg[128] = {0}; - ssprintf(msg, _("%s (%d) allowed map change. Total: %d/%d"), NetPlay.players[player].name, player, static_cast(getVoteTotal()), static_cast(NET_numHumanPlayers())); - sendRoomSystemMessage(msg); - } - - return true; -} - -// Show a vote popup to allow changing maps or using the randomization feature. -static void setupVoteChoice() -{ - //This shouldn't happen... - if (NetPlay.isHost) - { - ASSERT(false, "Host tried to send vote data to themself"); - return; - } - - if (!hasNotificationsWithTag(VOTE_TAG)) - { - WZ_Notification notification; - notification.duration = 0; - notification.contentTitle = _("Vote"); - notification.contentText = _("Allow host to change map or randomize?"); - notification.action = WZ_Notification_Action("Allow", [](const WZ_Notification&) { - uint8_t vote = 1; - sendVoteData(vote); - }); - notification.tag = VOTE_TAG; - - addNotification(notification, WZ_Notification_Trigger(GAME_TICKS_PER_SEC * 1)); - } -} - static bool canChangeMapOrRandomize() { ASSERT_HOST_ONLY(return true); uint8_t numHumans = NET_numHumanPlayers(); - bool allowed = (static_cast(getVoteTotal()) / static_cast(numHumans)) > 0.5f; + bool allowed = (static_cast(getLobbyChangeVoteTotal()) / static_cast(numHumans)) > 0.5f; - resetVoteData(); //So the host can only do one change every vote session + resetLobbyChangeVoteData(); //So the host can only do one change every vote session if (numHumans == 1) { @@ -1395,10 +1295,7 @@ static bool canChangeMapOrRandomize() if (!allowed) { - //setup a vote popup for the clients - NETbeginEncode(NETbroadcastQueue(), NET_VOTE_REQUEST); - NETend(); - + startLobbyChangeVote(); displayRoomSystemMessage(_("Not enough votes to randomize or change the map.")); } @@ -1759,6 +1656,8 @@ static std::shared_ptr addMultiButWithClickHandler(const std::sha void WzMultiplayerOptionsTitleUI::openDifficultyChooser(uint32_t player) { + ASSERT_HOST_ONLY(return); + std::shared_ptr aiForm = initRightSideChooser(_("DIFFICULTY")); if (!aiForm) { @@ -1825,6 +1724,8 @@ void WzMultiplayerOptionsTitleUI::openDifficultyChooser(uint32_t player) void WzMultiplayerOptionsTitleUI::openAiChooser(uint32_t player) { + ASSERT_HOST_ONLY(return); + std::shared_ptr aiForm = initRightSideChooser(_("CHOOSE AI")); if (!aiForm) { @@ -3335,7 +3236,7 @@ static SwapPlayerIndexesResult recvSwapPlayerIndexes(NETQUEUE queue, const std:: NETsetPlayerConnectionStatus(CONNECTIONSTATUS_PLAYER_DROPPED, playerIndex); // needed?? if (playerIndex < MAX_PLAYERS) { - playerVotes[playerIndex] = 0; + resetLobbyChangePlayerVote(playerIndex); } } swapPlayerMultiStatsLocal(playerIndexA, playerIndexB); @@ -5425,7 +5326,7 @@ static void stopJoining(std::shared_ptr parent) bInActualHostedLobby = false; reloadMPConfig(); // reload own settings - cancelOrDismissNotificationsWithTag(VOTE_TAG); + cancelOrDismissVoteNotifications(); cancelOrDismissNotificationIfTag([](const std::string& tag) { return (tag.rfind(SLOTTYPE_TAG_PREFIX, 0) == 0); }); @@ -6355,7 +6256,7 @@ void WzMultiplayerOptionsTitleUI::processMultiopWidgets(UDWORD id) sstrcpy(game.name, widgGetString(psWScreen, MULTIOP_GNAME)); sstrcpy(sPlayer, widgGetString(psWScreen, MULTIOP_PNAME)); - resetVoteData(); + resetLobbyChangeVoteData(); resetDataHash(); startHost(); @@ -6377,7 +6278,7 @@ void WzMultiplayerOptionsTitleUI::processMultiopWidgets(UDWORD id) if (NetPlay.bComms && ingame.side == InGameSide::MULTIPLAYER_CLIENT && !NetPlay.isHost) { // remove a potential "allow" vote if we gracefully leave - sendVoteData(0); + sendLobbyChangeVoteData(0); } NETGameLocked(false); // reset status on a cancel stopJoining(parent); @@ -6910,7 +6811,7 @@ void WzMultiplayerOptionsTitleUI::frontendMultiMessages(bool running) NETsetPlayerConnectionStatus(CONNECTIONSTATUS_PLAYER_DROPPED, player_id); if (player_id < MAX_PLAYERS) { - playerVotes[player_id] = 0; + resetLobbyChangePlayerVote(player_id); } ActivityManager::instance().updateMultiplayGameData(game, ingame, NETGameIsLocked()); if (player_id == NetPlay.hostPlayer || player_id == selectedPlayer) // if host quits or we quit, abort out @@ -6935,7 +6836,7 @@ void WzMultiplayerOptionsTitleUI::frontendMultiMessages(bool running) break; } case NET_FIREUP: // campaign game started.. can fire the whole shebang up... - cancelOrDismissNotificationsWithTag(VOTE_TAG); // don't need vote notifications anymore + cancelOrDismissVoteNotifications(); // don't need vote notifications anymore cancelOrDismissNotificationsWithTag(LOBBY_DISABLED_TAG); cancelOrDismissNotificationIfTag([](const std::string& tag) { return (tag.rfind(SLOTTYPE_TAG_PREFIX, 0) == 0); @@ -6992,7 +6893,7 @@ void WzMultiplayerOptionsTitleUI::frontendMultiMessages(bool running) if (player_id < MAX_PLAYERS) { - playerVotes[player_id] = 0; + resetLobbyChangePlayerVote(player_id); } if (player_id == NetPlay.hostPlayer) @@ -7079,7 +6980,7 @@ void WzMultiplayerOptionsTitleUI::frontendMultiMessages(bool running) case NET_VOTE_REQUEST: if (!NetPlay.isHost && !NetPlay.players[selectedPlayer].isSpectator) { - setupVoteChoice(); + recvVoteRequest(queue); } break; @@ -7362,7 +7263,7 @@ TITLECODE WzMultiplayerOptionsTitleUI::run() } if (!NetPlay.isHostAlive && ingame.side == InGameSide::MULTIPLAYER_CLIENT) { - cancelOrDismissNotificationsWithTag(VOTE_TAG); + cancelOrDismissVoteNotifications(); cancelOrDismissNotificationsWithTag(LOBBY_DISABLED_TAG); cancelOrDismissNotificationIfTag([](const std::string& tag) { return (tag.rfind(SLOTTYPE_TAG_PREFIX, 0) == 0); @@ -8323,6 +8224,7 @@ static bool multiplayIsStartingGame() void sendRoomSystemMessage(char const *text) { + ASSERT_HOST_ONLY(return); NetworkTextMessage message(SYSTEM_MESSAGE, text); displayRoomSystemMessage(text); message.enqueue(NETbroadcastQueue()); @@ -8330,6 +8232,7 @@ void sendRoomSystemMessage(char const *text) void sendRoomNotifyMessage(char const *text) { + ASSERT_HOST_ONLY(return); NetworkTextMessage message(NOTIFY_MESSAGE, text); displayRoomSystemMessage(text); message.enqueue(NETbroadcastQueue()); diff --git a/src/multiint.h b/src/multiint.h index 4bca5acd811..be0b4965897 100644 --- a/src/multiint.h +++ b/src/multiint.h @@ -96,7 +96,6 @@ void loadMapPreview(bool hideInterface); bool changeReadyStatus(UBYTE player, bool bReady); WzString formatGameName(WzString name); -void resetVoteData(); void sendRoomSystemMessage(char const *text); void sendRoomNotifyMessage(char const *text); void sendRoomSystemMessageToSingleReceiver(char const *text, uint32_t receiver); diff --git a/src/multijoin.cpp b/src/multijoin.cpp index df4a61d0fb0..b09267cd831 100644 --- a/src/multijoin.cpp +++ b/src/multijoin.cpp @@ -68,6 +68,7 @@ #include "multiint.h" #include "multistat.h" #include "multigifts.h" +#include "multivote.h" #include "qtscript.h" #include "clparse.h" #include "multilobbycommands.h" @@ -417,6 +418,7 @@ void recvPlayerLeft(NETQUEUE queue) NetPlay.players[playerIndex].allocated = false; NETsetPlayerConnectionStatus(CONNECTIONSTATUS_PLAYER_DROPPED, playerIndex); + cancelOrDismissKickVote(playerIndex); debug(LOG_INFO, "** player %u has dropped, in-game! (gameTime: %" PRIu32 ")", playerIndex, gameTime); ActivityManager::instance().updateMultiplayGameData(game, ingame, NETGameIsLocked()); diff --git a/src/multimenu.cpp b/src/multimenu.cpp index ae67780e5e8..32560c41549 100644 --- a/src/multimenu.cpp +++ b/src/multimenu.cpp @@ -68,6 +68,7 @@ #include "loop.h" #include "frontend.h" #include "hci/teamstrategy.h" +#include "multivote.h" // //////////////////////////////////////////////////////////////////////////// // defines @@ -877,17 +878,11 @@ class MultiMenuGrid: public GridLayout if (mouseDown(MOUSE_RMB) && NetPlay.isHost) // both buttons.... { - char buf[250]; - // Allow the host to kick the AI only in a MP game, or if they activated cheats in a skirmish game if ((NetPlay.bComms || Cheated) && (NetPlay.players[i].allocated || (NetPlay.players[i].allocated == false && NetPlay.players[i].ai != AI_OPEN))) { inputLoseFocus(); - ssprintf(buf, _("The host has kicked %s from the game!"), getPlayerName((unsigned int) i)); - sendInGameSystemMessage(buf); - ssprintf(buf, _("kicked %s : %s from the game, and added them to the banned list!"), getPlayerName((unsigned int) i), NetPlay.players[i].IPtextAddress); - NETlogEntry(buf, SYNC_FLAG, (unsigned int) i); - kickPlayer((unsigned int) i, _("The host has kicked you from the game."), ERROR_KICKED, false); + startKickVote(static_cast(i)); return; } } diff --git a/src/multiopt.cpp b/src/multiopt.cpp index e2d8635d493..9f294cf60f0 100644 --- a/src/multiopt.cpp +++ b/src/multiopt.cpp @@ -62,6 +62,7 @@ #include "multigifts.h" #include "multiint.h" #include "multirecv.h" +#include "multivote.h" #include "template.h" #include "activity.h" #include "warzoneconfig.h" @@ -726,5 +727,7 @@ bool multiGameShutdown() NET_InitPlayers(); + resetKickVoteData(); + return true; } diff --git a/src/multiplay.cpp b/src/multiplay.cpp index 3d636072b70..13317b05566 100644 --- a/src/multiplay.cpp +++ b/src/multiplay.cpp @@ -77,6 +77,7 @@ #include "cheat.h" #include "main.h" // for gamemode #include "multiint.h" +#include "multivote.h" #include "activity.h" #include "lib/framework/wztime.h" #include "chat.h" // for InGameChatMessage @@ -370,6 +371,7 @@ bool multiPlayerLoop() if (NetPlay.isHost) { autoLagKickRoutine(); + processPendingKickVotes(); } // if player has won then process the win effects... @@ -1314,6 +1316,18 @@ bool recvMessage() case GAME_ALLIANCE: recvAlliance(queue, true); break; + case NET_VOTE: + if (NetPlay.isHost) + { + recvVote(queue); + } + break; + case NET_VOTE_REQUEST: + if (!NetPlay.isHost && !NetPlay.players[selectedPlayer].isSpectator) + { + recvVoteRequest(queue); + } + break; case NET_KICK: // in-game kick message { uint32_t player_id; @@ -2064,7 +2078,11 @@ bool recvMapFileData(NETQUEUE queue) { netPlayersUpdated = true; // Remove download icon from ourselves. addConsoleMessage(_("MAP DOWNLOADED!"), DEFAULT_JUSTIFY, SYSTEM_MESSAGE); - sendInGameSystemMessage("MAP DOWNLOADED"); + + WzQuickChatTargeting targeting; + targeting.all = true; + sendQuickChat(WzQuickChatMessage::INTERNAL_LOBBY_NOTICE_MAP_DOWNLOADED, selectedPlayer, targeting); + debug(LOG_INFO, "=== File has been received. ==="); // clear out the old level list. diff --git a/src/multivote.cpp b/src/multivote.cpp new file mode 100644 index 00000000000..b00978f21ea --- /dev/null +++ b/src/multivote.cpp @@ -0,0 +1,722 @@ +/* + This file is part of Warzone 2100. + Copyright (C) 2005-2023 Warzone 2100 Project + + Warzone 2100 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 2 of the License, or + (at your option) any later version. + + Warzone 2100 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 Warzone 2100; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +#include "multivote.h" + +#include "lib/framework/frame.h" +#include "lib/gamelib/gtime.h" +#include "lib/netplay/netplay.h" + +#include "multiplay.h" +#include "multiint.h" +#include "notifications.h" +#include "hci/teamstrategy.h" + +#include + +#include +using nonstd::optional; +using nonstd::nullopt; + + +// MARK: - + +#define VOTE_TAG "voting" +#define VOTE_KICK_TAG_PREFIX "votekick::" + +static uint8_t playerVotes[MAX_PLAYERS]; + +enum class NetVoteType +{ + LOBBY_SETTING_CHANGE, + KICK_PLAYER +}; + +struct PendingVoteKick +{ + uint32_t unique_vote_id = 0; + uint32_t requester_player_id = 0; + uint32_t target_player_id = 0; + std::array, MAX_PLAYERS> votes; + uint32_t start_time = 0; + +public: + PendingVoteKick(uint32_t unique_vote_id, uint32_t requester_player_id, uint32_t target_player_id) + : unique_vote_id(unique_vote_id) + , requester_player_id(requester_player_id) + , target_player_id(target_player_id) + { + votes[requester_player_id] = true; + votes[target_player_id] = false; + start_time = realTime; + } + +public: + bool setPlayerVote(uint32_t sender, bool vote) + { + ASSERT_OR_RETURN(false, sender < votes.size(), "Invalid sender: %" PRIu32, sender); + if (votes[sender].has_value()) + { + // ignore double-votes + return false; + } + votes[sender] = vote; + return true; + } + + optional getKickVoteResult(); +}; + +static std::vector pendingKickVotes; +constexpr uint32_t PENDING_KICK_VOTE_TIMEOUT_MS = 20000; +constexpr uint32_t MIN_INTERVAL_BETWEEN_PLAYER_KICK_VOTES_MS = 60000; +static std::array, MAX_PLAYERS> lastKickVoteForEachPlayer; + +static bool handleVoteKickResult(PendingVoteKick& pendingVote); + +// MARK: - + +void resetKickVoteData() +{ + pendingKickVotes.clear(); + for (auto& val : lastKickVoteForEachPlayer) + { + val.reset(); + } +} + +void resetLobbyChangeVoteData() +{ + for (unsigned int i = 0; i < MAX_PLAYERS; ++i) + { + playerVotes[i] = 0; + } +} + +void resetLobbyChangePlayerVote(uint32_t player) +{ + ASSERT_OR_RETURN(, player < MAX_PLAYERS, "Invalid player: %" PRIu32, player); + playerVotes[player] = 0; +} + +void sendLobbyChangeVoteData(uint8_t currentVote) +{ + NETbeginEncode(NETbroadcastQueue(), NET_VOTE); + NETuint32_t(&selectedPlayer); + uint32_t voteID = 0; + NETuint32_t(&voteID); + uint8_t voteType = static_cast(NetVoteType::LOBBY_SETTING_CHANGE); + NETuint8_t(&voteType); + NETuint8_t(¤tVote); + NETend(); +} + +uint8_t getLobbyChangeVoteTotal() +{ + ASSERT_HOST_ONLY(return true); + + uint8_t total = 0; + + for (unsigned i = 0; i < MAX_PLAYERS; ++i) + { + if (isHumanPlayer(i)) + { + if (selectedPlayer == i) + { + // always count the host as a "yes" vote. + playerVotes[i] = 1; + } + total += playerVotes[i]; + } + else + { + playerVotes[i] = 0; + } + } + + return total; +} + +static void recvLobbyChangeVote(uint32_t player, uint8_t newVote) +{ + playerVotes[player] = (newVote == 1) ? 1 : 0; + + debug(LOG_NET, "total votes: %d/%d", static_cast(getLobbyChangeVoteTotal()), static_cast(NET_numHumanPlayers())); + + // there is no "votes" that disallows map change so assume they are all allowing + if(newVote == 1) { + char msg[128] = {0}; + ssprintf(msg, _("%s (%d) allowed map change. Total: %d/%d"), NetPlay.players[player].name, player, static_cast(getLobbyChangeVoteTotal()), static_cast(NET_numHumanPlayers())); + sendRoomSystemMessage(msg); + } +} + +void sendPlayerKickedVote(uint32_t voteID, uint8_t newVote) +{ + NETbeginEncode(NETbroadcastQueue(), NET_VOTE); + NETuint32_t(&selectedPlayer); + NETuint32_t(&voteID); + uint8_t voteType = static_cast(NetVoteType::KICK_PLAYER); + NETuint8_t(&voteType); + NETuint8_t(&newVote); + NETend(); +} + +static void recvPlayerKickVote(uint32_t voteID, uint32_t sender, uint8_t newVote) +{ + ASSERT_OR_RETURN(, sender < MAX_PLAYERS, "Invalid sender: %" PRIu32, sender); + + auto it = std::find_if(pendingKickVotes.begin(), pendingKickVotes.end(), [voteID](const PendingVoteKick& a) -> bool { + return a.unique_vote_id == voteID; + }); + if (it == pendingKickVotes.end()) + { + // didn't find the vote? may have already ended + return; + } + + bool voteToKick = (newVote == 1); + if (it->setPlayerVote(sender, voteToKick)) + { + std::string outputMsg; + if (voteToKick) + { + outputMsg = astringf(_("A player voted FOR kicking: %s"), getPlayerName(it->target_player_id)); + sendInGameSystemMessage(outputMsg.c_str()); + debug(LOG_INFO, "Player [%" PRIu32 "] %s voted FOR kicking player: %s", sender, getPlayerName(sender), getPlayerName(it->target_player_id)); + } + else + { + if (newVote == 0) + { + outputMsg = astringf(_("A player voted AGAINST kicking: %s"), getPlayerName(it->target_player_id)); + sendInGameSystemMessage(outputMsg.c_str()); + debug(LOG_INFO, "Player [%" PRIu32 "] %s voted AGAINST kicking player: %s", sender, getPlayerName(sender), getPlayerName(it->target_player_id)); + } + else + { + outputMsg = astringf(_("A player's client ignored your vote to kick request (too frequent): %s"), getPlayerName(it->target_player_id)); + addConsoleMessage(outputMsg.c_str(), DEFAULT_JUSTIFY, SYSTEM_MESSAGE, false); // only display to the host + debug(LOG_INFO, "Player [%" PRIu32 "] %s ignored vote to kick request for player: %s - (too frequent)", sender, getPlayerName(sender), getPlayerName(it->target_player_id)); + } + } + } + + if (handleVoteKickResult(*it)) + { + // got a result and handled it + pendingKickVotes.erase(it); + } +} + +bool recvVote(NETQUEUE queue) +{ + ASSERT_HOST_ONLY(return true); + + uint32_t player = MAX_PLAYERS; + uint32_t voteID = 0; + uint8_t voteType = 0; + uint8_t newVote = 0; + + NETbeginDecode(queue, NET_VOTE); + NETuint32_t(&player); + NETuint32_t(&voteID); + NETuint8_t(&voteType); + NETuint8_t(&newVote); + NETend(); + + if (player >= MAX_PLAYERS) + { + debug(LOG_ERROR, "Invalid NET_VOTE from player %d: player id = %d", queue.index, static_cast(player)); + return false; + } + + if (whosResponsible(player) != queue.index) + { + debug(LOG_NET, "Invalid NET_VOTE from player %d: (for player id = %d)", queue.index, static_cast(player)); + return false; + } + + switch (static_cast(voteType)) + { + case NetVoteType::LOBBY_SETTING_CHANGE: + recvLobbyChangeVote(player, newVote); + return true; + case NetVoteType::KICK_PLAYER: + recvPlayerKickVote(voteID, player, newVote); + return true; + } + + return false; +} + +// Show a vote popup to allow changing maps or using the randomization feature. +static void setupLobbyChangeVoteChoice() +{ + //This shouldn't happen... + if (NetPlay.isHost) + { + ASSERT(false, "Host tried to send vote data to themself"); + return; + } + + if (!hasNotificationsWithTag(VOTE_TAG)) + { + WZ_Notification notification; + notification.duration = 0; + notification.contentTitle = _("Vote"); + notification.contentText = _("Allow host to change map or randomize?"); + notification.action = WZ_Notification_Action(_("Allow"), [](const WZ_Notification&) { + uint8_t vote = 1; + sendLobbyChangeVoteData(vote); + }); + notification.tag = VOTE_TAG; + + addNotification(notification, WZ_Notification_Trigger(GAME_TICKS_PER_SEC * 1)); + } +} + +// Show a kick vote popup +static void setupKickVoteChoice(uint32_t voteID, uint32_t targetPlayer) +{ + //This shouldn't happen... + if (NetPlay.isHost) + { + ASSERT(false, "Host tried to send vote data to themself?"); + return; + } + + if (targetPlayer >= MAX_PLAYERS) + { + // Invalid targetPlayer + return; + } + + bool targetIsActiveAIPlayer = NetPlay.players[targetPlayer].allocated == false && NetPlay.players[targetPlayer].ai >= 0 && !NetPlay.players[targetPlayer].isSpectator; + if (!NetPlay.players[targetPlayer].allocated && !targetIsActiveAIPlayer) + { + // no active player to vote on + return; + } + + if (lastKickVoteForEachPlayer[targetPlayer].has_value()) + { + if (realTime - lastKickVoteForEachPlayer[targetPlayer].value() < MIN_INTERVAL_BETWEEN_PLAYER_KICK_VOTES_MS) + { + // Host is spamming kick requests - deny it automatically + if (targetPlayer != selectedPlayer) + { + sendPlayerKickedVote(voteID, 2); + } + return; + } + } + + if (targetPlayer == selectedPlayer) + { + // The vote is for the current player - just display a local console message and return + addConsoleMessage(_("A vote was started to kick you from the game."), DEFAULT_JUSTIFY, SYSTEM_MESSAGE, false); + debug(LOG_INFO, "A vote was started to kick you from the game."); + return; + } + + std::string notificationTag = VOTE_KICK_TAG_PREFIX + std::to_string(targetPlayer); + + if (hasNotificationsWithTag(notificationTag)) + { + // dismiss existing notification targeting this player + cancelOrDismissNotificationsWithTag(notificationTag); + } + + lastKickVoteForEachPlayer[targetPlayer] = realTime; + + std::string outputMsg = astringf(_("A vote was started to kick %s from the game."), getPlayerName(targetPlayer)); + addConsoleMessage(outputMsg.c_str(), DEFAULT_JUSTIFY, SYSTEM_MESSAGE, false); + debug(LOG_INFO, "A vote was started to kick %s from the game.", getPlayerName(targetPlayer)); + + WZ_Notification notification; + notification.duration = 0; + const char* pPlayerName = getPlayerName(static_cast(targetPlayer)); + std::string playerDisplayName = (pPlayerName) ? std::string(pPlayerName) : astringf("", targetPlayer); + notification.contentTitle = astringf(_("Vote To Kick: %s"), playerDisplayName.c_str()); + notification.contentText = astringf(_("Should player %s be kicked from the game?"), playerDisplayName.c_str()); + notification.action = WZ_Notification_Action(_("Yes, Kick Them"), [voteID](const WZ_Notification&) { + sendPlayerKickedVote(voteID, 1); + }); + notification.onDismissed = [voteID](const WZ_Notification&, WZ_Notification_Dismissal_Reason reason) { + if (reason != WZ_Notification_Dismissal_Reason::USER_DISMISSED) { return; } + sendPlayerKickedVote(voteID, 0); + }; + notification.tag = notificationTag; + + addNotification(notification, WZ_Notification_Trigger(GAME_TICKS_PER_SEC * 1)); +} + +static bool sendVoteRequest(NetVoteType type, uint32_t voteID = 0, uint32_t targetPlayer = 0) +{ + ASSERT_HOST_ONLY(return false); + + //setup a vote popup for the clients + NETbeginEncode(NETbroadcastQueue(), NET_VOTE_REQUEST); + NETuint32_t(&selectedPlayer); + NETuint32_t(&targetPlayer); + NETuint32_t(&voteID); + uint8_t voteType = static_cast(type); + NETuint8_t(&voteType); + NETend(); + + std::string outputMsg = astringf(_("Starting vote to kick player: %s"), getPlayerName(targetPlayer)); + addConsoleMessage(outputMsg.c_str(), DEFAULT_JUSTIFY, SYSTEM_MESSAGE, false); + debug(LOG_INFO, "Starting vote to kick player: %s", getPlayerName(targetPlayer)); + + return true; +} + +bool recvVoteRequest(NETQUEUE queue) +{ + uint32_t sender = MAX_PLAYERS; + uint32_t targetPlayer = MAX_PLAYERS; + uint32_t voteID = 0; + uint8_t voteType = 0; + NETbeginDecode(queue, NET_VOTE_REQUEST); + NETuint32_t(&sender); + NETuint32_t(&targetPlayer); + NETuint32_t(&voteID); + NETuint8_t(&voteType); + NETend(); + + if (sender >= MAX_PLAYERS) + { + debug(LOG_NET, "Invalid NET_VOTE_REQUEST from player %d: player id = %d", queue.index, static_cast(sender)); + return false; + } + + if (whosResponsible(sender) != queue.index) + { + debug(LOG_NET, "Invalid NET_VOTE_REQUEST from player %d: (for player id = %d)", queue.index, static_cast(sender)); + return false; + } + + switch (static_cast(voteType)) + { + case NetVoteType::LOBBY_SETTING_CHANGE: + setupLobbyChangeVoteChoice(); + return true; + case NetVoteType::KICK_PLAYER: + setupKickVoteChoice(voteID, targetPlayer); + return true; + } + + return false; +} + +void startLobbyChangeVote() +{ + ASSERT_HOST_ONLY(return); + sendVoteRequest(NetVoteType::LOBBY_SETTING_CHANGE, 0, NetPlay.hostPlayer); +} + +static std::vector getPlayersWhoCanVoteToKick() +{ + std::vector connectedHumanPlayers; + for (int32_t player = 0; player < std::min(game.maxPlayers, MAX_PLAYERS); ++player) + { + if (isHumanPlayer(player) // is an active (connected) human player + && !NetPlay.players[player].isSpectator // who is *not* a spectator + ) + { + connectedHumanPlayers.push_back(static_cast(player)); + } + } + return connectedHumanPlayers; +} + +static std::vector filterPlayersByTeam(const std::vector& playersToFilter, int32_t specifiedTeam) +{ + std::vector playersOnSameTeamAsDesired; + for (auto player : playersToFilter) + { + if (checkedGetPlayerTeam(player) != specifiedTeam) + { + continue; + } + playersOnSameTeamAsDesired.push_back(static_cast(player)); + } + + return playersOnSameTeamAsDesired; +} + +static std::vector getPlayersNotOnTeam(const std::vector& playersToFilter, int32_t specifiedTeam) +{ + std::vector playersNotOnTeam; + for (auto player : playersToFilter) + { + if (checkedGetPlayerTeam(player) == specifiedTeam) + { + continue; + } + playersNotOnTeam.push_back(static_cast(player)); + } + + return playersNotOnTeam; +} + +optional PendingVoteKick::getKickVoteResult() +{ + bool targetIsActiveAIPlayer = NetPlay.players[target_player_id].allocated == false && NetPlay.players[target_player_id].ai >= 0 && !NetPlay.players[target_player_id].isSpectator; + if (!NetPlay.players[target_player_id].allocated && !targetIsActiveAIPlayer) + { + // target player has already left / AI has lost + return false; + } + + auto eligible_voters = getPlayersWhoCanVoteToKick(); + + // Special case: + // - (If there are only two eligible voters): If they are on separate teams, vote fails immediately. If they are on the *same* team, just allow it (presumably they are against bots or the game would have already ended) + if (eligible_voters.size() <= 2) + { + if (eligible_voters.size() <= 1) + { + // If there's only 1 eligible voter, let them kick (presumably kicking an AI) + return true; + } + if (checkedGetPlayerTeam(eligible_voters[0]) == checkedGetPlayerTeam(eligible_voters[1])) + { + // If they are on the *same* team, just allow it (presumably they are against bots or the game would have already ended) + return true; + } + // If only two human players, but not on the same team, vote to kick can't succeed (the host can always quit, though) + return false; + } + + auto target_player_team = checkedGetPlayerTeam(target_player_id); + auto target_player_teammembers = filterPlayersByTeam(eligible_voters, target_player_team); + bool target_player_is_solo = target_player_teammembers.size() <= 1; + bool team_voteout_still_possible = false; + + // Check if vote reached a team threshold + if (!target_player_is_solo) + { + size_t team_votes_for_kick = 0; + size_t team_votes_against_kick = 0; + for (auto player : target_player_teammembers) + { + if (votes[player].has_value()) + { + if (votes[player].value()) + { + ++team_votes_for_kick; + } + else + { + ++team_votes_against_kick; + } + } + } + + // If 50%+ of the target player's team agrees to kick, that's a success + size_t team_threshold = static_cast(ceilf(static_cast(target_player_teammembers.size()) / 2.f)); + if (team_votes_for_kick >= team_threshold) + { + return true; + } + + size_t team_votes_outstanding = target_player_teammembers.size() - (team_votes_for_kick + team_votes_against_kick); + if (team_votes_for_kick + team_votes_outstanding >= team_threshold) + { + team_voteout_still_possible = true; + } + + // Otherwise, fall-through to overall thresholds + } + + size_t overall_votes_for_kick = 0; + size_t overall_votes_against_kick = 0; + for (auto player : eligible_voters) + { + if (votes[player].has_value()) + { + if (votes[player].value()) + { + ++overall_votes_for_kick; + } + else + { + ++overall_votes_against_kick; + } + } + } + + size_t num_voting = overall_votes_for_kick + overall_votes_against_kick; + size_t num_not_voting = eligible_voters.size() - num_voting; + auto players_on_other_teams = getPlayersNotOnTeam(eligible_voters, target_player_team); + + size_t overall_vote_threshold; + if (target_player_is_solo) + { + // If target player is the only human player on their team: 50%+ of all other eligible voters vote to kick + overall_vote_threshold = std::max(static_cast(ceilf(static_cast(eligible_voters.size()) / 2.f)), 2); + } + else + { + // If target player is *not* the only human player on their team, the smaller of: + // - 2/3 of all eligible voters + // or + // - num eligible voters on other teams + // but must be at least 2 + overall_vote_threshold = std::min(static_cast(ceilf(static_cast(eligible_voters.size()) * 2.f / 3.f)), players_on_other_teams.size()); + overall_vote_threshold = std::max(overall_vote_threshold, 2); + } + + // Check if vote reached an overall threshold + if (overall_votes_for_kick >= overall_vote_threshold) + { + return true; + } + + bool overall_voteout_still_possible = (overall_votes_for_kick + num_not_voting >= overall_vote_threshold); + if ((!overall_voteout_still_possible && !team_voteout_still_possible) || num_not_voting == 0) + { + // didn't (and can't) hit any threshold + return false; + } + + // waiting for more votes + return nullopt; +} + +// Returns: true if PendingVote has a result and was handled, false if still waiting for results +static bool handleVoteKickResult(PendingVoteKick& pendingVote) +{ + ASSERT_HOST_ONLY(return false); + + auto currentResult = pendingVote.getKickVoteResult(); + if (!currentResult.has_value()) + { + return false; + } + + if (currentResult.value()) + { + std::string outputMsg = astringf(_("The vote to kick player %s succeeded (sufficient votes in favor) - kicking"), getPlayerName(pendingVote.target_player_id)); + sendInGameSystemMessage(outputMsg.c_str()); + std::string logMsg = astringf("kicked %s : %s from the game", getPlayerName(pendingVote.target_player_id), NetPlay.players[pendingVote.target_player_id].IPtextAddress); + NETlogEntry(logMsg.c_str(), SYNC_FLAG, pendingVote.target_player_id); + + kickPlayer(pendingVote.target_player_id, "The players have voted to kick you from the game.", ERROR_KICKED, false); + } + else + { + // Vote failed - message all players + std::string outputMsg = astringf(_("The vote to kick player %s failed (insufficient votes in favor)"), getPlayerName(pendingVote.target_player_id)); + sendInGameSystemMessage(outputMsg.c_str()); + } + return true; +} + +void processPendingKickVotes() +{ + if (!NetPlay.isHost) + { + return; + } + + auto it = pendingKickVotes.begin(); + while (it != pendingKickVotes.end()) + { + if (realTime - it->start_time >= PENDING_KICK_VOTE_TIMEOUT_MS) + { + // pending vote timed-out + + // double-check if pending vote has a result (this might have changed if other players left) + if (!handleVoteKickResult(*it)) + { + // dismiss the pending vote + std::string outputMsg = astringf(_("The vote to kick player %s failed (insufficient votes before timeout)"), getPlayerName(it->target_player_id)); + sendInGameSystemMessage(outputMsg.c_str()); + debug(LOG_INFO, "%s", outputMsg.c_str()); + } + + it = pendingKickVotes.erase(it); + } + else + { + it++; + } + } +} + +bool startKickVote(uint32_t targetPlayer) +{ + ASSERT_HOST_ONLY(return false); + static uint32_t last_vote_id = 0; + + auto pendingVote = PendingVoteKick(last_vote_id++, selectedPlayer, targetPlayer); + auto currentStatus = pendingVote.getKickVoteResult(); + if (currentStatus.has_value()) + { + // Vote either isn't possible or is a special case + if (currentStatus.value()) + { + kickPlayer(targetPlayer, _("The host has kicked you from the game."), ERROR_KICKED, false); + return true; + } + else + { + // message to the requester that player kick vote failed + std::string outputMsg = astringf(_("The vote to kick player %s failed"), getPlayerName(targetPlayer)); + addConsoleMessage(outputMsg.c_str(), DEFAULT_JUSTIFY, SYSTEM_MESSAGE, false); + return false; + } + } + + if (lastKickVoteForEachPlayer[targetPlayer].has_value()) + { + if (realTime - lastKickVoteForEachPlayer[targetPlayer].value() < MIN_INTERVAL_BETWEEN_PLAYER_KICK_VOTES_MS + 5000) // + extra on the sender side + { + // Prevent spamming kick votes + std::string outputMsg = astringf(_("Cannot request vote to kick player %s yet - please wait a bit longer"), getPlayerName(targetPlayer)); + addConsoleMessage(outputMsg.c_str(), DEFAULT_JUSTIFY, SYSTEM_MESSAGE, false); + return false; + } + } + + // Store in the list of pending votes + pendingKickVotes.push_back(pendingVote); + + // Initiate a network vote + sendVoteRequest(NetVoteType::KICK_PLAYER, pendingVote.unique_vote_id, pendingVote.target_player_id); + + lastKickVoteForEachPlayer[targetPlayer] = realTime; + return true; +} + +void cancelOrDismissVoteNotifications() +{ + cancelOrDismissNotificationsWithTag(VOTE_TAG); + cancelOrDismissNotificationIfTag([](const std::string& tag) { + return (tag.rfind(VOTE_KICK_TAG_PREFIX, 0) == 0); + }); +} + +void cancelOrDismissKickVote(uint32_t targetPlayer) +{ + cancelOrDismissNotificationsWithTag(std::string(VOTE_KICK_TAG_PREFIX) + std::to_string(targetPlayer)); +} diff --git a/src/multivote.h b/src/multivote.h new file mode 100644 index 00000000000..a64ceabdf8c --- /dev/null +++ b/src/multivote.h @@ -0,0 +1,42 @@ +/* + This file is part of Warzone 2100. + Copyright (C) 2005-2023 Warzone 2100 Project + + Warzone 2100 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 2 of the License, or + (at your option) any later version. + + Warzone 2100 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 Warzone 2100; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +#ifndef __INCLUDED_WZ_MULTI_VOTE_H__ +#define __INCLUDED_WZ_MULTI_VOTE_H__ + +#include + +void resetLobbyChangeVoteData(); +void resetLobbyChangePlayerVote(uint32_t player); +void startLobbyChangeVote(); +uint8_t getLobbyChangeVoteTotal(); +void sendLobbyChangeVoteData(uint8_t currentVote); + +void resetKickVoteData(); +bool startKickVote(uint32_t target_player_id); +void processPendingKickVotes(); +void cancelOrDismissKickVote(uint32_t targetPlayer); + +void cancelOrDismissVoteNotifications(); + +struct NETQUEUE; +bool recvVoteRequest(NETQUEUE queue); +bool recvVote(NETQUEUE queue); + +#endif // __INCLUDED_WZ_MULTI_VOTE_H diff --git a/src/stdinreader.cpp b/src/stdinreader.cpp index 44e14611475..fc5d004bae4 100644 --- a/src/stdinreader.cpp +++ b/src/stdinreader.cpp @@ -660,6 +660,12 @@ int cmdInputThreadFunc(void *) std::string kickReasonStrCopy = (r >= 2) ? kickreasonstr : "You have been kicked by the administrator."; convertEscapedNewlines(kickReasonStrCopy); wzAsyncExecOnMainThread([playerIdentityStrCopy, kickReasonStrCopy] { + if (NetPlay.hostPlayer < MAX_PLAYERS && ingame.TimeEveryoneIsInGame.has_value()) + { + // host is not a spectator host + wz_command_interface_output("WZCMD error: Failed to execute in-game kick command - not a spectator host\n"); + return; + } bool foundActivePlayer = kickActivePlayerWithIdentity(playerIdentityStrCopy, kickReasonStrCopy, false); if (!foundActivePlayer) { @@ -698,6 +704,11 @@ int cmdInputThreadFunc(void *) std::string kickReasonStrCopy = "You have been kicked by the administrator."; wzAsyncExecOnMainThread([playerIdentityStrCopy, kickReasonStrCopy] { netPermissionsSet_Connect(playerIdentityStrCopy, ConnectPermissions::Blocked); + if (NetPlay.hostPlayer < MAX_PLAYERS) + { + // host is not a spectator host + return; + } kickActivePlayerWithIdentity(playerIdentityStrCopy, kickReasonStrCopy, true); }); } @@ -733,6 +744,12 @@ int cmdInputThreadFunc(void *) std::string banReasonStrCopy = (r >= 2) ? banreasonstr : "You have been banned from joining by the administrator."; convertEscapedNewlines(banReasonStrCopy); wzAsyncExecOnMainThread([banIPStrCopy, banReasonStrCopy] { + if (NetPlay.hostPlayer < MAX_PLAYERS && ingame.TimeEveryoneIsInGame.has_value()) + { + // host is not a spectator host + wz_command_interface_output("WZCMD error: Failed to execute in-game ban command - not a spectator host\n"); + return; + } bool foundActivePlayer = false; for (int i = 0; i < MAX_CONNECTED_PLAYERS; i++) {