From d58ec53096aaf56c5c22fa231d4c34523411f50b Mon Sep 17 00:00:00 2001 From: Bentley Davis <10065854+NonPolynomialTim@users.noreply.github.com> Date: Tue, 7 Jul 2026 04:43:00 -0400 Subject: [PATCH 1/3] feat(coop): permanent soldier ownership transfer + host-authoritative saves Adds a co-op feature to permanently hand a soldier to the other player, backed by a rework of how co-op games are persisted. Transfer: - New "Give unit" dialog (TransferSoldierMenu) reachable from the soldier list, craft-soldier list and soldier-info screens, plus in battle. - Guest-soldier model: a soldier's object lives in its owner's save, tagged with the station base's coop id; transferring serializes it, removes it from the giver and recreates it in the receiver while keeping the same station base, so it stays where it was and shows up for the new owner. - In battle only the control flags flip immediately; the physical move is queued and applied once the mission ends. - Duplicate-delivery guard keyed on a sender-unique xfer_id (roster-based checks are unreliable: two fresh saves both number soldiers from 1 and can roll identical names). - On-screen transfer notice (TransferNoticeState), geoscape-legible, with correct player names and instant visibility during peer base visits. Save-file rework (single authority): - The host's .sav embeds the latest client-world blob, so loading a host save atomically restores BOTH rosters; the client re-fetches its world on reconnect. Loading rolls both sides' transfers back together, making trades rollback-safe. Session transfer state is reset on load. Squashed from the feature branch; test harness intentionally excluded. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Basescape/CraftSoldiersState.cpp | 23 + src/Basescape/CraftSoldiersState.h | 2 + src/Basescape/SoldierInfoState.cpp | 22 + src/Basescape/SoldierInfoState.h | 2 + src/Basescape/SoldiersState.cpp | 24 + src/Basescape/SoldiersState.h | 2 + src/CMakeLists.txt | 2 + src/CoopMod/TransferNoticeState.cpp | 93 ++++ src/CoopMod/TransferNoticeState.h | 52 +++ src/CoopMod/TransferSoldierMenu.cpp | 147 ++++++ src/CoopMod/TransferSoldierMenu.h | 61 +++ src/CoopMod/connectionTCP.cpp | 665 ++++++++++++++++++++++++++- src/CoopMod/connectionTCP.h | 55 +++ src/Engine/Game.cpp | 57 ++- src/Engine/Game.h | 2 + src/Geoscape/GeoscapeState.cpp | 15 +- src/Menu/LoadGameState.cpp | 3 + src/OpenXcom.2010.vcxproj | 4 + src/Savegame/SavedGame.cpp | 31 ++ 19 files changed, 1228 insertions(+), 34 deletions(-) create mode 100644 src/CoopMod/TransferNoticeState.cpp create mode 100644 src/CoopMod/TransferNoticeState.h create mode 100644 src/CoopMod/TransferSoldierMenu.cpp create mode 100644 src/CoopMod/TransferSoldierMenu.h diff --git a/src/Basescape/CraftSoldiersState.cpp b/src/Basescape/CraftSoldiersState.cpp index a84ea5018..b93724c41 100644 --- a/src/Basescape/CraftSoldiersState.cpp +++ b/src/Basescape/CraftSoldiersState.cpp @@ -48,6 +48,8 @@ #include "CraftInfoState.h" #include "../CoopMod/CoopMenu.h" +#include "../CoopMod/connectionTCP.h" // coop +#include "../CoopMod/TransferSoldierMenu.h" // coop #include "../Savegame/Vehicle.h" namespace OpenXcom @@ -201,6 +203,8 @@ CraftSoldiersState::CraftSoldiersState(Base *base, size_t craft) _lstSoldiers->onRightArrowClick((ActionHandler)&CraftSoldiersState::lstItemsRightArrowClick); _lstSoldiers->onMouseClick((ActionHandler)&CraftSoldiersState::lstSoldiersClick, 0); _lstSoldiers->onMousePress((ActionHandler)&CraftSoldiersState::lstSoldiersMousePress); + // coop: transfer the hovered soldier to another player + _lstSoldiers->onKeyboardPress((ActionHandler)&CraftSoldiersState::lstSoldiersGiveUnitPress, Options::giveUnit); // Coop mode: if the game is in coop and this base is not a coop base if (_game->getCoopMod()->getCoopStatic() == true && _base->_coopBase == false && _game->getCoopMod()->getCoopCampaign() == true) @@ -666,6 +670,25 @@ void CraftSoldiersState::lstSoldiersMousePress(Action *action) } } +/** + * Coop: opens the transfer-ownership dialog for the hovered soldier. + * @param action Pointer to an action. + */ +void CraftSoldiersState::lstSoldiersGiveUnitPress(Action *) +{ + if (_game->getCoopMod()->getCoopStatic() != true || _game->getCoopMod()->getCoopCampaign() != true) + { + return; + } + unsigned int row = _lstSoldiers->getSelectedRow(); + if (row >= _base->getSoldiers()->size()) + { + return; + } + Soldier *soldier = _base->getSoldiers()->at(row); + _game->pushState(new TransferSoldierMenu(soldier, TransferSoldierMenu::resolveOwnerId(soldier))); +} + /** * De-assign all soldiers from all craft located in the base (i.e. not out on a mission). * @param action Pointer to an action. diff --git a/src/Basescape/CraftSoldiersState.h b/src/Basescape/CraftSoldiersState.h index 82d60e230..f7043a109 100644 --- a/src/Basescape/CraftSoldiersState.h +++ b/src/Basescape/CraftSoldiersState.h @@ -80,6 +80,8 @@ class CraftSoldiersState : public TouchState void lstSoldiersClick(Action *action); /// Handler for pressing-down a mouse-button in the list. void lstSoldiersMousePress(Action *action); + /// Coop: handler for the give-unit key on the hovered soldier. + void lstSoldiersGiveUnitPress(Action *action); /// Handler for clicking the De-assign All Soldiers button. void btnDeassignAllSoldiersClick(Action *action); void btnDeassignCraftSoldiersClick(Action *action); diff --git a/src/Basescape/SoldierInfoState.cpp b/src/Basescape/SoldierInfoState.cpp index d20d15e3e..1ee5613f0 100644 --- a/src/Basescape/SoldierInfoState.cpp +++ b/src/Basescape/SoldierInfoState.cpp @@ -46,6 +46,8 @@ #include "../Mod/RuleInterface.h" #include "../Mod/RuleSoldier.h" #include "../Savegame/SoldierDeath.h" +#include "../CoopMod/connectionTCP.h" // coop +#include "../CoopMod/TransferSoldierMenu.h" // coop namespace OpenXcom { @@ -251,6 +253,8 @@ SoldierInfoState::SoldierInfoState(Base *base, size_t soldierId, bool forceLimit _btnOk->setText(tr("STR_OK")); _btnOk->onMouseClick((ActionHandler)&SoldierInfoState::btnOkClick); _btnOk->onKeyboardPress((ActionHandler)&SoldierInfoState::btnOkClick, Options::keyCancel); + // coop: transfer this soldier to another player + _btnOk->onKeyboardPress((ActionHandler)&SoldierInfoState::btnGiveUnitPress, Options::giveUnit); _btnPrev->setText("<<"); if (_base == 0) @@ -688,6 +692,24 @@ void SoldierInfoState::btnOkClick(Action *) } } +/** + * Coop: opens the transfer-ownership dialog for this soldier. + * @param action Pointer to an action. + */ +void SoldierInfoState::btnGiveUnitPress(Action *) +{ + if (_game->getCoopMod()->getCoopStatic() != true || _game->getCoopMod()->getCoopCampaign() != true) + { + return; + } + // Only living soldiers in a base can be transferred (not the memorial view). + if (!_soldier || _base == 0 || _soldier->getDeath()) + { + return; + } + _game->pushState(new TransferSoldierMenu(_soldier, TransferSoldierMenu::resolveOwnerId(_soldier))); +} + /** * Goes to the previous soldier. * @param action Pointer to an action. diff --git a/src/Basescape/SoldierInfoState.h b/src/Basescape/SoldierInfoState.h index e17971a46..fce474278 100644 --- a/src/Basescape/SoldierInfoState.h +++ b/src/Basescape/SoldierInfoState.h @@ -75,6 +75,8 @@ class SoldierInfoState : public State void edtSoldierChange(Action *action); /// Handler for clicking the OK button. void btnOkClick(Action *action); + /// Coop: handler for the give-unit key on this soldier. + void btnGiveUnitPress(Action *action); /// Handler for clicking the Previous button. void btnPrevClick(Action *action); /// Handler for clicking the Next button. diff --git a/src/Basescape/SoldiersState.cpp b/src/Basescape/SoldiersState.cpp index e9af7984c..04fb1bbf9 100644 --- a/src/Basescape/SoldiersState.cpp +++ b/src/Basescape/SoldiersState.cpp @@ -47,6 +47,8 @@ #include "../Engine/Unicode.h" #include "../Savegame/Vehicle.h" +#include "../CoopMod/connectionTCP.h" // coop +#include "../CoopMod/TransferSoldierMenu.h" // coop namespace OpenXcom { @@ -243,6 +245,8 @@ SoldiersState::SoldiersState(Base *base) : _base(base), _origSoldierOrder(*_base _lstSoldiers->onMouseClick((ActionHandler)&SoldiersState::lstSoldiersClick); _lstSoldiers->onMouseClick((ActionHandler)&SoldiersState::lstSoldiersClick, SDL_BUTTON_RIGHT); _lstSoldiers->onMousePress((ActionHandler)&SoldiersState::lstSoldiersMousePress); + // coop: transfer the hovered soldier to another player + _lstSoldiers->onKeyboardPress((ActionHandler)&SoldiersState::lstSoldiersGiveUnitPress, Options::giveUnit); // Coop mode: if the game is in coop and this base is not a coop base @@ -935,4 +939,24 @@ void SoldiersState::lstSoldiersMousePress(Action *action) } } + +/** + * Coop: opens the transfer-ownership dialog for the hovered soldier. + * @param action Pointer to an action. + */ +void SoldiersState::lstSoldiersGiveUnitPress(Action *) +{ + if (_game->getCoopMod()->getCoopStatic() != true || _game->getCoopMod()->getCoopCampaign() != true) + { + return; + } + unsigned int row = _lstSoldiers->getSelectedRow(); + if (row >= _filteredListOfSoldiers.size()) + { + return; + } + Soldier *soldier = _filteredListOfSoldiers[row]; + _game->pushState(new TransferSoldierMenu(soldier, TransferSoldierMenu::resolveOwnerId(soldier))); +} + } diff --git a/src/Basescape/SoldiersState.h b/src/Basescape/SoldiersState.h index 46cb1e065..4469970f9 100644 --- a/src/Basescape/SoldiersState.h +++ b/src/Basescape/SoldiersState.h @@ -85,6 +85,8 @@ class SoldiersState : public State void lstSoldiersClick(Action *action); /// Handler for pressing-down a mouse-button in the list. void lstSoldiersMousePress(Action *action); + /// Coop: handler for the give-unit key on the hovered soldier. + void lstSoldiersGiveUnitPress(Action *action); }; } diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 148de3fd3..060d13be9 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -158,6 +158,8 @@ set(coopmod_src CoopMod/connectionUDP/rendezvous_client.cpp CoopMod/connectionUDP/rendezvous_config.cpp CoopMod/connectionUDP/rendezvous_server.cpp + CoopMod/TransferNoticeState.cpp + CoopMod/TransferSoldierMenu.cpp ) set ( engine_src diff --git a/src/CoopMod/TransferNoticeState.cpp b/src/CoopMod/TransferNoticeState.cpp new file mode 100644 index 000000000..e9c2e682d --- /dev/null +++ b/src/CoopMod/TransferNoticeState.cpp @@ -0,0 +1,93 @@ +/* + * Copyright 2010-2016 OpenXcom Developers. + * Copyright 2023-2026 XComCoopTeam (https://www.moddb.com/mods/openxcom-coop-mod) + * + * This file is part of OpenXcom. + * + * OpenXcom 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. + * + * OpenXcom 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 OpenXcom. If not, see . + */ +#include "TransferNoticeState.h" + +#include "../Engine/Action.h" +#include "../Engine/Game.h" +#include "../Engine/Options.h" +#include "../Interface/Text.h" +#include "../Interface/TextButton.h" +#include "../Interface/Window.h" +#include "../Geoscape/GeoscapeState.h" +#include "../Mod/Mod.h" +#include "../Mod/RuleInterface.h" + +namespace OpenXcom +{ + +TransferNoticeState::TransferNoticeState(const std::string &message) +{ + _screen = false; + + _window = new Window(this, 256, 88, 32, 56, POPUP_BOTH); + _txtMessage = new Text(236, 40, 42, 68); + _btnOk = new TextButton(120, 16, 100, 118); + + // Adopt the palette of whatever screen we're over - no palette swap, no + // flicker, works on geoscape, basescape and the peer-base view alike. + // Color indices come from an interface designed for that palette, or the + // text is illegible (sackSoldier's dark-blue text over PAL_GEOSCAPE). + // Skip other notices when deciding the context: with two notices stacked, + // the "top state" is the first notice, not the screen underneath. + std::string category = "sackSoldier"; + std::string textElement = "text"; + State* context = nullptr; + for (auto it = _game->getStates().rbegin(); it != _game->getStates().rend(); ++it) + { + if (dynamic_cast(*it) == nullptr) + { + context = *it; + break; + } + } + if (context) + { + setStatePalette(context->getPalette()); + if (dynamic_cast(context)) + { + category = "geoManufactureComplete"; // standard geoscape popup colors + textElement = "text1"; + } + } + _category = category; + + add(_window, "window", category); + add(_txtMessage, textElement, category); + add(_btnOk, "button", category); + + centerAllSurfaces(); + setWindowBackground(_window, category); + + _txtMessage->setAlign(ALIGN_CENTER); + _txtMessage->setWordWrap(true); + _txtMessage->setText(message); + + _btnOk->setText(tr("STR_OK")); + _btnOk->onMouseClick((ActionHandler)&TransferNoticeState::btnOkClick); + _btnOk->onKeyboardPress((ActionHandler)&TransferNoticeState::btnOkClick, Options::keyOk); + _btnOk->onKeyboardPress((ActionHandler)&TransferNoticeState::btnOkClick, Options::keyCancel); +} + +void TransferNoticeState::btnOkClick(Action *) +{ + _game->popState(); +} + +} diff --git a/src/CoopMod/TransferNoticeState.h b/src/CoopMod/TransferNoticeState.h new file mode 100644 index 000000000..0f97fb34a --- /dev/null +++ b/src/CoopMod/TransferNoticeState.h @@ -0,0 +1,52 @@ +#pragma once +/* + * Copyright 2010-2016 OpenXcom Developers. + * Copyright 2023-2026 XComCoopTeam (https://www.moddb.com/mods/openxcom-coop-mod) + * + * This file is part of OpenXcom. + * + * OpenXcom 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. + * + * OpenXcom 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 OpenXcom. If not, see . + */ +#include "../Engine/State.h" +#include + +namespace OpenXcom +{ + +class Window; +class Text; +class TextButton; + +/** + * Small co-op notification popup ("X transferred ownership of Y to you..."). + * Adopts the palette of the state it is pushed over, so it can appear on any + * screen (geoscape, basescape, peer-base view) without a palette flash. As a + * side effect, closing it re-inits the state below, refreshing soldier lists. + */ +class TransferNoticeState : public State +{ +private: + Window *_window; + Text *_txtMessage; + TextButton *_btnOk; + std::string _category; + +public: + TransferNoticeState(const std::string &message); + void btnOkClick(Action *action); + /// Interface category the widgets were themed with (test introspection). + const std::string &getCategory() const { return _category; } +}; + +} diff --git a/src/CoopMod/TransferSoldierMenu.cpp b/src/CoopMod/TransferSoldierMenu.cpp new file mode 100644 index 000000000..60bca1141 --- /dev/null +++ b/src/CoopMod/TransferSoldierMenu.cpp @@ -0,0 +1,147 @@ +/* + * Copyright 2010-2016 OpenXcom Developers. + * Copyright 2023-2026 XComCoopTeam (https://www.moddb.com/mods/openxcom-coop-mod) + * + * This file is part of OpenXcom. + * + * OpenXcom 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. + * + * OpenXcom 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 OpenXcom. If not, see . + */ +#include "TransferSoldierMenu.h" + +#include "../Engine/Action.h" +#include "../Engine/Game.h" +#include "../Engine/Options.h" +#include "../Interface/Text.h" +#include "../Interface/TextButton.h" +#include "../Interface/Window.h" +#include "../Savegame/SavedGame.h" +#include "../Savegame/Soldier.h" +#include "connectionTCP.h" + +namespace OpenXcom +{ + +int TransferSoldierMenu::resolveOwnerId(Soldier *soldier) +{ + if (soldier->getOwnerPlayerId() != 999) + { + return soldier->getOwnerPlayerId(); + } + // 999 = never explicitly assigned. Such a soldier belongs to the player + // whose save it lives in - i.e. the LOCAL player, on whichever machine + // the dialog is open. (The old fallback returned 0/host, which on the + // client's machine offered the client their own name as a transfer + // target for their own fresh soldiers.) + return connectionTCP::getHost() ? 0 : 1; +} + +TransferSoldierMenu::TransferSoldierMenu(Soldier *soldier, int currentOwnerId) : _soldier(soldier) +{ + _screen = false; + + // One button per player that is not the current owner. Player ids follow + // the co-op convention: 0 = host, 1 = client. Built as a list so more + // players slot in when the game grows past two. + // + // Name getters are machine-relative, not role-fixed: getHostName() is the + // LOCAL player's own name (every machine writes its own name box there) + // and getCurrentClientName() is the PEER's name (from received packets). + connectionTCP *coop = _game->getCoopMod(); + int localPlayerId = coop->getHost() ? 0 : 1; + + std::vector > targets; + for (int playerId = 0; playerId <= 1; ++playerId) + { + if (playerId != currentOwnerId) + { + std::string name = (playerId == localPlayerId) ? coop->getHostName() : coop->getCurrentClientName(); + targets.push_back(std::make_pair(playerId, name)); + } + } + + const int btnHeight = 16; + const int btnSpacing = 4; + const int windowWidth = 240; + const int windowHeight = 60 + (int)(targets.size() + 1) * (btnHeight + btnSpacing); + const int windowX = (320 - windowWidth) / 2; + const int windowY = (200 - windowHeight) / 2; + + _window = new Window(this, windowWidth, windowHeight, windowX, windowY, POPUP_BOTH); + _txtTitle = new Text(windowWidth - 20, 32, windowX + 10, windowY + 12); + + int y = windowY + 48; + for (size_t i = 0; i < targets.size(); ++i) + { + _btnTargets.push_back(new TextButton(windowWidth - 40, btnHeight, windowX + 20, y)); + _targetIds.push_back(targets[i].first); + y += btnHeight + btnSpacing; + } + _btnCancel = new TextButton(windowWidth - 40, btnHeight, windowX + 20, y); + + // sackSoldier: a base-palette dialog interface. Using pauseMenu here + // (geoscape palette) forced a hardware palette swap when opened over the + // basescape soldier screens, flashing the whole screen on open and close. + // The battle-game param switches to the battlescape palette in battle. + setInterface("sackSoldier", false, _game->getSavedGame() ? _game->getSavedGame()->getSavedBattle() : 0); + + add(_window, "window", "sackSoldier"); + add(_txtTitle, "text", "sackSoldier"); + for (auto *btn : _btnTargets) + { + add(btn, "button", "sackSoldier"); + } + add(_btnCancel, "button", "sackSoldier"); + + centerAllSurfaces(); + setWindowBackground(_window, "sackSoldier"); + + _txtTitle->setAlign(ALIGN_CENTER); + _txtTitle->setWordWrap(true); + _txtTitle->setText("Transfer " + _soldier->getName() + " to another player?"); + + for (size_t i = 0; i < _btnTargets.size(); ++i) + { + std::string name = targets[i].second; + if (name.empty()) + { + name = targets[i].first == 0 ? "HOST" : "CLIENT"; + } + _btnTargets[i]->setText(name); + _btnTargets[i]->onMouseClick((ActionHandler)&TransferSoldierMenu::btnTransferClick); + } + + _btnCancel->setText(tr("STR_CANCEL")); + _btnCancel->onMouseClick((ActionHandler)&TransferSoldierMenu::btnCancelClick); + _btnCancel->onKeyboardPress((ActionHandler)&TransferSoldierMenu::btnCancelClick, Options::keyCancel); +} + +void TransferSoldierMenu::btnTransferClick(Action *action) +{ + for (size_t i = 0; i < _btnTargets.size(); ++i) + { + if (action->getSender() == _btnTargets[i]) + { + _game->getCoopMod()->transferSoldierOwnership(_soldier, _targetIds[i], true); + break; + } + } + _game->popState(); +} + +void TransferSoldierMenu::btnCancelClick(Action *) +{ + _game->popState(); +} + +} diff --git a/src/CoopMod/TransferSoldierMenu.h b/src/CoopMod/TransferSoldierMenu.h new file mode 100644 index 000000000..18e61b3df --- /dev/null +++ b/src/CoopMod/TransferSoldierMenu.h @@ -0,0 +1,61 @@ +#pragma once +/* + * Copyright 2010-2016 OpenXcom Developers. + * Copyright 2023-2026 XComCoopTeam (https://www.moddb.com/mods/openxcom-coop-mod) + * + * This file is part of OpenXcom. + * + * OpenXcom 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. + * + * OpenXcom 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 OpenXcom. If not, see . + */ +#include "../Engine/State.h" +#include +#include +#include + +namespace OpenXcom +{ + +class Window; +class Text; +class TextButton; +class Soldier; + +/** + * Co-op dialog to permanently transfer ownership of a soldier to another + * player. Shows one button per other player plus Cancel. Opened with the + * "Give Unit to Teammate" keybind (Options::giveUnit) from the base soldier + * lists, the soldier stat screen, or the battlescape. + */ +class TransferSoldierMenu : public State +{ +private: + Window *_window; + Text *_txtTitle; + std::vector _btnTargets; + TextButton *_btnCancel; + Soldier *_soldier; + // target player ids matching _btnTargets by index + std::vector _targetIds; + +public: + /// Creates the dialog. currentOwnerId: 0 = host, 1 = client. + TransferSoldierMenu(Soldier *soldier, int currentOwnerId); + /// Resolves who currently owns a soldier (0 = host, 1 = client) from its + /// persistent owner id, falling back to the co-op control flag. + static int resolveOwnerId(Soldier *soldier); + void btnTransferClick(Action *action); + void btnCancelClick(Action *action); +}; + +} diff --git a/src/CoopMod/connectionTCP.cpp b/src/CoopMod/connectionTCP.cpp index 6aff91edb..3eb2ab932 100644 --- a/src/CoopMod/connectionTCP.cpp +++ b/src/CoopMod/connectionTCP.cpp @@ -52,9 +52,15 @@ #include "PasswordCheckMenu.h" #include "ModCheckMenu.h" +#include "TransferNoticeState.h" #include "connectionUDP/connection_udp_glue.h" #include "../Savegame/BaseFacility.h" +#include "../Engine/Logger.h" +#include "../Engine/Yaml.h" +#include "../Mod/Mod.h" +#include "../Savegame/Base.h" +#include "../Savegame/Soldier.h" namespace OpenXcom { @@ -623,6 +629,332 @@ void connectionTCP::loopData() } } +void connectionTCP::transferSoldierOwnership(Soldier* soldier, int newOwnerId, bool broadcast) +{ + + if (!soldier || !_game->getSavedGame()) + { + return; + } + + soldier->setOwnerPlayerId(newOwnerId); + soldier->setCoop(newOwnerId); + + int localPlayerId = getHost() ? 0 : 1; + + Log(LOG_INFO) << "[coop-transfer] transferSoldierOwnership '" << soldier->getName() << "' id=" << soldier->getId() + << " newOwner=" << newOwnerId << " localPlayer=" << localPlayerId + << " broadcast=" << (broadcast ? 1 : 0) + << " inBattle=" << (_game->getSavedGame()->getSavedBattle() ? 1 : 0); + + if (_game->getSavedGame()->getSavedBattle()) + { + + // Battle running: flip live control now, do the physical move only + // after the mission ends (the BattleUnit and the debriefing still + // reference this Soldier). + auto* battle = _game->getSavedGame()->getSavedBattle(); + + for (auto& unit : *battle->getUnits()) + { + + if (unit->getGeoscapeSoldier() == soldier) + { + + unit->setCoop(newOwnerId); + + // The unit is no longer ours: move the selection along. + if (battle->getSelectedUnit() == unit && newOwnerId != localPlayerId) + { + battle->selectNextPlayerUnit(); + } + + if (broadcast) + { + + // Immediate control flip on the peer's battle too. + Json::Value obj; + obj["state"] = "transferSoldier"; + obj["soldier_id"] = soldier->getId(); + obj["owner"] = newOwnerId; + obj["unit_id"] = unit->getId(); + + sendTCPPacketData(obj.toStyledString()); + + } + + break; + + } + + } + + if (broadcast && newOwnerId != localPlayerId) + { + _pendingSoldierTransfers.push_back(std::make_pair(soldier, newOwnerId)); + } + + } + else if (broadcast && newOwnerId != localPlayerId) + { + + // The soldier's object lives in its owner's save (guest-soldier + // model): hand it to the peer and drop it from our world. It keeps + // its station base, so it stays "in" the base it is in right now. + sendSoldierTransferPacket(soldier, newOwnerId); + removeSoldierFromLocalBases(soldier); + _transferredSoldiers.push_back(soldier); + _transferredAwaySoldierIds.insert(soldier->getId()); + + // keep the host-side client blob fresh (no-op on the host itself) + pushProgressToHostSilently(); + + } + +} + +void connectionTCP::processPendingSoldierTransfers() +{ + + // Replay physical transfers that arrived while our world was swapped out + // for the peer's base view. The flag clears before LoadGameState has + // actually restored our save, so also require that an own (non-mirror) + // base is present - the swapped peer world has none. + bool ownWorldReady = false; + + if (!_pendingIncomingTransfers.empty() && _game->getCoopMod()->playerInsideCoopBase == false && _game->getSavedGame()) + { + + for (auto& base : *_game->getSavedGame()->getBases()) + { + if (base->_coopBase == false && base->_coopIcon == false) + { + ownWorldReady = true; + break; + } + } + + } + + if (ownWorldReady) + { + + std::vector replay; + replay.swap(_pendingIncomingTransfers); + + for (auto& obj : replay) + { + onTCPMessage("transferSoldier", obj); + } + + } + + if (_game->getSavedGame() && !_game->getSavedGame()->getSavedBattle() && getCoopStatic() && getCoopCampaign() && _pendingSoldierTransfers.empty()) + { + + // Targeted sweep: a stale copy of a soldier we transferred away this + // session can resurrect when the pre-visit "basehost" snapshot is + // restored after a transfer made while viewing the peer's base. Park + // exactly those (matched by id AND still peer-owned - a soldier + // traded back to us has our owner id and is left alone). Deliberately + // NOT a blanket owner check: legacy saves carry stale ownerPlayerId + // values on unrelated soldiers. + if (!_transferredAwaySoldierIds.empty()) + { + + int localPlayerId = getHost() ? 0 : 1; + + for (auto& base : *_game->getSavedGame()->getBases()) + { + + auto& soldiers = *base->getSoldiers(); + + for (auto it = soldiers.begin(); it != soldiers.end();) + { + + Soldier* s = *it; + + if (_transferredAwaySoldierIds.count(s->getId()) != 0 && s->getOwnerPlayerId() != 999 && s->getOwnerPlayerId() != localPlayerId) + { + _transferredSoldiers.push_back(s); + it = soldiers.erase(it); + } + else + { + ++it; + } + + } + + } + + } + + } + + if (_pendingSoldierTransfers.empty()) + { + return; + } + + if (_game->getSavedGame() && _game->getSavedGame()->getSavedBattle()) + { + // Still in battle; try again later. + return; + } + + for (auto& pending : _pendingSoldierTransfers) + { + + Soldier* soldier = pending.first; + + // Died during the mission: stays in the giver's memorial. + if (soldier->getDeath()) + { + continue; + } + + sendSoldierTransferPacket(soldier, pending.second); + removeSoldierFromLocalBases(soldier); + _transferredSoldiers.push_back(soldier); + _transferredAwaySoldierIds.insert(soldier->getId()); + + } + + _pendingSoldierTransfers.clear(); + + // transfers happened while our world was busy - sync the blob now + pushProgressToHostSilently(); + +} + +void connectionTCP::pushProgressToHostSilently() +{ + + // Client only: serialize the current world and stream it to the host so + // the host's next save embeds an up-to-date client blob. Never while the + // world is swapped out for a base visit (we'd upload the peer's world). + if (getServerOwner() || !_host_save_progress || !getCoopStatic() || connectionTCP::saveID == 0) + { + return; + } + if (!_game->getSavedGame() || _game->getSavedGame()->getSavedBattle() || _game->getCoopMod()->playerInsideCoopBase) + { + return; + } + + std::string filename = "client_" + std::to_string(connectionTCP::saveID) + "_" + _game->getCoopMod()->getHostName() + ".data"; + _game->getSavedGame()->saveCoopToMemory(filename, _game->getMod(), filename); + + Json::Value obj; + obj["state"] = "SEND_FILE_HOST_TRUE_SAVE_PROGRESS"; + sendTCPPacketData(obj.toStyledString()); + + Log(LOG_INFO) << "[coop-transfer] pushed client progress to host (" << filename << ")"; + +} + +void connectionTCP::resetTransferSessionState() +{ + + _pendingSoldierTransfers.clear(); + _pendingIncomingTransfers.clear(); + _seenTransferPacketIds.clear(); + _transferredAwaySoldierIds.clear(); + +} + +void connectionTCP::sendSoldierTransferPacket(Soldier* soldier, int newOwnerId) +{ + + // Which base is the soldier stationed at? If it is already a guest at the + // peer's base, keep that station; otherwise it is in one of our own bases + // - find it and use that base's coop id. + int stationBaseId = soldier->getCoopBase(); + + if (stationBaseId == -1 && _game->getSavedGame()) + { + + for (auto& base : *_game->getSavedGame()->getBases()) + { + + auto containsSoldier = [soldier](const std::vector& list) + { + return std::find(list.begin(), list.end(), soldier) != list.end(); + }; + + // The live roster may be temporarily swapped out while a soldier + // list screen is open - check the snapshots too. + if (containsSoldier(*base->getSoldiers()) || containsSoldier(base->base_oldsoldiers) || containsSoldier(base->base_oldsoldiers2)) + { + stationBaseId = base->_coop_base_id; + break; + } + + } + + } + + // Detach from any craft so the serialized soldier does not carry a craft + // reference that would be resolved against the receiver's save. + soldier->setCraft(0); + + YAML::YamlRootNodeWriter writer; + writer.setAsMap(); + soldier->save(writer["soldier"], _game->getMod()->getScriptGlobal()); + + // Durable unique id: player tag + wall-clock + counter. Receipts persist + // in saves across sessions, so a per-run counter alone would collide. + long long xferId = (getHost() ? 1000000000000000LL : 2000000000000000LL) + (long long)time(0) * 1000LL + (++_transferSendCounter % 1000); + + std::string yaml = writer.emit().yaml; + + Json::Value obj; + obj["state"] = "transferSoldier"; + obj["soldier_id"] = soldier->getId(); + obj["owner"] = newOwnerId; + obj["unit_id"] = -1; + obj["station_base_id"] = stationBaseId; + obj["xfer_id"] = Json::Value::Int64(xferId); + obj["soldier_yaml"] = yaml; + + std::string packet = obj.toStyledString(); + + Log(LOG_INFO) << "[coop-transfer] SEND soldier '" << soldier->getName() << "' id=" << soldier->getId() + << " newOwner=" << newOwnerId << " stationBaseId=" << stationBaseId + << " packetBytes=" << packet.size(); + + sendTCPPacketData(packet); + +} + +void connectionTCP::removeSoldierFromLocalBases(Soldier* soldier) +{ + + if (!_game->getSavedGame()) + { + return; + } + + for (auto& base : *_game->getSavedGame()->getBases()) + { + + auto eraseFrom = [soldier](std::vector& list) + { + list.erase(std::remove(list.begin(), list.end(), soldier), list.end()); + }; + + eraseFrom(*base->getSoldiers()); + // SoldiersState/CraftSoldiersState swap the roster while open and + // restore it from these snapshots afterwards - purge them too so the + // soldier cannot resurrect on the giver's side. + eraseFrom(base->base_oldsoldiers); + eraseFrom(base->base_oldsoldiers2); + + } + +} + void connectionTCP::clearAllReceivedTCPPackets() { @@ -641,6 +973,11 @@ void connectionTCP::createLoopdataThread() void connectionTCP::updateCoopTask() { + // coop: finish queued in-battle soldier transfers as soon as no battle is + // active (fallback for the client, which may not run the host's + // coopMissionEnd path in GeoscapeState). + processPendingSoldierTransfers(); + if (connectionTCP::saveError == true) { @@ -1913,6 +2250,317 @@ void connectionTCP::onTCPMessage(std::string stateString, Json::Value obj) } + if (stateString == "transferSoldier") + { + + if (_game->getSavedGame()) + { + + int soldier_id = obj["soldier_id"].asInt(); + int owner = obj["owner"].asInt(); + int unit_id = obj["unit_id"].asInt(); + + Log(LOG_INFO) << "[coop-transfer] RECV transferSoldier id=" << soldier_id << " owner=" << owner + << " hasYaml=" << (obj.isMember("soldier_yaml") ? 1 : 0) + << " inBattle=" << (_game->getSavedGame()->getSavedBattle() ? 1 : 0); + + if (obj.isMember("soldier_yaml") && _game->getCoopMod()->playerInsideCoopBase == true) + { + + // Our SavedGame is currently swapped out for the peer's base + // view; applying the transfer now would land the soldier in + // the temporary world and lose it on exit. Queue the real + // apply for later, but ALSO drop a display copy into the + // visited base so the new owner sees the soldier right away, + // and show the notification now. + Log(LOG_INFO) << "[coop-transfer] RECV deferred (viewing peer base)"; + + try + { + + int stationBaseId = obj["station_base_id"].asInt(); + + YAML::YamlRootNodeReader reader(YAML::YamlString{obj["soldier_yaml"].asString()}, "transferSoldier"); + auto soldierReader = reader["soldier"]; + std::string type = soldierReader["type"].readVal(_game->getMod()->getSoldiersList().front()); + std::string soldierName = soldierReader["name"].readVal(std::string()); + + Base* visited = 0; + + for (auto& base : *_game->getSavedGame()->getBases()) + { + if (base->_coop_base_id == stationBaseId) + { + visited = base; + break; + } + } + + if (visited && _game->getMod()->getSoldier(type)) + { + // Display-only copy: this world is discarded on exit; + // the durable copy comes from the deferred replay. + Soldier* copy = new Soldier(_game->getMod()->getSoldier(type), 0, 0 /*nationality*/); + copy->load(soldierReader, _game->getMod(), _game->getSavedGame(), _game->getMod()->getScriptGlobal()); + copy->setCraft(0); + copy->setCoopBase(stationBaseId); + copy->setOwnerPlayerId(owner); + copy->setCoop(owner); + visited->getSoldiers()->push_back(copy); + } + + if (!obj.get("notified", false).asBool()) + { + std::string baseName = visited ? visited->getName() : "their base"; + _game->pushState(new TransferNoticeState(getCurrentClientName() + " transferred ownership of " + soldierName + " to you at base " + baseName)); + obj["notified"] = true; + } + + } + catch (const std::exception& e) + { + Log(LOG_INFO) << "[coop-transfer] RECV display-copy failed: " << e.what(); + } + + _pendingIncomingTransfers.push_back(obj); + + } + else if (obj.isMember("soldier_yaml")) + { + + // Physical transfer: the giver removed the soldier from their + // save; recreate it in ours, keeping its station base. + int stationBaseId = obj["station_base_id"].asInt(); + + try + { + + YAML::YamlRootNodeReader reader(YAML::YamlString{obj["soldier_yaml"].asString()}, "transferSoldier"); + auto soldierReader = reader["soldier"]; + + std::string type = soldierReader["type"].readVal(_game->getMod()->getSoldiersList().front()); + + if (_game->getMod()->getSoldier(type)) + { + + // If the station base is one of OUR real bases, the + // soldier is coming home: it lives there normally. + // Otherwise it stays a guest at the giver's base and + // merely lives in our save (guest-soldier model). + Base* homeBase = 0; + Base* firstOwnBase = 0; + + for (auto& base : *_game->getSavedGame()->getBases()) + { + + if (base->_coopBase == false && base->_coopIcon == false) + { + + if (!firstOwnBase) + { + firstOwnBase = base; + } + + if (base->_coop_base_id == stationBaseId) + { + homeBase = base; + break; + } + + } + + } + + Base* targetBase = homeBase ? homeBase : firstOwnBase; + + // No own base in the current save = our world is not + // (yet) loaded, e.g. the transitional frames right + // after leaving the peer's base view. Never consume + // the packet in that window - defer and replay. + if (!targetBase) + { + Log(LOG_INFO) << "[coop-transfer] RECV deferred (no own base in current save)"; + _pendingIncomingTransfers.push_back(obj); + } + else + { + + // Ignore duplicate deliveries via the sender's unique + // packet id (in-memory: with the host save as the single + // authority, packets are never re-sent across sessions). + long long xferId = obj.get("xfer_id", 0).asInt64(); + bool exists = (xferId != 0 && _seenTransferPacketIds.count(xferId) != 0); + + if (xferId != 0) + { + _seenTransferPacketIds.insert(xferId); + } + + Log(LOG_INFO) << "[coop-transfer] RECV type=" << type << " exists=" << (exists ? 1 : 0) + << " homeBase=" << (homeBase ? homeBase->getName() : "none") + << " targetBase=" << targetBase->getName() + << " stationBaseId=" << stationBaseId; + + if (!exists) + { + + Soldier* soldier = new Soldier(_game->getMod()->getSoldier(type), 0, 0 /*nationality*/); + soldier->load(soldierReader, _game->getMod(), _game->getSavedGame(), _game->getMod()->getScriptGlobal()); + + // The peer's soldier ids and ours come from + // separate saves: on collision give the incoming + // soldier a fresh id so lookups stay unambiguous. + int maxId = soldier->getId(); + + bool collision = false; + + for (auto& base : *_game->getSavedGame()->getBases()) + { + for (auto& s : *base->getSoldiers()) + { + if (s->getId() == soldier->getId()) + { + collision = true; + } + maxId = std::max(maxId, s->getId()); + } + } + + for (auto& s : *_game->getSavedGame()->getDeadSoldiers()) + { + if (s->getId() == soldier->getId()) + { + collision = true; + } + maxId = std::max(maxId, s->getId()); + } + + if (collision) + { + soldier->setId(maxId + 1); + } + + soldier->setCraft(0); + soldier->setCoopBase(homeBase ? -1 : stationBaseId); + soldier->setOwnerPlayerId(owner); + soldier->setCoop(owner); + + targetBase->getSoldiers()->push_back(soldier); + + // SoldiersState/CraftSoldiersState restore the + // roster from these snapshots when they close; if + // one is open right now (snapshot non-empty), add + // the soldier there too or the restore drops it. + if (!targetBase->base_oldsoldiers.empty()) + { + targetBase->base_oldsoldiers.push_back(soldier); + } + if (!targetBase->base_oldsoldiers2.empty()) + { + targetBase->base_oldsoldiers2.push_back(soldier); + } + + Log(LOG_INFO) << "[coop-transfer] RECV added soldier '" << soldier->getName() + << "' id=" << soldier->getId() << " to base '" << targetBase->getName() + << "' coopBase=" << soldier->getCoopBase(); + + // keep the host-side client blob fresh (no-op on + // the host itself) + pushProgressToHostSilently(); + + // Tell the new owner (skip if the deferred path + // already notified during a base visit). The + // station base's name is the one the player + // recognizes - for a guest that's the giver's + // (mirror) base, not the own base holding it. + if (!obj.get("notified", false).asBool()) + { + + std::string baseName = targetBase->getName(); + + for (auto& base : *_game->getSavedGame()->getBases()) + { + if (base->_coop_base_id == stationBaseId) + { + baseName = base->getName(); + break; + } + } + + _game->pushState(new TransferNoticeState(getCurrentClientName() + " transferred ownership of " + soldier->getName() + " to you at base " + baseName)); + + } + + } + + } + + } + else + { + Log(LOG_INFO) << "[coop-transfer] RECV unknown soldier type " << type; + } + + } + catch (const std::exception& e) + { + Log(LOG_INFO) << "[coop-transfer] RECV failed to load soldier yaml: " << e.what(); + } + + } + else + { + + // Control flip for a soldier deployed in the current battle. + // The physical move arrives in a later packet once the + // giver's mission has ended. + if (_game->getSavedGame()->getSavedBattle()) + { + + auto* battle = _game->getSavedGame()->getSavedBattle(); + + for (auto& unit : *battle->getUnits()) + { + + bool match = (unit_id != -1 && unit->getId() == unit_id); + + if (!match && unit->getGeoscapeSoldier() && unit->getGeoscapeSoldier()->getId() == soldier_id) + { + match = true; + } + + if (match) + { + + unit->setCoop(owner); + + if (unit->getGeoscapeSoldier()) + { + unit->getGeoscapeSoldier()->setOwnerPlayerId(owner); + unit->getGeoscapeSoldier()->setCoop(owner); + } + + int localPlayerId = getHost() ? 0 : 1; + + if (battle->getSelectedUnit() == unit && owner != localPlayerId) + { + battle->selectNextPlayerUnit(); + } + + break; + + } + + } + + } + + } + + } + + } + if (stateString == "calc_explode_fov") { @@ -5506,8 +6154,13 @@ void connectionTCP::onTCPMessage(std::string stateString, Json::Value obj) if (stateString == "close_save_progress") { - // Closing save progress popup - _game->popState(); + // Closing save progress popup - only if one is actually open (silent + // background pushes don't show the dialog; popping the screen under + // it would tear down the geoscape). + if (!_game->getStates().empty() && dynamic_cast(_game->getStates().back())) + { + _game->popState(); + } } @@ -5539,8 +6192,12 @@ void connectionTCP::onTCPMessage(std::string stateString, Json::Value obj) std::string jsonData333 = "{\"state\" : \"close_save_progress\"}"; sendTCPPacketData(jsonData333); - // Closing save progress popup - _game->popState(); + // Closing save progress popup - only if one is actually open (silent + // background pushes don't show the dialog). + if (!_game->getStates().empty() && dynamic_cast(_game->getStates().back())) + { + _game->popState(); + } // WRITE THE FILE RECEIVED FROM THE CLIENT TO THE HOST if (_game->getSavedGame()) diff --git a/src/CoopMod/connectionTCP.h b/src/CoopMod/connectionTCP.h index 5fd34acfa..5454dc8d2 100644 --- a/src/CoopMod/connectionTCP.h +++ b/src/CoopMod/connectionTCP.h @@ -30,6 +30,7 @@ #include #include #include +#include #include #include @@ -470,6 +471,60 @@ class connectionTCP static int manuallyAddedServerRemoveID; static bool canRemoveManuallyAddedServer; static bool isInfoboxClosed; + + // Permanently transfers a soldier to another player (0 = host, 1 = client). + // Follows the guest-soldier model: a soldier's object lives in its OWNER's + // save, tagged with coopBase = the station base's coop id when that base + // belongs to the other player (-1 when stationed at one of the owner's own + // bases). The soldier is serialized, removed from the giver's save and + // recreated in the receiver's save, keeping the same station base - so it + // stays "in" the base it was in, and shows up when the new owner views + // that base. During battle only the control flags flip immediately; the + // physical move is queued and runs after the mission ends. Transfers + // overwrite unconditionally, so soldiers can be traded back and forth. + void transferSoldierOwnership(Soldier* soldier, int newOwnerId, bool broadcast); + // Completes queued in-battle transfers once no battle is active. Must run + // before the post-battle coop cleanup (GeoscapeState calls it first). + void processPendingSoldierTransfers(); + + private: + // Serializes the soldier (with its station base id) and sends the + // physical-transfer packet to the peer. + void sendSoldierTransferPacket(Soldier* soldier, int newOwnerId); + // Erases the soldier pointer from every base roster (including the + // SoldiersState/CraftSoldiersState base_oldsoldiers snapshots). + void removeSoldierFromLocalBases(Soldier* soldier); + // In-battle transfers waiting for the mission to end: soldier + new owner. + std::vector > _pendingSoldierTransfers; + // Soldiers transferred away are parked here instead of deleted: UI states + // (sort snapshots, open dialogs) may still hold pointers to them. + std::vector _transferredSoldiers; + // Ids of soldiers transferred away this session. A stale copy of one of + // these can resurrect when the pre-visit "basehost" snapshot is restored; + // the sweep in processPendingSoldierTransfers() parks exactly those (and + // nothing else - legacy saves carry unrelated ownerPlayerId values). + std::unordered_set _transferredAwaySoldierIds; + // Counter feeding the unique per-packet transfer id, plus the in-memory + // duplicate-delivery guard (sufficient now: the host's save is the single + // authority, so packets are never re-sent across sessions). + int _transferSendCounter = 0; + std::unordered_set _seenTransferPacketIds; + // Incoming physical transfers received while our SavedGame is swapped out + // (viewing the peer's base, playerInsideCoopBase). Applying them then + // would mutate the temporary peer world and be discarded on exit - the + // soldier would vanish on both machines. Replayed once our world is back. + std::vector _pendingIncomingTransfers; + public: + // Single-authority model: the HOST's .sav embeds the latest client-world + // blob (see SavedGame::save/load), so loading a host save atomically + // restores BOTH players' rosters; the client re-fetches its world from + // the host on reconnect. To keep the embedded blob fresh, the client + // silently pushes its progress to the host after every soldier transfer. + void pushProgressToHostSilently(); + // Clears session transfer state (pending queues, dedup ids, away-ids) + // after a save load - stale in-memory state must never outlive the save + // that is now the authority. + void resetTransferSessionState(); }; } diff --git a/src/Engine/Game.cpp b/src/Engine/Game.cpp index a1da5a326..424c1915c 100644 --- a/src/Engine/Game.cpp +++ b/src/Engine/Game.cpp @@ -46,7 +46,8 @@ #include #include "../fallthrough.h" -#include "../CoopMod/CrashHandler.h" // coop +#include "../CoopMod/CrashHandler.h" // coop +#include "../CoopMod/TransferSoldierMenu.h" // coop namespace OpenXcom { @@ -352,44 +353,42 @@ void Game::run() if (_save->getSavedBattle()->getSelectedUnit()->getFaction() == FACTION_PLAYER && !_save->getSavedBattle()->getSelectedUnit()->isOut()) { - // save - if (_tcpConnection->getHost() == true && _tcpConnection->getCoopCampaign() == true) - { - - if (_save->getSavedBattle()->getSelectedUnit()->getGeoscapeSoldier()) - { - - if (_save->getSavedBattle()->getSelectedUnit()->getGeoscapeSoldier()->getOwnerPlayerId() == 999) - { - - _save->getSavedBattle()->getSelectedUnit()->getGeoscapeSoldier()->setOwnerPlayerId(_save->getSavedBattle()->getSelectedUnit()->getCoop()); + BattleUnit* selectedUnit = _save->getSavedBattle()->getSelectedUnit(); - } - - } + if (_tcpConnection->getCoopCampaign() == true && selectedUnit->getGeoscapeSoldier()) + { - } + // Campaign soldier: open the permanent-transfer dialog. + pushState(new TransferSoldierMenu(selectedUnit->getGeoscapeSoldier(), selectedUnit->getCoop())); - if (_tcpConnection->getHost() == true) - { - _save->getSavedBattle()->getSelectedUnit()->setCoop(1); } else { - _save->getSavedBattle()->getSelectedUnit()->setCoop(0); - } - // send - Json::Value obj; - obj["state"] = "giveUnit"; + // Legacy battle-only loan (skirmish games and units + // without a geoscape soldier). + if (_tcpConnection->getHost() == true) + { + selectedUnit->setCoop(1); + } + else + { + selectedUnit->setCoop(0); + } + + // send + Json::Value obj; + obj["state"] = "giveUnit"; - obj["unit_id"] = _save->getSavedBattle()->getSelectedUnit()->getId(); - obj["coop"] = _save->getSavedBattle()->getSelectedUnit()->getCoop(); + obj["unit_id"] = selectedUnit->getId(); + obj["coop"] = selectedUnit->getCoop(); - _tcpConnection->sendTCPPacketData(obj.toStyledString()); + _tcpConnection->sendTCPPacketData(obj.toStyledString()); - // reset - _save->getSavedBattle()->selectNextPlayerUnit(); + // reset + _save->getSavedBattle()->selectNextPlayerUnit(); + + } } diff --git a/src/Engine/Game.h b/src/Engine/Game.h index e5192c257..f22fcc850 100644 --- a/src/Engine/Game.h +++ b/src/Engine/Game.h @@ -121,6 +121,8 @@ class Game bool containsNotesState() const; /// Returns the GeoscapeState from the background (if available). GeoscapeState* getGeoscapeState() const; + /// Read-only access to the state stack (introspection). + const std::list& getStates() const { return _states; } /// Returns whether the game is shutting down. bool isQuitting() const; /// Loads the default and current language. diff --git a/src/Geoscape/GeoscapeState.cpp b/src/Geoscape/GeoscapeState.cpp index 6810da885..a831ee25c 100644 --- a/src/Geoscape/GeoscapeState.cpp +++ b/src/Geoscape/GeoscapeState.cpp @@ -896,6 +896,13 @@ void GeoscapeState::init() } + // coop: complete queued in-battle soldier transfers before the cleanup + // below deletes other-player soldiers (the queue still references them). + if (_game->getCoopMod()->coopMissionEnd == true) + { + _game->getCoopMod()->processPendingSoldierTransfers(); + } + // HOST AFTER THE BATTLE // Make sure the other player's units aren't saved in single-player mode. if ((_game->getCoopMod()->getHost() == true || _game->getCoopMod()->getCoopStatic() == false) && _game->getCoopMod()->coopMissionEnd == true) @@ -917,7 +924,13 @@ void GeoscapeState::init() } - if (soldier->getCoop() != 0) + // coop: permanently transferred soldiers (explicit owner + + // stationed here via coopBase) live in this save and must + // survive - only the battle-merged copies of the other + // player's own soldiers get cleaned up. + bool transferredGuest = (soldier->getOwnerPlayerId() != 999 && soldier->getCoopBase() != -1); + + if (soldier->getCoop() != 0 && !transferredGuest) { delete soldier; // Freeing the soldier object from memory it = soldiers->erase(it); // Remove the pointer from the vector and move to the next one diff --git a/src/Menu/LoadGameState.cpp b/src/Menu/LoadGameState.cpp index ee42b61cb..4bd06f036 100644 --- a/src/Menu/LoadGameState.cpp +++ b/src/Menu/LoadGameState.cpp @@ -183,6 +183,9 @@ void LoadGameState::think() if (_coopKey.empty()) { s->load(_filename, _game->getMod(), _game->getLanguage()); + // the loaded save is now the authority - drop stale + // in-memory transfer session state + _game->getCoopMod()->resetTransferSessionState(); } else { diff --git a/src/OpenXcom.2010.vcxproj b/src/OpenXcom.2010.vcxproj index 3d013d2c9..26610309e 100644 --- a/src/OpenXcom.2010.vcxproj +++ b/src/OpenXcom.2010.vcxproj @@ -346,6 +346,8 @@ + + @@ -765,6 +767,8 @@ + + diff --git a/src/Savegame/SavedGame.cpp b/src/Savegame/SavedGame.cpp index d5b11e1ff..407baa651 100644 --- a/src/Savegame/SavedGame.cpp +++ b/src/Savegame/SavedGame.cpp @@ -767,6 +767,21 @@ void SavedGame::load(const std::string &filename, Mod *mod, Language *lang) reader.tryRead("saveID", connectionTCP::saveID); reader.tryRead("coop_gamemode", connectionTCP::_coopGamemode); reader.tryRead("coop_save_owner_player_id", connectionTCP::coop_save_owner_player_id); + // Single-authority: a host save embeds the client-world blob captured at + // save time. Restore it as the served copy (RAM + the sidecar file the + // reconnect flow streams), so a rolled-back save rolls the client back too. + { + std::string coopClientKey; + reader.tryRead("coopClientSaveKey", coopClientKey); + if (!coopClientKey.empty() && reader["coopClientSaveBlob"]) + { + std::vector blob = reader["coopClientSaveBlob"].readValBase64(); + std::string blobStr(blob.begin(), blob.end()); + connectionTCP::coopFilesHost[coopClientKey] = blobStr; + CrossPlatform::writeFile(Options::getMasterUserFolder() + coopClientKey, blobStr); + Log(LOG_INFO) << "[coop-transfer] restored embedded client blob '" << coopClientKey << "' (" << blobStr.size() << " bytes)"; + } + } if (connectionTCP::isCoopBaseLoading == false && connectionTCP::getServerOwner() == false) { reader.tryRead("no_bases", connectionTCP::no_bases); @@ -1416,6 +1431,22 @@ void SavedGame::save(const std::string &filename, Mod *mod) const writer.write("coop_gamemode", connectionTCP::_coopGamemode); writer.write("coop_save_owner_player_id", connectionTCP::coop_save_owner_player_id); writer.write("no_bases", connectionTCP::no_bases); + // Single-authority: embed the freshest client-world blob so this save + // captures BOTH players' rosters atomically. Skip when this call is itself + // writing a client sidecar (.data) to avoid recursive embedding. + if (filename.size() < 5 || filename.substr(filename.size() - 5) != ".data") + { + const std::string prefix = "host_" + std::to_string(connectionTCP::saveID) + "_"; + for (const auto& kv : connectionTCP::coopFilesHost) + { + if (kv.first.compare(0, prefix.size(), prefix) == 0 && !kv.second.empty()) + { + writer.write("coopClientSaveKey", kv.first); + writer.writeBase64("coopClientSaveBlob", const_cast(kv.second.data()), kv.second.size()); + break; + } + } + } writer.write("monthsPassed", _monthsPassed); writer.write("daysPassed", _daysPassed); From 02ccb7a3f4bd8245f7f5958992701df1d7f1f8ba Mon Sep 17 00:00:00 2001 From: Bentley Davis <10065854+NonPolynomialTim@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:57:20 -0400 Subject: [PATCH 2/3] fix(coop): keep transferred soldiers assigned and prevent loss on battle transfer Fixes two bugs in the coop soldier-transfer flow. Bug 1 (guest unassigned from host craft): the client's guest -> host-craft assignment was written only into the "basehost" blob (its copy of the host world). The mission-end own-world reload (GeoscapeState) reads the client's own-world blob, which never saw the assignment, so CoopCraft reverted to its stale value and the soldier came back unassigned. Add connectionTCP::syncOwnWorldGuestCraft() to durably mirror each guest's CoopCraft/CoopCraftType into the own-world blob, wired from SoldiersState::btnOkClick after the basehost save. Bug 2 (soldier transferred mid-battle lost entirely): at mission end the client's live save is still the host's throwaway battle world, so the deferred physical hand-off was applied there (coopBase cleared, soldier deleted by the post-battle cleanup) and pushProgressToHostSilently() then uploaded that host world as the client's own-world blob, destroying the roster. Defer soldier_yaml transfers that arrive during the mission-end / active-battle window and replay them once the own world is restored; guard pushProgressToHostSilently() and the incoming-transfer replay against the swapped-world window (mission-end, savedBattle, or a pending LoadGameState). Also, in processPendingSoldierTransfers(): auto-keep an in-battle-transferred soldier on the craft it was deployed on (unless wounded) via the guest CoopCraft mechanism, and reset a fallen transfer to a plain own-soldier so the giver's memorial records it correctly. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Basescape/SoldiersState.cpp | 17 ++++ src/CoopMod/connectionTCP.cpp | 155 ++++++++++++++++++++++++++++++-- src/CoopMod/connectionTCP.h | 10 +++ 3 files changed, 175 insertions(+), 7 deletions(-) diff --git a/src/Basescape/SoldiersState.cpp b/src/Basescape/SoldiersState.cpp index 04fb1bbf9..1d86b7694 100644 --- a/src/Basescape/SoldiersState.cpp +++ b/src/Basescape/SoldiersState.cpp @@ -711,6 +711,23 @@ void SoldiersState::btnOkClick(Action *) // save changes basehost_save->saveCoopToMemory(filename, _game->getMod(), filename); + + // Fix B (Bug 1): the CoopCraft assignments above land ONLY in the + // "basehost" blob (the client's copy of the HOST world). The client's + // OWN-world blob (client__.data) - which GeoscapeState + // reloads at mission end - is never touched here, so a guest's craft + // assignment reverts to its stale value and the soldier is unassigned + // from the skyranger after a battle. Mirror each guest's assignment + // into the own-world blob so it survives the reload. + std::map> guestCraftAssignments; + for (auto* soldier : *_base->getSoldiers()) + { + Craft* assignedCraft = soldier->getCraft(); + guestCraftAssignments[soldier->getName()] = std::make_pair( + assignedCraft ? assignedCraft->getId() : -1, + assignedCraft ? assignedCraft->getType() : std::string()); + } + _game->getCoopMod()->syncOwnWorldGuestCraft(_base->_coop_base_id, guestCraftAssignments); // estetaan dublikaatio tallenuksen jalkeen... auto& soldiers = *_base->getSoldiers(); diff --git a/src/CoopMod/connectionTCP.cpp b/src/CoopMod/connectionTCP.cpp index 3eb2ab932..6e577d90c 100644 --- a/src/CoopMod/connectionTCP.cpp +++ b/src/CoopMod/connectionTCP.cpp @@ -47,6 +47,7 @@ #include "../Savegame/CraftWeapon.h" #include "../Menu/NewGameState.h" +#include "../Menu/LoadGameState.h" #include "./connectionUDP/connection_rendezvous_glue.h" @@ -717,12 +718,23 @@ void connectionTCP::processPendingSoldierTransfers() { // Replay physical transfers that arrived while our world was swapped out - // for the peer's base view. The flag clears before LoadGameState has - // actually restored our save, so also require that an own (non-mirror) - // base is present - the swapped peer world has none. + // for the peer's base view OR for a coop battle. The flags clear before + // LoadGameState has actually restored our save, so also require that an + // own (non-mirror) base is present, that no own-world reload is still + // pending (LoadGameState on top of the stack), and that we are not mid + // mission-end - the swapped peer/battle world would otherwise re-swallow + // the soldier and mark its packet id as seen, losing it for good. bool ownWorldReady = false; - if (!_pendingIncomingTransfers.empty() && _game->getCoopMod()->playerInsideCoopBase == false && _game->getSavedGame()) + State* topState = _game->getStates().empty() ? nullptr : _game->getStates().back(); + bool ownWorldLoadPending = (dynamic_cast(topState) != nullptr); + + if (!_pendingIncomingTransfers.empty() + && _game->getCoopMod()->playerInsideCoopBase == false + && _game->getCoopMod()->coopMissionEnd == false + && !ownWorldLoadPending + && _game->getSavedGame() + && !_game->getSavedGame()->getSavedBattle()) { for (auto& base : *_game->getSavedGame()->getBases()) @@ -808,12 +820,43 @@ void connectionTCP::processPendingSoldierTransfers() Soldier* soldier = pending.first; - // Died during the mission: stays in the giver's memorial. + // Died during the mission: stays in the giver's memorial. The physical + // hand-off never happened, so undo the in-battle ownership flip that + // transferSoldierOwnership applied - otherwise the fallen soldier would + // sit in the giver's Hall of Honour still flagged as the peer's + // (coop/ownerPlayerId), and the receiver never gets a memorial entry + // (its transfer was skipped). Reset to a plain own-soldier so the + // giver's memorial records it correctly. if (soldier->getDeath()) { + soldier->setCoop(0); + soldier->setOwnerPlayerId(999); continue; } + // Auto-keep an in-battle-transferred soldier on the craft it was + // deployed on, mirroring how a giver's own crew stays aboard their + // craft after a mission. The guest lives in the receiver's world, so + // the live Craft* pointer cannot survive the hand-off (it is detached + // in sendSoldierTransferPacket) - translate it into the guest CoopCraft + // mechanism, which the receiver's mission-end reload and battle merge + // honour (CoopCraft = the host craft id, CoopCraftType = its type). + // A wounded survivor is deliberately left unassigned so it is not flown + // straight back out while it should be recovering. + if (Craft* craft = soldier->getCraft()) + { + if (!soldier->isWounded()) + { + soldier->setCoopCraft(craft->getId()); + soldier->setCoopCraftType(craft->getType()); + } + else + { + soldier->setCoopCraft(-1); + soldier->setCoopCraftType(""); + } + } + sendSoldierTransferPacket(soldier, pending.second); removeSoldierFromLocalBases(soldier); _transferredSoldiers.push_back(soldier); @@ -833,12 +876,19 @@ void connectionTCP::pushProgressToHostSilently() // Client only: serialize the current world and stream it to the host so // the host's next save embeds an up-to-date client blob. Never while the - // world is swapped out for a base visit (we'd upload the peer's world). + // world is swapped out - a base visit, an active battle, or the mission-end + // window before the own-world reload (GeoscapeState::init) has run. In all + // of those the live save is the PEER's world; uploading it would overwrite + // our own-world blob with the host's world and destroy our roster. if (getServerOwner() || !_host_save_progress || !getCoopStatic() || connectionTCP::saveID == 0) { return; } - if (!_game->getSavedGame() || _game->getSavedGame()->getSavedBattle() || _game->getCoopMod()->playerInsideCoopBase) + State* topStatePush = _game->getStates().empty() ? nullptr : _game->getStates().back(); + if (!_game->getSavedGame() || _game->getSavedGame()->getSavedBattle() + || _game->getCoopMod()->playerInsideCoopBase + || _game->getCoopMod()->coopMissionEnd + || (dynamic_cast(topStatePush) != nullptr)) { return; } @@ -854,6 +904,78 @@ void connectionTCP::pushProgressToHostSilently() } +void connectionTCP::syncOwnWorldGuestCraft(int coopBaseId, const std::map>& assignments) +{ + + // Fix B (Bug 1). Client only. Durably mirror the client's guest->host-craft + // assignments into the client's OWN-world blob (client__.data), + // which is exactly what GeoscapeState::init reloads at mission end. The real + // mirror-base UI (SoldiersState::btnOkClick) only writes CoopCraft into the + // "basehost" blob (the client's copy of the HOST world), so without this the + // guest's CoopCraft reverts to its stale value and the soldier is unassigned + // from the skyranger after every battle. + // + // Unlike pushProgressToHostSilently() this deliberately NEVER serializes or + // uploads the LIVE save and does NOT send SEND_FILE_HOST_TRUE_SAVE_PROGRESS: + // this runs while the client is inside the mirror base, where the live save + // is the swapped-in HOST world. That packet round-trips and drives the client + // to upload its live (host) world as the client blob - the exact corruption + // Fix A prevents. We only edit the in-memory own-world blob, which is all the + // mission-end reload needs. + if (getServerOwner() || !_host_save_progress || !getCoopStatic() || connectionTCP::saveID == 0) + { + return; + } + if (assignments.empty()) + { + return; + } + + std::string filename = "client_" + std::to_string(connectionTCP::saveID) + "_" + _game->getCoopMod()->getHostName() + ".data"; + if (!hasCoopFile(filename)) + { + return; + } + + SavedGame* ownWorld = new SavedGame(); + ownWorld->loadCoopSaveFromMemory(filename, _game->getMod(), _game->getLanguage(), filename); + + bool changed = false; + for (auto* base : *ownWorld->getBases()) + { + for (auto* s : *base->getSoldiers()) + { + // Only the client's own guests live in this blob; matching by the + // peer base id + coop name skips any unrelated soldiers. Soldier ids + // differ across worlds, so coopName is the stable cross-world key. + if (s->getCoopBase() != coopBaseId) + { + continue; + } + auto it = assignments.find(s->getCoopName()); + if (it == assignments.end()) + { + continue; + } + if (s->getCoopCraft() != it->second.first || s->getCoopCraftType() != it->second.second) + { + s->setCoopCraft(it->second.first); + s->setCoopCraftType(it->second.second); + changed = true; + } + } + } + + if (changed) + { + ownWorld->saveCoopToMemory(filename, _game->getMod(), filename); + Log(LOG_INFO) << "[coop-transfer] synced guest craft assignments into own-world blob (" << filename << ")"; + } + + delete ownWorld; + +} + void connectionTCP::resetTransferSessionState() { @@ -2324,6 +2446,25 @@ void connectionTCP::onTCPMessage(std::string stateString, Json::Value obj) _pendingIncomingTransfers.push_back(obj); + } + else if (obj.isMember("soldier_yaml") + && (_game->getCoopMod()->coopMissionEnd + || _game->getSavedGame()->getSavedBattle())) + { + + // Coop battle just ended (or is still tearing down): our live + // SavedGame is still the HOST's battle world - the own-world + // reload (GeoscapeState::init) has not run yet. Applying the + // physical transfer now would match the giver's real base in + // that throwaway world (coopBase cleared to -1, soldier deleted + // by the post-battle cleanup) and the follow-up client-progress + // push would upload the host world as our own-world blob. Defer + // and let processPendingSoldierTransfers() replay it once our + // own world is restored (host base is a mirror there, so the + // soldier correctly stays a guest at station_base_id). + Log(LOG_INFO) << "[coop-transfer] RECV deferred (mission-end swapped world)"; + _pendingIncomingTransfers.push_back(obj); + } else if (obj.isMember("soldier_yaml")) { diff --git a/src/CoopMod/connectionTCP.h b/src/CoopMod/connectionTCP.h index 5454dc8d2..2fbd06e89 100644 --- a/src/CoopMod/connectionTCP.h +++ b/src/CoopMod/connectionTCP.h @@ -521,6 +521,16 @@ class connectionTCP // the host on reconnect. To keep the embedded blob fresh, the client // silently pushes its progress to the host after every soldier transfer. void pushProgressToHostSilently(); + // Fix B (Bug 1): when the client assigns/unassigns its guest soldiers to a + // host craft via the mirror-base UI, the assignment is written only into the + // "basehost" blob (the client's copy of the HOST world). The client's OWN + // world blob (client__.data) - which GeoscapeState reloads at + // mission end - is never updated, so the guest's CoopCraft reverts to its + // stale value (unassigned) after a battle. This durably mirrors the + // per-guest CoopCraft/CoopCraftType into the own-world blob (and pushes it + // to the host) so the assignment survives the mission-end reload. + // assignments maps a guest's CoopName to {CoopCraft, CoopCraftType}. + void syncOwnWorldGuestCraft(int coopBaseId, const std::map>& assignments); // Clears session transfer state (pending queues, dedup ids, away-ids) // after a save load - stale in-memory state must never outlive the save // that is now the authority. From 35f3fb2dde0c005209f7370e51dfb040268b99fa Mon Sep 17 00:00:00 2001 From: Bentley Davis <10065854+NonPolynomialTim@users.noreply.github.com> Date: Tue, 7 Jul 2026 23:19:33 -0400 Subject: [PATCH 3/3] fix(coop): make soldier-transfer dialogs legible in the battlescape sackSoldier's base-palette colors are illegible under the battle palette. In battle, match the coop lobby window: setInterface("geoscape", true, battle) + saveMenus element colors + applyBattlescapeTheme("saveMenus") for the uniform battlescape theme color, high contrast and TAC00 background. Out-of-battle behavior is unchanged (sackSoldier over basescape, palette-adopt for the notice). Co-Authored-By: Claude Opus 4.8 --- src/CoopMod/TransferNoticeState.cpp | 45 +++++++++++++++++++++-------- src/CoopMod/TransferSoldierMenu.cpp | 37 ++++++++++++++++++++---- 2 files changed, 64 insertions(+), 18 deletions(-) diff --git a/src/CoopMod/TransferNoticeState.cpp b/src/CoopMod/TransferNoticeState.cpp index e9c2e682d..685a1742d 100644 --- a/src/CoopMod/TransferNoticeState.cpp +++ b/src/CoopMod/TransferNoticeState.cpp @@ -28,6 +28,8 @@ #include "../Geoscape/GeoscapeState.h" #include "../Mod/Mod.h" #include "../Mod/RuleInterface.h" +#include "../Savegame/SavedGame.h" +#include "../Savegame/SavedBattleGame.h" namespace OpenXcom { @@ -48,22 +50,36 @@ TransferNoticeState::TransferNoticeState(const std::string &message) // the "top state" is the first notice, not the screen underneath. std::string category = "sackSoldier"; std::string textElement = "text"; - State* context = nullptr; - for (auto it = _game->getStates().rbegin(); it != _game->getStates().rend(); ++it) + if (_game->getSavedGame() && _game->getSavedGame()->getSavedBattle()) { - if (dynamic_cast(*it) == nullptr) - { - context = *it; - break; - } + // In the battlescape, match the coop lobby window exactly (geoscape + // interface with alterPal, saveMenus colors, under the battle palette). + setInterface("geoscape", true, _game->getSavedGame()->getSavedBattle()); + category = "saveMenus"; } - if (context) + else { - setStatePalette(context->getPalette()); - if (dynamic_cast(context)) + // Adopt the palette of whatever screen we're over - no palette swap, no + // flicker, works on geoscape, basescape and the peer-base view alike. + // Skip other notices when deciding the context: with two notices stacked, + // the "top state" is the first notice, not the screen underneath. + State* context = nullptr; + for (auto it = _game->getStates().rbegin(); it != _game->getStates().rend(); ++it) + { + if (dynamic_cast(*it) == nullptr) + { + context = *it; + break; + } + } + if (context) { - category = "geoManufactureComplete"; // standard geoscape popup colors - textElement = "text1"; + setStatePalette(context->getPalette()); + if (dynamic_cast(context)) + { + category = "geoManufactureComplete"; // standard geoscape popup colors + textElement = "text1"; + } } } _category = category; @@ -74,6 +90,11 @@ TransferNoticeState::TransferNoticeState(const std::string &message) centerAllSurfaces(); setWindowBackground(_window, category); + if (_game->getSavedGame() && _game->getSavedGame()->getSavedBattle()) + { + // Same as the coop lobby in battle: uniform battlescape theme. + applyBattlescapeTheme(category); + } _txtMessage->setAlign(ALIGN_CENTER); _txtMessage->setWordWrap(true); diff --git a/src/CoopMod/TransferSoldierMenu.cpp b/src/CoopMod/TransferSoldierMenu.cpp index 60bca1141..b0dbf9527 100644 --- a/src/CoopMod/TransferSoldierMenu.cpp +++ b/src/CoopMod/TransferSoldierMenu.cpp @@ -26,6 +26,7 @@ #include "../Interface/TextButton.h" #include "../Interface/Window.h" #include "../Savegame/SavedGame.h" +#include "../Savegame/SavedBattleGame.h" #include "../Savegame/Soldier.h" #include "connectionTCP.h" @@ -93,18 +94,42 @@ TransferSoldierMenu::TransferSoldierMenu(Soldier *soldier, int currentOwnerId) : // (geoscape palette) forced a hardware palette swap when opened over the // basescape soldier screens, flashing the whole screen on open and close. // The battle-game param switches to the battlescape palette in battle. - setInterface("sackSoldier", false, _game->getSavedGame() ? _game->getSavedGame()->getSavedBattle() : 0); + // Out of battle, sackSoldier's base palette avoids a hardware palette flash + // over the basescape soldier screens. In the battlescape those colors are + // illegible, so match the coop lobby window exactly (geoscape interface with + // alterPal, saveMenus element colors, under the battle palette). + SavedBattleGame *battle = _game->getSavedGame() ? _game->getSavedGame()->getSavedBattle() : 0; + std::string cat = "sackSoldier"; + if (battle) + { + setInterface("geoscape", true, battle); + cat = "saveMenus"; + } + else + { + setInterface("sackSoldier", false, 0); + } - add(_window, "window", "sackSoldier"); - add(_txtTitle, "text", "sackSoldier"); + add(_window, "window", cat); + add(_txtTitle, "text", cat); for (auto *btn : _btnTargets) { - add(btn, "button", "sackSoldier"); + add(btn, "button", cat); } - add(_btnCancel, "button", "sackSoldier"); + add(_btnCancel, "button", cat); centerAllSurfaces(); - setWindowBackground(_window, "sackSoldier"); + if (battle) + { + // Same as the coop lobby in battle: uniform battlescape theme color + + // high contrast + TAC00 background. + setWindowBackground(_window, cat); + applyBattlescapeTheme(cat); + } + else + { + setWindowBackground(_window, cat); + } _txtTitle->setAlign(ALIGN_CENTER); _txtTitle->setWordWrap(true);