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..1d86b7694 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 @@ -707,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(); @@ -935,4 +956,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..685a1742d --- /dev/null +++ b/src/CoopMod/TransferNoticeState.cpp @@ -0,0 +1,114 @@ +/* + * 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" +#include "../Savegame/SavedGame.h" +#include "../Savegame/SavedBattleGame.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"; + if (_game->getSavedGame() && _game->getSavedGame()->getSavedBattle()) + { + // 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"; + } + else + { + // 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) + { + 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); + 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); + _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..b0dbf9527 --- /dev/null +++ b/src/CoopMod/TransferSoldierMenu.cpp @@ -0,0 +1,172 @@ +/* + * 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/SavedBattleGame.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. + // 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", cat); + add(_txtTitle, "text", cat); + for (auto *btn : _btnTargets) + { + add(btn, "button", cat); + } + add(_btnCancel, "button", cat); + + centerAllSurfaces(); + 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); + _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..6e577d90c 100644 --- a/src/CoopMod/connectionTCP.cpp +++ b/src/CoopMod/connectionTCP.cpp @@ -47,14 +47,21 @@ #include "../Savegame/CraftWeapon.h" #include "../Menu/NewGameState.h" +#include "../Menu/LoadGameState.h" #include "./connectionUDP/connection_rendezvous_glue.h" #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 +630,453 @@ 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 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; + + 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()) + { + 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. 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); + _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 - 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; + } + 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; + } + + 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::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() +{ + + _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 +1095,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 +2372,336 @@ 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") + && (_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")) + { + + // 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 +6295,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 +6333,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..2fbd06e89 100644 --- a/src/CoopMod/connectionTCP.h +++ b/src/CoopMod/connectionTCP.h @@ -30,6 +30,7 @@ #include #include #include +#include #include #include @@ -470,6 +471,70 @@ 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(); + // 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. + 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);