Browse Source

Merge pull request #6243 from Laserlicht/battleonly

Battleonly mode
Ivan Savenko 4 weeks ago
parent
commit
7cabae89e3
52 changed files with 1085 additions and 114 deletions
  1. BIN
      Mods/vcmi/Content/Sprites/lobby/battle-normal.png
  2. BIN
      Mods/vcmi/Content/Sprites/lobby/battle-pressed.png
  3. 8 0
      Mods/vcmi/Content/Sprites/lobby/battleButton.json
  4. 7 0
      Mods/vcmi/Content/config/english.json
  5. 7 0
      Mods/vcmi/Content/config/german.json
  6. 2 0
      client/CMakeLists.txt
  7. 3 2
      client/CPlayerInterface.cpp
  8. 7 0
      client/CServerHandler.cpp
  9. 2 0
      client/CServerHandler.h
  10. 1 0
      client/LobbyClientNetPackVisitors.h
  11. 9 2
      client/NetPacksClient.cpp
  12. 13 0
      client/NetPacksLobbyClient.cpp
  13. 12 1
      client/battle/BattleResultWindow.cpp
  14. 3 0
      client/battle/BattleWindow.cpp
  15. 517 0
      client/lobby/BattleOnlyMode.cpp
  16. 92 0
      client/lobby/BattleOnlyMode.h
  17. 16 4
      client/lobby/RandomMapTab.cpp
  18. 11 0
      client/lobby/SelectionTab.cpp
  19. 2 0
      client/lobby/SelectionTab.h
  20. 89 11
      client/render/AssetGenerator.cpp
  21. 2 0
      client/render/AssetGenerator.h
  22. 2 0
      client/render/CanvasImage.cpp
  23. 59 32
      client/widgets/CTextInput.cpp
  24. 2 2
      client/widgets/CTextInput.h
  25. 15 4
      client/widgets/Images.cpp
  26. 3 0
      client/widgets/Images.h
  27. 5 3
      client/widgets/MiscWidgets.cpp
  28. 2 2
      client/windows/CCreatureWindow.cpp
  29. 3 1
      client/windows/CWindowObject.cpp
  30. 17 15
      client/windows/GUIClasses.cpp
  31. 3 3
      client/windows/GUIClasses.h
  32. 9 0
      lib/StartInfo.cpp
  33. 23 0
      lib/StartInfo.h
  34. 0 22
      lib/mapObjects/CGHeroInstance.cpp
  35. 0 2
      lib/mapObjects/CGHeroInstance.h
  36. 1 0
      lib/mapping/CMapHeader.cpp
  37. 4 0
      lib/mapping/CMapHeader.h
  38. 4 4
      lib/mapping/MapEditUtils.cpp
  39. 4 0
      lib/mapping/MapFormatJson.cpp
  40. 1 0
      lib/networkPacks/NetPackVisitor.h
  41. 5 0
      lib/networkPacks/NetPacksLib.cpp
  42. 2 0
      lib/networkPacks/PacksForClient.h
  43. 13 1
      lib/networkPacks/PacksForLobby.h
  44. 2 1
      lib/serializer/ESerializationVersion.h
  45. 1 0
      lib/serializer/RegisterTypes.h
  46. 67 0
      lib/texts/TextOperations.h
  47. 13 0
      server/CGameHandler.cpp
  48. 1 1
      server/CVCMIServer.cpp
  49. 1 0
      server/LobbyNetPackVisitors.h
  50. 5 1
      server/NetPacksLobbyServer.cpp
  51. 1 0
      server/battles/BattleResultProcessor.cpp
  52. 14 0
      server/processors/TurnOrderProcessor.cpp

BIN
Mods/vcmi/Content/Sprites/lobby/battle-normal.png


BIN
Mods/vcmi/Content/Sprites/lobby/battle-pressed.png


+ 8 - 0
Mods/vcmi/Content/Sprites/lobby/battleButton.json

@@ -0,0 +1,8 @@
+{
+	"basepath" : "lobby/",
+	"images" :
+	[
+		{ "frame" : 0, "file" : "battle-normal.png"},
+		{ "frame" : 1, "file" : "battle-pressed.png"}
+	]
+}

+ 7 - 0
Mods/vcmi/Content/config/english.json

@@ -138,6 +138,13 @@
 	"vcmi.lobby.deleteFile" : "Do you want to delete following file?",
 	"vcmi.lobby.deleteFolder" : "Do you want to delete following folder?",
 	"vcmi.lobby.deleteMode" : "Switch to delete mode and back",
+	"vcmi.lobby.battleOnlyMode" : "Battle only mode",
+	"vcmi.lobby.battleOnlyModeBattlefield" : "Battlefield",
+	"vcmi.lobby.battleOnlyModeBattlefieldSelect" : "Select Battlefield",
+	"vcmi.lobby.battleOnlyModeHeroSelect" : "Select Hero",
+	"vcmi.lobby.battleOnlyModeCreatureSelect" : "Select Creature",
+	"vcmi.lobby.battleOnlyModeSelect" : "Select",
+	"vcmi.lobby.battleOnlyModeReset" : "Reset",
 	"vcmi.lobby.templatesSelect.hover" : "Templates",
 	"vcmi.lobby.templatesSelect.help" : "Search and select template",
 

+ 7 - 0
Mods/vcmi/Content/config/german.json

@@ -138,6 +138,13 @@
 	"vcmi.lobby.deleteFile" : "Möchtet Ihr folgende Datei löschen?",
 	"vcmi.lobby.deleteFolder" : "Möchtet Ihr folgenden Ordner löschen?",
 	"vcmi.lobby.deleteMode" : "In den Löschmodus wechseln und zurück",
+	"vcmi.lobby.battleOnlyMode" : "Nur Kämpfen Modus",
+	"vcmi.lobby.battleOnlyModeBattlefield" : "Schlachtfeld",
+	"vcmi.lobby.battleOnlyModeBattlefieldSelect" : "Schlachtfeld auswählen",
+	"vcmi.lobby.battleOnlyModeHeroSelect" : "Helden auswählen",
+	"vcmi.lobby.battleOnlyModeCreatureSelect" : "Kreatur auswählen",
+	"vcmi.lobby.battleOnlyModeSelect" : "Wählen",
+	"vcmi.lobby.battleOnlyModeReset" : "Zurücksetzen",
 	"vcmi.lobby.templatesSelect.hover" : "Templates",
 	"vcmi.lobby.templatesSelect.help" : "Suche und wähle Template aus",
 

+ 2 - 0
client/CMakeLists.txt

