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