@@ -52,6 +52,7 @@ set(vcmiclientcommon_SRCS
 	gui/ShortcutHandler.cpp
 	gui/WindowHandler.cpp
 
+	lobby/BattleOnlyMode.cpp
 	lobby/CBonusSelection.cpp
 	lobby/CCampaignInfoScreen.cpp
 	lobby/CLobbyScreen.cpp
@@ -262,6 +263,7 @@ set(vcmiclientcommon_HEADERS
 	gui/TextAlignment.h
 	gui/WindowHandler.h
 
+	lobby/BattleOnlyMode.h
 	lobby/CBonusSelection.h
 	lobby/CCampaignInfoScreen.h
 	lobby/CLobbyScreen.h

+ 3 - 2
client/CPlayerInterface.cpp

@@ -97,6 +97,7 @@
 #include "../lib/mapObjects/MiscObjects.h"
 #include "../lib/mapObjects/ObjectTemplate.h"
 
+#include "../lib/mapping/CMap.h"
 #include "../lib/mapping/CMapHeader.h"
 
 #include "../lib/networkPacks/PacksForClient.h"
@@ -658,7 +659,7 @@ void CPlayerInterface::battleStart(const BattleID & battleID, const CCreatureSet
 {
 	EVENT_HANDLER_CALLED_BY_CLIENT;
 
-	bool useQuickCombat = settings["adventure"]["quickCombat"].Bool();
+	bool useQuickCombat = settings["adventure"]["quickCombat"].Bool() || GAME->map().getMap()->battleOnly;
 	bool forceQuickCombat = settings["adventure"]["forceQuickCombat"].Bool();
 
 	if ((replayAllowed && useQuickCombat) || forceQuickCombat)
@@ -1482,7 +1483,7 @@ void CPlayerInterface::playerBlocked(int reason, bool start)
 {
 	if(reason == PlayerBlocked::EReason::UPCOMING_BATTLE)
 	{
-		if(GAME->server().howManyPlayerInterfaces() > 1 && GAME->interface() != this && GAME->interface()->makingTurn == false)
+		if(GAME->server().howManyPlayerInterfaces() > 1 && GAME->interface() != this && GAME->interface()->makingTurn == false && !GAME->map().getMap()->battleOnly)
 		{
 			//one of our players who isn't last in order got attacked not by our another player (happens for example in hotseat mode)
 			GAME->setInterfaceInstance(this);

+ 7 - 0
client/CServerHandler.cpp

@@ -432,6 +432,13 @@ void CServerHandler::setCampaignBonus(int bonusId) const
 	sendLobbyPack(lscb);
 }
 
+void CServerHandler::setBattleOnlyModeStartInfo(std::shared_ptr<BattleOnlyModeStartInfo> startInfo) const
+{
+	LobbySetBattleOnlyModeStartInfo lsbomsui;
+	lsbomsui.startInfo = startInfo;
+	sendLobbyPack(lsbomsui);
+}
+
 void CServerHandler::setMapInfo(std::shared_ptr<CMapInfo> to, std::shared_ptr<CMapGenOptions> mapGenOpts) const
 {
 	LobbySetMap lsm;

+ 2 - 0
client/CServerHandler.h

@@ -77,6 +77,7 @@ public:
 	virtual void setCampaignState(std::shared_ptr<CampaignState> newCampaign) = 0;
 	virtual void setCampaignMap(CampaignScenarioID mapId) const = 0;
 	virtual void setCampaignBonus(int bonusId) const = 0;
+	virtual void setBattleOnlyModeStartInfo(std::shared_ptr<BattleOnlyModeStartInfo> startInfo) const = 0;
 	virtual void setMapInfo(std::shared_ptr<CMapInfo> to, std::shared_ptr<CMapGenOptions> mapGenOpts = {}) const = 0;
 	virtual void setPlayer(PlayerColor color) const = 0;
 	virtual void setPlayerName(PlayerColor color, const std::string & name) const = 0;
@@ -186,6 +187,7 @@ public:
 	void setCampaignState(std::shared_ptr<CampaignState> newCampaign) override;
 	void setCampaignMap(CampaignScenarioID mapId) const override;
 	void setCampaignBonus(int bonusId) const override;
+	void setBattleOnlyModeStartInfo(std::shared_ptr<BattleOnlyModeStartInfo> startInfo) const override;
 	void setMapInfo(std::shared_ptr<CMapInfo> to, std::shared_ptr<CMapGenOptions> mapGenOpts = {}) const override;
 	void setPlayer(PlayerColor color) const override;
 	void setPlayerName(PlayerColor color, const std::string & name) const override;

+ 1 - 0
client/LobbyClientNetPackVisitors.h

@@ -59,4 +59,5 @@ public:
 	void visitLobbyLoadProgress(LobbyLoadProgress & pack) override;
 	void visitLobbyUpdateState(LobbyUpdateState & pack) override;
 	void visitLobbyShowMessage(LobbyShowMessage & pack) override;
+	void visitLobbySetBattleOnlyModeStartInfo(LobbySetBattleOnlyModeStartInfo & pack) override;
 };

+ 9 - 2
client/NetPacksClient.cpp

@@ -15,6 +15,7 @@
 #include "windows/GUIClasses.h"
 #include "windows/CCastleInterface.h"
 #include "mapView/mapHandler.h"
+#include "mainmenu/CMainMenu.h"
 #include "adventureMap/AdventureMapInterface.h"
 #include "adventureMap/CInGameConsole.h"
 #include "battle/BattleInterface.h"
@@ -400,7 +401,7 @@ void ApplyClientNetPackVisitor::visitPlayerEndsGame(PlayerEndsGame & pack)
 	bool localHumanWinsGame = vstd::contains(cl.playerint, pack.player) && cl.gameInfo().getPlayerState(pack.player)->human && pack.victoryLossCheckResult.victory();
 	bool lastHumanEndsGame = GAME->server().howManyPlayerInterfaces() == 1 && vstd::contains(cl.playerint, pack.player) && cl.gameInfo().getPlayerState(pack.player)->human && !settings["session"]["spectate"].Bool();
 
-	if(lastHumanEndsGame || localHumanWinsGame)
+	if(lastHumanEndsGame || localHumanWinsGame || pack.silentEnd)
 	{
 		assert(adventureInt);
 		if(adventureInt)
@@ -409,7 +410,13 @@ void ApplyClientNetPackVisitor::visitPlayerEndsGame(PlayerEndsGame & pack)
 			adventureInt.reset();
 		}
 
-		GAME->server().showHighScoresAndEndGameplay(pack.player, pack.victoryLossCheckResult.victory(), pack.statistic);
+		if(!pack.silentEnd)
+			GAME->server().showHighScoresAndEndGameplay(pack.player, pack.victoryLossCheckResult.victory(), pack.statistic);
+		else
+		{
+			GAME->server().endGameplay();
+			GAME->mainmenu()->menu->switchToTab("main");
+		}
 	}
 
 	// In auto testing pack.mode we always close client if red pack.player won or lose

+ 13 - 0
client/NetPacksLobbyClient.cpp

@@ -19,6 +19,7 @@
 #include "lobby/ExtraOptionsTab.h"
 #include "lobby/SelectionTab.h"
 #include "lobby/CBonusSelection.h"
+#include "lobby/BattleOnlyMode.h"
 #include "globalLobby/GlobalLobbyWindow.h"
 #include "globalLobby/GlobalLobbyServerSetup.h"
 #include "globalLobby/GlobalLobbyClient.h"
@@ -113,6 +114,9 @@ void ApplyOnLobbyScreenNetPackVisitor::visitLobbyGuiAction(LobbyGuiAction & pack
 	if(!lobby || !handler.isGuest())
 		return;
 
+	if(auto topWindow = ENGINE->windows().topWindow<BattleOnlyModeWindow>())
+		topWindow->close();
+
 	switch(pack.action)
 	{
 	case LobbyGuiAction::NO_TAB:
@@ -133,6 +137,9 @@ void ApplyOnLobbyScreenNetPackVisitor::visitLobbyGuiAction(LobbyGuiAction & pack
 	case LobbyGuiAction::OPEN_EXTRA_OPTIONS:
 		lobby->toggleTab(lobby->tabExtraOptions);
 		break;
+	case LobbyGuiAction::BATTLE_MODE:
+		BattleOnlyMode::openBattleWindow();
+		break;
 	}
 }
 
@@ -232,3 +239,9 @@ void ApplyOnLobbyScreenNetPackVisitor::visitLobbyShowMessage(LobbyShowMessage &
 	lobby->buttonStart->block(false);
 	handler.showServerError(pack.message.toString());
 }
+
+void ApplyOnLobbyScreenNetPackVisitor::visitLobbySetBattleOnlyModeStartInfo(LobbySetBattleOnlyModeStartInfo & pack)
+{
+	if(auto topWindow = ENGINE->windows().topWindow<BattleOnlyModeWindow>())
+		topWindow->applyStartInfo(pack.startInfo);
+}

+ 12 - 1
client/battle/BattleResultWindow.cpp

@@ -12,6 +12,9 @@
 
 #include "BattleWindow.h"
 
+#include "../GameInstance.h"
+#include "../Client.h"
+#include "../CServerHandler.h"
 #include "../CPlayerInterface.h"
 #include "../GameEngine.h"
 #include "../gui/Shortcut.h"
@@ -23,6 +26,7 @@
 #include "../widgets/VideoWidget.h"
 
 #include "../../lib/CStack.h"
+#include "../../lib/CPlayerState.h"
 #include "../../lib/ConditionalWait.h"
 #include "../../lib/GameLibrary.h"
 #include "../../lib/StartInfo.h"
@@ -45,7 +49,14 @@ BattleResultWindow::BattleResultWindow(const BattleResult & br, CPlayerInterface
 	exit = std::make_shared<CButton>(Point(384, 505), AnimationPath::builtin("iok6432.def"), std::make_pair("", ""), [this](){ bExitf();}, EShortcut::GLOBAL_ACCEPT);
 	exit->setBorderColor(Colors::METALLIC_GOLD);
 
-	if(allowReplay || owner.cb->getStartInfo()->extraOptionsInfo.unlimitedReplay)
+	auto battle = owner.cb->getBattle(br.battleID);
+	const auto * attackerPlayer = GAME->server().client->gameInfo().getPlayerState(battle->sideToPlayer(BattleSide::ATTACKER));
+	const auto * defenderPlayer = GAME->server().client->gameInfo().getPlayerState(battle->sideToPlayer(BattleSide::DEFENDER));
+	bool isAttackerHuman = attackerPlayer && attackerPlayer->isHuman();
+	bool isDefenderHuman = defenderPlayer && defenderPlayer->isHuman();
+	bool onlyOnePlayerHuman = isAttackerHuman != isDefenderHuman;
+
+	if((allowReplay || owner.cb->getStartInfo()->extraOptionsInfo.unlimitedReplay) && onlyOnePlayerHuman)
 	{
 		repeat = std::make_shared<CButton>(Point(24, 505), AnimationPath::builtin("icn6432.def"), std::make_pair("", ""), [this](){ bRepeatf();}, EShortcut::GLOBAL_CANCEL);
 		repeat->setBorderColor(Colors::METALLIC_GOLD);

+ 3 - 0
client/battle/BattleWindow.cpp

@@ -51,6 +51,7 @@
 #include "../../lib/entities/artifact/CArtHandler.h"
 #include "../../lib/filesystem/ResourcePath.h"
 #include "../../lib/gameState/InfoAboutArmy.h"
+#include "../../lib/mapping/CMapHeader.h"
 #include "../../lib/mapObjects/CGHeroInstance.h"
 #include "../../lib/texts/CGeneralTextHandler.h"
 
@@ -851,6 +852,8 @@ void BattleWindow::endWithAutocombat()
 
 void BattleWindow::showAll(Canvas & to)
 {
+	if(owner.curInt->cb->getMapHeader()->battleOnly)
+		to.fillTexture(ENGINE->renderHandler().loadImage(ImagePath::builtin("DiBoxBck"), EImageBlitMode::OPAQUE));
 	CIntObject::showAll(to);
 
 	if (ENGINE->screenDimensions().x != 800 || ENGINE->screenDimensions().y !=600)

+ 517 - 0
client/lobby/BattleOnlyMode.cpp

@@ -0,0 +1,517 @@
+/*
+ * BattleOnlyMode.cpp, part of VCMI engine
+ *
+ * Authors: listed in file AUTHORS in main folder
+ *
+ * License: GNU General Public License v2.0 or later
+ * Full text of license available in license.txt file, in main folder
+ *
+ */
+
+#include "StdInc.h"
+#include "BattleOnlyMode.h"
+
+#include "../CServerHandler.h"
+#include "../GameEngine.h"
+#include "../GameInstance.h"
+
+#include "../render/IRenderHandler.h"
+#include "../render/CAnimation.h"
+#include "../render/Canvas.h"
+#include "../render/CanvasImage.h"
+#include "../gui/Shortcut.h"
+#include "../gui/WindowHandler.h"
+#include "../widgets/Buttons.h"
+#include "../widgets/GraphicalPrimitiveCanvas.h"
+#include "../widgets/TextControls.h"
+#include "../widgets/CTextInput.h"
+#include "../widgets/Images.h"
+#include "../windows/GUIClasses.h"
+#include "../windows/CHeroOverview.h"
+#include "../windows/CCreatureWindow.h"
+
+#include "../../lib/GameLibrary.h"
+#include "../../lib/gameState/CGameState.h"
+#include "../../lib/networkPacks/PacksForLobby.h"
+#include "../../lib/StartInfo.h"
+#include "../../lib/VCMIDirs.h"
+#include "../../lib/CRandomGenerator.h"
+#include "../../lib/callback/EditorCallback.h"
+#include "../../lib/entities/hero/CHero.h"
+#include "../../lib/entities/hero/CHeroClass.h"
+#include "../../lib/entities/hero/CHeroHandler.h"
+#include "../../lib/entities/faction/CTown.h"
+#include "../../lib/entities/faction/CTownHandler.h"
+#include "../../lib/mapObjects/CGHeroInstance.h"
+#include "../../lib/mapObjects/CGTownInstance.h"
+#include "../../lib/mapObjectConstructors/AObjectTypeHandler.h"
+#include "../../lib/mapObjectConstructors/CObjectClassesHandler.h"
+#include "../../lib/mapping/CMap.h"
+#include "../../lib/mapping/CMapInfo.h"
+#include "../../lib/mapping/CMapEditManager.h"
+#include "../../lib/mapping/CMapService.h"
+#include "../../lib/mapping/MapFormat.h"
+#include "../../lib/texts/CGeneralTextHandler.h"
+#include "../../lib/texts/MetaString.h"
+#include "../../lib/texts/TextOperations.h"
+#include "../../lib/filesystem/Filesystem.h"
+
+void BattleOnlyMode::openBattleWindow()
+{
+	GAME->server().sendGuiAction(LobbyGuiAction::BATTLE_MODE);
+	ENGINE->windows().createAndPushWindow<BattleOnlyModeWindow>();
+}
+
+BattleOnlyModeWindow::BattleOnlyModeWindow()
+	: CWindowObject(BORDERED)
+	, startInfo(std::make_shared<BattleOnlyModeStartInfo>())
+	, disabledColor(GAME->server().isHost() ? Colors::WHITE : Colors::ORANGE)
+{
+	OBJECT_CONSTRUCTION;
+
+	pos.w = 519;
+	pos.h = 238;
+
+	updateShadow();
+	center();
+
+	init();
+
+	backgroundTexture = std::make_shared<FilledTexturePlayerColored>(Rect(0, 0, pos.w, pos.h));
+	backgroundTexture->setPlayerColor(PlayerColor(1));
+	buttonOk = std::make_shared<CButton>(Point(191, 203), AnimationPath::builtin("MuBchck"), CButton::tooltip(), [this](){ startBattle(); }, EShortcut::GLOBAL_ACCEPT);
+	buttonOk->block(true);
+	buttonAbort = std::make_shared<CButton>(Point(265, 203), AnimationPath::builtin("MuBcanc"), CButton::tooltip(), [this](){
+		GAME->server().sendGuiAction(LobbyGuiAction::NO_TAB);
+		close();
+	}, EShortcut::GLOBAL_CANCEL);
+	buttonAbort->block(true);
+	title = std::make_shared<CLabel>(260, 20, FONT_BIG, ETextAlignment::CENTER, Colors::YELLOW, LIBRARY->generaltexth->translate("vcmi.lobby.battleOnlyMode"));
+
+	battlefieldSelector = std::make_shared<CButton>(Point(29, 174), AnimationPath::builtin("GSPButtonClear"), CButton::tooltip(), [this](){
+		std::vector<std::string> texts;
+		std::vector<std::shared_ptr<IImage>> images;
+
+		auto & terrains = LIBRARY->terrainTypeHandler->objects;
+		for (const auto & terrain : terrains)
+		{
+			if(!terrain->isPassable())
+				continue;
+
+			texts.push_back(terrain->getNameTranslated());
+
+			const auto & patterns = LIBRARY->terviewh->getTerrainViewPatterns(terrain->getId());
+			TerrainViewPattern pattern;
+			for(auto & p : patterns)
+				if(p[0].id == "n1")
+					pattern = p[0];
+			auto image = ENGINE->renderHandler().loadImage(terrain->tilesFilename, pattern.mapping[0].first, 0, EImageBlitMode::OPAQUE);
+			image->scaleTo(Point(23, 23), EScalingAlgorithm::NEAREST);
+			images.push_back(image);
+		}
+
+		auto factions = LIBRARY->townh->getDefaultAllowed();
+		for (const auto & faction : factions)
+		{
+			texts.push_back(faction.toFaction()->getNameTranslated());
+
+			auto image = ENGINE->renderHandler().loadImage(AnimationPath::builtin("ITPA"), faction.toFaction()->town->clientInfo.icons[true][false] + 2, 0, EImageBlitMode::OPAQUE);
+			image->scaleTo(Point(35, 23), EScalingAlgorithm::NEAREST);
+			images.push_back(image);
+		}
+
+		ENGINE->windows().createAndPushWindow<CObjectListWindow>(texts, nullptr, LIBRARY->generaltexth->translate("vcmi.lobby.battleOnlyModeBattlefield"), LIBRARY->generaltexth->translate("vcmi.lobby.battleOnlyModeBattlefieldSelect"), [this, terrains, factions](int index){
+			if(terrains.size() > index)
+			{
+				startInfo->selectedTerrain = terrains[index]->getId();
+				startInfo->selectedTown = std::nullopt;
+			}
+			else
+			{
+				startInfo->selectedTerrain = std::nullopt;
+				auto it = std::next(factions.begin(), index - terrains.size());
+				if (it != factions.end())
+    				startInfo->selectedTown = *it;
+			}
+			onChange();
+		}, (startInfo->selectedTerrain ? static_cast<int>(*startInfo->selectedTerrain) : static_cast<int>(*startInfo->selectedTown + terrains.size())), images, true, true);
+	});
+	battlefieldSelector->block(GAME->server().isGuest());
+	buttonReset = std::make_shared<CButton>(Point(289, 174), AnimationPath::builtin("GSPButtonClear"), CButton::tooltip(), [this](){
+		if(GAME->server().isHost())
+		{
+			startInfo->selectedTerrain = TerrainId::DIRT;
+			startInfo->selectedTown = std::nullopt;
+			startInfo->selectedHero[0] = std::nullopt;
+			startInfo->selectedArmy[0].fill(CStackBasicDescriptor(CreatureID::NONE, 1));
+			for(size_t i=0; i<GameConstants::ARMY_SIZE; i++)
+				heroSelector1->selectedArmyInput.at(i)->disable();
+		}
+		startInfo->selectedHero[1] = std::nullopt;
+		startInfo->selectedArmy[1].fill(CStackBasicDescriptor(CreatureID::NONE, 1));
+		for(size_t i=0; i<GameConstants::ARMY_SIZE; i++)
+			heroSelector2->selectedArmyInput.at(i)->disable();
+		onChange();
+	});
+	buttonReset->setTextOverlay(LIBRARY->generaltexth->translate("vcmi.lobby.battleOnlyModeReset"), EFonts::FONT_SMALL, Colors::WHITE);
+
+	heroSelector1 = std::make_shared<BattleOnlyModeHeroSelector>(0, *this, Point(0, 40));
+	heroSelector2 = std::make_shared<BattleOnlyModeHeroSelector>(1, *this, Point(260, 40));
+
+	heroSelector1->setInputEnabled(GAME->server().isHost());
+
+	onChange();
+}
+
+void BattleOnlyModeWindow::init()
+{
+	map = std::make_unique<CMap>(nullptr);
+	map->version = EMapFormat::VCMI;
+	map->creationDateTime = std::time(nullptr);
+	map->width = 10;
+	map->height = 10;
+	map->mapLevels = 1;
+	map->battleOnly = true;
+	map->name = MetaString::createFromTextID("vcmi.lobby.battleOnlyMode");
+
+	cb = std::make_unique<EditorCallback>(map.get());
+}
+
+void BattleOnlyModeWindow::onChange()
+{
+	GAME->server().setBattleOnlyModeStartInfo(startInfo);
+}
+
+void BattleOnlyModeWindow::update()
+{
+	setTerrainButtonText();
+	setOkButtonEnabled();
+	
+	heroSelector1->setHeroIcon();
+	heroSelector1->setCreatureIcons();
+	heroSelector2->setHeroIcon();
+	heroSelector2->setCreatureIcons();
+	redraw();
+}
+
+void BattleOnlyModeWindow::applyStartInfo(std::shared_ptr<BattleOnlyModeStartInfo> si)
+{
+	startInfo = si;
+	update();
+}
+
+void BattleOnlyModeWindow::setTerrainButtonText()
+{
+	battlefieldSelector->setTextOverlay(LIBRARY->generaltexth->translate("vcmi.lobby.battleOnlyModeBattlefield") + ":   " + (startInfo->selectedTerrain ? (*startInfo->selectedTerrain).toEntity(LIBRARY)->getNameTranslated() : (*startInfo->selectedTown).toEntity(LIBRARY)->getNameTranslated()), EFonts::FONT_SMALL, disabledColor);
+}
+
+void BattleOnlyModeWindow::setOkButtonEnabled()
+{
+	bool army2Empty = std::all_of(startInfo->selectedArmy[1].begin(), startInfo->selectedArmy[1].end(), [](const auto x) { return x.getId() == CreatureID::NONE; });
+
+	bool canStart = (startInfo->selectedTerrain || startInfo->selectedTown);
+	canStart &= (startInfo->selectedHero[0] && ((startInfo->selectedHero[1]) || (startInfo->selectedTown && !army2Empty)));
+	buttonOk->block(!canStart || GAME->server().isGuest());
+	buttonAbort->block(GAME->server().isGuest());
+}
+
+std::shared_ptr<IImage> drawBlackBox(Point size, std::string text, ColorRGBA color)
+{
+	auto image = ENGINE->renderHandler().createImage(size, CanvasScalingPolicy::AUTO);
+	Canvas canvas = image->getCanvas();
+	canvas.drawColor(Rect(0, 0, size.x, size.y), Colors::BLACK);
+	canvas.drawText(Point(size.x / 2, size.y / 2), FONT_TINY, color, ETextAlignment::CENTER, text);
+	return image;
+}
+
+BattleOnlyModeHeroSelector::BattleOnlyModeHeroSelector(int id, BattleOnlyModeWindow& p, Point position)
+: parent(p)
+, id(id)
+{
+	OBJECT_CONSTRUCTION;
+
+	pos.x += position.x;
+	pos.y += position.y;
+
+	backgroundImage = std::make_shared<CPicture>(ImagePath::builtin("heroSlotsBlue"), Point(3, 4));
+
+	for(size_t i=0; i<GameConstants::PRIMARY_SKILLS; i++)
+	{
+		auto image = std::make_shared<CAnimImage>(AnimationPath::builtin("PSKIL32"), i, 0, 78 + i * 36, 26);
+		primSkills.push_back(image);
+		primSkillsBorder.push_back(std::make_shared<GraphicalPrimitiveCanvas>(Rect(78 + i * 36, 26, 32, 32)));
+		primSkillsBorder.back()->addRectangle(Point(0, 0), Point(32, 32), ColorRGBA(44, 108, 255));
+		primSkillsInput.push_back(std::make_shared<CTextInput>(Rect(78 + i * 36, 58, 32, 16), EFonts::FONT_SMALL, ETextAlignment::CENTER, false));
+		primSkillsInput.back()->setColor(id == 1 ? Colors::WHITE : parent.disabledColor);
+		primSkillsInput.back()->setFilterNumber(0, 100);
+		primSkillsInput.back()->setText("0");
+		primSkillsInput.back()->setCallback([this, i, id](const std::string & text){
+			parent.startInfo->primSkillLevel[id][i] = std::stoi(primSkillsInput[i]->getText());
+			parent.onChange();
+		});
+	}
+
+	creatureImage.resize(GameConstants::ARMY_SIZE);
+	for(size_t i=0; i<GameConstants::ARMY_SIZE; i++)
+	{
+		selectedArmyInput.push_back(std::make_shared<CTextInput>(Rect(5 + i * 36, 113, 32, 16), EFonts::FONT_SMALL, ETextAlignment::CENTER, false));
+		selectedArmyInput.back()->setColor(id == 1 ? Colors::WHITE : parent.disabledColor);
+		selectedArmyInput.back()->setFilterNumber(1, 10000000, 3);
+		selectedArmyInput.back()->setText("1");
+		selectedArmyInput.back()->setCallback([this, i, id](const std::string & text){
+			if(parent.startInfo->selectedArmy[id][i].getId() != CreatureID::NONE)
+			{
+				parent.startInfo->selectedArmy[id][i].setCount(TextOperations::parseMetric<int>(text));
+				parent.onChange();
+				selectedArmyInput[i]->enable();
+			}
+			else
+				selectedArmyInput[i]->disable();
+		});
+	}
+
+	setHeroIcon();
+	setCreatureIcons();
+}
+
+void BattleOnlyModeHeroSelector::setHeroIcon()
+{
+	OBJECT_CONSTRUCTION;
+
+	if(!parent.startInfo->selectedHero[id])
+	{
+		heroImage = std::make_shared<CPicture>(drawBlackBox(Point(58, 64), LIBRARY->generaltexth->translate("vcmi.lobby.battleOnlyModeSelect"), id == 1 ? Colors::WHITE : parent.disabledColor), Point(6, 7));
+		heroLabel = std::make_shared<CLabel>(160, 16, FONT_SMALL, ETextAlignment::CENTER, id == 1 ? Colors::WHITE : parent.disabledColor, LIBRARY->generaltexth->translate("core.genrltxt.507"));
+		for(size_t i=0; i<GameConstants::PRIMARY_SKILLS; i++)
+			primSkillsInput[i]->setText("0");
+	}
+	else
+	{
+		heroImage = std::make_shared<CPicture>(ENGINE->renderHandler().loadAnimation(AnimationPath::builtin("PortraitsLarge"), EImageBlitMode::COLORKEY)->getImage((*parent.startInfo->selectedHero[id]).toHeroType()->imageIndex), Point(6, 7));
+		heroLabel = std::make_shared<CLabel>(160, 16, FONT_SMALL, ETextAlignment::CENTER, id == 1 ? Colors::WHITE : parent.disabledColor, (*parent.startInfo->selectedHero[id]).toHeroType()->getNameTranslated());
+		for(size_t i=0; i<GameConstants::PRIMARY_SKILLS; i++)
+			primSkillsInput[i]->setText(std::to_string(parent.startInfo->primSkillLevel[id][i]));
+	}
+
+	heroImage->addLClickCallback([this](){
+		auto allowedSet = LIBRARY->heroh->getDefaultAllowed();
+		std::vector<HeroTypeID> heroes(allowedSet.begin(), allowedSet.end());
+		std::sort(heroes.begin(), heroes.end(), [](auto a, auto b) {
+			auto heroA = a.toHeroType();
+			auto heroB = b.toHeroType();
+			if(heroA->heroClass->faction != heroB->heroClass->faction)
+				return heroA->heroClass->faction < heroB->heroClass->faction;
+			if(heroA->heroClass->getId() != heroB->heroClass->getId())
+				return heroA->heroClass->getId() < heroB->heroClass->getId();
+			return heroA->getNameTranslated() < heroB->getNameTranslated();
+		});
+
+		int selectedIndex = !parent.startInfo->selectedHero[id] ? 0 : (1 + std::distance(heroes.begin(), std::find_if(heroes.begin(), heroes.end(), [this](auto heroID) {
+			return heroID == (*parent.startInfo->selectedHero[id]);
+    	})));
+		
+		std::vector<std::string> texts;
+		std::vector<std::shared_ptr<IImage>> images;
+		// Add "no hero" option
+		texts.push_back(LIBRARY->generaltexth->translate("core.genrltxt.507"));
+		images.push_back(nullptr);
+		for (const auto & h : heroes)
+		{
+			texts.push_back(h.toHeroType()->getNameTranslated());
+
+			auto image = ENGINE->renderHandler().loadImage(AnimationPath::builtin("PortraitsSmall"), h.toHeroType()->imageIndex, 0, EImageBlitMode::OPAQUE);
+			image->scaleTo(Point(35, 23), EScalingAlgorithm::NEAREST);
+			images.push_back(image);
+		}
+		auto window = std::make_shared<CObjectListWindow>(texts, nullptr, LIBRARY->generaltexth->translate("vcmi.lobby.battleOnlyModeHeroSelect"), LIBRARY->generaltexth->translate("vcmi.lobby.battleOnlyModeHeroSelect"), [this, heroes](int index){
+			if(index == 0)
+			{
+				parent.startInfo->selectedHero[id] = std::nullopt;
+				parent.onChange();
+				return;
+			}
+			index--;
+
+			parent.startInfo->selectedHero[id] = heroes[index];
+
+			for(size_t i=0; i<GameConstants::PRIMARY_SKILLS; i++)
+				parent.startInfo->primSkillLevel[id][i] = 0;
+			parent.onChange();
+		}, selectedIndex, images, true, true);
+		window->onPopup = [heroes](int index) {
+			if(index == 0)
+				return;
+			index--;
+
+			ENGINE->windows().createAndPushWindow<CHeroOverview>(heroes.at(index));
+		};
+		ENGINE->windows().pushWindow(window);
+	});
+
+	heroImage->addRClickCallback([this](){
+		if(!parent.startInfo->selectedHero[id])
+			return;
+		
+		ENGINE->windows().createAndPushWindow<CHeroOverview>(parent.startInfo->selectedHero[id]->toHeroType()->getId());
+	});
+}
+
+void BattleOnlyModeHeroSelector::setCreatureIcons()
+{
+	OBJECT_CONSTRUCTION;
+
+	for(int i = 0; i < creatureImage.size(); i++)
+	{
+		if(parent.startInfo->selectedArmy[id][i].getId() == CreatureID::NONE)
+		{
+			creatureImage[i] = std::make_shared<CPicture>(drawBlackBox(Point(32, 32), LIBRARY->generaltexth->translate("vcmi.lobby.battleOnlyModeSelect"), id == 1 ? Colors::WHITE : parent.disabledColor), Point(6 + i * 36, 78));
+			selectedArmyInput[i]->disable();
+		}
+		else
+		{
+			auto unit = parent.startInfo->selectedArmy[id][i];
+			auto creatureID = unit.getId();
+			creatureImage[i] = std::make_shared<CPicture>(ENGINE->renderHandler().loadAnimation(AnimationPath::builtin("CPRSMALL"), EImageBlitMode::COLORKEY)->getImage(LIBRARY->creh->objects.at(creatureID)->getIconIndex()), Point(6 + i * 36, 78));
+			selectedArmyInput[i]->setText(TextOperations::formatMetric(unit.getCount(), 3));
+			selectedArmyInput[i]->enable();
+		}
+
+		creatureImage[i]->addLClickCallback([this, i](){
+			auto allowedSet = LIBRARY->creh->getDefaultAllowed();
+			std::vector<CreatureID> creatures(allowedSet.begin(), allowedSet.end());
+			std::sort(creatures.begin(), creatures.end(), [](auto a, auto b) {
+				auto creatureA = a.toCreature();
+				auto creatureB = b.toCreature();
+				if(creatureA->getFactionID() != creatureB->getFactionID())
+					return creatureA->getFactionID() < creatureB->getFactionID();
+				if(creatureA->getLevel() != creatureB->getLevel())
+					return creatureA->getLevel() < creatureB->getLevel();
+				if(creatureA->upgrades.size() != creatureB->upgrades.size())
+					return creatureA->upgrades.size() > creatureB->upgrades.size();
+				return creatureA->getNameSingularTranslated() < creatureB->getNameSingularTranslated();
+			});
+
+			int selectedIndex = parent.startInfo->selectedArmy[id][i].getId() == CreatureID::NONE ? 0 : (1 + std::distance(creatures.begin(), std::find_if(creatures.begin(), creatures.end(), [this, i](auto creatureID) {
+				return creatureID == parent.startInfo->selectedArmy[id][i].getId();
+			})));
+			
+			std::vector<std::string> texts;
+			std::vector<std::shared_ptr<IImage>> images;
+			// Add "no creature" option
+			texts.push_back(LIBRARY->generaltexth->translate("core.genrltxt.507"));
+			images.push_back(nullptr);
+			for (const auto & c : creatures)
+			{
+				texts.push_back(c.toCreature()->getNameSingularTranslated());
+
+				auto image = ENGINE->renderHandler().loadImage(AnimationPath::builtin("CPRSMALL"), c.toCreature()->getIconIndex(), 0, EImageBlitMode::OPAQUE);
+				image->scaleTo(Point(23, 23), EScalingAlgorithm::NEAREST);
+				images.push_back(image);
+			}
+			auto window = std::make_shared<CObjectListWindow>(texts, nullptr, LIBRARY->generaltexth->translate("vcmi.lobby.battleOnlyModeCreatureSelect"), LIBRARY->generaltexth->translate("vcmi.lobby.battleOnlyModeCreatureSelect"), [this, creatures, i](int index){
+				if(index == 0)
+				{
+					parent.startInfo->selectedArmy[id][i] = CStackBasicDescriptor(CreatureID::NONE, 1);
+					parent.onChange();
+					return;
+				}
+				index--;
+
+				auto creature = creatures.at(index).toCreature();
+				parent.startInfo->selectedArmy[id][i] = CStackBasicDescriptor(creature->getId(), 100);
+				parent.onChange();
+			}, selectedIndex, images, true, true);
+			window->onPopup = [creatures](int index) {
+				if(index == 0)
+					return;
+				index--;
+
+				ENGINE->windows().createAndPushWindow<CStackWindow>(creatures.at(index).toCreature(), true);
+			};
+			ENGINE->windows().pushWindow(window);
+		});
+
+		creatureImage[i]->addRClickCallback([this, i](){
+			if(parent.startInfo->selectedArmy[id][i].getId() == CreatureID::NONE)
+				return;
+			
+			ENGINE->windows().createAndPushWindow<CStackWindow>(LIBRARY->creh->objects.at(parent.startInfo->selectedArmy[id][i].getId()).get(), true);
+		});
+	}
+}
+
+void BattleOnlyModeWindow::startBattle()
+{
+	auto rng = &CRandomGenerator::getDefault();
+	
+	map->initTerrain();
+	map->getEditManager()->clearTerrain(rng);
+
+	map->getEditManager()->getTerrainSelection().selectAll();
+	map->getEditManager()->drawTerrain(!startInfo->selectedTerrain ? TerrainId::DIRT : *startInfo->selectedTerrain, 0, rng);
+
+	map->players[0].canComputerPlay = true;
+	map->players[0].canHumanPlay = true;
+	map->players[1] = map->players[0];
+
+	auto knownHeroes = LIBRARY->objtypeh->knownSubObjects(Obj::HERO);
+
+	auto addHero = [&, this](int sel, PlayerColor color, const int3 & position)
+	{
+		auto factory = LIBRARY->objtypeh->getHandlerFor(Obj::HERO, (*startInfo->selectedHero[sel]).toHeroType()->heroClass->getId());
+		auto templates = factory->getTemplates();
+		auto obj = std::dynamic_pointer_cast<CGHeroInstance>(factory->create(cb.get(), templates.front()));
+		obj->setHeroType(*startInfo->selectedHero[sel]);
+
+		obj->setOwner(color);
+		obj->pos = position;
+		for(size_t i=0; i<GameConstants::PRIMARY_SKILLS; i++)
+			obj->pushPrimSkill(PrimarySkill(i), startInfo->primSkillLevel[sel][i]);
+		obj->clearSlots();
+		for(int slot = 0; slot < GameConstants::ARMY_SIZE; slot++)
+			if(startInfo->selectedArmy[sel][slot].getId() != CreatureID::NONE)
+				obj->setCreature(SlotID(slot), startInfo->selectedArmy[sel][slot].getId(), startInfo->selectedArmy[sel][slot].getCount());
+		map->getEditManager()->insertObject(obj);
+	};
+
+	addHero(0, PlayerColor(0), int3(5, 6, 0));
+	if(!startInfo->selectedTown)
+		addHero(1, PlayerColor(1), int3(5, 5, 0));
+	else
+	{
+		auto factory = LIBRARY->objtypeh->getHandlerFor(Obj::TOWN, *startInfo->selectedTown);
+		auto templates = factory->getTemplates();
+		auto obj = factory->create(cb.get(), templates.front());
+		auto townObj = std::dynamic_pointer_cast<CGTownInstance>(obj);
+		obj->setOwner(PlayerColor(1));
+		obj->pos = int3(5, 5, 0);
+		for (const auto & building : townObj->getTown()->getAllBuildings())
+			townObj->addBuilding(building);
+		if(!startInfo->selectedHero[1])
+		{
+			for(int slot = 0; slot < GameConstants::ARMY_SIZE; slot++)
+				if(startInfo->selectedArmy[1][slot].getId() != CreatureID::NONE)
+					townObj->getArmy()->setCreature(SlotID(slot), startInfo->selectedArmy[1][slot].getId(), startInfo->selectedArmy[1][slot].getCount());
+		}
+		else
+			addHero(1, PlayerColor(1), int3(5, 5, 0));
+
+		map->getEditManager()->insertObject(townObj);
+	}
+
+	auto path = VCMIDirs::get().userDataPath() / "Maps";
+	boost::filesystem::create_directories(path);
+	const std::string fileName = "BattleOnlyMode.vmap";
+	const auto fullPath = path / fileName;
+	CMapService mapService;
+	mapService.saveMap(map, fullPath);
+	CResourceHandler::get()->updateFilteredFiles([&](const std::string & mount) { return true; });
+
+	auto mapInfo = std::make_shared<CMapInfo>();
+	mapInfo->mapInit("Maps/BattleOnlyMode");
+	GAME->server().setMapInfo(mapInfo);
+	ExtraOptionsInfo extraOptions;
+	extraOptions.unlimitedReplay = true;
+	GAME->server().setExtraOptionsInfo(extraOptions);
+	GAME->server().sendStartGame();
+}

+ 92 - 0
client/lobby/BattleOnlyMode.h

@@ -0,0 +1,92 @@
+/*
+ * BattleOnlyMode.h, part of VCMI engine
+ *
+ * Authors: listed in file AUTHORS in main folder
+ *
+ * License: GNU General Public License v2.0 or later
+ * Full text of license available in license.txt file, in main folder
+ *
+ */
+#pragma once
+
+#include "../windows/CWindowObject.h"
+#include "../../lib/constants/EntityIdentifiers.h"
+
+
+VCMI_LIB_NAMESPACE_BEGIN
+class CGHeroInstance;
+class CCreatureSet;
+class CMap;
+class EditorCallback;
+class BattleOnlyModeStartInfo;
+VCMI_LIB_NAMESPACE_END
+
+class FilledTexturePlayerColored;
+class CButton;
+class CPicture;
+class CLabel;
+class BattleOnlyModeWindow;
+class CAnimImage;
+class GraphicalPrimitiveCanvas;
+class CTextInput;
+class TransparentFilledRectangle;
+
+class BattleOnlyMode
+{
+public:
+	static void openBattleWindow();
+};
+
+class BattleOnlyModeHeroSelector : public CIntObject
+{
+private:
+	BattleOnlyModeWindow& parent;
+
+	std::shared_ptr<CPicture> backgroundImage;
+	std::shared_ptr<CPicture> heroImage;
+	std::shared_ptr<CLabel> heroLabel;
+	std::vector<std::shared_ptr<CPicture>> creatureImage;
+
+	int id;
+public:
+	std::vector<std::shared_ptr<CAnimImage>> primSkills;
+	std::vector<std::shared_ptr<GraphicalPrimitiveCanvas>> primSkillsBorder;
+	std::vector<std::shared_ptr<CTextInput>> primSkillsInput;
+
+	std::vector<std::shared_ptr<CTextInput>> selectedArmyInput;
+
+	void setHeroIcon();
+	void setCreatureIcons();
+	BattleOnlyModeHeroSelector(int id, BattleOnlyModeWindow& parent, Point position);
+};
+
+class BattleOnlyModeWindow : public CWindowObject
+{
+	friend class BattleOnlyModeHeroSelector;
+private:
+	std::shared_ptr<BattleOnlyModeStartInfo> startInfo;
+	std::unique_ptr<CMap> map;
+	std::shared_ptr<EditorCallback> cb;
+
+	std::shared_ptr<FilledTexturePlayerColored> backgroundTexture;
+	std::shared_ptr<CButton> buttonOk;
+	std::shared_ptr<CButton> buttonAbort;
+	std::shared_ptr<CLabel> title;
+
+	std::shared_ptr<CButton> battlefieldSelector;
+	std::shared_ptr<CButton> buttonReset;
+	std::shared_ptr<BattleOnlyModeHeroSelector> heroSelector1;
+	std::shared_ptr<BattleOnlyModeHeroSelector> heroSelector2;
+
+	ColorRGBA disabledColor;
+
+	void init();
+	void onChange();
+	void update();
+	void setTerrainButtonText();
+	void setOkButtonEnabled();
+	void startBattle();
+public:
+	BattleOnlyModeWindow();
+	void applyStartInfo(std::shared_ptr<BattleOnlyModeStartInfo> si);
+};

+ 16 - 4
client/lobby/RandomMapTab.cpp

@@ -172,14 +172,26 @@ RandomMapTab::RandomMapTab():
 		{
 			std::vector<std::string> texts;
 			texts.push_back(readText(variables["randomTemplate"]));
-			for(auto & t : getTemplates())
-				texts.push_back(t->getName());
+
+			auto selectedTemplate = mapGenOptions->getMapTemplate();
+			const auto& templates = getTemplates();
+			for(int i = 0; i < templates.size(); i++)
+			{
+				if(selectedTemplate)
+				{
+					if(templates[i]->getId() == selectedTemplate->getId())
+						templateIndex = i + 1;
+				}
+				else
+					templateIndex = 0;
+
+				texts.push_back(templates[i]->getName());
+			}
 
 			ENGINE->windows().popWindows(1);
 			ENGINE->windows().createAndPushWindow<CObjectListWindow>(texts, nullptr, LIBRARY->generaltexth->translate("vcmi.lobby.templatesSelect.hover"), LIBRARY->generaltexth->translate("vcmi.lobby.templatesSelect.help"), [this](int index){
 				widget<ComboBox>("templateList")->setItem(index);
-				templateIndex = index;
-			}, templateIndex, std::vector<std::shared_ptr<IImage>>(), true);
+			}, templateIndex, std::vector<std::shared_ptr<IImage>>(), true, true);
 		});
 	}
 	

+ 11 - 0
client/lobby/SelectionTab.cpp

@@ -12,6 +12,7 @@
 #include "SelectionTab.h"
 #include "CSelectionBase.h"
 #include "CLobbyScreen.h"
+#include "BattleOnlyMode.h"
 
 #include "../CPlayerInterface.h"
 #include "../CServerHandler.h"
@@ -241,6 +242,13 @@ SelectionTab::SelectionTab(ESelectionScreen Type)
 		sortByDate->setOverlay(std::make_shared<CPicture>(ImagePath::builtin("lobby/selectionTabSortDate")));
 		buttonsSortBy.push_back(sortByDate);
 
+		if(tabType == ESelectionScreen::newGame)
+		{
+			buttonBattleOnlyMode = std::make_shared<CButton>(Point(23, 18), AnimationPath::builtin("lobby/battleButton"), CButton::tooltip("", LIBRARY->generaltexth->translate("vcmi.lobby.battleOnlyMode")), [tabTitle, tabTitleDelete](){
+				BattleOnlyMode::openBattleWindow();
+			});
+		}
+
 		if(tabType == ESelectionScreen::loadGame || tabType == ESelectionScreen::newGame)
 		{
 			buttonDeleteMode = std::make_shared<CButton>(Point(367, 18), AnimationPath::builtin("lobby/deleteButton"), CButton::tooltip("", LIBRARY->generaltexth->translate("vcmi.lobby.deleteMode")), [this, tabTitle, tabTitleDelete](){
@@ -315,6 +323,8 @@ void SelectionTab::toggleMode()
 	{
 		if(slider)
 			slider->block(true);
+		if(buttonBattleOnlyMode)
+			buttonBattleOnlyMode->block(true);
 	}
 	else
 	{
@@ -325,6 +335,7 @@ void SelectionTab::toggleMode()
 				inputName->disable();
 				auto files = getFiles("Maps/", EResType::MAP);
 				files.erase(ResourcePath("Maps/Tutorial.tut", EResType::MAP));
+				files.erase(ResourcePath("Maps/BattleOnlyMode.vmap", EResType::MAP));
 				parseMaps(files);
 				break;
 			}

+ 2 - 0
client/lobby/SelectionTab.h

@@ -128,6 +128,8 @@ private:
 	std::shared_ptr<CButton> buttonDeleteMode;
 	bool deleteMode;
 
+	std::shared_ptr<CButton> buttonBattleOnlyMode;
+
 	bool enableUiEnhancements;
 	std::shared_ptr<CButton> buttonCampaignSet;
 

+ 89 - 11
client/render/AssetGenerator.cpp

@@ -93,6 +93,14 @@ void AssetGenerator::initialize()
 	
 	animationFiles[AnimationPath::builtin("SPRITES/GSPButtonClear")] = createGSPButtonClear();
 
+	for (PlayerColor color(-1); color < PlayerColor::PLAYER_LIMIT; ++color)
+	{
+		std::string name = "TownPortalBackgroundBlue" + (color == -1 ? "" : "-" + color.toString());
+		imageFiles[ImagePath::builtin(name)] = [this, color](){ return createGateListColored(std::max(PlayerColor(0), color), PlayerColor(1)); };
+	}
+
+	imageFiles[ImagePath::builtin("heroSlotsBlue.png")] = [this](){ return createHeroSlotsColored(PlayerColor(1));};
+
 	createPaletteShiftedSprites();
 }
 
@@ -129,6 +137,22 @@ void AssetGenerator::addAnimationFile(const AnimationPath & path, AnimationLayou
 	animationFiles[path] = anim;
 }
 
+auto getColorFilters()
+{
+	auto filterSettings = LIBRARY->settingsHandler->getFullConfig()["interface"]["playerColoredBackground"];
+	static const std::array<ColorFilter, PlayerColor::PLAYER_LIMIT_I> filters = {
+		ColorFilter::genRangeShifter( filterSettings["red"   ].convertTo<std::vector<float>>() ),
+		ColorFilter::genRangeShifter( filterSettings["blue"  ].convertTo<std::vector<float>>() ),
+		ColorFilter::genRangeShifter( filterSettings["tan"   ].convertTo<std::vector<float>>() ),
+		ColorFilter::genRangeShifter( filterSettings["green" ].convertTo<std::vector<float>>() ),
+		ColorFilter::genRangeShifter( filterSettings["orange"].convertTo<std::vector<float>>() ),
+		ColorFilter::genRangeShifter( filterSettings["purple"].convertTo<std::vector<float>>() ),
+		ColorFilter::genRangeShifter( filterSettings["teal"  ].convertTo<std::vector<float>>() ),
+		ColorFilter::genRangeShifter( filterSettings["pink"  ].convertTo<std::vector<float>>() )
+	};
+	return filters;
+}
+
 AssetGenerator::CanvasPtr AssetGenerator::createAdventureOptionsCleanBackground() const
 {
 	auto locator = ImageLocator(ImagePath::builtin("ADVOPTBK"), EImageBlitMode::OPAQUE);
@@ -208,17 +232,7 @@ AssetGenerator::CanvasPtr AssetGenerator::createPlayerColoredBackground(const Pl
 	std::shared_ptr<IImage> texture = ENGINE->renderHandler().loadImage(locator);
 
 	// transform to make color of brown DIBOX.PCX texture match color of specified player
-	auto filterSettings = LIBRARY->settingsHandler->getFullConfig()["interface"]["playerColoredBackground"];
-	static const std::array<ColorFilter, PlayerColor::PLAYER_LIMIT_I> filters = {
-		ColorFilter::genRangeShifter( filterSettings["red"   ].convertTo<std::vector<float>>() ),
-		ColorFilter::genRangeShifter( filterSettings["blue"  ].convertTo<std::vector<float>>() ),
-		ColorFilter::genRangeShifter( filterSettings["tan"   ].convertTo<std::vector<float>>() ),
-		ColorFilter::genRangeShifter( filterSettings["green" ].convertTo<std::vector<float>>() ),
-		ColorFilter::genRangeShifter( filterSettings["orange"].convertTo<std::vector<float>>() ),
-		ColorFilter::genRangeShifter( filterSettings["purple"].convertTo<std::vector<float>>() ),
-		ColorFilter::genRangeShifter( filterSettings["teal"  ].convertTo<std::vector<float>>() ),
-		ColorFilter::genRangeShifter( filterSettings["pink"  ].convertTo<std::vector<float>>() )
-	};
+	static const std::array<ColorFilter, PlayerColor::PLAYER_LIMIT_I> filters = getColorFilters();
 
 	assert(player.isValidPlayer());
 	if (!player.isValidPlayer())
@@ -884,3 +898,67 @@ AssetGenerator::AnimationLayoutMap AssetGenerator::createGSPButtonClear()
 
 	return layout;
 }
+
+AssetGenerator::CanvasPtr AssetGenerator::createGateListColored(PlayerColor color, PlayerColor backColor) const
+{
+	auto locator = ImageLocator(ImagePath::builtin("TpGate"), EImageBlitMode::COLORKEY);
+	std::shared_ptr<IImage> img = ENGINE->renderHandler().loadImage(locator);
+	img->playerColored(color);
+	std::shared_ptr<IImage> imgColored = ENGINE->renderHandler().loadImage(locator);
+	static const std::array<ColorFilter, PlayerColor::PLAYER_LIMIT_I> filters = getColorFilters();
+	imgColored->adjustPalette(filters[backColor.getNum()], 0);
+
+	auto image = ENGINE->renderHandler().createImage(img->dimensions(), CanvasScalingPolicy::IGNORE);
+	Canvas canvas = image->getCanvas();
+
+	canvas.draw(imgColored, Point(0, 0));
+
+	std::vector<Rect> keepOriginalRects = {
+		Rect(0, 0, 14, 393),
+		Rect(293, 0, 13, 393),
+		Rect(0, 393, 8, 76),
+		Rect(299, 393, 6, 76),
+		Rect(0, 0, 306, 16),
+		Rect(0, 383, 306, 10),
+		Rect(0, 441, 306, 2),
+		Rect(0, 462, 306, 7),
+		// Edges
+		Rect(14, 15, 2, 5),
+		Rect(16, 15, 3, 2),
+		Rect(16, 17, 1, 1),
+		Rect(14, 379, 3, 4),
+		Rect(16, 381, 2, 2),
+		Rect(16, 380, 1, 1),
+		Rect(289, 16, 2, 2),
+		Rect(291, 16, 2, 4),
+		Rect(289, 381, 2, 2),
+		Rect(291, 379, 2, 4)
+	};
+	for(auto & rect : keepOriginalRects)
+		canvas.draw(img, Point(rect.x, rect.y), rect);
+
+	std::vector<Rect> blackRect = {
+		Rect(14, 401, 66, 32),
+		Rect(227, 401, 66, 32)
+	};
+	for(auto & rect : blackRect)
+		canvas.drawBorder(rect, Colors::BLACK);
+
+	return image;
+}
+
+AssetGenerator::CanvasPtr AssetGenerator::createHeroSlotsColored(PlayerColor backColor) const
+{
+	auto locator = ImageLocator(AnimationPath::builtin("OVSLOT"), 4, 0, EImageBlitMode::COLORKEY);
+	std::shared_ptr<IImage> img = ENGINE->renderHandler().loadImage(locator);
+	static const std::array<ColorFilter, PlayerColor::PLAYER_LIMIT_I> filters = getColorFilters();
+	img->adjustPalette(filters[backColor.getNum()], 0);
+
+	auto image = ENGINE->renderHandler().createImage(Point(260, 150), CanvasScalingPolicy::IGNORE);
+	Canvas canvas = image->getCanvas();
+	canvas.draw(img, Point(0, 0), Rect(3, 4, 253, 107));
+	for(int i = 0; i<7; i++)
+		canvas.draw(img, Point(1 + i * 36, 108), Rect(76, 57, 35, 17));
+
+	return image;
+}

+ 2 - 0
client/render/AssetGenerator.h

@@ -66,6 +66,8 @@ private:
 	CanvasPtr createCreatureInfoPanelElement(CreatureInfoPanelElement element) const;
 	CanvasPtr createQuestWindow() const;
 	AnimationLayoutMap createGSPButtonClear();
+	CanvasPtr createGateListColored(PlayerColor color, PlayerColor backColor) const;
+	CanvasPtr createHeroSlotsColored(PlayerColor backColor) const;
 
 	void createPaletteShiftedSprites();
 	void generatePaletteShiftedAnimation(const AnimationPath & source, const std::vector<PaletteAnimation> & animation);

+ 2 - 0
client/render/CanvasImage.cpp

@@ -65,6 +65,8 @@ Rect CanvasImage::contentRect() const
 
 Point CanvasImage::dimensions() const
 {
+	if (scalingPolicy != CanvasScalingPolicy::IGNORE)
+		return Point(surface->w, surface->h) / ENGINE->screenHandler().getScalingFactor();
 	return {surface->w, surface->h};
 }
 

+ 59 - 32
client/widgets/CTextInput.cpp

@@ -200,9 +200,9 @@ void CTextInput::setFilterFilename()
 	onTextFiltering = std::bind(&CTextInput::filenameFilter, _1, _2);
 }
 
-void CTextInput::setFilterNumber(int minValue, int maxValue)
+void CTextInput::setFilterNumber(int minValue, int maxValue, int metricDigits)
 {
-	onTextFiltering = std::bind(&CTextInput::numberFilter, _1, _2, minValue, maxValue);
+	onTextFiltering = std::bind(&CTextInput::numberFilter, _1, _2, minValue, maxValue, metricDigits);
 }
 
 std::string CTextInput::getVisibleText() const
@@ -256,6 +256,10 @@ void CTextInput::keyPressed(EShortcut key)
 
 	if(redrawNeeded)
 	{
+		std::string oldText = currentText;
+		if(onTextFiltering)
+			onTextFiltering(currentText, oldText);
+
 		updateLabel();
 		if(onTextEdited)
 			onTextEdited(currentText);
@@ -295,9 +299,9 @@ void CTextInput::textInputted(const std::string & enteredText)
 	if(onTextFiltering)
 		onTextFiltering(currentText, oldText);
 
+	updateLabel();
 	if(currentText != oldText)
 	{
-		updateLabel();
 		if(onTextEdited)
 			onTextEdited(currentText);
 	}
@@ -321,40 +325,63 @@ void CTextInput::filenameFilter(std::string & text, const std::string &oldText)
 		text.erase(pos, 1);
 }
 
-void CTextInput::numberFilter(std::string & text, const std::string & oldText, int minValue, int maxValue)
+std::optional<char> getMetricSuffix(const std::string& text)
+{
+	const std::string suffixes = "kKmMgGtTpPeE";
+	std::vector<char> found;
+
+	// Collect all suffixes in the string
+	for (char c : text) {
+		if (suffixes.find(c) != std::string::npos) {
+			// Normalize: 'k' lowercase, others uppercase
+			found.push_back((c == 'k' || c == 'K') ? 'k' : static_cast<char>(std::toupper(c)));
+		}
+	}
+
+	if (found.empty()) return std::nullopt;            // No suffix
+	if (found.size() == 1) return found[0];           // Single suffix
+	// More than one suffix
+	bool allSame = std::all_of(found.begin(), found.end(), [&](char c){ return c == found[0]; });
+	if (allSame) return std::nullopt;                 // Multiple but identical → nullopt
+	return found.back();                               // Multiple different → last suffix
+}
+
+
+void CTextInput::numberFilter(std::string & text, const std::string & oldText, int minValue, int maxValue, int metricDigits)
 {
 	assert(minValue < maxValue);
 
-	if(text.empty())
-		text = "0";
+	bool isNegative = std::count_if(text.begin(), text.end(), [](char c){ return c == '-'; }) == 1 && minValue < 0;
+	auto suffix = getMetricSuffix(text);
+	if(metricDigits == 0)
+		suffix = std::nullopt;
 
-	size_t pos = 0;
-	if(text[0] == '-') //allow '-' sign as first symbol only
-		pos++;
+	// Remove all non-digit characters
+	text.erase(std::remove_if(text.begin(), text.end(), [](char c){ return !isdigit(c); }), text.end());
 
-	while(pos < text.size())
-	{
-		if(text[pos] < '0' || text[pos] > '9')
-		{
-			text = oldText;
-			return; //new text is not number.
-		}
-		pos++;
-	}
-	try
-	{
-		int value = boost::lexical_cast<int>(text);
-		if(value < minValue)
-			text = std::to_string(minValue);
-		else if(value > maxValue)
-			text = std::to_string(maxValue);
-	}
-	catch(boost::bad_lexical_cast &)
-	{
-		//Should never happen. Unless I missed some cases
-		logGlobal->warn("Warning: failed to convert %s to number!", text);
-		text = oldText;
-	}
+	// Remove leading zeros
+	size_t firstNonZero = text.find_first_not_of('0');
+	if (firstNonZero > 0)
+		text.erase(0, firstNonZero);
+
+	if (text.empty())
+		text = "0";
+
+	// Add negative sign
+	text = (isNegative ? "-" : "") + text;
+
+	// Restore suffix if it exists
+	if (suffix)
+		text += *suffix;
+
+	// Clamp value
+	int value = TextOperations::parseMetric<int>(text);
+	if (metricDigits)
+		text = (isNegative && value == 0 ? "-" : "") + TextOperations::formatMetric(value, metricDigits);
+	if (value < minValue)
+		text = metricDigits ? TextOperations::formatMetric(minValue, metricDigits) : std::to_string(minValue);
+	else if (value > maxValue)
+		text = metricDigits ? TextOperations::formatMetric(maxValue, metricDigits) : std::to_string(maxValue);
 }
 
 void CTextInput::activate()

+ 2 - 2
client/widgets/CTextInput.h

@@ -66,7 +66,7 @@ protected:
 	static void filenameFilter(std::string & text, const std::string & oldText);
 	//Filter that will allow only input of numbers in range min-max (min-max are allowed)
 	//min-max should be set via something like std::bind
-	static void numberFilter(std::string & text, const std::string & oldText, int minValue, int maxValue);
+	static void numberFilter(std::string & text, const std::string & oldText, int minValue, int maxValue, int metricDigits);
 
 	std::string getVisibleText() const;
 	void createLabel(bool giveFocusToInput);
@@ -99,7 +99,7 @@ public:
 	/// Enables filtering entered text that ensures that text is valid filename (existing or not)
 	void setFilterFilename();
 	/// Enable filtering entered text that ensures that text is valid number in provided range [min, max]
-	void setFilterNumber(int minValue, int maxValue);
+	void setFilterNumber(int minValue, int maxValue, int metricDigits=0);
 
 	void setFont(EFonts Font);
 	void setColor(const ColorRGBA & Color);

+ 15 - 4
client/widgets/Images.cpp

@@ -46,7 +46,7 @@ CPicture::CPicture(std::shared_ptr<IImage> image, const Point & position)
 	pos.w = bg->width();
 	pos.h = bg->height();
 
-	addUsedEvents(SHOW_POPUP);
+	addUsedEvents(LCLICK | SHOW_POPUP);
 }
 
 CPicture::CPicture( const ImagePath &bmpname, int x, int y )
@@ -75,7 +75,7 @@ CPicture::CPicture( const ImagePath & bmpname, const Point & position, EImageBli
 		pos.w = pos.h = 0;
 	}
 
-	addUsedEvents(SHOW_POPUP);
+	addUsedEvents(LCLICK | SHOW_POPUP);
 }
 
 CPicture::CPicture( const ImagePath & bmpname, const Point & position )
@@ -89,7 +89,7 @@ CPicture::CPicture(const ImagePath & bmpname, const Rect &SrcRect, int x, int y)
 	pos.w = srcRect->w;
 	pos.h = srcRect->h;
 
-	addUsedEvents(SHOW_POPUP);
+	addUsedEvents(LCLICK | SHOW_POPUP);
 }
 
 CPicture::CPicture(std::shared_ptr<IImage> image, const Rect &SrcRect, int x, int y)
@@ -99,7 +99,7 @@ CPicture::CPicture(std::shared_ptr<IImage> image, const Rect &SrcRect, int x, in
 	pos.w = srcRect->w;
 	pos.h = srcRect->h;
 
-	addUsedEvents(SHOW_POPUP);
+	addUsedEvents(LCLICK | SHOW_POPUP);
 }
 
 void CPicture::show(Canvas & to)
@@ -137,11 +137,22 @@ void CPicture::setPlayerColor(PlayerColor player)
 	bg->playerColored(player);
 }
 
+void CPicture::addLClickCallback(const std::function<void()> & callback)
+{
+	lCallback = callback;
+}
+
 void CPicture::addRClickCallback(const std::function<void()> & callback)
 {
 	rCallback = callback;
 }
 
+void CPicture::clickPressed(const Point & cursorPosition)
+{
+	if(lCallback)
+		lCallback();
+}
+
 void CPicture::showPopupWindow(const Point & cursorPosition)
 {
 	if(rCallback)

+ 3 - 0
client/widgets/Images.h

@@ -27,6 +27,7 @@ enum class EImageBlitMode : uint8_t;
 class CPicture : public CIntObject
 {
 	std::shared_ptr<IImage> bg;
+	std::function<void()> lCallback;
 	std::function<void()> rCallback;
 
 public:
@@ -60,10 +61,12 @@ public:
 	void scaleTo(Point size);
 	void setPlayerColor(PlayerColor player);
 
+	void addLClickCallback(const std::function<void()> & callback);
 	void addRClickCallback(const std::function<void()> & callback);
 
 	void show(Canvas & to) override;
 	void showAll(Canvas & to) override;
+	void clickPressed(const Point & cursorPosition) override;
 	void showPopupWindow(const Point & cursorPosition) override;
 };
 

+ 5 - 3
client/widgets/MiscWidgets.cpp

@@ -623,13 +623,15 @@ void MoraleLuckBox::set(const AFactionMember * node)
 	else if(morale && node && node->getBonusBearer()->hasBonusOfType(BonusType::NO_MORALE))
 	{
 		auto noMorale = node->getBonusBearer()->getBonus(Selector::type()(BonusType::NO_MORALE));
-		text += "\n" + noMorale->Description(GAME->interface()->cb.get());
+		if(GAME->interface())
+			text += "\n" + noMorale->Description(GAME->interface()->cb.get());
 		component.value = 0;
 	}
 	else if (!morale && node && node->getBonusBearer()->hasBonusOfType(BonusType::NO_LUCK))
 	{
 		auto noLuck = node->getBonusBearer()->getBonus(Selector::type()(BonusType::NO_LUCK));
-		text += "\n" + noLuck->Description(GAME->interface()->cb.get());
+		if(GAME->interface())
+			text += "\n" + noLuck->Description(GAME->interface()->cb.get());
 		component.value = 0;
 	}
 	else
@@ -637,7 +639,7 @@ void MoraleLuckBox::set(const AFactionMember * node)
 		std::string addInfo = "";
 		for(auto & bonus : * modifierList)
 		{
-			if(bonus->val) {
+			if(GAME->interface() && bonus->val) {
 				const std::string& description = bonus->Description(GAME->interface()->cb.get());
 				//arraytxt already contains \n
 				if (description.size() && description[0] != '\n')

+ 2 - 2
client/windows/CCreatureWindow.cpp

@@ -952,8 +952,8 @@ void CStackWindow::initSections()
 {
 	OBJECT_CONSTRUCTION;
 
-	bool showArt = GAME->interface()->cb->getSettings().getBoolean(EGameSettings::MODULE_STACK_ARTIFACT) && info->commander == nullptr && info->stackNode;
-	bool showExp = (GAME->interface()->cb->getSettings().getBoolean(EGameSettings::MODULE_STACK_EXPERIENCE) || info->commander != nullptr) && info->stackNode;
+	bool showArt = GAME->interface() && GAME->interface()->cb->getSettings().getBoolean(EGameSettings::MODULE_STACK_ARTIFACT) && info->commander == nullptr && info->stackNode;
+	bool showExp = ((GAME->interface() && GAME->interface()->cb->getSettings().getBoolean(EGameSettings::MODULE_STACK_EXPERIENCE)) || info->commander != nullptr) && info->stackNode;
 
 	mainSection = std::make_shared<MainSection>(this, pos.h, showExp, showArt);
 

+ 3 - 1
client/windows/CWindowObject.cpp

@@ -86,7 +86,9 @@ std::shared_ptr<CPicture> CWindowObject::createBg(const ImagePath & imageName, b
 		return nullptr;
 
 	auto image = std::make_shared<CPicture>(imageName, Point(0,0), EImageBlitMode::OPAQUE);
-	if(playerColored && GAME->interface())
+	if(!GAME->interface())
+		image->setPlayerColor(PlayerColor(1)); // in main menu we use blue
+	else if(playerColored)
 		image->setPlayerColor(GAME->interface()->playerID);
 	return image;
 }

+ 17 - 15
client/windows/GUIClasses.cpp

@@ -1535,8 +1535,9 @@ CObjectListWindow::CItem::CItem(CObjectListWindow * _parent, size_t _id, std::st
 	index(_id)
 {
 	OBJECT_CONSTRUCTION;
-	if(parent->images.size() > index)
-		icon = std::make_shared<CPicture>(parent->images[index], Point(1, 1));
+	auto imgIndex = parent->itemsVisible[index].first;
+	if(parent->images.size() > index && parent->images[imgIndex])
+		icon = std::make_shared<CPicture>(parent->images[imgIndex], Point(1, 1));
 	border = std::make_shared<CPicture>(ImagePath::builtin("TPGATES"));
 	pos = border->pos;
 
@@ -1577,12 +1578,13 @@ void CObjectListWindow::CItem::clickDouble(const Point & cursorPosition)
 
 void CObjectListWindow::CItem::showPopupWindow(const Point & cursorPosition)
 {
+	int where = parent->itemsVisible[index].first;
 	if(parent->onPopup)
-		parent->onPopup(index);
+		parent->onPopup(where);
 }
 
-CObjectListWindow::CObjectListWindow(const std::vector<int> & _items, std::shared_ptr<CIntObject> titleWidget_, std::string _title, std::string _descr, std::function<void(int)> Callback, size_t initialSelection, std::vector<std::shared_ptr<IImage>> images, bool searchBoxEnabled)
-	: CWindowObject(PLAYER_COLORED, ImagePath::builtin("TPGATE")),
+CObjectListWindow::CObjectListWindow(const std::vector<int> & _items, std::shared_ptr<CIntObject> titleWidget_, std::string _title, std::string _descr, std::function<void(int)> Callback, size_t initialSelection, std::vector<std::shared_ptr<IImage>> images, bool searchBoxEnabled, bool blue)
+	: CWindowObject(PLAYER_COLORED, ImagePath::builtin(blue ? "TownPortalBackgroundBlue" : "TPGATE")),
 	onSelect(Callback),
 	selected(initialSelection),
 	images(images)
@@ -1601,12 +1603,12 @@ CObjectListWindow::CObjectListWindow(const std::vector<int> & _items, std::share
 	}
 	itemsVisible = items;
 
-	init(titleWidget_, _title, _descr, searchBoxEnabled);
+	init(titleWidget_, _title, _descr, searchBoxEnabled, blue);
 	list->scrollTo(std::min(static_cast<int>(initialSelection + 4), static_cast<int>(items.size() - 1))); // 4 is for centering (list have 9 elements)
 }
 
-CObjectListWindow::CObjectListWindow(const std::vector<std::string> & _items, std::shared_ptr<CIntObject> titleWidget_, std::string _title, std::string _descr, std::function<void(int)> Callback, size_t initialSelection, std::vector<std::shared_ptr<IImage>> images, bool searchBoxEnabled)
-	: CWindowObject(PLAYER_COLORED, ImagePath::builtin("TPGATE")),
+CObjectListWindow::CObjectListWindow(const std::vector<std::string> & _items, std::shared_ptr<CIntObject> titleWidget_, std::string _title, std::string _descr, std::function<void(int)> Callback, size_t initialSelection, std::vector<std::shared_ptr<IImage>> images, bool searchBoxEnabled, bool blue)
+	: CWindowObject(PLAYER_COLORED, ImagePath::builtin(blue ? "TownPortalBackgroundBlue" : "TPGATE")),
 	onSelect(Callback),
 	selected(initialSelection),
 	images(images)
@@ -1625,17 +1627,17 @@ CObjectListWindow::CObjectListWindow(const std::vector<std::string> & _items, st
 	}
 	itemsVisible = items;
 
-	init(titleWidget_, _title, _descr, searchBoxEnabled);
+	init(titleWidget_, _title, _descr, searchBoxEnabled, blue);
 	list->scrollTo(std::min(static_cast<int>(initialSelection + 4), static_cast<int>(items.size() - 1))); // 4 is for centering (list have 9 elements)
 }
 
-void CObjectListWindow::init(std::shared_ptr<CIntObject> titleWidget_, std::string _title, std::string _descr, bool searchBoxEnabled)
+void CObjectListWindow::init(std::shared_ptr<CIntObject> titleWidget_, std::string _title, std::string _descr, bool searchBoxEnabled, bool blue)
 {
 	titleWidget = titleWidget_;
 
 	title = std::make_shared<CLabel>(152, 27, FONT_BIG, ETextAlignment::CENTER, Colors::YELLOW, _title);
 	descr = std::make_shared<CLabel>(145, 133, FONT_SMALL, ETextAlignment::CENTER, Colors::WHITE, _descr);
-	exit = std::make_shared<CButton>( Point(228, 402), AnimationPath::builtin("ICANCEL.DEF"), CButton::tooltip(), std::bind(&CObjectListWindow::exitPressed, this), EShortcut::GLOBAL_CANCEL);
+	exit = std::make_shared<CButton>( Point(228, 402), AnimationPath::builtin(blue ? "MuBcanc" : "ICANCEL.DEF"), CButton::tooltip(), std::bind(&CObjectListWindow::exitPressed, this), EShortcut::GLOBAL_CANCEL);
 
 	if(titleWidget)
 	{
@@ -1644,10 +1646,10 @@ void CObjectListWindow::init(std::shared_ptr<CIntObject> titleWidget_, std::stri
 		titleWidget->pos.y = 75 + pos.y - titleWidget->pos.h/2;
 	}
 	list = std::make_shared<CListBox>(std::bind(&CObjectListWindow::genItem, this, _1),
-		Point(14, 151), Point(0, 25), 9, itemsVisible.size(), 0, 1, Rect(262, -32, 256, 256) );
+		Point(14, 151), Point(0, 25), 9, itemsVisible.size(), 0, 1 + (blue ? 4 : 0), Rect(262, -32, 256, 256) );
 	list->setRedrawParent(true);
 
-	ok = std::make_shared<CButton>(Point(15, 402), AnimationPath::builtin("IOKAY.DEF"), CButton::tooltip(), std::bind(&CObjectListWindow::elementSelected, this), EShortcut::GLOBAL_ACCEPT);
+	ok = std::make_shared<CButton>(Point(15, 402), AnimationPath::builtin(blue ? "MuBchck" : "IOKAY.DEF"), CButton::tooltip(), std::bind(&CObjectListWindow::elementSelected, this), EShortcut::GLOBAL_ACCEPT);
 	ok->block(!list->size());
 
 	if(!searchBoxEnabled)
@@ -1655,8 +1657,8 @@ void CObjectListWindow::init(std::shared_ptr<CIntObject> titleWidget_, std::stri
 
 	Rect r(50, 90, pos.w - 100, 16);
 	const ColorRGBA rectangleColor = ColorRGBA(0, 0, 0, 75);
-	const ColorRGBA borderColor = ColorRGBA(128, 100, 75);
-	const ColorRGBA grayedColor = ColorRGBA(158, 130, 105);
+	const ColorRGBA borderColor = blue ? ColorRGBA(75, 84, 128) : ColorRGBA(128, 100, 75);
+	const ColorRGBA grayedColor = blue ? ColorRGBA(105, 127, 159) : ColorRGBA(158, 130, 105);
 	searchBoxRectangle = std::make_shared<TransparentFilledRectangle>(r.resize(1), rectangleColor, borderColor);
 	searchBoxDescription = std::make_shared<CLabel>(r.center().x, r.center().y, FONT_SMALL, ETextAlignment::CENTER, grayedColor, LIBRARY->generaltexth->translate("vcmi.spellBook.search"));
 

+ 3 - 3
client/windows/GUIClasses.h

@@ -205,7 +205,7 @@ class CObjectListWindow : public CWindowObject
 	std::vector< std::pair<int, std::string> > items; //all items present in list
 	std::vector< std::pair<int, std::string> > itemsVisible; //visible items present in list
 
-	void init(std::shared_ptr<CIntObject> titleWidget_, std::string _title, std::string _descr, bool searchBoxEnabled);
+	void init(std::shared_ptr<CIntObject> titleWidget_, std::string _title, std::string _descr, bool searchBoxEnabled, bool blue);
 	void trimTextIfTooWide(std::string & text, bool preserveCountSuffix) const; // trim item's text to fit within window's width
 	void itemsSearchCallback(const std::string & text);
 	void exitPressed();
@@ -219,8 +219,8 @@ public:
 	/// Callback will be called when OK button is pressed, returns id of selected item. initState = initially selected item
 	/// Image can be nullptr
 	///item names will be taken from map objects
-	CObjectListWindow(const std::vector<int> &_items, std::shared_ptr<CIntObject> titleWidget_, std::string _title, std::string _descr, std::function<void(int)> Callback, size_t initialSelection = 0, std::vector<std::shared_ptr<IImage>> images = {}, bool searchBoxEnabled = false);
-	CObjectListWindow(const std::vector<std::string> &_items, std::shared_ptr<CIntObject> titleWidget_, std::string _title, std::string _descr, std::function<void(int)> Callback, size_t initialSelection = 0, std::vector<std::shared_ptr<IImage>> images = {}, bool searchBoxEnabled = false);
+	CObjectListWindow(const std::vector<int> &_items, std::shared_ptr<CIntObject> titleWidget_, std::string _title, std::string _descr, std::function<void(int)> Callback, size_t initialSelection = 0, std::vector<std::shared_ptr<IImage>> images = {}, bool searchBoxEnabled = false, bool blue = false);
+	CObjectListWindow(const std::vector<std::string> &_items, std::shared_ptr<CIntObject> titleWidget_, std::string _title, std::string _descr, std::function<void(int)> Callback, size_t initialSelection = 0, std::vector<std::shared_ptr<IImage>> images = {}, bool searchBoxEnabled = false, bool blue = false);
 
 	std::shared_ptr<CIntObject> genItem(size_t index);
 	void elementSelected();//call callback and close this window

+ 9 - 0
lib/StartInfo.cpp

@@ -241,4 +241,13 @@ TeamID LobbyInfo::getPlayerTeamId(const PlayerColor & color)
 		return TeamID::NO_TEAM;
 }
 
+BattleOnlyModeStartInfo::BattleOnlyModeStartInfo()
+	: selectedTerrain(TerrainId::DIRT)
+	, selectedTown(std::nullopt)
+{
+	for(auto & element : primSkillLevel)
+		for(size_t i=0; i<GameConstants::PRIMARY_SKILLS; i++)
+			element[i] = 0;
+}
+
 VCMI_LIB_NAMESPACE_END

+ 23 - 0
lib/StartInfo.h

@@ -18,6 +18,7 @@
 #include "serializer/GameConnectionID.h"
 #include "serializer/Serializeable.h"
 #include "serializer/PlayerConnectionID.h"
+#include "mapObjects/army/CStackBasicDescriptor.h"
 #include "ResourceSet.h"
 
 VCMI_LIB_NAMESPACE_BEGIN
@@ -239,5 +240,27 @@ struct DLL_LINKAGE LobbyInfo : public LobbyState
 	TeamID getPlayerTeamId(const PlayerColor & color);
 };
 
+class DLL_LINKAGE BattleOnlyModeStartInfo : public Serializeable
+{
+public:
+	std::optional<TerrainId> selectedTerrain;
+	std::optional<FactionID> selectedTown;
+
+	std::array<std::optional<HeroTypeID>, 2> selectedHero;
+	std::array<std::array<CStackBasicDescriptor, 7>, 2> selectedArmy;
+
+	std::array<std::array<int, GameConstants::PRIMARY_SKILLS>, 2> primSkillLevel;
+
+	BattleOnlyModeStartInfo();
+
+	template <typename Handler> void serialize(Handler &h)
+	{
+		h & selectedTerrain;
+		h & selectedTown;
+		h & selectedHero;
+		h & selectedArmy;
+		h & primSkillLevel;
+	}
+};
 
 VCMI_LIB_NAMESPACE_END

+ 0 - 22
lib/mapObjects/CGHeroInstance.cpp

@@ -1345,28 +1345,6 @@ void CGHeroInstance::restoreBonusSystem(CGameState & gs)
 	}
 }
 
-void CGHeroInstance::attachToBonusSystem(CGameState & gs)
-{
-	CArmedInstance::attachToBonusSystem(gs);
-	if (boardedBoat.hasValue())
-	{
-		auto boat = gs.getObjInstance(boardedBoat);
-		if (boat)
-			attachTo(dynamic_cast<CGBoat&>(*boat));
-	}
-}
-
-void CGHeroInstance::detachFromBonusSystem(CGameState & gs)
-{
-	CArmedInstance::detachFromBonusSystem(gs);
-	if (boardedBoat.hasValue())
-	{
-		auto boat = gs.getObjInstance(boardedBoat);
-		if (boat)
-			detachFrom(dynamic_cast<CGBoat&>(*boat));
-	}
-}
-
 CBonusSystemNode & CGHeroInstance::whereShouldBeAttached(CGameState & gs)
 {
 	if(visitedTown.hasValue())

+ 0 - 2
lib/mapObjects/CGHeroInstance.h

@@ -311,8 +311,6 @@ public:
 
 	void afterAddToMap(CMap * map) override;
 	void afterRemoveFromMap(CMap * map) override;
-	void attachToBonusSystem(CGameState & gs) override;
-	void detachFromBonusSystem(CGameState & gs) override;
 	void restoreBonusSystem(CGameState & gs) override;
 
 	void updateFrom(const JsonNode & data) override;

+ 1 - 0
lib/mapping/CMapHeader.cpp

@@ -129,6 +129,7 @@ CMapHeader::CMapHeader()
 	, defeatIconIndex(0)
 	, howManyTeams(0)
 	, areAnyPlayers(false)
+	, battleOnly(false)
 {
 	setupEvents();
 	allowedHeroes = LIBRARY->heroh->getDefaultAllowed();

+ 4 - 0
lib/mapping/CMapHeader.h

@@ -272,6 +272,8 @@ public:
 
 	bool areAnyPlayers; /// Unused. True if there are any playable players on the map.
 
+	bool battleOnly; /// Battle only mode
+
 	/// "main quests" of the map that describe victory and loss conditions
 	std::vector<TriggeredEvent> triggeredEvents;
 	
@@ -316,6 +318,8 @@ public:
 
 		h & levelLimit;
 		h & areAnyPlayers;
+		if (h.version >= Handler::Version::BATTLE_ONLY)
+			h & battleOnly;
 		h & players;
 		h & howManyTeams;
 		h & allowedHeroes;

+ 4 - 4
lib/mapping/MapEditUtils.cpp

@@ -120,14 +120,14 @@ void CTerrainSelection::setSelection(const std::vector<int3> & vec)
 
 void CTerrainSelection::selectAll()
 {
-	selectRange(MapRect(int3(0, 0, 0), getMap()->width, getMap()->height));
-	selectRange(MapRect(int3(0, 0, 1), getMap()->width, getMap()->height));
+	for(int i = 0; i < getMap()->mapLevels; i++)
+		selectRange(MapRect(int3(0, 0, i), getMap()->width, getMap()->height));
 }
 
 void CTerrainSelection::clearSelection()
 {
-	deselectRange(MapRect(int3(0, 0, 0), getMap()->width, getMap()->height));
-	deselectRange(MapRect(int3(0, 0, 1), getMap()->width, getMap()->height));
+	for(int i = 0; i < getMap()->mapLevels; i++)
+		deselectRange(MapRect(int3(0, 0, i), getMap()->width, getMap()->height));
 }
 
 CObjectSelection::CObjectSelection(CMap * map) : CMapSelection(map)

+ 4 - 0
lib/mapping/MapFormatJson.cpp

@@ -870,6 +870,8 @@ void CMapLoaderJson::readHeader(const bool complete)
 	readTeams(handler);
 	//TODO: check mods
 
+	mapHeader->battleOnly = header["battleOnly"].Bool();
+
 	if(complete)
 		readOptions(handler);
 	
@@ -1225,6 +1227,8 @@ void CMapSaverJson::writeHeader()
 
 	writeTeams(handler);
 
+	header["battleOnly"].Bool() = mapHeader->battleOnly;
+
 	writeOptions(handler);
 
 	writeTranslations();

+ 1 - 0
lib/networkPacks/NetPackVisitor.h

@@ -170,6 +170,7 @@ public:
 	virtual void visitLobbySetCampaign(LobbySetCampaign & pack) {}
 	virtual void visitLobbySetCampaignMap(LobbySetCampaignMap & pack) {}
 	virtual void visitLobbySetCampaignBonus(LobbySetCampaignBonus & pack) {}
+	virtual void visitLobbySetBattleOnlyModeStartInfo(LobbySetBattleOnlyModeStartInfo & pack) {}
 	virtual void visitLobbyChangePlayerOption(LobbyChangePlayerOption & pack) {}
 	virtual void visitLobbySetPlayer(LobbySetPlayer & pack) {}
 	virtual void visitLobbySetPlayerName(LobbySetPlayerName & pack) {}

+ 5 - 0
lib/networkPacks/NetPacksLib.cpp

@@ -773,6 +773,11 @@ void LobbySetCampaignBonus::visitTyped(ICPackVisitor & visitor)
 	visitor.visitLobbySetCampaignBonus(*this);
 }
 
+void LobbySetBattleOnlyModeStartInfo::visitTyped(ICPackVisitor & visitor)
+{
+	visitor.visitLobbySetBattleOnlyModeStartInfo(*this);
+}
+
 void LobbyChangePlayerOption::visitTyped(ICPackVisitor & visitor)
 {
 	visitor.visitLobbyChangePlayerOption(*this);

+ 2 - 0
lib/networkPacks/PacksForClient.h

@@ -524,6 +524,7 @@ struct DLL_LINKAGE PlayerEndsGame : public CPackForClient
 	PlayerColor player;
 	EVictoryLossCheckResult victoryLossCheckResult;
 	StatisticDataSet statistic;
+	bool silentEnd = false;
 
 	void visitTyped(ICPackVisitor & visitor) override;
 
@@ -532,6 +533,7 @@ struct DLL_LINKAGE PlayerEndsGame : public CPackForClient
 		h & player;
 		h & victoryLossCheckResult;
 		h & statistic;
+		h & silentEnd;
 	}
 };
 

+ 13 - 1
lib/networkPacks/PacksForLobby.h

@@ -90,7 +90,7 @@ struct DLL_LINKAGE LobbyChatMessage : public CLobbyPackToPropagate
 struct DLL_LINKAGE LobbyGuiAction : public CLobbyPackToPropagate
 {
 	enum EAction : ui8 {
-		NONE, NO_TAB, OPEN_OPTIONS, OPEN_SCENARIO_LIST, OPEN_RANDOM_MAP_OPTIONS, OPEN_TURN_OPTIONS, OPEN_EXTRA_OPTIONS
+		NONE, NO_TAB, OPEN_OPTIONS, OPEN_SCENARIO_LIST, OPEN_RANDOM_MAP_OPTIONS, OPEN_TURN_OPTIONS, OPEN_EXTRA_OPTIONS, BATTLE_MODE
 	} action = NONE;
 
 
@@ -230,6 +230,18 @@ struct DLL_LINKAGE LobbySetCampaignBonus : public CLobbyPackToServer
 	}
 };
 
+struct DLL_LINKAGE LobbySetBattleOnlyModeStartInfo : public CLobbyPackToPropagate
+{
+	std::shared_ptr<BattleOnlyModeStartInfo> startInfo;
+
+	void visitTyped(ICPackVisitor & visitor) override;
+
+	template <typename Handler> void serialize(Handler &h)
+	{
+		h & startInfo;
+	}
+};
+
 struct DLL_LINKAGE LobbyChangePlayerOption : public CLobbyPackToServer
 {
 	enum EWhat : ui8 {UNKNOWN, TOWN, HERO, BONUS, TOWN_ID, HERO_ID, BONUS_ID};

+ 2 - 1
lib/serializer/ESerializationVersion.h

@@ -51,8 +51,9 @@ enum class ESerializationVersion : int32_t
 	MORE_MAP_LAYERS, // more map layers
 	CONFIGURABLE_RESOURCES, // configurable resources
 	CUSTOM_NAMES, // custom names
+	BATTLE_ONLY, // battle only mode
 
-	CURRENT = CUSTOM_NAMES,
+	CURRENT = BATTLE_ONLY,
 };
 
 static_assert(ESerializationVersion::MINIMAL <= ESerializationVersion::CURRENT, "Invalid serialization version definition!");

+ 1 - 0
lib/serializer/RegisterTypes.h

@@ -293,6 +293,7 @@ void registerTypes(Serializer &s)
 	s.template registerType<PackageReceived>(251);
 	s.template registerType<ChangeTownName>(252);
 	s.template registerType<SetTownName>(253);
+	s.template registerType<LobbySetBattleOnlyModeStartInfo>(254);
 }
 
 VCMI_LIB_NAMESPACE_END

+ 67 - 0
lib/texts/TextOperations.h

@@ -9,6 +9,8 @@
  */
 #pragma once
 
+#include <boost/lexical_cast.hpp>
+
 VCMI_LIB_NAMESPACE_BEGIN
 
 /// Namespace that provides utilities for unicode support (UTF-8)
@@ -54,6 +56,9 @@ namespace TextOperations
 	template<typename Arithmetic>
 	inline std::string formatMetric(Arithmetic number, int maxDigits);
 
+	template<typename Arithmetic>
+	inline Arithmetic parseMetric(const std::string &text);
+
 	/// replaces all symbols that normally need escaping with appropriate escape sequences
 	std::string escapeString(std::string input);
 
@@ -116,4 +121,66 @@ inline std::string TextOperations::formatMetric(Arithmetic number, int maxDigits
 	return std::to_string(number) + *iter;
 }
 
+template<typename Arithmetic>
+inline Arithmetic TextOperations::parseMetric(const std::string &text)
+{
+	if (text.empty())
+		return 0;
+
+	// Trim whitespace
+	std::string trimmed = text;
+	trimmed.erase(trimmed.begin(), std::find_if(trimmed.begin(), trimmed.end(), [](unsigned char ch){ return !std::isspace(ch); }));
+	trimmed.erase(std::find_if(trimmed.rbegin(), trimmed.rend(), [](unsigned char ch){ return !std::isspace(ch); }).base(), trimmed.end());
+
+	// Check if last character is a metric suffix
+	char last = trimmed.back();
+	int power = 0; // number of *1000 multiplications
+
+	switch (std::toupper(last))
+	{
+		case 'K': power = 1; break;
+		case 'M': power = 2; break;
+		case 'G': power = 3; break;
+		case 'T': power = 4; break;
+		case 'P': power = 5; break;
+		case 'E': power = 6; break;
+		default: power = 0; break; // no suffix
+	}
+
+	std::string numberPart = trimmed;
+	if (power > 0)
+		numberPart.pop_back();
+
+	// Remove any non-digit or minus sign (same spirit as your numberFilter)
+	numberPart.erase(std::remove_if(numberPart.begin(), numberPart.end(), [](char c)
+	{
+		return !(std::isdigit(static_cast<unsigned char>(c)) || c == '-');
+	}), numberPart.end());
+
+	if (numberPart.empty() || (numberPart == "-"))
+		return 0;
+
+	try
+	{
+		Arithmetic value = std::stoll(numberPart);
+
+		for (int i = 0; i < power; ++i)
+		{
+			// Multiply by 1000, check for overflow if desired
+			if (value > std::numeric_limits<Arithmetic>::max() / 1000)
+				return std::numeric_limits<Arithmetic>::max();
+			if (value < std::numeric_limits<Arithmetic>::min() / 1000)
+				return std::numeric_limits<Arithmetic>::min();
+
+			value *= static_cast<Arithmetic>(1000);
+		}
+
+		return value;
+	}
+	catch (std::invalid_argument &)
+	{
+		return 0;
+	}
+}
+
 VCMI_LIB_NAMESPACE_END

+ 13 - 0
server/CGameHandler.cpp

@@ -3491,6 +3491,19 @@ void CGameHandler::checkVictoryLossConditionsForPlayer(PlayerColor player)
 
 	if(!p || p->status != EPlayerStatus::INGAME) return;
 
+	if(gameState().getMap().battleOnly)
+	{
+		for(const auto & playerIt : gameState().players)
+		{
+			PlayerEndsGame peg;
+			peg.player = playerIt.first;
+			peg.silentEnd = true;
+			sendAndApply(peg);
+		}
+		gameServer().setState(EServerState::SHUTDOWN);
+		return;
+	}
+
 	auto victoryLossCheckResult = gameState().checkForVictoryAndLoss(player);
 
 	if (victoryLossCheckResult.victory() || victoryLossCheckResult.loss())

+ 1 - 1
server/CVCMIServer.cpp

@@ -1021,7 +1021,7 @@ void CVCMIServer::multiplayerWelcomeMessage()
 		if(pi.second.isControlledByHuman())
 			humanPlayer++;
 
-	if(humanPlayer < 2) // Singleplayer
+	if(humanPlayer < 2 || mi->mapHeader->battleOnly) // Singleplayer or Battle only mode
 		return;
 
 	gh->playerMessages->broadcastSystemMessage(MetaString::createFromTextID("vcmi.broadcast.command"));

+ 1 - 0
server/LobbyNetPackVisitors.h

@@ -47,6 +47,7 @@ public:
 	void visitLobbyGuiAction(LobbyGuiAction & pack) override;
 	void visitLobbyPvPAction(LobbyPvPAction & pack) override;
 	void visitLobbyDelete(LobbyDelete & pack) override;
+	void visitLobbySetBattleOnlyModeStartInfo(LobbySetBattleOnlyModeStartInfo & pack) override;
 };
 
 class ApplyOnServerAfterAnnounceNetPackVisitor : public VCMI_LIB_WRAP_NAMESPACE(ICPackVisitor)

+ 5 - 1
server/NetPacksLobbyServer.cpp

@@ -385,12 +385,16 @@ void ApplyOnServerNetPackVisitor::visitLobbyPvPAction(LobbyPvPAction & pack)
 	result = true;
 }
 
-
 void ClientPermissionsCheckerNetPackVisitor::visitLobbyDelete(LobbyDelete & pack)
 {
 	result = srv.isClientHost(connection->connectionID);
 }
 
+void ClientPermissionsCheckerNetPackVisitor::visitLobbySetBattleOnlyModeStartInfo(LobbySetBattleOnlyModeStartInfo & pack)
+{
+	result = true;
+}
+
 void ApplyOnServerNetPackVisitor::visitLobbyDelete(LobbyDelete & pack)
 {
 	if(pack.type == LobbyDelete::EType::SAVEGAME || pack.type == LobbyDelete::EType::RANDOMMAP)

+ 1 - 0
server/battles/BattleResultProcessor.cpp

@@ -26,6 +26,7 @@
 #include "../../lib/entities/artifact/CArtifact.h"
 #include "../../lib/entities/artifact/CArtifactFittingSet.h"
 #include "../../lib/gameState/CGameState.h"
+#include "../../lib/mapping/CMap.h"
 #include "../../lib/mapObjects/CGTownInstance.h"
 #include "../../lib/networkPacks/PacksForClientBattle.h"
 #include "../../lib/spells/CSpellHandler.h"

+ 14 - 0
server/processors/TurnOrderProcessor.cpp

@@ -19,6 +19,8 @@
 #include "../../lib/CPlayerState.h"
 #include "../../lib/mapping/CMap.h"
 #include "../../lib/mapObjects/CGObjectInstance.h"
+#include "../../lib/mapObjects/CGHeroInstance.h"
+#include "../../lib/mapObjects/CGTownInstance.h"
 #include "../../lib/gameState/CGameState.h"
 #include "../../lib/pathfinder/CPathfinder.h"
 #include "../../lib/pathfinder/PathfinderOptions.h"
@@ -364,6 +366,18 @@ bool TurnOrderProcessor::onPlayerEndsTurn(PlayerColor which)
 
 void TurnOrderProcessor::onGameStarted()
 {
+	if(gameHandler->gameInfo().getMapHeader()->battleOnly)
+	{
+		auto towns = gameHandler->gameState().getMap().getObjects<CGTownInstance>();
+		auto heroes = gameHandler->gameState().getMap().getObjects<CGHeroInstance>();
+		if(!towns.size() && heroes.size() == 2)
+			gameHandler->startBattle(heroes.at(0), heroes.at(1));
+		else
+			towns.at(0)->onHeroVisit(*gameHandler, heroes.at(0));
+
+		return;
+	}
+
 	if (actingPlayers.empty())
 		blockedContacts = computeContactStatus();