瀏覽代碼

Merge branch 'develop' into hd_edition

Laserlicht 4 天之前
父節點
當前提交
d23a5f42ee
共有 100 個文件被更改,包括 10699 次插入151 次删除
  1. 1 1
      .github/workflows/aab-from-build.yml
  2. 24 14
      .github/workflows/github.yml
  3. 13 1
      AI/BattleAI/BattleEvaluator.cpp
  4. 1 1
      AI/BattleAI/BattleEvaluator.h
  5. 2 0
      AI/BattleAI/BattleExchangeVariant.cpp
  6. 4 0
      AI/CMakeLists.txt
  7. 1 0
      AI/MMAI/.gitignore
  8. 292 0
      AI/MMAI/BAI/base.cpp
  9. 205 0
      AI/MMAI/BAI/base.h
  10. 580 0
      AI/MMAI/BAI/model/NNModel.cpp
  11. 70 0
      AI/MMAI/BAI/model/NNModel.h
  12. 72 0
      AI/MMAI/BAI/model/ScriptedModel.cpp
  13. 33 0
      AI/MMAI/BAI/model/ScriptedModel.h
  14. 181 0
      AI/MMAI/BAI/model/util/bucketing.cpp
  15. 59 0
      AI/MMAI/BAI/model/util/bucketing.h
  16. 86 0
      AI/MMAI/BAI/model/util/common.h
  17. 268 0
      AI/MMAI/BAI/model/util/sampling.cpp
  18. 64 0
      AI/MMAI/BAI/model/util/sampling.h
  19. 301 0
      AI/MMAI/BAI/router.cpp
  20. 83 0
      AI/MMAI/BAI/router.h
  21. 728 0
      AI/MMAI/BAI/v13/BAI.cpp
  22. 81 0
      AI/MMAI/BAI/v13/BAI.h
  23. 178 0
      AI/MMAI/BAI/v13/action.cpp
  24. 38 0
      AI/MMAI/BAI/v13/action.h
  25. 78 0
      AI/MMAI/BAI/v13/attack_log.h
  26. 415 0
      AI/MMAI/BAI/v13/battlefield.cpp
  27. 66 0
      AI/MMAI/BAI/v13/battlefield.h
  28. 423 0
      AI/MMAI/BAI/v13/encoder.cpp
  29. 79 0
      AI/MMAI/BAI/v13/encoder.h
  30. 78 0
      AI/MMAI/BAI/v13/global_stats.cpp
  31. 38 0
      AI/MMAI/BAI/v13/global_stats.h
  32. 381 0
      AI/MMAI/BAI/v13/hex.cpp
  33. 89 0
      AI/MMAI/BAI/v13/hex.h
  34. 57 0
      AI/MMAI/BAI/v13/hexaction.h
  35. 37 0
      AI/MMAI/BAI/v13/hexactmask.h
  36. 44 0
      AI/MMAI/BAI/v13/links.h
  37. 88 0
      AI/MMAI/BAI/v13/player_stats.cpp
  38. 35 0
      AI/MMAI/BAI/v13/player_stats.h
  39. 1561 0
      AI/MMAI/BAI/v13/render.cpp
  40. 21 0
      AI/MMAI/BAI/v13/render.h
  41. 545 0
      AI/MMAI/BAI/v13/stack.cpp
  42. 107 0
      AI/MMAI/BAI/v13/stack.h
  43. 556 0
      AI/MMAI/BAI/v13/state.cpp
  44. 107 0
      AI/MMAI/BAI/v13/state.h
  45. 84 0
      AI/MMAI/BAI/v13/supplementary_data.cpp
  46. 122 0
      AI/MMAI/BAI/v13/supplementary_data.h
  47. 137 0
      AI/MMAI/CMakeLists.txt
  48. 13 0
      AI/MMAI/MMAI.h
  49. 12 0
      AI/MMAI/README.md
  50. 12 0
      AI/MMAI/StdInc.cpp
  51. 18 0
      AI/MMAI/StdInc.h
  52. 33 0
      AI/MMAI/common.h
  53. 33 0
      AI/MMAI/main.cpp
  54. 125 0
      AI/MMAI/schema/base.h
  55. 22 0
      AI/MMAI/schema/schema.h
  56. 298 0
      AI/MMAI/schema/v13/constants.h
  57. 14 0
      AI/MMAI/schema/v13/schema.h
  58. 640 0
      AI/MMAI/schema/v13/types.h
  59. 197 0
      AI/MMAI/schema/v13/util.h
  60. 554 0
      AI/MMAI/test/encoder_test.cpp
  61. 4 1
      AI/Nullkiller/Engine/PriorityEvaluator.cpp
  62. 4 1
      AI/Nullkiller2/Engine/PriorityEvaluator.cpp
  63. 28 10
      AI/StupidAI/StupidAI.cpp
  64. 1 0
      AI/StupidAI/StupidAI.h
  65. 1 0
      AUTHORS.h
  66. 7 0
      CI/before_install/linux_common.sh
  67. 2 0
      CI/before_install/linux_qt5.sh
  68. 2 0
      CI/before_install/linux_qt6.sh
  69. 5 0
      CMakeLists.txt
  70. 2 1
      CMakePresets.json
  71. 二進制
      Mods/vcmi/Content/Sprites/lobby/battle-normal.png
  72. 二進制
      Mods/vcmi/Content/Sprites/lobby/battle-pressed.png
  73. 0 8
      Mods/vcmi/Content/Sprites/lobby/battleButton.json
  74. 二進制
      Mods/vcmi/Content/Sprites/lobby/dropdownNormal.png
  75. 二進制
      Mods/vcmi/Content/Sprites/lobby/dropdownPressed.png
  76. 4 2
      Mods/vcmi/Content/config/english.json
  77. 7 6
      Mods/vcmi/Content/config/german.json
  78. 0 1
      android/vcmi-app/src/main/java/eu/vcmi/vcmi/NativeMethods.java
  79. 3 16
      android/vcmi-app/src/main/java/eu/vcmi/vcmi/VcmiSDLActivity.java
  80. 3 0
      client/CMakeLists.txt
  81. 14 9
      client/CPlayerInterface.cpp
  82. 2 0
      client/CPlayerInterface.h
  83. 0 23
      client/Client.cpp
  84. 3 0
      client/GameEngineUser.h
  85. 32 0
      client/GameInstance.cpp
  86. 3 0
      client/GameInstance.h
  87. 3 1
      client/adventureMap/AdventureMapInterface.cpp
  88. 2 19
      client/battle/BattleWindow.cpp
  89. 6 0
      client/eventsSDL/InputHandler.cpp
  90. 10 6
      client/lobby/CLobbyScreen.cpp
  91. 1 1
      client/lobby/CSelectionBase.h
  92. 13 0
      client/lobby/OptionsTabBase.cpp
  93. 0 10
      client/lobby/SelectionTab.cpp
  94. 0 2
      client/lobby/SelectionTab.h
  95. 3 11
      client/mainmenu/CPrologEpilogVideo.cpp
  96. 25 0
      client/render/AssetGenerator.cpp
  97. 1 0
      client/render/AssetGenerator.h
  98. 1 1
      client/widgets/CComponent.cpp
  99. 18 4
      client/windows/CCreatureWindow.cpp
  100. 5 1
      config/bonuses.json

+ 1 - 1
.github/workflows/aab-from-build.yml

@@ -42,7 +42,7 @@ jobs:
         echo "ANDROID_AAB_PATH=$ANDROID_AAB_PATH" >> $GITHUB_ENV
 
     - name: Artifact
-      uses: actions/upload-artifact@v5
+      uses: actions/upload-artifact@v6
       with:
         name: aab
         compression-level: 0

+ 24 - 14
.github/workflows/github.yml

@@ -143,7 +143,7 @@ jobs:
     - name: APT cache restore
       if: contains(matrix.os, 'ubuntu')
       id: aptcache
-      uses: actions/cache/restore@v4
+      uses: actions/cache/restore@v5
       with:
         path: ${{ runner.temp }}/apt-cache
         key: ${{ matrix.platform }}-apt-${{ matrix.os }}
@@ -157,7 +157,7 @@ jobs:
     # Save only on cache miss, GitHub caches are immutable per key
     - name: APT cache save
       if: contains(matrix.os, 'ubuntu') && steps.aptcache.outputs.cache-hit != 'true'
-      uses: actions/cache/save@v4
+      uses: actions/cache/save@v5
       with:
         path: ${{ runner.temp }}/apt-cache
         key: ${{ steps.aptcache.outputs.cache-primary-key }}
@@ -240,6 +240,16 @@ jobs:
         distribution: 'temurin'
         java-version: '17'
 
+    # Frees 10+GB on linux runners by removing unused tools (.NET, Haskell compiler)
+    - name: Free up disk space
+      if: contains(matrix.os, 'ubuntu')
+      run: |
+        echo "Disk usage BEFORE cleanup:"
+        df -h
+        sudo rm -rf /opt/ghc /usr/local/.ghcup /usr/share/dotnet || :
+        echo "Disk usage AFTER cleanup:"
+        df -h
+
     # a hack to build ID for x64 build in order for Google Play to allow upload of both 32 and 64 bit builds
     # TODO: x86_64
     - name: Bump Android x64 build ID
@@ -312,7 +322,7 @@ jobs:
 
     - name: Upload Artifact
       id: upload_artifact
-      uses: actions/upload-artifact@v5
+      uses: actions/upload-artifact@v6
       with:
         name: ${{ env.VCMI_PACKAGE_FILE_NAME }} - ${{ matrix.platform }}
         compression-level: 9
@@ -322,7 +332,7 @@ jobs:
     - name: Upload AAB Artifact
       id: upload_aab
       if: ${{ startsWith(matrix.platform, 'android') && github.ref == 'refs/heads/master' }}
-      uses: actions/upload-artifact@v5
+      uses: actions/upload-artifact@v6
       with:
         name: ${{ env.VCMI_PACKAGE_FILE_NAME }} - ${{ matrix.platform }} - aab
         compression-level: 9
@@ -332,7 +342,7 @@ jobs:
     - name: Upload debug symbols
       id: upload_symbols
       if: ${{ startsWith(matrix.platform, 'msvc') }}
-      uses: actions/upload-artifact@v5
+      uses: actions/upload-artifact@v6
       with:
         name: ${{ env.VCMI_PACKAGE_FILE_NAME }} - ${{ matrix.platform }} - symbols
         compression-level: 9
@@ -361,7 +371,7 @@ jobs:
         python3 CI/emit_partial.py
 
     - name: Upload partial JSON with build informations
-      uses: actions/upload-artifact@v5
+      uses: actions/upload-artifact@v6
       with:
         name: partial-json-${{ matrix.platform }}
         path: .summary/${{ matrix.platform }}.json
@@ -392,7 +402,7 @@ jobs:
 
         - name: Upload source code archive
           id: upload_source
-          uses: actions/upload-artifact@v5
+          uses: actions/upload-artifact@v6
           with:
             name: ${{ env.VCMI_PACKAGE_FILE_NAME }}
             compression-level: 9
@@ -408,7 +418,7 @@ jobs:
             JSON
 
         - name: Upload partial JSON with source informations
-          uses: actions/upload-artifact@v5
+          uses: actions/upload-artifact@v6
           with:
             name: partial-json-source
             path: .summary/source.json
@@ -468,7 +478,7 @@ jobs:
     - name: APT cache restore
       if: contains(matrix.os, 'ubuntu')
       id: aptcache
-      uses: actions/cache/restore@v4
+      uses: actions/cache/restore@v5
       with:
         path: ${{ runner.temp }}/apt-cache
         key: ${{ matrix.platform }}-apt-${{ matrix.os }}
@@ -480,7 +490,7 @@ jobs:
 
     - name: APT cache save
       if: contains(matrix.os, 'ubuntu') && steps.aptcache.outputs.cache-hit != 'true'
-      uses: actions/cache/save@v4
+      uses: actions/cache/save@v5
       with:
         path: ${{ runner.temp }}/apt-cache
         key: ${{ steps.aptcache.outputs.cache-primary-key }}
@@ -595,7 +605,7 @@ jobs:
         PULL_REQUEST: ${{ github.event.pull_request.number }}
 
     - name: Download Artifact
-      uses: actions/download-artifact@v6
+      uses: actions/download-artifact@v7
       with:
         name: ${{ env.VCMI_PACKAGE_FILE_NAME }} - ${{ matrix.platform }}
         path: ${{github.workspace}}/artifact
@@ -625,7 +635,7 @@ jobs:
 
     - name: Upload VCMI Installer Artifacts
       id: upload_installer
-      uses: actions/upload-artifact@v5
+      uses: actions/upload-artifact@v6
       with:
         name: ${{ env.VCMI_PACKAGE_FILE_NAME }} - ${{ matrix.platform }} - installer
         compression-level: 9
@@ -653,7 +663,7 @@ jobs:
         JSON
 
     - name: Upload partial JSON with installer informations
-      uses: actions/upload-artifact@v5
+      uses: actions/upload-artifact@v6
       with:
         name: partial-json-${{ matrix.platform }}-installer
         path: .summary/installer-${{ matrix.platform }}.json
@@ -700,7 +710,7 @@ jobs:
 
       - name: Download all partial JSON artifacts
         continue-on-error: true
-        uses: actions/download-artifact@v6
+        uses: actions/download-artifact@v7
         with:
           pattern: partial-json-*
           merge-multiple: true

+ 13 - 1
AI/BattleAI/BattleEvaluator.cpp

@@ -133,7 +133,7 @@ bool BattleEvaluator::hasWorkingTowers() const
 std::optional<PossibleSpellcast> BattleEvaluator::findBestCreatureSpell(const CStack *stack)
 {
 	//TODO: faerie dragon type spell should be selected by server
-	SpellID creatureSpellToCast = cb->getBattle(battleID)->getRandomCastedSpell(CRandomGenerator::getDefault(), stack);
+	SpellID creatureSpellToCast = cb->getBattle(battleID)->getRandomCastedSpell(CRandomGenerator::getDefault(), stack, true);
 
 	if(stack->canCast() && creatureSpellToCast != SpellID::NONE)
 	{
@@ -891,6 +891,7 @@ void BattleEvaluator::evaluateCreatureSpellcast(const CStack * stack, PossibleSp
 		healthOfStack[unit->unitId()] = unit->getAvailableHealth();
 	}
 
+
 	spells::BattleCast cast(&state, stack, spells::Mode::CREATURE_ACTIVE, ps.spell);
 	cast.castEval(state.getServerCallback(), ps.dest);
 
@@ -922,6 +923,17 @@ void BattleEvaluator::evaluateCreatureSpellcast(const CStack * stack, PossibleSp
 		totalGain += healthDiff;
 	}
 
+	// consider the case in which spell summons units
+	auto newUnits = state.getUnitsIf([&](const battle::Unit * u) -> bool
+		{
+			return !u->isGhost() && !u->isTurret() && !vstd::contains(healthOfStack, u->unitId());
+		});
+
+	for(auto unit : newUnits)
+	{
+		totalGain += unit->getAvailableHealth();
+	}
+
 	ps.value = totalGain;
 }
 

+ 1 - 1
AI/BattleAI/BattleEvaluator.h

@@ -31,7 +31,7 @@ struct CachedAttack
 	bool waited = false;
 };
 
-class BattleEvaluator
+class DLL_EXPORT BattleEvaluator
 {
 	std::unique_ptr<PotentialTargets> targets;
 	std::shared_ptr<HypotheticBattle> hb;

+ 2 - 0
AI/BattleAI/BattleExchangeVariant.cpp

@@ -392,6 +392,8 @@ MoveTarget BattleExchangeEvaluator::findMoveTowardsUnreachable(
 
 		for(auto & hex : hexes)
 		{
+			if (!dists.isReachable(hex))
+				continue;
 			// FIXME: provide distance info for Jousting bonus
 			auto bai = BattleAttackInfo(activeStack, enemy, 0, cb->battleCanShoot(activeStack));
 			auto attack = AttackPossibility::evaluate(bai, hex, damageCache, hb);

+ 4 - 0
AI/CMakeLists.txt

@@ -44,6 +44,10 @@ if(ENABLE_BATTLE_AI)
 	add_subdirectory(BattleAI)
 endif()
 
+if(ENABLE_MMAI)
+	add_subdirectory(MMAI)
+endif()
+
 # Adventure AI's
 add_subdirectory(EmptyAI)
 

+ 1 - 0
AI/MMAI/.gitignore

@@ -0,0 +1 @@
+_notes

+ 292 - 0
AI/MMAI/BAI/base.cpp

@@ -0,0 +1,292 @@
+/*
+ * base.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 "callback/CBattleCallback.h"
+#include "networkPacks/PacksForClientBattle.h"
+#include "networkPacks/SetStackEffect.h"
+#include "spells/CSpellHandler.h"
+#include "vcmi/Environment.h"
+
+#include "BAI/v13/BAI.h"
+#include "base.h"
+
+namespace MMAI::BAI
+{
+// static
+std::shared_ptr<Base>
+Base::Create(Schema::IModel * model, const std::shared_ptr<Environment> & env, const std::shared_ptr<CBattleCallback> & cb, bool enableSpellsUsage)
+{
+	std::shared_ptr<Base> res;
+	auto version = model->getVersion();
+
+	if(version == 13)
+		res = std::make_shared<V13::BAI>(model, version, env, cb);
+	else
+		throw std::runtime_error("Unsupported schema version: " + std::to_string(version));
+
+	res->init(enableSpellsUsage);
+	return res;
+}
+
+Base::Base(Schema::IModel * model, int version, const std::shared_ptr<Environment> & env, const std::shared_ptr<CBattleCallback> & cb)
+	: model(model), version(version), name("BAI-v" + std::to_string(version)), colorname(cb->getPlayerID()->toString()), env(env), cb(cb)
+{
+	std::ostringstream oss;
+
+	// Store the memory address and include it in logging
+	const auto * ptr = static_cast<const void *>(this);
+	oss << ptr;
+	addrstr = oss.str();
+
+	const char * envvar = std::getenv("MMAI_VERBOSE");
+	verbose = envvar != nullptr && strcmp(envvar, "1") == 0;
+}
+
+/*
+ * These methods MUST be overridden by derived BAI (e.g. BAI::V1)
+ * Their base implementation is is for logging purposes only.
+ */
+
+void Base::activeStack(const BattleID & bid, const CStack * astack)
+{
+	debug("*** activeStack ***");
+	trace("activeStack called for " + astack->nodeName());
+};
+
+void Base::yourTacticPhase(const BattleID & bid, int distance)
+{
+	debug("*** yourTacticPhase ***");
+};
+
+/*
+ * These methods MAY be overriden by derived BAI (e.g. BAI::V1)
+ * Their implementation here is a no-op.
+ */
+
+void Base::init(bool enableSpellsUsage_)
+{
+	enableSpellsUsage = enableSpellsUsage_;
+	debug("*** init ***");
+}
+
+void Base::actionFinished(const BattleID & bid, const BattleAction & action)
+{
+	debug("*** actionFinished ***");
+}
+
+void Base::actionStarted(const BattleID & bid, const BattleAction & action)
+{
+	debug("*** actionStarted ***");
+}
+
+void Base::battleAttack(const BattleID & bid, const BattleAttack * ba)
+{
+	debug("*** battleAttack ***");
+}
+
+void Base::battleCatapultAttacked(const BattleID & bid, const CatapultAttack & ca)
+{
+	debug("*** battleCatapultAttacked ***");
+}
+
+void Base::battleEnd(const BattleID & bid, const BattleResult * br, QueryID queryID)
+{
+	debug("*** battleEnd ***");
+}
+
+void Base::battleGateStateChanged(const BattleID & bid, const EGateState state)
+{
+	debug("*** battleGateStateChanged ***");
+	trace("New gate state: %d", EI(state));
+}
+
+void Base::battleLogMessage(const BattleID & bid, const std::vector<MetaString> & lines)
+{
+	debug("*** battleLogMessage ***");
+	if(verbose)
+	{
+		std::string res = "Messages:";
+		for(const auto & line : lines)
+		{
+			std::string formatted = line.toString();
+			boost::algorithm::trim(formatted);
+			res = res + "\n\t* " + formatted;
+		}
+		std::cout << "MMAI_VERBOSE: " << res << "\n";
+	}
+}
+
+void Base::battleNewRound(const BattleID & bid)
+{
+	debug("*** battleNewRound ***");
+}
+
+void Base::battleNewRoundFirst(const BattleID & bid)
+{
+	debug("*** battleNewRoundFirst ***");
+}
+
+void Base::battleObstaclesChanged(const BattleID & bid, const std::vector<ObstacleChanges> & obstacles)
+{
+	debug("*** battleObstaclesChanged ***");
+}
+
+void Base::battleSpellCast(const BattleID & bid, const BattleSpellCast * sc)
+{
+	debug("*** battleSpellCast ***");
+	if(verbose)
+	{
+		std::string res = "Spellcast info:";
+		auto battle = cb->getBattle(bid);
+		const auto * caster = battle->battleGetStackByID(sc->casterStack, false);
+
+		res += "\n\t* spell: " + sc->spellID.toSpell()->identifier;
+		res += "\n\t* castByHero=" + std::to_string(sc->castByHero);
+		res += "\n\t* casterStack=" + (caster ? caster->getDescription() : "");
+		res += "\n\t* activeCast=" + std::to_string(sc->activeCast);
+		res += "\n\t* side=" + std::to_string(EI(sc->side));
+		res += "\n\t* tile=" + std::to_string(sc->tile.toInt());
+
+		res += "\n\t* affected:";
+		for(const auto & cid : sc->affectedCres)
+			res += "\n\t  > " + battle->battleGetStackByID(cid, false)->getDescription();
+
+		res += "\n\t* resisted:";
+		for(const auto & cid : sc->resistedCres)
+			res += "\n\t  > " + battle->battleGetStackByID(cid, false)->getDescription();
+
+		res += "\n\t* reflected:";
+		for(const auto & cid : sc->reflectedCres)
+			res += "\n\t  > " + battle->battleGetStackByID(cid, false)->getDescription();
+
+		std::cout << "MMAI_VERBOSE: " << res << "\n";
+	}
+}
+
+void Base::battleStackMoved(const BattleID & bid, const CStack * stack, const BattleHexArray & dest, int distance, bool teleport)
+{
+	debug("*** battleStackMoved ***");
+	if(verbose)
+	{
+		auto battle = cb->getBattle(bid);
+		std::string fmt = "Movement info:";
+
+		fmt += "\n\t* stack description=%s";
+		fmt += "\n\t* stack owner=%s";
+		fmt += "\n\t* dest[0]=%d (Hex#%d, y=%d, x=%d)";
+		fmt += "\n\t* distance=%d";
+		fmt += "\n\t* teleport=%d";
+
+		auto bh0 = dest.at(dest.size() - 1);
+		auto hexid0 = bh0.getX() - 1 + (bh0.getY() * 15);
+		auto x0 = bh0.getX() - 1;
+		auto y0 = bh0.getY();
+
+		auto res = boost::format(fmt) % stack->getDescription() % stack->getOwner().toString() % bh0 % hexid0 % y0 % x0 % distance % teleport;
+
+		std::cout << "MMAI_VERBOSE: " << boost::str(res) << "\n";
+	}
+}
+
+void Base::battleStacksAttacked(const BattleID & bid, const std::vector<BattleStackAttacked> & bsa, bool ranged)
+{
+	debug("*** battleStacksAttacked ***");
+}
+
+void Base::battleStacksEffectsSet(const BattleID & bid, const SetStackEffect & sse)
+{
+	debug("*** battleStacksEffectsSet ***");
+	if(verbose)
+	{
+		auto battle = cb->getBattle(bid);
+
+		std::string res = "Effects set:";
+
+		for(const auto & [unitid, bonuses] : sse.toAdd)
+		{
+			const auto & cstack = battle->battleGetStackByID(unitid);
+			res += "\n\t* stack=" + (cstack ? cstack->getDescription() : "");
+			for(const auto & bonus : bonuses)
+			{
+				res += "\n\t  > add bonus=" + bonus.description.toString();
+			}
+		}
+
+		for(const auto & [unitid, bonuses] : sse.toRemove)
+		{
+			const auto & cstack = battle->battleGetStackByID(unitid);
+			res += "\n\t* stack=" + (cstack ? cstack->getDescription() : "");
+			for(const auto & bonus : bonuses)
+			{
+				res += "\n\t  > remove bonus=" + bonus.description.toString();
+			}
+		}
+
+		for(const auto & [unitid, bonuses] : sse.toUpdate)
+		{
+			const auto & cstack = battle->battleGetStackByID(unitid);
+			res += "\n\t* stack=" + (cstack ? cstack->getDescription() : "");
+			for(const auto & bonus : bonuses)
+			{
+				res += "\n\t  > update bonus=" + bonus.description.toString();
+			}
+		}
+
+		std::cout << "MMAI_VERBOSE: " << res << "\n";
+	}
+}
+
+void Base::battleStart(
+	const BattleID & bid,
+	const CCreatureSet * army1,
+	const CCreatureSet * army2,
+	int3 tile,
+	const CGHeroInstance * hero1,
+	const CGHeroInstance * hero2,
+	BattleSide side,
+	bool replayAllowed
+)
+{
+	debug("*** battleStart ***");
+}
+
+// XXX: positive morale triggers an effect
+//      negative morale just skips turn
+void Base::battleTriggerEffect(const BattleID & bid, const BattleTriggerEffect & bte)
+{
+	debug("*** battleTriggerEffect ***");
+	if(verbose)
+	{
+		auto battle = cb->getBattle(bid);
+		const auto * cstack = battle->battleGetStackByID(bte.stackID);
+		std::string res = "Effect triggered:";
+		res += "\n\t* bonus id=" + std::to_string(EI(bte.effect));
+		res += "\n\t* bonus value=" + std::to_string(bte.val);
+		res += "\n\t* stack=" + (cstack ? cstack->getDescription() : "");
+		std::cout << "MMAI_VERBOSE: " << res << "\n";
+	}
+}
+
+void Base::battleUnitsChanged(const BattleID & bid, const std::vector<UnitChanges> & changes)
+{
+	debug("*** battleUnitsChanged ***");
+	if(verbose)
+	{
+		std::string res = "Changes:";
+		for(const auto & change : changes)
+		{
+			res += "\n\t* operation=" + std::to_string(EI(change.operation));
+			res += "\n\t* healthDelta=" + std::to_string(change.healthDelta);
+		}
+		std::cout << "MMAI_VERBOSE: " << res << "\n";
+	}
+}
+}

+ 205 - 0
AI/MMAI/BAI/base.h

@@ -0,0 +1,205 @@
+/*
+ * base.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
+
+// CI build fails without this
+#include <utility>
+
+#include "Global.h"
+
+#include "battle/CPlayerBattleCallback.h"
+#include "callback/CBattleCallback.h"
+#include "callback/CBattleGameInterface.h"
+
+#include "schema/base.h"
+
+namespace MMAI::BAI
+{
+class Base : public CBattleGameInterface
+{
+public:
+	// Factory method for versioned derived BAI (e.g. BAI::V1)
+	static std::shared_ptr<Base>
+	Create(Schema::IModel * model, const std::shared_ptr<Environment> & env, const std::shared_ptr<CBattleCallback> & cb, bool enableSpellsUsage);
+
+	Base() = delete;
+	Base(Schema::IModel * model, int version, const std::shared_ptr<Environment> & env, const std::shared_ptr<CBattleCallback> & cb);
+
+	/*
+	 * These methods MUST be overridden by derived BAI (e.g. BAI::V1)
+	 * Their base implementation is is for logging purposes only.
+	 */
+
+	virtual Schema::Action getNonRenderAction() = 0;
+	void activeStack(const BattleID & bid, const CStack * stack) override;
+	void yourTacticPhase(const BattleID & bid, int distance) override;
+
+	/*
+	 * These methods MAY be overriden by derived BAI (e.g. BAI::V1)
+	 * Their base implementation is for logging purposes only.
+	 */
+
+	virtual void init(bool enableSpellsUsage); // called shortly after object construction
+
+	void actionFinished(const BattleID & bid, const BattleAction & action) override;
+	void actionStarted(const BattleID & bid, const BattleAction & action) override;
+	void battleAttack(const BattleID & bid, const BattleAttack * ba) override;
+	void battleCatapultAttacked(const BattleID & bid, const CatapultAttack & ca) override;
+	void battleEnd(const BattleID & bid, const BattleResult * br, QueryID queryID) override;
+	void battleGateStateChanged(const BattleID & bid, EGateState state) override;
+	void battleLogMessage(const BattleID & bid, const std::vector<MetaString> & lines) override;
+	void battleNewRound(const BattleID & bid) override;
+	void battleNewRoundFirst(const BattleID & bid) override;
+	void battleObstaclesChanged(const BattleID & bid, const std::vector<ObstacleChanges> & obstacles) override;
+	void battleSpellCast(const BattleID & bid, const BattleSpellCast * sc) override;
+	void battleStackMoved(const BattleID & bid, const CStack * stack, const BattleHexArray & dest, int distance, bool teleport) override;
+	void battleStacksAttacked(const BattleID & bid, const std::vector<BattleStackAttacked> & bsa, bool ranged) override;
+	void battleStacksEffectsSet(const BattleID & bid, const SetStackEffect & sse) override;
+	void battleStart(
+		const BattleID & bid,
+		const CCreatureSet * army1,
+		const CCreatureSet * army2,
+		int3 tile,
+		const CGHeroInstance * hero1,
+		const CGHeroInstance * hero2,
+		BattleSide side,
+		bool replayAllowed
+	) override;
+	void battleTriggerEffect(const BattleID & bid, const BattleTriggerEffect & bte) override;
+	void battleUnitsChanged(const BattleID & bid, const std::vector<UnitChanges> & changes) override;
+
+	/*
+	 * These methods MUST NOT be called.
+	 * Their base implementation throws a runtime error
+	 * (whistleblower for developer mistakes)
+	 */
+	void initBattleInterface(std::shared_ptr<Environment> _1, std::shared_ptr<CBattleCallback> _2) override
+	{
+		throw std::runtime_error("BAI (base class) received initBattleInterface call");
+	}
+	void initBattleInterface(std::shared_ptr<Environment> _1, std::shared_ptr<CBattleCallback> _2, AutocombatPreferences _3) override
+	{
+		throw std::runtime_error("BAI (base class) received initBattleInterface call");
+	}
+
+	Schema::IModel * model;
+	const int version;
+	const std::string name = "BAI"; // used in logging
+	const std::string colorname;
+
+	const std::shared_ptr<Environment> env;
+	const std::shared_ptr<CBattleCallback> cb;
+
+	std::string addrstr = "?";
+
+	// Set via VCMI_BAI_VERBOSE env var ("1" to enable)
+	bool verbose = false;
+
+	bool enableSpellsUsage = false;
+
+	/*
+	 * Templates defined in the header
+	 * Needed to prevent linker errors for calls from derived classes
+	 */
+
+	template<typename... Args>
+	void _log(const ELogLevel::ELogLevel level, const std::string & format, Args... args) const
+	{
+		logAi->log(level, "%s-%s [%s] " + format, name, addrstr, colorname, std::move(args)...);
+	}
+
+	template<typename... Args>
+	void error(const std::string & format, Args... args) const
+	{
+		log(ELogLevel::ERROR, format, args...);
+	}
+	template<typename... Args>
+	void warn(const std::string & format, Args... args) const
+	{
+		log(ELogLevel::WARN, format, args...);
+	}
+	template<typename... Args>
+	void info(const std::string & format, Args... args) const
+	{
+		log(ELogLevel::INFO, format, args...);
+	}
+	template<typename... Args>
+	void debug(const std::string & format, Args... args) const
+	{
+		log(ELogLevel::DEBUG, format, args...);
+	}
+	template<typename... Args>
+	void trace(const std::string & format, Args... args) const
+	{
+		log(ELogLevel::DEBUG, format, args...);
+	}
+	template<typename... Args>
+	void log(ELogLevel::ELogLevel level, const std::string & format, Args... args) const
+	{
+		if(logAi->getEffectiveLevel() <= level)
+			_log(level, format, args...);
+	}
+
+	void error(const std::string & text) const
+	{
+		log(ELogLevel::ERROR, text);
+	}
+	void warn(const std::string & text) const
+	{
+		log(ELogLevel::WARN, text);
+	}
+	void info(const std::string & text) const
+	{
+		log(ELogLevel::INFO, text);
+	}
+	void debug(const std::string & text) const
+	{
+		log(ELogLevel::DEBUG, text);
+	}
+	void trace(const std::string & text) const
+	{
+		log(ELogLevel::TRACE, text);
+	}
+	void log(ELogLevel::ELogLevel level, const std::string & text) const
+	{
+		if(logAi->getEffectiveLevel() <= level)
+			_log(level, "%s", text);
+	}
+
+	void error(const std::function<std::string()> & f) const
+	{
+		log(ELogLevel::ERROR, f);
+	}
+	void warn(const std::function<std::string()> & f) const
+	{
+		log(ELogLevel::WARN, f);
+	}
+	void info(const std::function<std::string()> & f) const
+	{
+		log(ELogLevel::INFO, f);
+	}
+	void debug(const std::function<std::string()> & f) const
+	{
+		log(ELogLevel::DEBUG, f);
+	}
+	void trace(const std::function<std::string()> & f) const
+	{
+		log(ELogLevel::TRACE, f);
+	}
+
+	template<typename F>
+	void log(ELogLevel::ELogLevel level, F & f) const
+	requires(std::is_invocable_r_v<std::string, F &>)
+	{
+		if(logAi->getEffectiveLevel() <= level)
+			_log(level, "%s", f());
+	}
+};
+}

+ 580 - 0
AI/MMAI/BAI/model/NNModel.cpp

@@ -0,0 +1,580 @@
+/*
+ * NNModel.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 "BAI/model/util/bucketing.h"
+#include "BAI/model/util/common.h"
+#include "BAI/model/util/sampling.h"
+#include "NNModel.h"
+#include "filesystem/Filesystem.h"
+#include "vstd/CLoggerBase.h"
+#include "json/JsonNode.h"
+
+#include <algorithm>
+#include <onnxruntime_c_api.h>
+#include <onnxruntime_cxx_api.h>
+
+namespace MMAI::BAI
+{
+
+namespace
+{
+	struct ScopedTimer
+	{
+		std::string name;
+		std::chrono::steady_clock::time_point t0;
+		explicit ScopedTimer(const std::string & n) : name(n), t0(std::chrono::steady_clock::now()) {}
+
+		ScopedTimer(const ScopedTimer &) = delete;
+		ScopedTimer & operator=(const ScopedTimer &) = delete;
+		ScopedTimer(ScopedTimer &&) = delete;
+		ScopedTimer & operator=(ScopedTimer &&) = delete;
+		~ScopedTimer()
+		{
+			auto dt = std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now() - t0).count();
+			logAi->info("%s: %lld ms", name, dt);
+		}
+	};
+
+	std::array<std::vector<int32_t>, 165> buildNeighbourhoods_unpadded(const std::vector<int64_t> & dst)
+	{
+		// Validate and count degrees per node
+		std::array<int, 165> deg{};
+		for(auto e : dst)
+		{
+			auto v = static_cast<int>(e);
+			if(v < 0 || v >= 165)
+				throwf("dst contains node id out of range: %d", v);
+			++deg[v];
+		}
+
+		std::array<std::vector<int32_t>, 165> res{};
+		for(int v = 0; v < 165; ++v)
+			res[v].reserve(deg[v]);
+		for(size_t e = 0; e < dst.size(); ++e)
+		{
+			auto v = static_cast<int>(dst[e]);
+			res[v].push_back(static_cast<int32_t>(e));
+		}
+
+		return res;
+	}
+}
+
+std::unique_ptr<Ort::Session> NNModel::loadModel(const std::string & path, const Ort::SessionOptions & opts)
+{
+	static const auto env = Ort::Env{ORT_LOGGING_LEVEL_WARNING, "vcmi"};
+	const auto rpath = ResourcePath(path, EResType::AI_MODEL);
+	const auto * rhandler = CResourceHandler::get();
+	if(!rhandler->existsResource(rpath))
+		throwf("resource does not exist: %s", rpath.getName());
+
+	const auto & [data, length] = rhandler->load(rpath)->readAll();
+	return std::make_unique<Ort::Session>(env, data.get(), length, opts);
+}
+
+int NNModel::readVersion(const Ort::ModelMetadata & md) const
+{
+	/*
+	 * version
+	 *   dtype=int
+	 *   shape=scalar
+	 *
+	 * Version of the model (current implementation is at version 13).
+	 * If needed, NNModel may be extended to support other versions as well.
+	 *
+	 */
+	int res = -1;
+
+	Ort::AllocatedStringPtr v = md.LookupCustomMetadataMapAllocated("version", allocator);
+	if(!v)
+		throwf("readVersion: no such key");
+
+	std::string vs(v.get());
+	try
+	{
+		res = std::stoi(vs);
+	}
+	catch(...)
+	{
+		throwf("readVersion: not an int: %s", vs);
+	}
+
+	if(res != 13)
+		throwf("readVersion: want: 13, have: %d (%s)", res, vs);
+
+	return res;
+}
+
+Schema::Side NNModel::readSide(const Ort::ModelMetadata & md) const
+{
+	/*
+	 * side
+	 *   dtype=int
+	 *   shape=scalar
+	 *
+	 * Battlefield side the model was trained on (see Schema::Side enum).
+	 *
+	 */
+	Schema::Side res;
+	Ort::AllocatedStringPtr v = md.LookupCustomMetadataMapAllocated("side", allocator);
+	if(!v)
+		throw std::runtime_error("metadata error: side: no such key");
+	std::string vs(v.get());
+	try
+	{
+		res = static_cast<Schema::Side>(std::stoi(vs));
+	}
+	catch(...)
+	{
+		throw std::runtime_error("metadata error: side: not an int");
+	}
+
+	return res;
+}
+
+Vec3D<int32_t> NNModel::readBucketSizes(const Ort::ModelMetadata & md) const
+{
+	/*
+	 * all_sizes
+	 *   dtype=int
+	 *   shape=[5, 7, 2]:
+	 *     d1: bucket size (S, M, L, XL, XXL)
+	 *     d2: edge type (see Schema::V13::LinkType enum)
+	 *     d3: pairs of [Emax, Kmax]:
+	 *      Emax = max number of outbound node edges
+	 *      Kmax = max number of inbound node edges
+	 *
+	 * Stats (10K steps):
+	 *
+	 *   Outbound edges (E)   avg   max   p99   p90   p75   p50   p25
+	 * -----------------------------------------------------------------
+	 *             ADJACENT   888   888   888   888   888   888   888
+	 *                REACH   355   988   820   614   478   329   209
+	 *           RANGED_MOD   408   2403  1285  646   483   322   162
+	 *          ACTS_BEFORE   51    268   203   118   75    35    15
+	 *        MELEE_DMG_REL   43    198   160   103   60    31    14
+	 *        RETAL_DMG_REL   27    165   113   67    38    18    8
+	 *       RANGED_DMG_REL   12    133   60    29    18    9     4
+	 *
+	 *    Inbound edges (K)   avg   max   p99   p90   p75   p50   p25
+	 * -----------------------------------------------------------------
+	 *             ADJACENT   5.4   6     6     6     6     6     6
+	 *                REACH   2.2   13    10    8     6     4     3
+	 *           RANGED_MOD   2.5   15    8     4     3     2     1
+	 *          ACTS_BEFORE   0.3   23    19    15    12    8     5
+	 *        MELEE_DMG_REL   0.3   10    9     8     7     5     3
+	 *        RETAL_DMG_REL   0.2   10    9     8     6     5     3
+	 *       RANGED_DMG_REL   0.1   8     6     3     2     2     1
+	 *
+	 * Approx. sizes are S=p50 / M=p90 / L=p99 / XL=max / XXL=2*max
+	 * Exact values defined in the vcmi-gym project and are subject to change.
+	 *
+	 */
+
+	Vec3D<int32_t> res = {};
+	Ort::AllocatedStringPtr ab = md.LookupCustomMetadataMapAllocated("all_sizes", allocator);
+	if(!ab)
+		throw std::runtime_error("metadata key 'all_sizes' missing");
+	const std::string jsonstr(ab.get());
+	try
+	{
+		auto jn = JsonNode(jsonstr.data(), jsonstr.size(), "<ONNX metadata: all_sizes>");
+
+		if(!jn.isVector())
+			throwf("readBucketSizes: bad JsonType: want: %d, have: %d", EI(JsonNode::JsonType::DATA_VECTOR), EI(jn.getType()));
+
+		for(auto & jv0 : jn.Vector())
+		{
+			auto vec1 = std::vector<std::vector<int32_t>>{};
+			for(auto & jv1 : jv0.Vector())
+			{
+				auto vec2 = std::vector<int32_t>{};
+				for(auto & jv2 : jv1.Vector())
+				{
+					if(!jv2.isNumber())
+					{
+						throwf("readBucketSizes: invalid data type: want: %d, got: %d", EI(JsonNode::JsonType::DATA_INTEGER), EI(jv2.getType()));
+					}
+					vec2.push_back(static_cast<int32_t>(jv2.Integer()));
+				}
+				vec1.emplace_back(vec2);
+			}
+			res.emplace_back(vec1);
+		}
+	}
+	catch(const std::exception & e)
+	{
+		throw std::runtime_error(std::string("readBucketSizes: failed to parse JSON: ") + e.what());
+	}
+
+	if(res.size() != 5)
+		throwf("readBucketSizes: bad size for d1: want: 5, have: %zu", res.size());
+	if(res[0].size() != 7)
+		throwf("readBucketSizes: bad size for d2: want: 7, have: %zu", res[0].size());
+	if(res[0][0].size() != 2)
+		throwf("readBucketSizes: bad size for d3: want: 2, have: %zu", res[0][0].size());
+
+	return res;
+}
+
+Vec3D<int32_t> NNModel::readActionTable(const Ort::ModelMetadata & md) const
+{
+	/*
+	 * action_table
+	 *   dtype=int
+	 *   shape=[4, 165, 165]:
+	 *     d1: action (WAIT, MOVE, AMOVE, SHOOT)
+	 *     d2: target hex for MOVE, AMOVE (hex to move to) or SHOOT
+	 *     d3: target hex for AMOVE (hex to melee-attack at after moving)
+	 *
+	 */
+
+	Vec3D<int32_t> res = {};
+	Ort::AllocatedStringPtr ab = md.LookupCustomMetadataMapAllocated("action_table", allocator);
+	if(!ab)
+		throwf("readActionTable: metadata key 'action_table' missing");
+	const std::string jsonstr(ab.get());
+
+	try
+	{
+		auto jn = JsonNode(jsonstr.data(), jsonstr.size(), "<ONNX metadata: all_sizes>");
+
+		for(auto & jv0 : jn.Vector())
+		{
+			auto vec1 = std::vector<std::vector<int32_t>>{};
+			for(auto & jv1 : jv0.Vector())
+			{
+				auto vec2 = std::vector<int32_t>{};
+				for(auto & jv2 : jv1.Vector())
+				{
+					if(!jv2.isNumber())
+					{
+						throwf("invalid data type: want: %d, got: %d", EI(JsonNode::JsonType::DATA_INTEGER), EI(jv2.getType()));
+					}
+					vec2.push_back(static_cast<int32_t>(jv2.Integer()));
+				}
+				vec1.emplace_back(vec2);
+			}
+			res.emplace_back(vec1);
+		}
+	}
+	catch(const std::exception & e)
+	{
+		throwf(std::string("failed to parse 'action_table' JSON: ") + e.what());
+	}
+
+	if(res.size() != 4)
+		throwf("readActionTable: bad size for d1: want: 4, have: %zu", res.size());
+	if(res[0].size() != 165)
+		throwf("readActionTable: bad size for d2: want: 165, have: %zu", res[0].size());
+	if(res[0][0].size() != 165)
+		throwf("readActionTable: bad size for d3: want: 165, have: %zu", res[0][0].size());
+
+	return res;
+}
+
+std::vector<const char *> NNModel::readInputNames()
+{
+	/*
+	 * Model inputs (4):
+	 *   [0] battlefield state
+	 *        dtype=float
+	 *        shape=[S] where S=Schema::V13::BATTLEFIELD_STATE_SIZE
+	 * 	 [1] edge index
+	 *        dtype=int32
+	 *        shape=[2, E*] where E* depends on the bucket (see readBucketSizes)
+	 * 	 [2] edge attributes
+	 *        dtype=float
+	 *        shape=[E*, 1] where E* depends on the bucket
+	 * 	 [3] node neighbourhoods
+	 *        dtype=int
+	 *        shape=[165, K*] where K* depends on the bucket
+	 */
+	std::vector<const char *> res;
+	auto count = model->GetInputCount();
+	if(count != 4)
+		throwf("wrong input count: want: %d, have: %lld", 4, count);
+
+	inputNamePtrs.reserve(count);
+	res.reserve(count);
+	for(size_t i = 0; i < count; ++i)
+	{
+		inputNamePtrs.emplace_back(model->GetInputNameAllocated(i, allocator));
+		res.push_back(inputNamePtrs.back().get());
+	}
+
+	return res;
+}
+
+std::vector<const char *> NNModel::readOutputNames()
+{
+	/*
+	 * Model outputs (10):
+     *   [0] greedy action (not used)
+	 *        dtype=int
+	 *        shape=[1]
+     *   [1] main action logits (see readActionTable, d0)
+	 *        dtype=float
+	 *        shape=[4]
+     *   [2] hex#1 logits (see readActionTable, d1)
+	 *        dtype=float
+	 *        shape=[165]
+     *   [3] hex#2 logits (see readActionTable, d2)
+	 *        dtype=float
+	 *        shape=[165]
+     *   [4] main action mask
+	 *        dtype=int
+	 *        shape=[4]
+     *   [5] hex#1 mask
+	 *        dtype=int
+	 *        shape=[165]
+     *   [6] hex#2 mask
+	 *        dtype=int
+	 *        shape=[165]
+     *   [7] greedy main action (not used)
+	 *        dtype=int
+	 *        shape=[1]
+     *   [8] greedy hex1 (not used)
+	 *        dtype=int
+	 *        shape=[1]
+     *   [9] greedy hex2 (not used)
+	 *        dtype=int
+	 *        shape=[1]
+	 *
+	 * The greedy output values are unused since their stochastic counterparts
+	 * are sampled here instead (see sampling::sample_triplet).
+	 */
+	std::vector<const char *> res;
+	auto count = model->GetOutputCount();
+	if(count != 10)
+		throwf("wrong output count: want: %d, have: %lld", count, count);
+
+	outputNamePtrs.reserve(count);
+	res.reserve(count);
+
+	for(size_t i = 0; i < count; ++i)
+	{
+		outputNamePtrs.emplace_back(model->GetOutputNameAllocated(i, allocator));
+		res.push_back(outputNamePtrs.back().get());
+	}
+
+	return res;
+}
+
+NNModel::NNModel(const std::string & path, float temperature, uint64_t seed)
+	: path(path), temperature(temperature), meminfo(Ort::MemoryInfo::CreateCpu(OrtArenaAllocator, OrtMemTypeDefault))
+{
+	logAi->info("MMAI: NNModel params: seed=%1%, temperature=%2%, model=%3%", seed, temperature, path);
+
+	if(seed == 0)
+	{
+		seed = std::chrono::high_resolution_clock::now().time_since_epoch().count();
+		logAi->info("Generated new seed: %1%", seed);
+	}
+
+	rng = std::mt19937(seed);
+
+	auto opts = Ort::SessionOptions();
+	opts.SetIntraOpNumThreads(4);
+	opts.SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_ENABLE_BASIC);
+
+	model = loadModel(path, opts);
+
+	auto md = model->GetModelMetadata();
+	version = readVersion(md);
+	side = readSide(md);
+	bucketSizes = readBucketSizes(md);
+	actionTable = readActionTable(md);
+	inputNames = readInputNames();
+	outputNames = readOutputNames();
+
+	logAi->info("MMAI version " + std::to_string(version) + " initialized on side=" + std::to_string(EI(side)));
+}
+
+Schema::ModelType NNModel::getType()
+{
+	return Schema::ModelType::NN;
+};
+
+std::string NNModel::getName()
+{
+	return "MMAI_MODEL";
+};
+
+int NNModel::getVersion()
+{
+	return version;
+};
+
+Schema::Side NNModel::getSide()
+{
+	return side;
+};
+
+int NNModel::getAction(const MMAI::Schema::IState * s)
+{
+	auto timer = ScopedTimer("getAction");
+	auto any = s->getSupplementaryData();
+
+	if(s->version() != version)
+		throwf("getAction: unsupported IState version: want: %d, have: %d", version, s->version());
+
+	if(!any.has_value())
+		throw std::runtime_error("extractSupplementaryData: supdata is empty");
+	auto err = MMAI::Schema::AnyCastError(any, typeid(const MMAI::Schema::V13::ISupplementaryData *));
+	if(!err.empty())
+		throwf("getAction: anycast failed: %s", err);
+
+	const auto * sup = std::any_cast<const MMAI::Schema::V13::ISupplementaryData *>(any);
+
+	if(sup->getIsBattleEnded())
+	{
+		timer.name = boost::str(boost::format("MMAI action: %d (battle ended)") % MMAI::Schema::ACTION_RESET);
+		return MMAI::Schema::ACTION_RESET;
+	}
+
+	auto [inputs, size_idx] = prepareInputsV13(s, sup);
+	auto outputs = model->Run(Ort::RunOptions(), inputNames.data(), inputs.data(), inputs.size(), outputNames.data(), outputNames.size());
+
+	if(outputs.size() != 10)
+		throwf("getAction: bad output size: want: 10, have: %d", outputs.size());
+
+	// deterministic action (useful for debugging)
+	auto action = toVector<int32_t>("getAction: t_action", outputs[0], 1).at(0);
+
+	auto sample = sampling::sample_triplet(
+		MaskedLogits{.logits = outputs[1], .mask = outputs[4]}, // act0 [1, 4]
+		MaskedLogits{.logits = outputs[2], .mask = outputs[5]}, // hex1 [1, 4, 165]
+		MaskedLogits{.logits = outputs[3], .mask = outputs[6]}, // hex2 [1, 4, 165, 165]
+		temperature,
+		rng
+	);
+
+	auto s_action = actionTable.at(sample.act0).at(sample.hex1).at(sample.hex2);
+
+	if(s_action != action)
+		logAi->debug("Sampled a non-greedy action: %d != %d", s_action, action);
+
+	timer.name = boost::str(boost::format("MMAI action: %d (confidence=%.2f)") % action % sample.confidence);
+
+	return static_cast<MMAI::Schema::Action>(action);
+};
+
+double NNModel::getValue(const MMAI::Schema::IState * s)
+{
+	// This quantifies how good is the current state as perceived by the model
+	// (not used, not implemented)
+	return 0;
+}
+
+std::pair<std::vector<Ort::Value>, int> NNModel::prepareInputsV13(const MMAI::Schema::IState * s, const MMAI::Schema::V13::ISupplementaryData * sup)
+{
+	auto containers = std::array<IndexContainer, LT_COUNT>{};
+
+	int count = 0;
+
+	for(const auto & [type, links] : sup->getAllLinks())
+	{
+		// assert order
+		if(EI(type) != count)
+			throwf("unexpected link type: want: %d, have: %d", count, EI(type));
+
+		auto & c = containers.at(count);
+
+		const auto srcinds = links->getSrcIndex();
+		const auto dstinds = links->getDstIndex();
+		const auto attrs = links->getAttributes();
+
+		auto nlinks = srcinds.size();
+
+		if(dstinds.size() != nlinks)
+			throwf("unexpected dstinds.size() for LinkType(%d): want: %d, have: %d", EI(type), nlinks, dstinds.size());
+
+		if(attrs.size() != nlinks)
+			throwf("unexpected attrs.size() for LinkType(%d): want: %d, have: %d", EI(type), nlinks, attrs.size());
+
+		c.edgeIndex.at(0).reserve(nlinks);
+		c.edgeIndex.at(1).reserve(nlinks);
+		c.edgeIndex.at(0).insert(c.edgeIndex.at(0).end(), srcinds.begin(), srcinds.end());
+		c.edgeIndex.at(1).insert(c.edgeIndex.at(1).end(), dstinds.begin(), dstinds.end());
+
+		c.edgeAttrs.reserve(nlinks);
+		c.edgeAttrs.insert(c.edgeAttrs.end(), attrs.begin(), attrs.end());
+
+		c.neighbourhoods = buildNeighbourhoods_unpadded(dstinds);
+
+		++count;
+	}
+
+	if(count != LT_COUNT)
+		throwf("unexpected links count: want: %d, have: %d", LT_COUNT, count);
+
+	auto bdata = bucketing::BucketBuilder(containers, bucketSizes).build_bucket_data();
+
+	const auto * state = s->getBattlefieldState();
+	auto estate = std::vector<float>(state->size());
+	std::ranges::copy(*state, estate.begin());
+
+	int sum_e = bdata.edgeIndex_flat.at(0).size();
+	int sum_k = bdata.neighbourhoods_flat.at(0).size();
+
+	if(bdata.edgeIndex_flat.at(0).size() != sum_e)
+		throwf("unexpected bdata.edgeIndex_flat.at(0).size(): want: %d, have: %d", sum_e, bdata.edgeIndex_flat.at(0).size());
+	if(bdata.edgeIndex_flat.at(1).size() != sum_e)
+		throwf("unexpected bdata.edgeIndex_flat.at(1).size(): want: %d, have: %d", sum_e, bdata.edgeIndex_flat.at(1).size());
+	if(bdata.edgeAttrs_flat.size() != sum_e)
+		throwf("unexpected bdata.edgeAttrs_flat.size(): want: %d, have: %d", sum_e, bdata.edgeAttrs_flat.size());
+
+	for(int i = 0; i < 165; ++i)
+	{
+		if(bdata.neighbourhoods_flat.at(i).size() != sum_k)
+			throwf("unexpected bdata.neighbourhoods_flat.at(%d).size(): want: %d, have: %d", i, sum_k, bdata.neighbourhoods_flat.at(i).size());
+	}
+
+	auto edgeIndex_flat = std::vector<int32_t>{};
+	edgeIndex_flat.reserve(2 * sum_e);
+	for(auto & ei : bdata.edgeIndex_flat)
+		edgeIndex_flat.insert(edgeIndex_flat.end(), ei.begin(), ei.end());
+
+	auto neighbourhoods = std::vector<int32_t>{};
+	neighbourhoods.reserve(165 * sum_k);
+	for(auto & nbr : bdata.neighbourhoods_flat)
+		neighbourhoods.insert(neighbourhoods.end(), nbr.begin(), nbr.end());
+
+	auto tensors = std::vector<Ort::Value>{};
+	tensors.push_back(toTensor("state", estate, {static_cast<int64_t>(estate.size())}));
+	tensors.push_back(toTensor("edgeIndex_flat", edgeIndex_flat, {2, sum_e}));
+	tensors.push_back(toTensor("edgeAttrs_flat", bdata.edgeAttrs_flat, {sum_e, 1}));
+	tensors.push_back(toTensor("nbr_flat", neighbourhoods, {165, sum_k}));
+
+	return {std::move(tensors), bdata.size_index};
+}
+
+template<typename T>
+Ort::Value NNModel::toTensor(const std::string & name, std::vector<T> & vec, const std::vector<int64_t> & shape)
+{
+	// Sanity check
+	int64_t numel = 1;
+	for(int64_t d : shape)
+		numel *= d;
+
+	if(numel != vec.size())
+		throwf("toTensor: %s: numel check failed: want: %d, have: %d", name, numel, vec.size());
+
+	// Create a memory-owning tensor then copy data
+	auto res = Ort::Value::CreateTensor<T>(allocator, shape.data(), shape.size());
+	T * dst = res.template GetTensorMutableData<T>();
+	std::memcpy(dst, vec.data(), vec.size() * sizeof(T));
+	return res;
+}
+
+} // namespace MMAI::BAI

+ 70 - 0
AI/MMAI/BAI/model/NNModel.h

@@ -0,0 +1,70 @@
+/*
+ * NNModel.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 <onnxruntime_cxx_api.h>
+
+#include "BAI/model/util/common.h"
+#include "schema/base.h"
+#include "schema/v13/types.h"
+
+namespace MMAI::BAI
+{
+
+class NNModel : public MMAI::Schema::IModel
+{
+public:
+	explicit NNModel(const std::string & path, float temperature, uint64_t seed);
+
+	Schema::ModelType getType() override;
+	std::string getName() override;
+	int getVersion() override;
+	Schema::Side getSide() override;
+	int getAction(const MMAI::Schema::IState * s) override;
+	double getValue(const MMAI::Schema::IState * s) override;
+
+private:
+	std::string path;
+	float temperature;
+	std::string name;
+	int version;
+	Schema::Side side;
+
+	std::mt19937 rng;
+	Vec3D<int32_t> bucketSizes;
+	Vec3D<int32_t> actionTable;
+
+	// AllocatedStringPtrs manage the string lifetime
+	// but names passed to model.Run must be const char*
+	std::vector<Ort::AllocatedStringPtr> inputNamePtrs;
+	std::vector<Ort::AllocatedStringPtr> outputNamePtrs;
+	std::vector<const char *> inputNames;
+	std::vector<const char *> outputNames;
+
+	std::unique_ptr<Ort::Session> model = nullptr;
+	Ort::AllocatorWithDefaultOptions allocator;
+	Ort::MemoryInfo meminfo;
+
+	std::pair<std::vector<Ort::Value>, int> prepareInputsV13(const MMAI::Schema::IState * state, const MMAI::Schema::V13::ISupplementaryData * sup);
+
+	template<typename T>
+	Ort::Value toTensor(const std::string & name, std::vector<T> & vec, const std::vector<int64_t> & shape);
+
+	std::unique_ptr<Ort::Session> loadModel(const std::string & path, const Ort::SessionOptions & opts);
+	int readVersion(const Ort::ModelMetadata & md) const;
+	Schema::Side readSide(const Ort::ModelMetadata & md) const;
+	Vec3D<int32_t> readBucketSizes(const Ort::ModelMetadata & md) const;
+	Vec3D<int32_t> readActionTable(const Ort::ModelMetadata & md) const;
+	std::vector<const char *> readInputNames();
+	std::vector<const char *> readOutputNames();
+};
+
+}

+ 72 - 0
AI/MMAI/BAI/model/ScriptedModel.cpp

@@ -0,0 +1,72 @@
+/*
+ * ScriptedModel.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 "ScriptedModel.h"
+#include "schema/base.h"
+
+namespace MMAI::BAI
+{
+
+ScriptedModel::ScriptedModel(const std::string & keyword) : keyword(keyword)
+{
+	static const std::vector<std::string> FALLBACKS = {"StupidAI", "BattleAI"};
+	auto it = std::ranges::find(FALLBACKS, keyword);
+	if(it == FALLBACKS.end())
+		throw std::runtime_error("Unsupported fallback keyword: " + keyword);
+}
+
+std::string ScriptedModel::getName()
+{
+	return keyword;
+}
+
+Schema::ModelType ScriptedModel::getType()
+{
+	return Schema::ModelType::SCRIPTED;
+}
+
+Schema::Side ScriptedModel::getSide()
+{
+	return Schema::Side::BOTH;
+}
+
+// SCRIPTED models are dummy models which should not be used for anything
+// other than their getType() and getName() methods. Based on the return
+// value, the corresponding scripted bot (e.g. StupidAI) should be
+// used for the upcoming battle instead.
+// When MMAI fails to load an ML model, it loads a SCRIPTED model instead
+// as per MMAI mod's "fallback" setting in order to prevent a game crash.
+
+// The below methods should never be called on this object:
+int ScriptedModel::getVersion()
+{
+	warn("getVersion", -666);
+	return -666;
+};
+
+int ScriptedModel::getAction(const MMAI::Schema::IState * s)
+{
+	warn("getAction", -666);
+	return -666;
+};
+
+double ScriptedModel::getValue(const MMAI::Schema::IState * s)
+{
+	warn("getValue", -666);
+	return -666;
+};
+
+void ScriptedModel::warn(const std::string & m, int retval) const
+{
+	logAi->error("WARNING: method %s called on a ScriptedModel object; returning %d\n", m.c_str(), retval);
+}
+}

+ 33 - 0
AI/MMAI/BAI/model/ScriptedModel.h

@@ -0,0 +1,33 @@
+/*
+ * ScriptedModel.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 "schema/base.h"
+
+namespace MMAI::BAI
+{
+class ScriptedModel : public MMAI::Schema::IModel
+{
+public:
+	explicit ScriptedModel(const std::string & keyword);
+
+	Schema::ModelType getType() override;
+	std::string getName() override;
+	int getVersion() override;
+	int getAction(const MMAI::Schema::IState * s) override;
+	Schema::Side getSide() override;
+	double getValue(const MMAI::Schema::IState * s) override;
+
+private:
+	const std::string keyword;
+	void warn(const std::string & m, int retval) const;
+};
+}

+ 181 - 0
AI/MMAI/BAI/model/util/bucketing.cpp

@@ -0,0 +1,181 @@
+/*
+ * bucketing.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 "BAI/model/util/bucketing.h"
+
+#include <boost/range/numeric.hpp>
+
+namespace MMAI::BAI::bucketing
+{
+BucketBuilder::BucketBuilder(const std::array<IndexContainer, LT_COUNT> & containers, const std::vector<std::vector<std::vector<int32_t>>> & all_sizes)
+	: containers_(containers), all_sizes_(all_sizes)
+{
+}
+
+BucketData BucketBuilder::build_bucket_data() const
+{
+	BucketData bdata{};
+
+	const Requirements req = compute_requirements();
+	const BucketChoice choice = choose_bucket(req);
+
+	if(choice.index < 0)
+		throwf("too many units on the battlefield");
+
+	bdata.size_index = choice.index;
+	bdata.emax = choice.emax;
+	bdata.kmax = choice.kmax;
+
+	build_edges_flat(bdata.emax, bdata);
+	build_neighbors_flat(bdata.kmax, bdata);
+
+	return bdata;
+}
+
+Requirements BucketBuilder::compute_requirements() const
+{
+	Requirements req{};
+	for(int l = 0; l < LT_COUNT; ++l)
+	{
+		req.e_req[l] = containers_[l].edgeAttrs.size();
+		size_t km = 0;
+
+		for(int v = 0; v < 165; ++v)
+			km = std::max(km, containers_[l].neighbourhoods[v].size());
+
+		req.k_req[l] = km;
+	}
+	return req;
+}
+
+bool BucketBuilder::bucket_satisfies(const std::vector<std::vector<int32_t>> & sz, const Requirements & req) const
+{
+	if(static_cast<int>(sz.size()) != LT_COUNT)
+		return false;
+
+	for(int l = 0; l < LT_COUNT; ++l)
+	{
+		if(sz[l].size() != 2)
+			return false;
+
+		const int32_t emax_l = sz[l][0];
+		const int32_t kmax_l = sz[l][1];
+
+		if(emax_l < static_cast<int32_t>(req.e_req[l]) || kmax_l < static_cast<int32_t>(req.k_req[l]))
+		{
+			return false;
+		}
+	}
+	return true;
+}
+
+void BucketBuilder::log_bucket_choice(int chosen, const Requirements & req) const
+{
+	logAi->debug("Size: %d", chosen);
+	for(int i = 0; i < LT_COUNT; ++i)
+	{
+		logAi->debug("  %d: [%ld, %ld] -> [%lld, %lld]", i, req.e_req[i], req.k_req[i], all_sizes_[chosen][i][0], all_sizes_[chosen][i][1]);
+	}
+}
+
+BucketChoice BucketBuilder::choose_bucket(const Requirements & req) const
+{
+	BucketChoice choice{};
+
+	for(int s = 0; s < static_cast<int>(all_sizes_.size()); ++s)
+	{
+		const auto & sz = all_sizes_[static_cast<size_t>(s)];
+		if(!bucket_satisfies(sz, req))
+			continue;
+
+		choice.index = s;
+		for(int l = 0; l < LT_COUNT; ++l)
+		{
+			choice.emax[l] = sz[l][0];
+			choice.kmax[l] = sz[l][1];
+		}
+		break;
+	}
+
+	if(choice.index >= 0)
+		log_bucket_choice(choice.index, req);
+
+	return choice;
+}
+
+void BucketBuilder::build_edges_flat(const std::array<int32_t, LT_COUNT> & emax, BucketData & bdata) const
+{
+	const size_t sum_emax = boost::accumulate(emax, static_cast<size_t>(0));
+
+	bdata.edgeIndex_flat.at(0).clear();
+	bdata.edgeIndex_flat.at(1).clear();
+	bdata.edgeAttrs_flat.clear();
+
+	bdata.edgeIndex_flat.at(0).reserve(sum_emax);
+	bdata.edgeIndex_flat.at(1).reserve(sum_emax);
+	bdata.edgeAttrs_flat.reserve(sum_emax);
+
+	for(int l = 0; l < LT_COUNT; ++l)
+	{
+		const auto & edgeIndex = containers_[l].edgeIndex;
+		const auto & edgeAttrs = containers_[l].edgeAttrs;
+
+		bdata.edgeIndex_flat.at(0).insert(bdata.edgeIndex_flat.at(0).end(), edgeIndex.at(0).begin(), edgeIndex.at(0).end());
+		bdata.edgeIndex_flat.at(1).insert(bdata.edgeIndex_flat.at(1).end(), edgeIndex.at(1).begin(), edgeIndex.at(1).end());
+		bdata.edgeAttrs_flat.insert(bdata.edgeAttrs_flat.end(), edgeAttrs.begin(), edgeAttrs.end());
+
+		auto need = static_cast<size_t>(emax[l]) - edgeIndex.at(0).size();
+		if(need > 0)
+			bdata.edgeIndex_flat.at(0).insert(bdata.edgeIndex_flat.at(0).end(), need, 0);
+
+		need = static_cast<size_t>(emax[l]) - edgeIndex.at(1).size();
+		if(need > 0)
+			bdata.edgeIndex_flat.at(1).insert(bdata.edgeIndex_flat.at(1).end(), need, 0);
+
+		need = static_cast<size_t>(emax[l]) - edgeAttrs.size();
+		if(need > 0)
+			bdata.edgeAttrs_flat.insert(bdata.edgeAttrs_flat.end(), need, 0.0f);
+	}
+
+	if(bdata.edgeIndex_flat.at(0).size() != sum_emax)
+		throwf("edgeIndex_flat.at(0) size mismatch: want: %d, have: %zu", sum_emax, bdata.edgeIndex_flat.at(0).size());
+
+	if(bdata.edgeIndex_flat.at(1).size() != sum_emax)
+		throwf("edgeIndex_flat.at(1) size mismatch: want: %d, have: %zu", sum_emax, bdata.edgeIndex_flat.at(1).size());
+
+	if(bdata.edgeAttrs_flat.size() != sum_emax)
+		throwf("edgeAttrs_flat size mismatch: want: %d, have: %zu", sum_emax, bdata.edgeAttrs_flat.size());
+}
+
+void BucketBuilder::build_neighbors_flat(const std::array<int32_t, LT_COUNT> & kmax, BucketData & bdata) const
+{
+	const size_t sum_kmax = boost::accumulate(kmax, static_cast<size_t>(0));
+
+	for(int v = 0; v < 165; ++v)
+	{
+		auto & dst = bdata.neighbourhoods_flat[static_cast<size_t>(v)];
+		dst.clear();
+		dst.reserve(sum_kmax);
+
+		for(int l = 0; l < LT_COUNT; ++l)
+		{
+			const auto & src = containers_[l].neighbourhoods[v];
+			dst.insert(dst.end(), src.begin(), src.end());
+			const auto need = static_cast<size_t>(kmax[l]) - src.size();
+			if(need > 0)
+				dst.insert(dst.end(), need, -1);
+		}
+
+		if(dst.size() != sum_kmax)
+			throwf("neighbourhoods_flat row size mismatch: want: %zu, have: %zu", sum_kmax, dst.size());
+	}
+}
+}

+ 59 - 0
AI/MMAI/BAI/model/util/bucketing.h

@@ -0,0 +1,59 @@
+/*
+ * bucketing.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 "BAI/model/util/common.h"
+
+namespace MMAI::BAI::bucketing
+{
+struct Requirements
+{
+	std::array<size_t, LT_COUNT> e_req{};
+	std::array<size_t, LT_COUNT> k_req{};
+};
+
+struct BucketChoice
+{
+	int index = -1;
+	std::array<int32_t, LT_COUNT> emax{};
+	std::array<int32_t, LT_COUNT> kmax{};
+};
+
+struct BucketData
+{
+	int size_index = -1; // chosen index in all_sizes
+	std::array<int32_t, LT_COUNT> emax{}; // chosen emax per link type
+	std::array<int32_t, LT_COUNT> kmax{}; // chosen kmax per link type
+
+	std::vector<float> edgeAttrs_flat; // length sum(emax)
+	std::array<std::vector<int32_t>, 2> edgeIndex_flat; // each length sum(emax)
+	std::array<std::vector<int32_t>, 165> neighbourhoods_flat; // each length sum(kmax)
+};
+
+class BucketBuilder
+{
+public:
+	BucketBuilder(const std::array<IndexContainer, LT_COUNT> & containers, const std::vector<std::vector<std::vector<int32_t>>> & all_sizes);
+
+	BucketData build_bucket_data() const;
+
+private:
+	const std::array<IndexContainer, LT_COUNT> & containers_;
+	const std::vector<std::vector<std::vector<int32_t>>> & all_sizes_;
+
+	Requirements compute_requirements() const;
+	bool bucket_satisfies(const std::vector<std::vector<int32_t>> & sz, const Requirements & req) const;
+
+	void log_bucket_choice(int chosen, const Requirements & req) const;
+	BucketChoice choose_bucket(const Requirements & req) const;
+	void build_edges_flat(const std::array<int32_t, LT_COUNT> & emax, BucketData & bdata) const;
+	void build_neighbors_flat(const std::array<int32_t, LT_COUNT> & kmax, BucketData & bdata) const;
+};
+}

+ 86 - 0
AI/MMAI/BAI/model/util/common.h

@@ -0,0 +1,86 @@
+/*
+ * common.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 <onnxruntime_cxx_api.h>
+
+#include "schema/v13/types.h"
+
+namespace MMAI::BAI
+{
+
+template<typename T>
+using Vec2D = std::vector<std::vector<T>>;
+
+template<typename T>
+using Vec3D = std::vector<std::vector<std::vector<T>>>;
+
+constexpr int LT_COUNT = EI(MMAI::Schema::V13::LinkType::_count);
+
+struct MaskedLogits
+{
+	const Ort::Value & logits;
+	const Ort::Value & mask;
+};
+
+struct IndexContainer
+{
+	std::array<std::vector<int32_t>, 2> edgeIndex;
+	std::vector<float> edgeAttrs;
+	std::array<std::vector<int32_t>, 165> neighbourhoods;
+};
+
+template<class... Args>
+[[noreturn]] inline void throwf(const std::string & fmt, Args &&... args)
+{
+	boost::format f("NNModel: " + fmt);
+	(void)std::initializer_list<int>{((f % std::forward<Args>(args)), 0)...};
+	throw std::runtime_error(f.str());
+}
+
+// tensor-to-vector convenience
+template<typename T>
+std::vector<T> toVector(const std::string & name, const Ort::Value & tensor, int numel)
+{
+	// Expect int32 tensor of shape {1}
+	auto type_info = tensor.GetTensorTypeAndShapeInfo();
+	auto dtype = type_info.GetElementType();
+
+	if constexpr(std::is_same_v<T, float>)
+	{
+		if(dtype != ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT)
+			throwf("t2v: %s: bad dtype: want: %d, have: %d", name, EI(ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT), EI(dtype));
+	}
+	else if constexpr(std::is_same_v<T, int>)
+	{
+		if(dtype != ONNX_TENSOR_ELEMENT_DATA_TYPE_INT32)
+			throwf("t2v: %s: bad dtype: want: %d, have: %d", name, EI(ONNX_TENSOR_ELEMENT_DATA_TYPE_INT32), EI(dtype));
+	}
+	else
+	{
+		throwf("t2v: %s: can only work with float and int", name);
+	}
+
+	auto shape = type_info.GetShape();
+	if(shape.size() != 1)
+		throwf("t2v: %s: expected ndim=1, got: %d", name, shape.size());
+
+	if(shape != std::vector<int64_t>{numel})
+		throwf("t2v: %s: bad shape", name);
+
+	const T * data = tensor.GetTensorData<T>();
+
+	auto res = std::vector<T>{};
+	res.reserve(numel);
+	res.assign(data, data + numel); // v now owns a copy
+	return res;
+}
+
+}

+ 268 - 0
AI/MMAI/BAI/model/util/sampling.cpp

@@ -0,0 +1,268 @@
+/*
+ * sampling.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 <algorithm>
+#include <cmath>
+#include <cstdint>
+#include <limits>
+#include <random>
+#include <vector>
+
+#include <onnxruntime_cxx_api.h>
+
+#include "BAI/model/util/sampling.h"
+
+namespace MMAI::BAI::sampling
+{
+std::vector<int64_t> shape_of(const Ort::Value & v)
+{
+	if(!v.IsTensor())
+		throwf("sampling: expected tensor Ort::Value");
+	Ort::TensorTypeAndShapeInfo info = v.GetTensorTypeAndShapeInfo();
+	return info.GetShape();
+}
+
+template<typename T>
+std::vector<T> to_vector(const Ort::Value & v)
+{
+	if(!v.IsTensor())
+		throwf("sampling: expected tensor Ort::Value");
+	Ort::TensorTypeAndShapeInfo info = v.GetTensorTypeAndShapeInfo();
+	const std::vector<int64_t> shp = info.GetShape();
+	size_t n = 1;
+	for(auto d : shp)
+	{
+		if(d < 0)
+			throwf("sampling: dynamic dim not supported here");
+		n *= static_cast<size_t>(d);
+	}
+	const T * p = v.GetTensorData<T>(); // pointer used only for the copy
+	return std::vector<T>(p, p + n); // everything below uses vectors only
+}
+
+std::vector<double> softmax(const std::vector<double> & logits)
+{
+	if(logits.empty())
+		return {};
+	double m = -std::numeric_limits<double>::infinity();
+	for(double logit : logits)
+		m = std::max(m, logit);
+	std::vector<double> exps(logits.size(), 0.0);
+	double sum = 0.0;
+	for(size_t i = 0; i < logits.size(); ++i)
+	{
+		const double v = logits.at(i) - m;
+		const double e = std::isfinite(v) ? std::exp(v) : 0.0;
+		exps.at(i) = e;
+		sum += e;
+	}
+	if(std::fabs(sum) < 1e-8)
+		return std::vector<double>(logits.size(), 0.0);
+	for(double & exp : exps)
+		exp /= sum;
+	return exps;
+}
+
+int argmax(const std::vector<double> & xs)
+{
+	if(xs.empty())
+		throwf("sampling: argmax on empty vector");
+	size_t best = 0;
+	for(size_t i = 1; i < xs.size(); ++i)
+	{
+		if(xs.at(i) > xs.at(best))
+			best = i;
+	}
+	return static_cast<int>(best);
+}
+
+std::vector<double> make_masked_logits(const std::vector<float> & logits_1d, const std::vector<int32_t> & mask_1d)
+{
+	const size_t K = logits_1d.size();
+	const double neginf = -std::numeric_limits<double>::infinity();
+
+	std::vector<double> masked_logits(K, neginf);
+	for(size_t i = 0; i < K; ++i)
+	{
+		if(mask_1d.at(i))
+		{
+			masked_logits.at(i) = static_cast<double>(logits_1d.at(i));
+		}
+	}
+	return masked_logits;
+}
+
+SampleResult sample_uniform_over_mask(const std::vector<int32_t> & mask_1d, int n_valid, std::mt19937 & rng)
+{
+	const size_t K = mask_1d.size();
+	std::vector<double> probs(K, 0.0);
+	const double p = 1.0 / static_cast<double>(n_valid);
+
+	for(size_t i = 0; i < K; ++i)
+	{
+		if(mask_1d.at(i))
+		{
+			probs.at(i) = p;
+		}
+	}
+
+	std::discrete_distribution<int> dist(probs.begin(), probs.end());
+	const int idx_chosen = dist(rng);
+	const double p_chosen = probs.at(static_cast<size_t>(idx_chosen));
+
+	return {.index = idx_chosen, .prob = p_chosen, .fallback = false};
+}
+
+SampleResult sample_softmax_over_mask(const std::vector<double> & masked_logits, const std::vector<int32_t> & mask_1d, double temperature, std::mt19937 & rng)
+{
+	const size_t K = masked_logits.size();
+	const double neginf = -std::numeric_limits<double>::infinity();
+
+	std::vector<double> scaled(K, neginf);
+	for(size_t i = 0; i < K; ++i)
+	{
+		if(mask_1d.at(i))
+		{
+			scaled.at(i) = masked_logits.at(i) / temperature;
+		}
+	}
+
+	const std::vector<double> probs = softmax(scaled);
+	if(!std::ranges::all_of(
+		   probs,
+		   [](double prob)
+		   {
+			   return std::isfinite(prob);
+		   }
+	   ))
+		throwf("sampling: non-finite probabilities");
+
+	std::discrete_distribution<int> dist(probs.begin(), probs.end());
+	const int idx_chosen = dist(rng);
+	const double p_chosen = probs.at(static_cast<size_t>(idx_chosen));
+
+	return {.index = idx_chosen, .prob = p_chosen, .fallback = false};
+}
+
+// Masked categorical sampling given a logits vector
+SampleResult
+sample_masked_logits(const std::vector<float> & logits_1d, const std::vector<int32_t> & mask_1d, bool throw_if_empty, double temperature, std::mt19937 & rng)
+{
+	const size_t K = logits_1d.size();
+	if(K == 0 || mask_1d.size() != K)
+		throwf("sampling: invalid logits/mask sizes");
+	if(temperature < 0.0)
+		throwf("sampling: negative temperature");
+
+	const int n_valid = std::ranges::count_if(
+		mask_1d,
+		[](int v)
+		{
+			return v != 0;
+		}
+	);
+
+	if(n_valid == 0)
+	{
+		if(throw_if_empty)
+			throwf("sampling: no valid options available");
+		return {.index = 0, .prob = 0.0, .fallback = true};
+	}
+
+	const std::vector<double> masked_logits = sampling::make_masked_logits(logits_1d, mask_1d);
+
+	if(temperature > 1e8)
+	{
+		return sampling::sample_uniform_over_mask(mask_1d, n_valid, rng);
+	}
+
+	if(temperature < 1e-8)
+	{
+		const int idx_chosen = argmax(masked_logits);
+		return {.index = idx_chosen, .prob = 1.0, .fallback = false};
+	}
+
+	return sampling::sample_softmax_over_mask(masked_logits, mask_1d, temperature, rng);
+}
+
+//
+// Samples a {action, hex1, hex2} triplet given output logits and masks
+//
+// Expected shapes:
+//   act0_logits: [1, 4]            float32
+//   hex1_logits: [1, 165]          float32
+//   hex2_logits: [1, 165]          float32
+//   mask_act0:   [1, 4]            int32
+//   mask_hex1:   [1, 4, 165]       int32
+//   mask_hex2:   [1, 4, 165, 165]  int32
+//
+TripletSample
+sample_triplet(const MaskedLogits & act0_logits, const MaskedLogits & hex1_logits, const MaskedLogits & hex2_logits, double temperature, std::mt19937 & rng)
+{
+	const std::vector<int64_t> s_a0 = shape_of(act0_logits.logits);
+	const std::vector<int64_t> s_h1 = shape_of(hex1_logits.logits);
+	const std::vector<int64_t> s_h2 = shape_of(hex2_logits.logits);
+	const std::vector<int64_t> s_m0 = shape_of(act0_logits.mask);
+	const std::vector<int64_t> s_m1 = shape_of(hex1_logits.mask);
+	const std::vector<int64_t> s_m2 = shape_of(hex2_logits.mask);
+
+	if(s_a0 != std::vector<int64_t>({1, 4}))
+		throwf("sampling: act0_logits must be [1,4]");
+	if(s_h1 != std::vector<int64_t>({1, 165}))
+		throwf("sampling: hex1_logits must be [1,165]");
+	if(s_h2 != std::vector<int64_t>({1, 165}))
+		throwf("sampling: hex2_logits must be [1,165]");
+	if(s_m0 != std::vector<int64_t>({1, 4}))
+		throwf("sampling: mask_act0 must be [1,4]");
+	if(s_m1 != std::vector<int64_t>({1, 4, 165}))
+		throwf("sampling: mask_hex1 must be [1,4,165]");
+	if(s_m2 != std::vector<int64_t>({1, 4, 165, 165}))
+		throwf("sampling: mask_hex2 must be [1,4,165,165]");
+
+	// Materialize host vectors and squeeze batch
+	std::vector<float> a0_log = to_vector<float>(act0_logits.logits); // 4
+	std::vector<float> h1_log = to_vector<float>(hex1_logits.logits); // 165
+	std::vector<float> h2_log = to_vector<float>(hex2_logits.logits); // 165
+
+	std::vector<int32_t> m_a0 = to_vector<int32_t>(act0_logits.mask); // 4
+	std::vector<int32_t> m_h1 = to_vector<int32_t>(hex1_logits.mask); // 4*165
+	std::vector<int32_t> m_h2 = to_vector<int32_t>(hex2_logits.mask); // 4*165*165
+
+	// ---- act0 ----
+	const SampleResult act0 = sample_masked_logits(a0_log, m_a0, true, temperature, rng);
+
+	// ---- hex1 mask slice for chosen act0 ----
+	const auto h1_row_offset = static_cast<size_t>(act0.index) * static_cast<size_t>(165);
+	std::vector<int32_t> m_h1_for_act0(static_cast<size_t>(165), 0);
+	for(size_t k = 0; k < static_cast<size_t>(165); ++k)
+	{
+		m_h1_for_act0.at(k) = m_h1.at(h1_row_offset + k);
+	}
+
+	// ---- hex1 ----
+	const SampleResult hex1 = sample_masked_logits(h1_log, m_h1_for_act0, false, temperature, rng);
+
+	// ---- hex2 mask slice for (act0, hex1) ----
+	const size_t base = (act0.index * 165 + hex1.index) * static_cast<size_t>(165);
+	std::vector<int32_t> m_h2_for_pair(static_cast<size_t>(165), 0);
+	for(size_t k = 0; k < static_cast<size_t>(165); ++k)
+	{
+		m_h2_for_pair.at(k) = m_h2.at(base + k);
+	}
+
+	// ---- hex2 ----
+	const SampleResult hex2 = sample_masked_logits(h2_log, m_h2_for_pair, false, temperature, rng);
+
+	// ---- joint confidence ----
+	const double confidence = act0.prob * (hex1.fallback ? 1.0 : hex1.prob) * (hex2.fallback ? 1.0 : hex2.prob);
+
+	return {.act0 = act0.index, .hex1 = hex1.index, .hex2 = hex2.index, .confidence = confidence};
+}
+}

+ 64 - 0
AI/MMAI/BAI/model/util/sampling.h

@@ -0,0 +1,64 @@
+/*
+ * sampling.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 <onnxruntime_cxx_api.h>
+
+#include "BAI/model/util/common.h"
+
+namespace MMAI::BAI::sampling
+{
+struct SampleResult
+{
+	int index;
+	double prob;
+	bool fallback;
+};
+
+struct TripletSample
+{
+	int act0;
+	int hex1;
+	int hex2;
+	double confidence;
+};
+
+std::vector<int64_t> shape_of(const Ort::Value & v);
+
+template<typename T>
+std::vector<T> to_vector(const Ort::Value & v);
+
+std::vector<double> softmax(const std::vector<double> & logits);
+int argmax(const std::vector<double> & xs);
+int count_valid(const std::vector<int32_t> & mask_1d);
+std::vector<double> make_masked_logits(const std::vector<float> & logits_1d, const std::vector<int32_t> & mask_1d);
+
+SampleResult sample_uniform_over_mask(const std::vector<int32_t> & mask_1d, int n_valid, std::mt19937 & rng);
+
+SampleResult sample_softmax_over_mask(const std::vector<double> & masked_logits, const std::vector<int32_t> & mask_1d, double temperature, std::mt19937 & rng);
+
+// Masked categorical sampling given a logits vector
+SampleResult
+sample_masked_logits(const std::vector<float> & logits_1d, const std::vector<int32_t> & mask_1d, bool throw_if_empty, double temperature, std::mt19937 & rng);
+
+//
+// Samples a {action, hex1, hex2} triplet given output logits and masks
+//
+// Expected shapes:
+//   act0_logits: [1, 4]            float32
+//   hex1_logits: [1, 165]          float32
+//   hex2_logits: [1, 165]          float32
+//   mask_act0:   [1, 4]            int32
+//   mask_hex1:   [1, 4, 165]       int32
+//   mask_hex2:   [1, 4, 165, 165]  int32
+//
+TripletSample
+sample_triplet(const MaskedLogits & act0_logits, const MaskedLogits & hex1_logits, const MaskedLogits & hex2_logits, double temperature, std::mt19937 & rng);
+}

+ 301 - 0
AI/MMAI/BAI/router.cpp

@@ -0,0 +1,301 @@
+/*
+ * router.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 "callback/CBattleCallback.h"
+#include "callback/CDynLibHandler.h"
+#include "callback/IGameInfoCallback.h"
+#include "filesystem/Filesystem.h"
+#include "json/JsonUtils.h"
+
+#include "BAI/base.h"
+#include "BAI/model/NNModel.h"
+#include "BAI/model/ScriptedModel.h"
+#include "BAI/router.h"
+
+#include "common.h"
+
+#include <utility>
+
+namespace MMAI::BAI
+{
+using ConfigStorage = std::map<std::string, std::string>;
+using ModelStorage = std::map<std::string, std::unique_ptr<NNModel>>;
+
+namespace
+{
+	struct ModelRepository
+	{
+		ModelStorage models;
+		float temperature = 1.0;
+		uint64_t seed = 0;
+		std::unique_ptr<ScriptedModel> fallbackModel;
+		std::string fallbackName;
+	};
+
+	std::unique_ptr<ModelRepository> InitModelRepository()
+	{
+		auto repo = std::make_unique<ModelRepository>();
+		auto json = JsonUtils::assembleFromFiles("MMAI/CONFIG/mmai-settings.json");
+		if(!json.isStruct())
+		{
+			logAi->error("Could not load MMAI config. Is MMAI mod enabled?");
+			return repo;
+		}
+
+		JsonUtils::validate(json, "vcmi:mmaiSettings", "mmai");
+		repo->temperature = static_cast<float>(json["temperature"].Float());
+		repo->seed = json["seed"].Integer();
+		for(const std::string key : {"attacker", "defender"})
+		{
+			std::string value = "MMAI/models/" + json["models"][key].String();
+			logAi->debug("MMAI: Loading NN %s model from: %s", key, value);
+			try
+			{
+				repo->models.try_emplace(key, std::make_unique<NNModel>(value, repo->temperature, repo->seed));
+			}
+			catch(std::exception & e)
+			{
+				logAi->error("MMAI: error loading " + key + ": " + std::string(e.what()));
+			}
+		}
+
+		auto fallback = json["fallback"].String();
+		logAi->debug("MMAI: preparing fallback model: %s", fallback);
+		repo->fallbackModel = std::make_unique<ScriptedModel>(fallback);
+		repo->fallbackName = fallback;
+
+		return repo;
+	}
+
+	Schema::IModel * GetModel(const std::string & key)
+	{
+		static const auto MODEL_REPO = InitModelRepository();
+		auto it = MODEL_REPO->models.find(key);
+		if(it == MODEL_REPO->models.end())
+		{
+			logAi->error("MMAI: no %s model loaded, trying fallback: %s", key, MODEL_REPO->fallbackName);
+			ASSERT(MODEL_REPO->fallbackModel, "fallback failed: model is null");
+			return MODEL_REPO->fallbackModel.get();
+		}
+
+		return it->second.get();
+	}
+}
+
+Router::Router()
+{
+	std::ostringstream oss;
+	// Store the memory address and include it in logging
+	const auto * ptr = static_cast<const void *>(this);
+	oss << ptr;
+	addrstr = oss.str();
+	info("+++ constructor +++"); // log after addrstr is set
+}
+
+Router::~Router()
+{
+	info("--- destructor ---");
+	cb->waitTillRealize = wasWaitingForRealize;
+}
+
+void Router::initBattleInterface(std::shared_ptr<Environment> ENV, std::shared_ptr<CBattleCallback> CB)
+{
+	info("*** initBattleInterface ***");
+	env = ENV;
+	cb = CB;
+	colorname = cb->getPlayerID()->toString();
+	wasWaitingForRealize = cb->waitTillRealize;
+
+	cb->waitTillRealize = false;
+	bai.reset();
+}
+
+void Router::initBattleInterface(std::shared_ptr<Environment> ENV, std::shared_ptr<CBattleCallback> CB, AutocombatPreferences prefs)
+{
+	autocombatPreferences = prefs;
+	initBattleInterface(ENV, CB);
+}
+
+/*
+ * Delegated methods
+ */
+
+void Router::actionFinished(const BattleID & bid, const BattleAction & action)
+{
+	bai->actionFinished(bid, action);
+}
+
+void Router::actionStarted(const BattleID & bid, const BattleAction & action)
+{
+	bai->actionStarted(bid, action);
+}
+
+void Router::activeStack(const BattleID & bid, const CStack * astack)
+{
+	bai->activeStack(bid, astack);
+}
+
+void Router::battleAttack(const BattleID & bid, const BattleAttack * ba)
+{
+	bai->battleAttack(bid, ba);
+}
+
+void Router::battleCatapultAttacked(const BattleID & bid, const CatapultAttack & ca)
+{
+	bai->battleCatapultAttacked(bid, ca);
+}
+
+void Router::battleEnd(const BattleID & bid, const BattleResult * br, QueryID queryID)
+{
+	bai->battleEnd(bid, br, queryID);
+}
+
+void Router::battleGateStateChanged(const BattleID & bid, const EGateState state)
+{
+	bai->battleGateStateChanged(bid, state);
+};
+
+void Router::battleLogMessage(const BattleID & bid, const std::vector<MetaString> & lines)
+{
+	bai->battleLogMessage(bid, lines);
+};
+
+void Router::battleNewRound(const BattleID & bid)
+{
+	bai->battleNewRound(bid);
+}
+
+void Router::battleNewRoundFirst(const BattleID & bid)
+{
+	bai->battleNewRoundFirst(bid);
+}
+
+void Router::battleObstaclesChanged(const BattleID & bid, const std::vector<ObstacleChanges> & obstacles)
+{
+	bai->battleObstaclesChanged(bid, obstacles);
+};
+
+void Router::battleSpellCast(const BattleID & bid, const BattleSpellCast * sc)
+{
+	bai->battleSpellCast(bid, sc);
+}
+
+void Router::battleStackMoved(const BattleID & bid, const CStack * stack, const BattleHexArray & dest, int distance, bool teleport)
+{
+	bai->battleStackMoved(bid, stack, dest, distance, teleport);
+}
+
+void Router::battleStacksAttacked(const BattleID & bid, const std::vector<BattleStackAttacked> & bsa, bool ranged)
+{
+	bai->battleStacksAttacked(bid, bsa, ranged);
+}
+
+void Router::battleStacksEffectsSet(const BattleID & bid, const SetStackEffect & sse)
+{
+	bai->battleStacksEffectsSet(bid, sse);
+}
+
+void Router::battleStart(
+	const BattleID & bid,
+	const CCreatureSet * army1,
+	const CCreatureSet * army2,
+	int3 tile,
+	const CGHeroInstance * hero1,
+	const CGHeroInstance * hero2,
+	BattleSide side,
+	bool replayAllowed
+)
+{
+	Schema::IModel * model;
+	const std::string modelkey = side == BattleSide::ATTACKER ? "attacker" : "defender";
+	model = GetModel(modelkey);
+
+	auto modelside = model->getSide();
+	auto realside = static_cast<Schema::Side>(EI(side));
+
+	if(modelside != realside && modelside != Schema::Side::BOTH)
+		logAi->warn("The loaded '%s' model was not trained to play as %s", modelkey, modelkey);
+
+	switch(model->getType())
+	{
+		case Schema::ModelType::SCRIPTED:
+			if(model->getName() == "StupidAI")
+			{
+				bai = CDynLibHandler::getNewBattleAI("StupidAI");
+				bai->initBattleInterface(env, cb, autocombatPreferences);
+			}
+			else if(model->getName() == "BattleAI")
+			{
+				bai = CDynLibHandler::getNewBattleAI("BattleAI");
+				bai->initBattleInterface(env, cb, autocombatPreferences);
+			}
+			else
+			{
+				THROW_FORMAT("Unexpected scripted model name: %s", model->getName());
+			}
+			break;
+		case Schema::ModelType::NN:
+			// XXX: must not call initBattleInterface here
+			bai = Base::Create(model, env, cb, autocombatPreferences.enableSpellsUsage);
+			break;
+
+		default:
+			THROW_FORMAT("Unexpected model type: %d", EI(model->getType()));
+	}
+
+	bai->battleStart(bid, army1, army2, tile, hero1, hero2, side, replayAllowed);
+}
+
+void Router::battleTriggerEffect(const BattleID & bid, const BattleTriggerEffect & bte)
+{
+	bai->battleTriggerEffect(bid, bte);
+}
+
+void Router::battleUnitsChanged(const BattleID & bid, const std::vector<UnitChanges> & changes)
+{
+	bai->battleUnitsChanged(bid, changes);
+}
+
+void Router::yourTacticPhase(const BattleID & bid, int distance)
+{
+	bai->yourTacticPhase(bid, distance);
+}
+
+/*
+ * private
+ */
+
+void Router::error(const std::string & text) const
+{
+	log(ELogLevel::ERROR, text);
+}
+void Router::warn(const std::string & text) const
+{
+	log(ELogLevel::WARN, text);
+}
+void Router::info(const std::string & text) const
+{
+	log(ELogLevel::INFO, text);
+}
+void Router::debug(const std::string & text) const
+{
+	log(ELogLevel::DEBUG, text);
+}
+void Router::trace(const std::string & text) const
+{
+	log(ELogLevel::TRACE, text);
+}
+void Router::log(ELogLevel::ELogLevel level, const std::string & text) const
+{
+	if(logAi->getEffectiveLevel() <= level)
+		logAi->debug("Router-%s [%s] %s", addrstr, colorname, text);
+}
+}

+ 83 - 0
AI/MMAI/BAI/router.h

@@ -0,0 +1,83 @@
+/*
+ * router.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 "battle/AutocombatPreferences.h"
+#include "battle/CPlayerBattleCallback.h"
+
+#include "BAI/base.h"
+
+namespace MMAI::BAI
+{
+class Router : public CBattleGameInterface
+{
+public:
+	Router();
+	~Router() override;
+
+	/*
+	 * Handled locally (not delegated)
+	 */
+
+	void initBattleInterface(std::shared_ptr<Environment> ENV, std::shared_ptr<CBattleCallback> CB) override;
+	void initBattleInterface(std::shared_ptr<Environment> ENV, std::shared_ptr<CBattleCallback> CB, AutocombatPreferences prefs) override;
+
+	/*
+	 * Delegated to BAI
+	 */
+
+	void actionFinished(const BattleID & bid, const BattleAction & action) override;
+	void actionStarted(const BattleID & bid, const BattleAction & action) override;
+	void activeStack(const BattleID & bid, const CStack * stack) override; //called when it's turn of that stack
+	void battleAttack(const BattleID & bid, const BattleAttack * ba) override;
+	void battleCatapultAttacked(const BattleID & bid, const CatapultAttack & ca) override;
+	void battleEnd(const BattleID & bid, const BattleResult * br, QueryID queryID) override;
+	void battleGateStateChanged(const BattleID & bid, EGateState state) override;
+	void battleLogMessage(const BattleID & bid, const std::vector<MetaString> & lines) override;
+	void battleNewRound(const BattleID & bid) override;
+	void battleNewRoundFirst(const BattleID & bid) override;
+	void battleObstaclesChanged(const BattleID & bid, const std::vector<ObstacleChanges> & obstacles) override;
+	void battleSpellCast(const BattleID & bid, const BattleSpellCast * sc) override;
+	void battleStackMoved(const BattleID & bid, const CStack * stack, const BattleHexArray & dest, int distance, bool teleport) override;
+	void battleStacksAttacked(const BattleID & bid, const std::vector<BattleStackAttacked> & bsa, bool ranged) override;
+	void battleStacksEffectsSet(const BattleID & bid, const SetStackEffect & sse) override;
+	void battleStart(
+		const BattleID & bid,
+		const CCreatureSet * army1,
+		const CCreatureSet * army2,
+		int3 tile,
+		const CGHeroInstance * hero1,
+		const CGHeroInstance * hero2,
+		BattleSide side,
+		bool replayAllowed
+	) override;
+	void battleTriggerEffect(const BattleID & bid, const BattleTriggerEffect & bte) override;
+	void battleUnitsChanged(const BattleID & bid, const std::vector<UnitChanges> & changes) override;
+	void yourTacticPhase(const BattleID & bid, int distance) override;
+
+private:
+	std::shared_ptr<Environment> env;
+	std::shared_ptr<CBattleCallback> cb;
+	std::shared_ptr<CBattleGameInterface> bai; // calls will be delegated to this object
+
+	bool wasWaitingForRealize = false;
+	AutocombatPreferences autocombatPreferences;
+	std::string addrstr = "?";
+	std::string colorname = "?";
+
+	void error(const std::string & text) const;
+	void warn(const std::string & text) const;
+	void info(const std::string & text) const;
+	void debug(const std::string & text) const;
+	void trace(const std::string & text) const;
+	void log(ELogLevel::ELogLevel level, const std::string & text) const;
+};
+}

+ 728 - 0
AI/MMAI/BAI/v13/BAI.cpp

@@ -0,0 +1,728 @@
+/*
+ * BAI.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 "battle/BattleAction.h"
+#include "battle/BattleStateInfoForRetreat.h"
+#include "battle/CBattleInfoEssentials.h"
+
+#include "BAI/base.h"
+#include "BAI/v13/BAI.h"
+#include "BAI/v13/action.h"
+#include "BAI/v13/hexaction.h"
+#include "BAI/v13/hexactmask.h"
+#include "BAI/v13/render.h"
+#include "BAI/v13/supplementary_data.h"
+#include "common.h"
+#include "schema/v13/types.h"
+
+#include "AI/BattleAI/BattleEvaluator.h"
+#include <algorithm>
+#include <optional>
+
+namespace MMAI::BAI::V13
+{
+
+using ErrorCode = Schema::V13::ErrorCode;
+using PA = Schema::V13::PlayerAttribute;
+
+Schema::Action BAI::getNonRenderAction()
+{
+	// info("getNonRenderAciton called with result type: " + std::to_string(result->type));
+	const auto * s = state.get();
+	auto action = model->getAction(s);
+	debug("Got action: " + std::to_string(action));
+	while(action == Schema::ACTION_RENDER_ANSI)
+	{
+		if(state->supdata->ansiRender.empty())
+		{
+			state->supdata->ansiRender = renderANSI();
+			state->supdata->type = Schema::V13::ISupplementaryData::Type::ANSI_RENDER;
+		}
+
+		// info("getNonRenderAciton (loop) called with result type: " + std::to_string(res.type));
+		action = model->getAction(state.get());
+	}
+	state->supdata->ansiRender.clear();
+	state->supdata->type = Schema::V13::ISupplementaryData::Type::REGULAR;
+	return action;
+}
+
+std::unique_ptr<State> BAI::initState(const CPlayerBattleCallback * b)
+{
+	return std::make_unique<State>(version, colorname, b);
+}
+
+void BAI::battleStart(
+	const BattleID & bid,
+	const CCreatureSet * army1,
+	const CCreatureSet * army2,
+	int3 tile,
+	const CGHeroInstance * hero1,
+	const CGHeroInstance * hero2,
+	BattleSide side,
+	bool replayAllowed
+)
+{
+	Base::battleStart(bid, army1, army2, tile, hero1, hero2, side, replayAllowed);
+	battle = cb->getBattle(bid);
+	state = initState(battle.get());
+	getActionTotalMs = 0;
+	getActionTotalCalls = 0;
+}
+
+// XXX: battleEnd() is NOT called by CPlayerInterface (i.e. GUI)
+//      However, it's called by AAI (i.e. headless) and that's all we want
+//      since the terminal result is needed only during training.
+void BAI::battleEnd(const BattleID & bid, const BattleResult * br, QueryID queryID)
+{
+	Base::battleEnd(bid, br, queryID);
+	state->onBattleEnd(br);
+
+	debug("MMAI %s this battle.", (br->winner == battle->battleGetMySide() ? "won" : "lost"));
+
+	// Check if battle ended normally or was forced via a RETREAT action
+	if(state->action == nullptr)
+	{
+		// no previous action means battle ended without giving us a turn (OK)
+		// Happens if the enemy immediately retreats (we won)
+		// or if the enemy one-shots us (we lost)
+		info("Battle ended without giving us a turn: nothing to do");
+	}
+	else if(state->action->action == Schema::ACTION_RETREAT)
+	{
+		if(resetting)
+		{
+			// this is an intended restart (i.e. converted ACTION_RESTART)
+			info("Battle ended due to ACTION_RESET: nothing to do");
+		}
+		else
+		{
+			// this is real retreat
+			info("Battle ended due to ACTION_RETREAT: reporting terminal state, expecting ACTION_RESET");
+			auto a = getNonRenderAction();
+			ASSERT(a == Schema::ACTION_RESET, "expected ACTION_RESET, got: " + std::to_string(EI(a)));
+		}
+	}
+	else
+	{
+		debug("Battle ended normally: reporting terminal state, expecting ACTION_RESET");
+		auto a = getNonRenderAction();
+		ASSERT(a == Schema::ACTION_RESET, "expected ACTION_RESET, got: " + std::to_string(EI(a)));
+	}
+
+	if(getActionTotalCalls > 0)
+		info("MMAI stats after battle end: %d predictions, %d ms per prediction", getActionTotalCalls, getActionTotalMs / getActionTotalCalls);
+	else
+		info("MMAI stats after battle end: 0 predictions");
+
+	// BAI is destroyed after this call
+	debug("Leaving battleEnd, embracing death");
+}
+
+void BAI::battleStacksAttacked(const BattleID & bid, const std::vector<BattleStackAttacked> & bsa, bool ranged)
+{
+	Base::battleStacksAttacked(bid, bsa, ranged);
+	state->onBattleStacksAttacked(bsa);
+}
+
+void BAI::battleTriggerEffect(const BattleID & bid, const BattleTriggerEffect & bte)
+{
+	Base::battleTriggerEffect(bid, bte);
+	state->onBattleTriggerEffect(bte);
+}
+
+void BAI::yourTacticPhase(const BattleID & bid, int distance)
+{
+	Base::yourTacticPhase(bid, distance);
+	cb->battleMakeTacticAction(bid, BattleAction::makeEndOFTacticPhase(battle->battleGetTacticsSide()));
+}
+
+bool BAI::maybeCastSpell(const CStack * astack, const BattleID & bid)
+{
+	if(!enableSpellsUsage)
+		return false;
+
+	const auto * hero = battle->battleGetMyHero();
+
+	if(!hero)
+		return false;
+
+	if(battle->battleCanCastSpell(hero, spells::Mode::HERO) != ESpellCastProblem::OK)
+		return false;
+
+	auto lv = state->lpstats->getAttr(PA::ARMY_VALUE_NOW_ABS);
+	auto rv = state->rpstats->getAttr(PA::ARMY_VALUE_NOW_ABS);
+	auto vratio = static_cast<float>(lv) / rv;
+	if(battle->battleGetMySide() == BattleSide::RIGHT_SIDE)
+		vratio = 1 / vratio;
+
+	logAi->debug("Attempting a BattleAI spellcast");
+	auto evaluator = BattleEvaluator(env, cb, astack, *cb->getPlayerID(), bid, battle->battleGetMySide(), vratio, 2);
+	return evaluator.attemptCastingSpell(astack);
+}
+
+std::shared_ptr<BattleAction> BAI::maybeBuildAutoAction(const CStack * astack, const BattleID & bid) const
+{
+	// Guard against infinite battles
+	// (print warning once, make only fallback actions from there on)
+	if(getActionTotalCalls == 100)
+		warn("Reached 100 predictions, will fall back to BattleAI until this combat ends");
+
+	if(getActionTotalCalls >= 100)
+	{
+		auto evaluator = BattleEvaluator(env, cb, astack, *cb->getPlayerID(), bid, battle->battleGetMySide(), 1.0f, 2);
+		return std::make_shared<BattleAction>(evaluator.selectStackAction(astack));
+	}
+
+	if(astack->creatureId() == CreatureID::FIRST_AID_TENT)
+	{
+		const CStack * target = nullptr;
+		auto maxdmg = 0;
+		for(const auto * stack : battle->battleGetStacks(CBattleInfoEssentials::ONLY_MINE))
+		{
+			auto dmg = stack->getMaxHealth() - stack->getFirstHPleft();
+			if(dmg <= maxdmg)
+				continue;
+			maxdmg = dmg;
+			target = stack;
+		}
+
+		if(target)
+		{
+			return std::make_shared<BattleAction>(BattleAction::makeHeal(astack, target));
+		}
+	}
+	else if(astack->creatureId() == CreatureID::CATAPULT)
+	{
+		if(!astack->canShoot())
+			// out of ammo
+			return std::make_shared<BattleAction>(BattleAction::makeDefend(astack)); // out of ammo (arrow towers have 99 shots)
+
+		auto ba = std::make_shared<BattleAction>();
+		ba->side = astack->unitSide();
+		ba->stackNumber = astack->unitId();
+		ba->actionType = EActionType::CATAPULT;
+
+		if(battle->battleGetGateState() == EGateState::CLOSED)
+		{
+			ba->aimToHex(battle->wallPartToBattleHex(EWallPart::GATE));
+			return ba;
+		}
+
+		using WP = EWallPart;
+		auto wallparts = {WP::KEEP, WP::BOTTOM_TOWER, WP::UPPER_TOWER, WP::BELOW_GATE, WP::OVER_GATE, WP::BOTTOM_WALL, WP::UPPER_WALL};
+
+		for(auto wp : wallparts)
+		{
+			using WS = EWallState;
+			auto ws = battle->battleGetWallState(wp);
+			if(ws == WS::REINFORCED || ws == WS::INTACT || ws == WS::DAMAGED)
+			{
+				ba->aimToHex(battle->wallPartToBattleHex(wp));
+				return ba;
+			}
+		}
+
+		// no walls left
+		return std::make_shared<BattleAction>(BattleAction::makeDefend(astack)); // out of ammo (arrow towers have 99 shots)
+	}
+	else if(astack->creatureId() == CreatureID::ARROW_TOWERS)
+	{
+		if(!astack->canShoot())
+			// out of ammo (arrow towers have 99 shots)
+			return std::make_shared<BattleAction>(BattleAction::makeDefend(astack));
+
+		auto allstacks = battle->battleGetStacks(CBattleInfoEssentials::ONLY_ENEMY);
+		auto target = std::ranges::max_element(
+			allstacks,
+			[](const CStack * a, const CStack * b)
+			{
+				return Stack::GetValue(a->unitType()) < Stack::GetValue(b->unitType());
+			}
+		);
+
+		ASSERT(target != allstacks.end(), "Could not find an enemy stack to attack. Falling back to a defend.");
+		return std::make_shared<BattleAction>(BattleAction::makeShotAttack(astack, *target));
+	}
+
+	return nullptr;
+}
+
+std::optional<BattleAction> BAI::maybeFleeOrSurrender(const BattleID & bid)
+{
+	BattleStateInfoForRetreat bs;
+
+	bs.canFlee = battle->battleCanFlee();
+	bs.canSurrender = battle->battleCanSurrender(*cb->getPlayerID());
+	if(!bs.canFlee && !bs.canSurrender)
+	{
+		logAi->debug("Can't flee or surrender.");
+		return std::nullopt;
+	}
+
+	bs.ourSide = battle->battleGetMySide();
+	bs.ourHero = battle->battleGetMyHero();
+	bs.enemyHero = nullptr;
+
+	for(const auto * stack : battle->battleGetAllStacks(false))
+	{
+		if(stack->alive())
+		{
+			if(stack->unitSide() == bs.ourSide)
+			{
+				bs.ourStacks.push_back(stack);
+			}
+			else
+			{
+				bs.enemyStacks.push_back(stack);
+				bs.enemyHero = battle->battleGetOwnerHero(stack);
+			}
+		}
+	}
+
+	logAi->info("Making surrender/retreat decision...");
+	return cb->makeSurrenderRetreatDecision(bid, bs);
+}
+
+void BAI::activeStack(const BattleID & bid, const CStack * astack)
+{
+	Base::activeStack(bid, astack);
+
+	auto ba = maybeBuildAutoAction(astack, bid);
+
+	if(ba)
+	{
+		info("Making automatic action with %s", astack->getDescription());
+		cb->battleMakeUnitAction(bid, *ba);
+		return;
+	}
+
+	state->onActiveStack(astack);
+
+	if(maybeCastSpell(astack, bid))
+		return;
+
+	if(state->battlefield->astack == nullptr)
+	{
+		error(
+			"The current stack is not part of the state. "
+			"This should NOT happen. "
+			"Falling back to a wait/defend action."
+		);
+		auto fa = astack->waitedThisTurn ? BattleAction::makeDefend(astack) : BattleAction::makeWait(astack);
+		cb->battleMakeUnitAction(bid, fa);
+		return;
+	}
+
+	auto concede = maybeFleeOrSurrender(bid);
+	if(concede)
+	{
+		info("Making retreat/surrender action.");
+		cb->battleMakeUnitAction(bid, *concede);
+		return;
+	}
+
+	logAi->debug("Not conceding.");
+
+	while(true)
+	{
+		auto t0 = std::chrono::steady_clock::now();
+		auto a = getNonRenderAction();
+		auto dt = std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now() - t0).count();
+		getActionTotalMs += dt;
+		getActionTotalCalls += 1;
+
+		allactions.push_back(a);
+
+		if(a == Schema::ACTION_RESET)
+		{
+			// XXX: retreat is always allowed for ML, limited by action mask only
+			debug("Received ACTION_RESET, converting to ACTION_RETREAT in order to reset battle");
+			a = Schema::ACTION_RETREAT;
+			resetting = true;
+		}
+
+		state->action = std::make_unique<Action>(a, state->battlefield.get(), colorname);
+		debug("(%lld ms) Got action: %d: %s ", dt, a, state->action->name());
+
+		try
+		{
+			ba = buildBattleAction();
+
+			if(ba)
+			{
+				debug("Action is VALID: " + state->action->name());
+				errcounter = 0;
+				cb->battleMakeUnitAction(bid, *ba);
+				break;
+			}
+			else
+			{
+				std::cout << Render(state.get(), state->action.get()) << "\n";
+				++errcounter;
+				if(errcounter > 10)
+				{
+					throw std::runtime_error("Received 10 consecutive invalid actions");
+				}
+				error("Action is INVALID: " + state->action->name());
+			}
+		}
+		catch(const std::exception & e)
+		{
+			std::cout << Render(state.get(), state->action.get()) << "\n";
+			std::cout << "FATAL ERROR: " << e.what() << "\n";
+			throw;
+		}
+	}
+}
+
+std::shared_ptr<BattleAction> BAI::buildBattleAction()
+{
+	ASSERT(state->battlefield, "Cannot build battle action if state->battlefield is missing");
+	auto * action = state->action.get();
+	const auto * bf = state->battlefield.get();
+	const auto * acstack = bf->astack->cstack;
+
+	auto [x, y] = Hex::CalcXY(acstack->getPosition());
+	auto & hex = bf->hexes->at(y).at(x);
+	std::shared_ptr<BattleAction> res = nullptr;
+
+	if(!state->action->hex)
+	{
+		switch(static_cast<GlobalAction>(state->action->action))
+		{
+			case GlobalAction::RETREAT:
+				res = std::make_shared<BattleAction>(BattleAction::makeRetreat(battle->battleGetMySide()));
+				break;
+			case GlobalAction::WAIT:
+				if(acstack->waitedThisTurn)
+				{
+					ASSERT(!state->actmask.at(EI(GlobalAction::WAIT)), "mask allowed wait when stack has already waited");
+					state->supdata->errcode = ErrorCode::ALREADY_WAITED;
+					error("Action error: %s (%d): ALREADY_WAITED", action->name(), EI(action->action));
+					return res;
+				}
+				res = std::make_shared<BattleAction>(BattleAction::makeWait(acstack));
+				break;
+			default:
+				THROW_FORMAT("Unexpected non-hex action: %d", state->action->action);
+		}
+
+		return res;
+	}
+
+	// With action masking, invalid actions should never occur
+	// However, for manual playing/testing, it's bad to raise exceptions
+	// => return errcode (Gym env will raise an exception if errcode > 0)
+	const auto & bhex = action->hex->bhex;
+	auto & stack = action->hex->stack; // may be null
+	auto mask = HexActMask(action->hex->attr(HexAttribute::ACTION_MASK));
+	if(mask.test(EI(action->hexaction)))
+	{
+		// Action is VALID
+		// XXX: Do minimal asserts to prevent bugs with nullptr deref
+		//      Server will log any attempted invalid actions otherwise
+		switch(action->hexaction)
+		{
+			case HexAction::MOVE:
+			{
+				auto ba = (bhex == acstack->getPosition()) ? BattleAction::makeDefend(acstack) : BattleAction::makeMove(acstack, bhex);
+				res = std::make_shared<BattleAction>(ba);
+			}
+			break;
+			case HexAction::SHOOT:
+				ASSERT(stack, "no target to shoot");
+				res = std::make_shared<BattleAction>(BattleAction::makeShotAttack(acstack, stack->cstack));
+				break;
+			case HexAction::AMOVE_TR:
+			case HexAction::AMOVE_R:
+			case HexAction::AMOVE_BR:
+			case HexAction::AMOVE_BL:
+			case HexAction::AMOVE_L:
+			case HexAction::AMOVE_TL:
+			{
+				const auto & edir = AMOVE_TO_EDIR.at(EI(action->hexaction));
+				auto nbh = bhex.cloneInDirection(edir, false); // neighbouring bhex
+				ASSERT(nbh.isAvailable(), "mask allowed attack to an unavailable hex #" + std::to_string(nbh.toInt()));
+				const auto * estack = battle->battleGetStackByPos(nbh);
+				ASSERT(estack, "no enemy stack for melee attack");
+				res = std::make_shared<BattleAction>(BattleAction::makeMeleeAttack(acstack, nbh, bhex));
+			}
+			break;
+			case HexAction::AMOVE_2TR:
+			case HexAction::AMOVE_2R:
+			case HexAction::AMOVE_2BR:
+			case HexAction::AMOVE_2BL:
+			case HexAction::AMOVE_2L:
+			case HexAction::AMOVE_2TL:
+			{
+				ASSERT(acstack->doubleWide(), "got AMOVE_2 action for a single-hex stack");
+				const auto & edir = AMOVE_TO_EDIR.at(EI(action->hexaction));
+				auto obh = acstack->occupiedHex(bhex);
+				auto nbh = obh.cloneInDirection(edir, false); // neighbouring bhex
+				ASSERT(nbh.isAvailable(), "mask allowed attack to an unavailable hex #" + std::to_string(nbh.toInt()));
+				const auto * estack = battle->battleGetStackByPos(nbh);
+				ASSERT(estack, "no enemy stack for melee attack");
+				res = std::make_shared<BattleAction>(BattleAction::makeMeleeAttack(acstack, nbh, bhex));
+			}
+			break;
+			default:
+				THROW_FORMAT("Unexpected hexaction: %d", EI(action->hexaction));
+		}
+
+		return res;
+	}
+
+	// Action is INVALID
+	// XXX:
+	// mask prevents certain actions, but during TESTING
+	// those actions may be taken anyway.
+	//
+	// IF we are here, it means the mask disallows that action
+	//
+	// => *throw* errors here only if the mask SHOULD HAVE ALLOWED it
+	//    and *set* regular, non-throw errors otherwise
+	//
+	handleUnexpectedAction(acstack, hex, action);
+	ASSERT(state->supdata->errcode != ErrorCode::OK, "Could not identify why the action is invalid" + debugInfo(action, acstack, nullptr));
+
+	return res;
+}
+
+void BAI::handleUnexpectedAction(const CStack * acstack, std::unique_ptr<Hex> & hex, Action * action)
+{
+	const auto & bhex = action->hex->bhex;
+	auto & stack = action->hex->stack; // may be null
+	auto rinfo = battle->getReachability(acstack);
+	auto ainfo = battle->getAccessibility();
+
+	switch(state->action->hexaction)
+	{
+		case HexAction::AMOVE_TR:
+		case HexAction::AMOVE_R:
+		case HexAction::AMOVE_BR:
+		case HexAction::AMOVE_BL:
+		case HexAction::AMOVE_L:
+		case HexAction::AMOVE_TL:
+		case HexAction::AMOVE_2TR:
+		case HexAction::AMOVE_2R:
+		case HexAction::AMOVE_2BR:
+		case HexAction::AMOVE_2BL:
+		case HexAction::AMOVE_2L:
+		case HexAction::AMOVE_2TL:
+		case HexAction::MOVE:
+		{
+			auto a = ainfo.at(action->hex->bhex.toInt());
+			if(a == EAccessibility::OBSTACLE)
+			{
+				auto statemask = HexStateMask(hex->attr(HexAttribute::STATE_MASK));
+				ASSERT(
+					!statemask.test(EI(HexState::PASSABLE)),
+					"accessibility is OBSTACLE, but hex state mask has PASSABLE set: " + statemask.to_string() + debugInfo(action, acstack, nullptr)
+				);
+				state->supdata->errcode = ErrorCode::HEX_BLOCKED;
+				error("Action error: %s (%d): HEX_BLOCKED", action->name(), EI(action->action));
+				break;
+			}
+			else if(a == EAccessibility::ALIVE_STACK)
+			{
+				auto bh = action->hex->bhex;
+				if(bh.toInt() == acstack->getPosition().toInt())
+				{
+					// means we want to defend (moving to self)
+					// or attack from same hex we're currently at
+					// this should always be allowed
+					ASSERT(false, "mask prevented (A)MOVE to own hex" + debugInfo(action, acstack, nullptr));
+				}
+				else if(bh.toInt() == acstack->occupiedHex().toInt())
+				{
+					ASSERT(
+						rinfo.distances.at(bh.toInt()) == ReachabilityInfo::INFINITE_DIST,
+						"mask prevented (A)MOVE to self-occupied hex" + debugInfo(action, acstack, nullptr)
+					);
+					// means we can't fit on our own back hex
+				}
+
+				// means we try to move onto another stack
+				state->supdata->errcode = ErrorCode::HEX_BLOCKED;
+				error("Action error: %s (%d): HEX_BLOCKED", action->name(), EI(action->action));
+				break;
+			}
+
+			// only remaining is ACCESSIBLE
+			ASSERT(a == EAccessibility::ACCESSIBLE, "accessibility should've been ACCESSIBLE, was: " = std::to_string(EI(a)));
+
+			auto nbh = BattleHex{};
+
+			if(action->hexaction < HexAction::AMOVE_2TR)
+			{
+				auto edir = AMOVE_TO_EDIR.at(EI(action->hexaction));
+				nbh = bhex.cloneInDirection(edir, false);
+			}
+			else
+			{
+				if(!acstack->doubleWide())
+				{
+					state->supdata->errcode = ErrorCode::INVALID_DIR;
+					error("Action error: %s (%d): INVALID_DIR", action->name(), EI(action->action));
+					break;
+				}
+
+				auto edir = AMOVE_TO_EDIR.at(EI(action->hexaction));
+				nbh = acstack->occupiedHex().cloneInDirection(edir, false);
+			}
+
+			if(!nbh.isAvailable())
+			{
+				state->supdata->errcode = ErrorCode::HEX_MELEE_NA;
+				error("Action error: %s (%d): HEX_MELEE_NA", action->name(), EI(action->action));
+				break;
+			}
+
+			const auto * estack = battle->battleGetStackByPos(nbh);
+
+			if(!estack)
+			{
+				state->supdata->errcode = ErrorCode::STACK_NA;
+				error("Action error: %s (%d): STACK_NA", action->name(), EI(action->action));
+				break;
+			}
+
+			if(estack->unitSide() == acstack->unitSide())
+			{
+				state->supdata->errcode = ErrorCode::FRIENDLY_FIRE;
+				error("Action error: %s (%d): FRIENDLY_FIRE", action->name(), EI(action->action));
+				break;
+			}
+		}
+		break;
+		case HexAction::SHOOT:
+			if(!stack)
+			{
+				state->supdata->errcode = ErrorCode::STACK_NA;
+				error("Action error: %s (%d): STACK_NA", action->name(), EI(action->action));
+				break;
+			}
+			else if(stack->cstack->unitSide() == acstack->unitSide())
+			{
+				state->supdata->errcode = ErrorCode::FRIENDLY_FIRE;
+				error("Action error: %s (%d): FRIENDLY_FIRE", action->name(), EI(action->action));
+				break;
+			}
+			else
+			{
+				ASSERT(!battle->battleCanShoot(acstack, bhex), "mask prevented SHOOT at a shootable bhex " + action->hex->name());
+				state->supdata->errcode = ErrorCode::CANNOT_SHOOT;
+				error("Action error: %s (%d): CANNOT_SHOOT", action->name(), EI(action->action));
+				break;
+			}
+			break;
+		default:
+			THROW_FORMAT("Unexpected hexaction: %d", EI(action->hexaction));
+	}
+}
+
+std::string BAI::debugInfo(Action * action, const CStack * astack, const BattleHex * const nbh)
+{
+	auto info = std::stringstream();
+	info << "\n*** DEBUG INFO ***\n";
+	info << "action: " << action->name() << " [" << action->action << "]\n";
+	info << "action->hex->bhex.toInt() = " << action->hex->bhex.toInt() << "\n";
+
+	auto ainfo = battle->getAccessibility();
+	auto rinfo = battle->getReachability(astack);
+
+	info << "ainfo[bhex]=" << EI(ainfo.at(action->hex->bhex.toInt())) << "\n";
+	info << "rinfo.distances[bhex] <= astack->getMovementRange(): " << (rinfo.distances[action->hex->bhex.toInt()] <= astack->getMovementRange()) << "\n";
+
+	info << "action->hex->name = " << action->hex->name() << "\n";
+
+	for(int i = 0; i < action->hex->attrs.size(); i++)
+		info << "action->hex->attrs[" << i << "] = " << EI(action->hex->attrs[i]) << "\n";
+
+	info << "action->hex->hexactmask = ";
+	info << HexActMask(action->hex->attr(HexAttribute::ACTION_MASK)).to_string();
+	info << "\n";
+
+	auto stack = action->hex->stack;
+	if(stack)
+	{
+		info << "stack->cstack->getPosition().toInt()=" << stack->cstack->getPosition().toInt() << "\n";
+		info << "stack->cstack->slot=" << stack->cstack->unitSlot() << "\n";
+		info << "stack->cstack->doubleWide=" << stack->cstack->doubleWide() << "\n";
+		info << "cb->battleCanShoot(stack->cstack)=" << battle->battleCanShoot(stack->cstack) << "\n";
+	}
+	else
+	{
+		info << "cstack: (nullptr)\n";
+	}
+
+	info << "astack->getPosition().toInt()=" << astack->getPosition().toInt() << "\n";
+	info << "astack->slot=" << astack->unitSlot() << "\n";
+	info << "astack->doubleWide=" << astack->doubleWide() << "\n";
+	info << "cb->battleCanShoot(astack)=" << battle->battleCanShoot(astack) << "\n";
+
+	if(nbh)
+	{
+		info << "nbh->toInt()=" << nbh->toInt() << "\n";
+		info << "ainfo[nbh]=" << EI(ainfo.at(nbh->toInt())) << "\n";
+		info << "rinfo.distances[nbh] <= astack->getMovementRange(): " << (rinfo.distances[nbh->toInt()] <= astack->getMovementRange()) << "\n";
+
+		if(stack)
+			info << "astack->isMeleeAttackPossible(...)=" << astack->isMeleeAttackPossible(astack, stack->cstack, *nbh) << "\n";
+	}
+
+	info << "\nACTION TRACE:\n";
+	for(const auto & a : allactions)
+		info << a << ",";
+
+	info << "\nRENDER:\n";
+	info << renderANSI();
+
+	return info.str();
+}
+
+std::string BAI::renderANSI() const
+{
+	try
+	{
+		Verify(state.get());
+	}
+	catch(const std::exception & e)
+	{
+		try
+		{
+			std::cout << e.what();
+			std::cout << "Disaster render:\n";
+			std::cout << Render(state.get(), state->action.get()) << "\n";
+		}
+		catch(std::exception & e2)
+		{
+			std::cerr << "(failed: " << e2.what() << ")\n";
+		}
+		throw;
+	}
+
+	return Render(state.get(), state->action.get());
+}
+
+void BAI::actionStarted(const BattleID & bid, const BattleAction & action)
+{
+	Base::actionStarted(bid, action);
+	state->onActionStarted(action);
+};
+
+void BAI::actionFinished(const BattleID & bid, const BattleAction & action)
+{
+	Base::actionFinished(bid, action);
+	state->onActionFinished(action);
+};
+}

+ 81 - 0
AI/MMAI/BAI/v13/BAI.h

@@ -0,0 +1,81 @@
+/*
+ * BAI.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 "BAI/base.h"
+#include "BAI/v13/action.h"
+#include "BAI/v13/state.h"
+
+namespace MMAI::BAI::V13
+{
+class BAI : public Base
+{
+public:
+	using Base::Base;
+
+	// Bring thes template functions into the derived class's scope
+	using Base::_log;
+	using Base::debug;
+	using Base::error;
+	using Base::info;
+	using Base::log;
+	using Base::trace;
+	using Base::warn;
+
+	void activeStack(const BattleID & bid, const CStack * stack) override;
+	void actionStarted(const BattleID & bid, const BattleAction & action) override;
+	void actionFinished(const BattleID & bid, const BattleAction & action) override;
+	void yourTacticPhase(const BattleID & bid, int distance) override;
+
+	void battleStacksAttacked(
+		const BattleID & bid,
+		const std::vector<BattleStackAttacked> & bsa,
+		bool ranged
+	) override; //called when stack receives damage (after battleAttack())
+	void battleTriggerEffect(const BattleID & bid, const BattleTriggerEffect & bte) override;
+	void battleEnd(const BattleID & bid, const BattleResult * br, QueryID queryID) override;
+	void battleStart(
+		const BattleID & bid,
+		const CCreatureSet * army1,
+		const CCreatureSet * army2,
+		int3 tile,
+		const CGHeroInstance * hero1,
+		const CGHeroInstance * hero2,
+		BattleSide side,
+		bool replayAllowed
+	) override; //called by engine when battle starts; side=0 - left, side=1 - right
+
+	Schema::Action getNonRenderAction() override;
+
+	// Subsequent versions may override this with subclasses of State
+	virtual std::unique_ptr<State> initState(const CPlayerBattleCallback * battle);
+	std::unique_ptr<State> state = nullptr;
+
+	// consecutive invalid actions counter
+	int errcounter = 0;
+
+	int getActionTotalMs;
+	int getActionTotalCalls;
+
+	bool resetting = false;
+	std::vector<Schema::Action> allactions; // DEBUG ONLY
+	std::shared_ptr<CPlayerBattleCallback> battle = nullptr;
+
+	std::string renderANSI() const;
+	std::string debugInfo(Action * action, const CStack * astack, const BattleHex * nbh); // DEBUG ONLY
+	void handleUnexpectedAction(const CStack * acstack, std::unique_ptr<Hex> & hex, Action * action);
+	std::shared_ptr<BattleAction> buildBattleAction();
+	std::shared_ptr<BattleAction> maybeBuildAutoAction(const CStack * stack, const BattleID & bid) const;
+	bool maybeCastSpell(const CStack * stack, const BattleID & bid);
+
+	std::optional<BattleAction> maybeFleeOrSurrender(const BattleID & bid);
+};
+}

+ 178 - 0
AI/MMAI/BAI/v13/action.cpp

@@ -0,0 +1,178 @@
+/*
+ * action.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 "battle/CBattleInfoEssentials.h"
+
+#include "BAI/v13/action.h"
+#include "BAI/v13/hex.h"
+#include "BAI/v13/hexaction.h"
+#include "common.h"
+
+namespace MMAI::BAI::V13
+{
+
+// static
+std::unique_ptr<Hex> Action::initHex(const Schema::Action & a, const Battlefield * bf)
+{
+	// Control actions (<0) should never reach here
+	ASSERT(a >= 0 && a < N_ACTIONS, "Invalid action: " + std::to_string(a));
+
+	auto i = a - EI(GlobalAction::_count);
+
+	if(i < 0)
+		return nullptr;
+
+	i = i / EI(HexAction::_count);
+	auto y = i / 15;
+	auto x = i % 15;
+
+	// create a new unique_ptr with a copy of Hex
+	return std::make_unique<Hex>(*bf->hexes->at(y).at(x));
+}
+
+// static
+std::unique_ptr<Hex> Action::initAMoveTargetHex(const Schema::Action & a, const Battlefield * bf)
+{
+	auto hex = initHex(a, bf);
+	if(!hex)
+		return nullptr;
+
+	auto ha = initHexAction(a, bf);
+	if(EI(ha) == -1)
+		return nullptr;
+
+	if(ha == HexAction::MOVE || ha == HexAction::SHOOT)
+		return nullptr;
+	// throw std::runtime_error("MOVE and SHOOT are not AMOVE actions");
+
+	const auto & bh = hex->bhex;
+
+	auto edir = AMOVE_TO_EDIR.at(EI(ha));
+	auto nbh = bh.cloneInDirection(edir);
+
+	ASSERT(nbh.isAvailable(), "unavailable AMOVE target hex #" + std::to_string(nbh.toInt()));
+
+	auto [x, y] = Hex::CalcXY(nbh);
+
+	// create a new unique_ptr with a copy of Hex
+	return std::make_unique<Hex>(*bf->hexes->at(y).at(x));
+}
+
+// static
+HexAction Action::initHexAction(const Schema::Action & a, const Battlefield * bf)
+{
+	if(a < EI(GlobalAction::_count))
+		return static_cast<HexAction>(-1); // a is not about a hex
+	return static_cast<HexAction>((a - EI(GlobalAction::_count)) % EI(HexAction::_count));
+}
+
+Action::Action(const Schema::Action action_, const Battlefield * bf, const std::string & color_)
+	: action(action_), hex(initHex(action_, bf)), aMoveTargetHex(initAMoveTargetHex(action_, bf)), hexaction(initHexAction(action_, bf)), color(color_)
+{
+}
+
+std::string Action::name() const
+{
+	if(action == Schema::V13::ACTION_RETREAT)
+		return "Retreat";
+	else if(action == Schema::V13::ACTION_WAIT)
+		return "Wait";
+
+	ASSERT(hex, "hex is null");
+
+	auto ha = static_cast<HexAction>((action - EI(GlobalAction::_count)) % EI(HexAction::_count));
+	auto res = std::string{};
+	std::shared_ptr<const Stack> stack = nullptr;
+	std::string stackstr;
+
+	if(ha == HexAction::SHOOT || ha == HexAction::MOVE)
+	{
+		stack = hex->stack;
+	}
+	else if(aMoveTargetHex)
+	{
+		stack = aMoveTargetHex->stack;
+	}
+
+	// colored output does not look good (scrambles default VCMI log coloring)
+	// Additionally, hardcoded red/blue colors are relevant during training only
+	// => replace with attacker/defender colorless strings instead
+	// if (stack) {
+	//     std::string targetcolor = "\033[31m";  // red
+	//     if (color == "red") targetcolor = "\033[34m"; // blue
+	//     stackstr = targetcolor + "#" + std::string(1, stack->getAlias()) + "\033[0m";
+	// } else {
+	//     std::string targetcolor = "\033[7m";  // white
+	//     stackstr = targetcolor + "#?" + "\033[0m";
+	// }
+
+	if(stack)
+	{
+		std::string targetside = (color == "red") ? "L" : "R";
+		stackstr = targetside + "-" + std::string(1, stack->getAlias());
+	}
+	else
+	{
+		stackstr = "?";
+	}
+
+	switch(ha)
+	{
+		case HexAction::MOVE:
+			res = (stack && hex->bhex == stack->cstack->getPosition() ? "Defend on hex(" : "Move to (") + hex->name() + ")";
+			break;
+		case HexAction::AMOVE_TL:
+			res = "Attack stack(" + stackstr + ") from hex(" + hex->name() + ") /top-left/";
+			break;
+		case HexAction::AMOVE_TR:
+			res = "Attack stack(" + stackstr + ") from hex(" + hex->name() + ") /top-right/";
+			break;
+		case HexAction::AMOVE_R:
+			res = "Attack stack(" + stackstr + ") from hex(" + hex->name() + ") /right/";
+			break;
+		case HexAction::AMOVE_BR:
+			res = "Attack stack(" + stackstr + ") from hex(" + hex->name() + ") /bottom-right/";
+			break;
+		case HexAction::AMOVE_BL:
+			res = "Attack stack(" + stackstr + ") from hex(" + hex->name() + ") /bottom-left/";
+			break;
+		case HexAction::AMOVE_L:
+			res = "Attack stack(" + stackstr + ") from hex(" + hex->name() + ") /left/";
+			break;
+		case HexAction::AMOVE_2BL:
+			res = "Attack stack(" + stackstr + ") from hex(" + hex->name() + ") /bottom-left-2/";
+			break;
+		case HexAction::AMOVE_2L:
+			res = "Attack stack(" + stackstr + ") from hex(" + hex->name() + ") /left-2/";
+			break;
+		case HexAction::AMOVE_2TL:
+			res = "Attack stack(" + stackstr + ") from hex(" + hex->name() + ") /top-left-2/";
+			break;
+		case HexAction::AMOVE_2TR:
+			res = "Attack stack(" + stackstr + ") from hex(" + hex->name() + ") /top-right-2/";
+			break;
+		case HexAction::AMOVE_2R:
+			res = "Attack stack(" + stackstr + ") from hex(" + hex->name() + ") /right-2/";
+			break;
+		case HexAction::AMOVE_2BR:
+			res = "Attack stack(" + stackstr + ") from hex(" + hex->name() + ") /bottom-right-2/";
+			break;
+		case HexAction::SHOOT:
+			res = "Attack stack(" + stackstr + ") " + hex->name() + " (ranged)";
+			break;
+		default:
+			THROW_FORMAT("Unexpected hexaction: %d", EI(ha));
+	}
+
+	return res;
+}
+
+}

+ 38 - 0
AI/MMAI/BAI/v13/action.h

@@ -0,0 +1,38 @@
+/*
+ * action.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 "BAI/v13/battlefield.h"
+#include "BAI/v13/hex.h"
+#include "BAI/v13/hexaction.h"
+
+namespace MMAI::BAI::V13
+{
+/*
+ * Wrapper around Schema::Action
+ */
+struct Action
+{
+	static std::unique_ptr<Hex> initHex(const Schema::Action & a, const Battlefield * bf);
+	static std::unique_ptr<Hex> initAMoveTargetHex(const Schema::Action & a, const Battlefield * bf);
+	static HexAction initHexAction(const Schema::Action & a, const Battlefield * bf);
+
+	Action(Schema::Action action_, const Battlefield * bf, const std::string & color);
+
+	const Schema::Action action;
+	const std::unique_ptr<Hex> hex;
+	const std::unique_ptr<Hex> aMoveTargetHex;
+	const HexAction hexaction; // XXX: must come after action
+	const std::string color;
+
+	std::string name() const;
+};
+}

+ 78 - 0
AI/MMAI/BAI/v13/attack_log.h

@@ -0,0 +1,78 @@
+/*
+ * attack_log.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 "BAI/v13/stack.h"
+#include "schema/v13/types.h"
+
+namespace MMAI::BAI::V13
+{
+
+struct AttackLogData
+{
+	const std::shared_ptr<Stack> attacker; // XXX: can be nullptr if dmg is not from creature
+	const std::shared_ptr<Stack> defender;
+	const CStack * cattacker;
+	const CStack * cdefender;
+	const int dmg;
+	const int dmgPermille;
+	const int units;
+	const int value;
+	const int valuePermille;
+};
+
+class AttackLog : public Schema::V13::IAttackLog
+{
+public:
+	explicit AttackLog(const AttackLogData & data) : data(data) {};
+
+	const AttackLogData data;
+
+	// IAttackLog impl
+	Stack * getAttacker() const override
+	{
+		return data.attacker.get();
+	}
+	Stack * getDefender() const override
+	{
+		return data.defender.get();
+	}
+	int getDamageDealt() const override
+	{
+		return data.dmg;
+	}
+	int getDamageDealtPermille() const override
+	{
+		return data.dmgPermille;
+	}
+	int getUnitsKilled() const override
+	{
+		return data.units;
+	}
+	int getValueKilled() const override
+	{
+		return data.value;
+	}
+	int getValueKilledPermille() const override
+	{
+		return data.valuePermille;
+	}
+
+	/*
+	 * attacker dealing dmg might be our friendly fire
+	 * If we look at Attacker POV, we would count our friendly fire as "dmg dealt"
+	 * So we look at Defender POV, so our friendly fire is counted as "dmg received"
+	 * This means that if the enemy does friendly fire dmg,
+	 *  we would count it as our dmg dealt - that is OK (we have "tricked" the enemy!)
+	 * => store only defender slot
+	 */
+};
+}

+ 415 - 0
AI/MMAI/BAI/v13/battlefield.cpp

@@ -0,0 +1,415 @@
+/*
+ * battlefield.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 "battle/BattleHex.h"
+#include "battle/IBattleInfoCallback.h"
+#include "battle/ReachabilityInfo.h"
+
+#include "BAI/v13/battlefield.h"
+#include "BAI/v13/hex.h"
+#include "common.h"
+#include <algorithm>
+#include <memory>
+#include <ranges>
+
+namespace MMAI::BAI::V13
+{
+using HA = HexAttribute;
+using SA = StackAttribute;
+using LT = LinkType;
+
+// A custom hash function must be provided for the adjmap
+struct PairHash
+{
+	std::size_t operator()(const std::pair<si16, si16> & t) const
+	{
+		auto h0 = std::hash<int>{}(std::get<0>(t));
+		auto h1 = std::hash<int>{}(std::get<1>(t));
+		return h0 ^ (h1 << 1);
+	}
+};
+
+namespace
+{
+	std::unordered_map<std::pair<si16, si16>, bool, PairHash> InitAdjMap()
+	{
+		auto res = std::unordered_map<std::pair<si16, si16>, bool, PairHash>{};
+
+		for(int id1 = 0; id1 < GameConstants::BFIELD_SIZE; id1++)
+		{
+			auto hex1 = BattleHex(id1);
+			for(auto dir : BattleHex::hexagonalDirections())
+			{
+				auto hex2 = hex1.cloneInDirection(dir, false);
+				res[{hex1.toInt(), hex2.toInt()}] = true;
+			}
+		}
+
+		return res;
+	}
+}
+
+Battlefield::Battlefield(const std::shared_ptr<Hexes> & hexes_, const Stacks & stacks_, const AllLinks & allLinks_, const Stack * astack_)
+	: hexes(hexes_), stacks(stacks_), allLinks(allLinks_), astack(astack_) {};
+
+// static
+std::shared_ptr<const Battlefield> Battlefield::Create(
+	const CPlayerBattleCallback * battle,
+	const CStack * acstack,
+	const GlobalStats * oldgstats,
+	const GlobalStats * gstats,
+	std::map<const CStack *, Stack::Stats> & stacksStats,
+	bool isMorale
+)
+{
+	auto [stacks, queue] = InitStacks(battle, acstack, oldgstats, gstats, stacksStats, isMorale);
+	auto [hexes, astack] = InitHexes(battle, acstack, stacks);
+	auto links = InitAllLinks(battle, stacks, queue, hexes);
+
+	return std::make_shared<const Battlefield>(hexes, stacks, links, astack);
+}
+
+// static
+// result is a vector<UnitID>
+// XXX: there is a bug in VCMI when high morale occurs:
+//      - the stack acts as if it's already the next unit's turn
+//      - as a result, QueuePos for the ACTIVE stack is non-0
+//        while the QueuePos for the next (non-active) stack is 0
+// (this applies only to good morale; bad morale simply skips turn)
+// As a workaround, a "isMorale" flag is passed whenever the astack is
+// acting because of high morale and queue is "shifted" accordingly.
+Queue Battlefield::GetQueue(const CPlayerBattleCallback * battle, const CStack * astack, bool isMorale)
+{
+	auto res = Queue{};
+
+	auto tmp = std::vector<battle::Units>{};
+	battle->battleGetTurnOrder(tmp, S13::STACK_QUEUE_SIZE, 0);
+	for(auto & units : tmp)
+	{
+		for(auto & unit : units)
+		{
+			if(res.size() < S13::STACK_QUEUE_SIZE)
+				res.push_back(unit->unitId());
+			else
+				break;
+		}
+	}
+
+	// XXX: after morale, battleGetTurnOrder() returns wrong order
+	//      (where a non-active stack is first)
+	//      The active stack *must* be first-in-queue
+	if(isMorale && astack && res.at(0) != astack->unitId())
+	{
+		// logAi->debug("Morale triggered -- will rearrange stack queue");
+		std::rotate(res.rbegin(), res.rbegin() + 1, res.rend());
+		res.at(0) = astack->unitId();
+	}
+	else
+	{
+		// the only scenario where the active stack is not first in queue
+		// is at battle end (i.e. no active stack)
+		// assert(astack == nullptr || res.at(0) == astack->unitId());
+		ASSERT(astack == nullptr || res.at(0) == astack->unitId(), "queue[0] is not the currently active stack!");
+	}
+
+	return res;
+}
+
+// static
+std::tuple<std::shared_ptr<Hexes>, Stack *> Battlefield::InitHexes(const CPlayerBattleCallback * battle, const CStack * acstack, const Stacks & stacks)
+{
+	auto res = std::make_shared<Hexes>();
+	auto ainfo = battle->getAccessibility();
+	auto hexstacks = std::map<BattleHex, std::shared_ptr<Stack>>{};
+	auto hexobstacles = std::array<std::vector<std::shared_ptr<const CObstacleInstance>>, 165>{};
+
+	std::shared_ptr<ActiveStackInfo> astackinfo = nullptr;
+	Stack * astack = nullptr;
+
+	for(const auto & stack : stacks)
+	{
+		for(const auto & bh : stack->cstack->getHexes())
+			if(bh.isAvailable())
+				hexstacks.try_emplace(bh, stack);
+
+		// XXX: at battle_end, stack->cstack != acstack even if qpos=0
+		if((stack->attr(SA::QUEUE) & 1) && acstack)
+			astack = stack.get();
+	}
+
+	for(const auto & obstacle : battle->battleGetAllObstacles())
+		for(const auto & bh : obstacle->getAffectedTiles())
+			if(bh.isAvailable())
+				hexobstacles.at(Hex::CalcId(bh)).push_back(obstacle);
+
+	if(astack)
+	{
+		// astack can be nullptr if battle just begun (no turns yet)
+		astackinfo = std::make_shared<ActiveStackInfo>(astack, battle->battleCanShoot(astack->cstack), std::make_shared<ReachabilityInfo>(astack->rinfo));
+	}
+
+	auto gatestate = battle->battleGetGateState();
+
+	for(int y = 0; y < 11; ++y)
+	{
+		for(int x = 0; x < 15; ++x)
+		{
+			auto i = (y * 15) + x;
+			auto bh = BattleHex(x + 1, y);
+			res->at(y).at(x) = std::make_unique<Hex>(bh, ainfo.at(bh.toInt()), gatestate, hexobstacles.at(i), hexstacks, astackinfo);
+		}
+	}
+
+	// XXX: astack can be nullptr (even if acstack is not) -- see above
+	return {res, astack};
+};
+
+// static
+std::tuple<Stacks, Queue> Battlefield::InitStacks(
+	const CPlayerBattleCallback * battle,
+	const CStack * astack,
+	const GlobalStats * oldgstats,
+	const GlobalStats * gstats,
+	std::map<const CStack *, Stack::Stats> & stacksStats,
+	bool isMorale
+)
+{
+	auto stacks = Stacks{};
+	auto cstacks = battle->battleGetStacks();
+
+	// Sorting needed to ensure ordered insertion of summons/machines
+	std::ranges::sort(
+		cstacks,
+		[](const CStack * a, const CStack * b)
+		{
+			return a->unitId() < b->unitId();
+		}
+	);
+
+	/*
+	 * Units for each side are indexed as follows:
+	 *
+	 *  1. The 7 "regular" army stacks use indexes 0..6 (index=slot)
+	 *  2. Up to N* summoned units will use indexes 7+ (ordered by unit ID)
+	 *  3. Up to N* war machines will use FREE indexes 7+, if any (ordered by unit ID).
+	 *  4. Remaining units from 2. and 3. will use FREE indexes from 1, if any (ordered by unit ID).
+	 *  5. Remaining units from 4. will be ignored.
+	 */
+	auto queue = GetQueue(battle, astack, isMorale);
+	auto summons = std::array<std::deque<const CStack *>, 2>{};
+	auto machines = std::array<std::deque<const CStack *>, 2>{};
+
+	auto blocking = std::map<const CStack *, bool>{};
+	auto blocked = std::map<const CStack *, bool>{};
+
+	auto setBlockedBlocking = [&battle, &blocked, &blocking](const CStack * cstack)
+	{
+		blocked.emplace(cstack, false);
+		blocking.emplace(cstack, false);
+
+		for(const auto * adjacent : battle->battleAdjacentUnits(cstack))
+		{
+			if(adjacent->unitOwner() == cstack->unitOwner())
+				continue;
+
+			if(!blocked[cstack] && cstack->canShoot() && !cstack->hasBonusOfType(BonusType::FREE_SHOOTING) && !cstack->hasBonusOfType(BonusType::SIEGE_WEAPON))
+			{
+				blocked[cstack] = true;
+			}
+			if(!blocking[cstack] && adjacent->canShoot() && !adjacent->hasBonusOfType(BonusType::FREE_SHOOTING)
+			   && !adjacent->hasBonusOfType(BonusType::SIEGE_WEAPON))
+			{
+				blocking[cstack] = true;
+			}
+		}
+	};
+
+	// estimated dmg by active stack
+	// values are for ranged attack if unit is an unblocked shooter
+	// otherwise for melee attack
+	auto estdmg = std::map<const CStack *, DamageEstimation>{};
+
+	auto estimateDamage = [&battle, &astack, &estdmg, &blocked](const CStack * cstack)
+	{
+		if(!astack)
+		{
+			// no active stack (e.g. called during battleStart or battleEnd)
+			estdmg.try_emplace(cstack);
+		}
+		else if(astack->unitSide() == cstack->unitSide())
+		{
+			// no damage to friendly units
+			estdmg.try_emplace(cstack);
+		}
+		else
+		{
+			const auto attinfo = BattleAttackInfo(astack, cstack, 0, astack->canShoot() && !blocked[astack]);
+			estdmg.try_emplace(cstack, battle->calculateDmgRange(attinfo));
+		}
+	};
+
+	// This must be pre-set as dmg estimation depends on it
+	if(astack)
+		setBlockedBlocking(astack);
+
+	for(auto & cstack : cstacks)
+	{
+		if(cstack != astack)
+			setBlockedBlocking(cstack);
+
+		estimateDamage(cstack);
+
+		auto stack = std::make_shared<Stack>(
+			cstack,
+			queue,
+			// a blank stackStats entry is created if missing
+			Stack::StatsContainer{.oldgstats = oldgstats, .gstats = gstats, .stackStats = stacksStats[cstack]},
+			battle->getReachability(cstack),
+			blocked[cstack],
+			blocking[cstack],
+			estdmg[cstack]
+		);
+
+		stacks.push_back(stack);
+	}
+
+	return {stacks, queue};
+}
+
+// static
+AllLinks Battlefield::InitAllLinks(const CPlayerBattleCallback * battle, const Stacks & stacks, const Queue & queue, std::shared_ptr<Hexes> & hexes)
+{
+	auto allLinks = AllLinks();
+
+	for(auto i = 0; i < EI(LT::_count); ++i)
+		allLinks[static_cast<LT>(i)] = std::make_shared<Links>();
+
+	for(auto & srcrow : *hexes)
+	{
+		for(auto & srchex : srcrow)
+		{
+			for(auto & dstrow : *hexes)
+			{
+				for(auto & dsthex : dstrow)
+				{
+					LinkTwoHexes(allLinks, battle, stacks, queue, srchex.get(), dsthex.get());
+				}
+			}
+		}
+	}
+
+	return allLinks;
+}
+
+namespace
+{
+	float calculateRangeMod(const CPlayerBattleCallback * battle, const CStack * cstack, const BattleHex & src, const BattleHex & dst)
+	{
+		float rangemod = 1;
+		if(battle->battleHasDistancePenalty(cstack, src, dst))
+			rangemod *= 0.5;
+		if(battle->battleHasWallPenalty(cstack, src, dst))
+			rangemod *= 0.5;
+		return rangemod;
+	}
+}
+
+void Battlefield::LinkTwoHexes(
+	AllLinks & allLinks,
+	const CPlayerBattleCallback * battle,
+	const Stacks & stacks,
+	const Queue & queue,
+	const Hex * src,
+	const Hex * dst
+)
+{
+	static const auto adjmap = InitAdjMap();
+	bool neighbour = adjmap.contains({src->bhex.toInt(), dst->bhex.toInt()});
+	bool reachable = false;
+	float rangemod = 0;
+	float rangedDmgFrac = 0;
+	float meleeDmgFrac = 0;
+	float retalDmgFrac = 0;
+	int actsBefore = 0;
+
+	if(src->stack && !src->getAttr(HA::IS_REAR) && !src->stack->flag(StackFlag1::SLEEPING))
+	{
+		reachable = src->stack->rinfo.distances.at(dst->bhex.toInt()) <= src->stack->attr(SA::SPEED);
+
+		// rangemod is set even if dst is free
+		if(src->stack->cstack->canShoot() && !src->stack->cstack->coversPos(dst->bhex) && !src->stack->flag(StackFlag1::BLOCKED) && !neighbour)
+		{
+			rangemod = calculateRangeMod(battle, src->stack->cstack, src->bhex, dst->bhex);
+		}
+
+		// *dmgFracs are set only between opposing stacks
+		if(dst->stack && (dst->stack->cstack->unitSide() != src->stack->cstack->unitSide()))
+		{
+			if(rangemod > 0)
+			{
+				auto estdmg = battle->calculateDmgRange(BattleAttackInfo(src->stack->cstack, dst->stack->cstack, 0, true));
+				auto avgdmg = 0.5 * (estdmg.damage.max + estdmg.damage.min);
+				// negate the rangemod in the dmg calc (i.e. report the "base" dmg)
+				avgdmg *= 1 / rangemod;
+				rangedDmgFrac = avgdmg / dst->stack->cstack->getAvailableHealth();
+			}
+
+			auto bai = BattleAttackInfo(src->stack->cstack, dst->stack->cstack, 0, false);
+			auto retdmg = DamageEstimation{};
+			auto estdmg = battle->battleEstimateDamage(bai, &retdmg);
+			auto avgdmg = 0.5 * (estdmg.damage.max + estdmg.damage.min);
+			meleeDmgFrac = avgdmg / dst->stack->cstack->getAvailableHealth();
+
+			if(retdmg.damage.max > 0)
+			{
+				auto avgret = 0.5 * (retdmg.damage.max + retdmg.damage.min);
+				retalDmgFrac = avgret / src->stack->cstack->getAvailableHealth();
+			}
+		}
+	}
+
+	if(src->stack && dst->stack && src->id != dst->id)
+	{
+		auto srcpos = src->stack->qposFirst;
+		auto dstpos = dst->stack->qposFirst;
+		if(srcpos < dstpos)
+		{
+			ASSERT(dstpos <= queue.size(), "dstpos exceeds queue size");
+			actsBefore = true;
+		}
+	}
+
+	//
+	// Build links
+	//
+
+	if(neighbour)
+		allLinks[LT::ADJACENT]->add(src->id, dst->id, 1);
+
+	if(reachable)
+		allLinks[LT::REACH]->add(src->id, dst->id, 1);
+
+	if(actsBefore)
+		allLinks[LT::ACTS_BEFORE]->add(src->id, dst->id, std::min<int>(2, actsBefore));
+
+	if(rangemod)
+		allLinks[LT::RANGED_MOD]->add(src->id, dst->id, std::min<float>(2, rangemod));
+
+	if(rangedDmgFrac)
+		allLinks[LT::RANGED_DMG_REL]->add(src->id, dst->id, std::min<float>(2, rangedDmgFrac));
+
+	if(meleeDmgFrac)
+		allLinks[LT::MELEE_DMG_REL]->add(src->id, dst->id, std::min<float>(2, meleeDmgFrac));
+
+	if(retalDmgFrac)
+		allLinks[LT::RETAL_DMG_REL]->add(src->id, dst->id, std::min<float>(2, retalDmgFrac));
+}
+}

+ 66 - 0
AI/MMAI/BAI/v13/battlefield.h

@@ -0,0 +1,66 @@
+/*
+ * battlefield.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 "battle/CPlayerBattleCallback.h"
+
+#include "BAI/v13/hex.h"
+#include "BAI/v13/links.h"
+#include "BAI/v13/stack.h"
+
+namespace MMAI::BAI::V13
+{
+
+using LinkType = Schema::V13::LinkType;
+using Stacks = std::vector<std::shared_ptr<Stack>>;
+using Hexes = std::array<std::array<std::unique_ptr<Hex>, 15>, 11>;
+using AllLinks = std::map<LinkType, std::shared_ptr<Links>>;
+
+using XY = std::pair<int, int>;
+
+class Battlefield
+{
+public:
+	static std::shared_ptr<const Battlefield> Create(
+		const CPlayerBattleCallback * battle,
+		const CStack * acstack,
+		const GlobalStats * oldgstats,
+		const GlobalStats * gstats,
+		std::map<const CStack *, Stack::Stats> & stacksStats,
+		bool isMorale
+	);
+
+	Battlefield(const std::shared_ptr<Hexes> & hexes, const Stacks & stacks, const AllLinks & allLinks, const Stack * astack);
+
+	const std::shared_ptr<Hexes> hexes;
+	const Stacks stacks;
+	const AllLinks allLinks;
+	const Stack * const astack; // XXX: nullptr on battle start/end, or if army stacks > MAX_STACKS_PER_SIDE
+private:
+	static std::tuple<Stacks, Queue> InitStacks(
+		const CPlayerBattleCallback * battle,
+		const CStack * astack,
+		const GlobalStats * oldgstats,
+		const GlobalStats * gstats,
+		std::map<const CStack *, Stack::Stats> & stacksStats,
+		bool isMorale
+	);
+
+	static std::tuple<std::shared_ptr<Hexes>, Stack *> InitHexes(const CPlayerBattleCallback * battle, const CStack * acstack, const Stacks & stacks);
+
+	static AllLinks InitAllLinks(const CPlayerBattleCallback * battle, const Stacks & stacks, const Queue & queue, std::shared_ptr<Hexes> & hexes);
+
+	static void
+	LinkTwoHexes(AllLinks & allLinks, const CPlayerBattleCallback * battle, const Stacks & stacks, const Queue & queue, const Hex * src, const Hex * dst);
+
+	static Queue GetQueue(const CPlayerBattleCallback * battle, const CStack * astack, bool isMorale);
+};
+}

+ 423 - 0
AI/MMAI/BAI/v13/encoder.cpp

@@ -0,0 +1,423 @@
+/*
+ * encoder.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 "schema/v13/constants.h"
+#include "schema/v13/types.h"
+
+#include "BAI/v13/encoder.h"
+#include "common.h"
+
+namespace MMAI::BAI::V13
+{
+
+namespace S13 = Schema::V13;
+using Encoding = Schema::V13::Encoding;
+using BS = Schema::BattlefieldState;
+using clock = std::chrono::system_clock;
+
+#define ADD_ZEROS_AND_RETURN(n, out) \
+	out.insert((out).end(), n, 0);   \
+	return
+
+#define MAYBE_ADD_ZEROS_AND_RETURN(v, n, out) \
+	if((v) <= 0)                              \
+	{                                         \
+		ADD_ZEROS_AND_RETURN(n, out);         \
+	}
+
+#define MAYBE_ADD_MASKED_AND_RETURN(v, n, out)                 \
+	if((v) == S13::NULL_VALUE_UNENCODED)                       \
+	{                                                          \
+		(out).insert((out).end(), n, S13::NULL_VALUE_ENCODED); \
+		return;                                                \
+	}
+
+#define MAYBE_THROW_STRICT_ERROR(v)      \
+	if((v) == S13::NULL_VALUE_UNENCODED) \
+	throw std::runtime_error("NULL values are not allowed for strict encoding")
+
+void Encoder::Encode(const EncoderInput & in, BS & out)
+{
+	if(in.e == Encoding::RAW)
+	{
+		out.push_back(in.v);
+		return;
+	}
+
+	auto v = in.v;
+
+	if(in.v > in.vmax)
+	{
+		// THROW_FORMAT("Cannot encode value: %d (vmax=%d, a=%d, n=%d, e=%d)", v % vmax % EI(a) % n % EI(e));
+		// Can happen (e.g. DMG_*_ACC_REL0 > 1 if there were resurrected stacks)
+
+		// Warn at most once every 600s
+		auto now = clock::now();
+		static thread_local std::map<std::string, std::map<int, clock::time_point>> warns;
+		auto & warned_at = warns[std::string(in.attrname)][EI(in.a)];
+
+		if(std::chrono::duration_cast<std::chrono::seconds>(now - warned_at) > std::chrono::seconds(600))
+		{
+			// This is not critical; the value will be capped to vmax (should not occur often)
+			logAi->info(
+				"MMAI: Attribute value out of bounds: v=%d (vmax=%d, a=%d, e=%d, n=%d, attrname=%s)\n", in.v, in.vmax, EI(in.a), EI(in.e), in.n, in.attrname
+			);
+			warns[std::string(in.attrname)][EI(in.a)] = now;
+		}
+		v = in.vmax;
+	}
+
+	switch(in.e)
+	{
+		case Encoding::BINARY_EXPLICIT_NULL:
+			EncodeBinaryExplicitNull(v, in.n, out);
+			break;
+		case Encoding::BINARY_MASKING_NULL:
+			EncodeBinaryMaskingNull(v, in.n, out);
+			break;
+		case Encoding::BINARY_STRICT_NULL:
+			EncodeBinaryStrictNull(v, in.n, out);
+			break;
+		case Encoding::BINARY_ZERO_NULL:
+			EncodeBinaryZeroNull(v, in.n, out);
+			break;
+		case Encoding::EXPNORM_EXPLICIT_NULL:
+			EncodeExpnormExplicitNull(v, in.vmax, in.p, out);
+			break;
+		case Encoding::EXPNORM_MASKING_NULL:
+			EncodeExpnormMaskingNull(v, in.vmax, in.p, out);
+			break;
+		case Encoding::EXPNORM_STRICT_NULL:
+			EncodeExpnormStrictNull(v, in.vmax, in.p, out);
+			break;
+		case Encoding::EXPNORM_ZERO_NULL:
+			EncodeExpnormZeroNull(v, in.vmax, in.p, out);
+			break;
+		case Encoding::LINNORM_EXPLICIT_NULL:
+			EncodeLinnormExplicitNull(v, in.vmax, out);
+			break;
+		case Encoding::LINNORM_MASKING_NULL:
+			EncodeLinnormMaskingNull(v, in.vmax, out);
+			break;
+		case Encoding::LINNORM_STRICT_NULL:
+			EncodeLinnormStrictNull(v, in.vmax, out);
+			break;
+		case Encoding::LINNORM_ZERO_NULL:
+			EncodeLinnormZeroNull(v, in.vmax, out);
+			break;
+		case Encoding::CATEGORICAL_EXPLICIT_NULL:
+			EncodeCategoricalExplicitNull(v, in.n, out);
+			break;
+		case Encoding::CATEGORICAL_IMPLICIT_NULL:
+			EncodeCategoricalImplicitNull(v, in.n, out);
+			break;
+		case Encoding::CATEGORICAL_MASKING_NULL:
+			EncodeCategoricalMaskingNull(v, in.n, out);
+			break;
+		case Encoding::CATEGORICAL_STRICT_NULL:
+			EncodeCategoricalStrictNull(v, in.n, out);
+			break;
+		case Encoding::CATEGORICAL_ZERO_NULL:
+			EncodeCategoricalZeroNull(v, in.n, out);
+			break;
+		case Encoding::ACCUMULATING_EXPLICIT_NULL:
+			EncodeAccumulatingExplicitNull(v, in.n, out);
+			break;
+		case Encoding::ACCUMULATING_IMPLICIT_NULL:
+			EncodeAccumulatingImplicitNull(v, in.n, out);
+			break;
+		case Encoding::ACCUMULATING_MASKING_NULL:
+			EncodeAccumulatingMaskingNull(v, in.n, out);
+			break;
+		case Encoding::ACCUMULATING_STRICT_NULL:
+			EncodeAccumulatingStrictNull(v, in.n, out);
+			break;
+		case Encoding::ACCUMULATING_ZERO_NULL:
+			EncodeAccumulatingZeroNull(v, in.n, out);
+			break;
+		default:
+			THROW_FORMAT("Unexpected Encoding: %d", EI(in.e));
+	}
+}
+
+void Encoder::Encode(const S13::HexAttribute a, int v, BS & out)
+{
+	const auto & [_, e, n, vmax, p] = S13::HEX_ENCODING.at(EI(a));
+	Encode(EncoderInput{.attrname = "HexAttribute", .a = EI(a), .e = e, .n = n, .vmax = vmax, .p = p, .v = v}, out);
+}
+
+void Encoder::Encode(const S13::PlayerAttribute a, int v, BS & out)
+{
+	const auto & [_, e, n, vmax, p] = S13::PLAYER_ENCODING.at(EI(a));
+	Encode(EncoderInput{.attrname = "PlayerAttribute", .a = EI(a), .e = e, .n = n, .vmax = vmax, .p = p, .v = v}, out);
+}
+
+void Encoder::Encode(const S13::GlobalAttribute a, int v, BS & out)
+{
+	const auto & [_, e, n, vmax, p] = S13::GLOBAL_ENCODING.at(EI(a));
+	Encode(EncoderInput{.attrname = "GlobalAttribute", .a = EI(a), .e = e, .n = n, .vmax = vmax, .p = p, .v = v}, out);
+}
+
+//
+// ACCUMULATING
+//
+void Encoder::EncodeAccumulatingExplicitNull(int v, int n, BS & out)
+{
+	if(v == S13::NULL_VALUE_UNENCODED)
+	{
+		out.push_back(1);
+		ADD_ZEROS_AND_RETURN(n - 1, out);
+	}
+	out.push_back(0);
+	EncodeAccumulating(v, n - 1, out);
+}
+
+void Encoder::EncodeAccumulatingImplicitNull(int v, int n, BS & out)
+{
+	if(v == S13::NULL_VALUE_UNENCODED)
+	{
+		ADD_ZEROS_AND_RETURN(n, out);
+	}
+	EncodeAccumulating(v, n, out);
+}
+
+void Encoder::EncodeAccumulatingMaskingNull(int v, int n, BS & out)
+{
+	MAYBE_ADD_MASKED_AND_RETURN(v, n, out);
+	EncodeAccumulating(v, n, out);
+}
+
+void Encoder::EncodeAccumulatingStrictNull(int v, int n, BS & out)
+{
+	MAYBE_THROW_STRICT_ERROR(v);
+	EncodeAccumulating(v, n, out);
+}
+
+void Encoder::EncodeAccumulatingZeroNull(int v, int n, BS & out)
+{
+	if(v <= 0)
+	{
+		out.push_back(1);
+		ADD_ZEROS_AND_RETURN(n - 1, out);
+	}
+	EncodeAccumulating(v, n, out);
+}
+
+void Encoder::EncodeAccumulating(int v, int n, BS & out)
+{
+	out.insert(out.end(), v + 1, 1);
+	out.insert(out.end(), n - v - 1, 0);
+}
+
+//
+// BINARY
+//
+
+void Encoder::EncodeBinaryExplicitNull(int v, int n, BS & out)
+{
+	out.push_back(v == S13::NULL_VALUE_UNENCODED);
+	EncodeBinary(v, n - 1, out);
+}
+
+void Encoder::EncodeBinaryMaskingNull(int v, int n, BS & out)
+{
+	MAYBE_ADD_MASKED_AND_RETURN(v, n, out);
+	EncodeBinary(v, n, out);
+}
+
+void Encoder::EncodeBinaryStrictNull(int v, int n, BS & out)
+{
+	MAYBE_THROW_STRICT_ERROR(v);
+	EncodeBinary(v, n, out);
+}
+
+void Encoder::EncodeBinaryZeroNull(int v, int n, BS & out)
+{
+	EncodeBinary(v, n, out);
+}
+
+void Encoder::EncodeBinary(int v, int n, BS & out)
+{
+	MAYBE_ADD_ZEROS_AND_RETURN(v, n, out);
+
+	int vtmp = v;
+	for(int i = 0; i < n; ++i)
+	{
+		out.push_back(vtmp % 2);
+		vtmp /= 2;
+	}
+}
+
+//
+// CATEGORICAL
+//
+
+void Encoder::EncodeCategoricalExplicitNull(int v, int n, BS & out)
+{
+	if(v == S13::NULL_VALUE_UNENCODED)
+	{
+		out.push_back(v == S13::NULL_VALUE_UNENCODED);
+		ADD_ZEROS_AND_RETURN(n - 1, out);
+	}
+	out.push_back(0);
+	EncodeCategorical(v, n - 1, out);
+}
+
+void Encoder::EncodeCategoricalImplicitNull(int v, int n, BS & out)
+{
+	if(v == S13::NULL_VALUE_UNENCODED)
+	{
+		ADD_ZEROS_AND_RETURN(n, out);
+	}
+
+	EncodeCategorical(v, n, out);
+}
+
+void Encoder::EncodeCategoricalMaskingNull(int v, int n, BS & out)
+{
+	MAYBE_ADD_MASKED_AND_RETURN(v, n, out);
+	EncodeCategorical(v, n, out);
+}
+
+void Encoder::EncodeCategoricalStrictNull(int v, int n, BS & out)
+{
+	MAYBE_THROW_STRICT_ERROR(v);
+	EncodeCategorical(v, n, out);
+}
+
+void Encoder::EncodeCategoricalZeroNull(int v, int n, BS & out)
+{
+	EncodeCategorical(v, n, out);
+}
+
+void Encoder::EncodeCategorical(int v, int n, BS & out)
+{
+	if(v <= 0)
+	{
+		out.push_back(1);
+		ADD_ZEROS_AND_RETURN(n - 1, out);
+	}
+
+	for(int i = 0; i < n; ++i)
+	{
+		if(i == v)
+		{
+			out.push_back(1);
+			ADD_ZEROS_AND_RETURN(n - i - 1, out);
+		}
+		else
+		{
+			out.push_back(0);
+		}
+	}
+}
+
+//
+// EXPNORM
+//
+
+void Encoder::EncodeExpnormExplicitNull(int v, int vmax, double slope, BS & out)
+{
+	out.push_back(v == S13::NULL_VALUE_UNENCODED);
+	EncodeExpnorm(v, vmax, slope, out);
+}
+
+void Encoder::EncodeExpnormMaskingNull(int v, int vmax, double slope, BS & out)
+{
+	if(v == S13::NULL_VALUE_UNENCODED)
+	{
+		out.push_back(S13::NULL_VALUE_ENCODED);
+		return;
+	}
+	EncodeExpnorm(v, vmax, slope, out);
+}
+
+void Encoder::EncodeExpnormStrictNull(int v, int vmax, double slope, BS & out)
+{
+	MAYBE_THROW_STRICT_ERROR(v);
+	EncodeExpnorm(v, vmax, slope, out);
+}
+
+void Encoder::EncodeExpnormZeroNull(int v, int vmax, double slope, BS & out)
+{
+	EncodeExpnorm(v, vmax, slope, out);
+}
+
+void Encoder::EncodeExpnorm(int v, int vmax, double slope, BS & out)
+{
+	if(v <= 0)
+	{
+		out.push_back(0);
+		return;
+	}
+
+	out.push_back(CalcExpnorm(v, vmax, slope));
+}
+
+// Visualise on https://www.desmos.com/calculator:
+// ln(1 + (x/M) * (exp(S)-1))/S
+// Add slider "S" (slope) and "M" (vmax).
+// Play with the sliders to see the nonlinearity (use M=1 for best view)
+// XXX: slope cannot be 0
+float Encoder::CalcExpnorm(int v, int vmax, double slope)
+{
+	auto ratio = static_cast<double>(v) / vmax;
+	return std::log1p(ratio * (std::exp(slope) - 1.0)) / (slope + 1e-6);
+}
+
+//
+// LINNORM
+//
+
+void Encoder::EncodeLinnormExplicitNull(int v, int vmax, BS & out)
+{
+	out.push_back(v == S13::NULL_VALUE_UNENCODED);
+	EncodeLinnorm(v, vmax, out);
+}
+
+void Encoder::EncodeLinnormMaskingNull(int v, int vmax, BS & out)
+{
+	if(v == S13::NULL_VALUE_UNENCODED)
+	{
+		out.push_back(S13::NULL_VALUE_ENCODED);
+		return;
+	}
+	EncodeLinnorm(v, vmax, out);
+}
+
+void Encoder::EncodeLinnormStrictNull(int v, int vmax, BS & out)
+{
+	MAYBE_THROW_STRICT_ERROR(v);
+	EncodeLinnorm(v, vmax, out);
+}
+
+void Encoder::EncodeLinnormZeroNull(int v, int vmax, BS & out)
+{
+	EncodeLinnorm(v, vmax, out);
+}
+
+void Encoder::EncodeLinnorm(int v, int vmax, BS & out)
+{
+	if(v <= 0)
+	{
+		out.push_back(0);
+		return;
+	}
+
+	// XXX: this is a simplified version for 0..1 norm
+	out.push_back(CalcLinnorm(v, vmax));
+}
+
+float Encoder::CalcLinnorm(int v, int vmax)
+{
+	return static_cast<float>(v) / static_cast<float>(vmax);
+}
+}

+ 79 - 0
AI/MMAI/BAI/v13/encoder.h

@@ -0,0 +1,79 @@
+/*
+ * encoder.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 "schema/base.h"
+#include "schema/v13/types.h"
+
+namespace MMAI::BAI::V13
+{
+using GA = Schema::V13::GlobalAttribute;
+using HA = Schema::V13::HexAttribute;
+using PA = Schema::V13::PlayerAttribute;
+using BS = Schema::BattlefieldState;
+
+struct EncoderInput
+{
+	const std::string_view & attrname;
+	const int a;
+	const Schema::V13::Encoding e;
+	const int n;
+	const int vmax;
+	const double p;
+	const int v;
+};
+
+class Encoder
+{
+public:
+	static void Encode(HA a, int v, BS & out);
+	static void Encode(PA a, int v, BS & out);
+	static void Encode(GA a, int v, BS & out);
+
+	static void Encode(const EncoderInput & in, BS & out);
+
+	static void EncodeAccumulatingExplicitNull(int v, int n, BS & out);
+	static void EncodeAccumulatingImplicitNull(int v, int n, BS & out);
+	static void EncodeAccumulatingMaskingNull(int v, int n, BS & out);
+	static void EncodeAccumulatingStrictNull(int v, int n, BS & out);
+	static void EncodeAccumulatingZeroNull(int v, int n, BS & out);
+
+	static void EncodeBinaryExplicitNull(int v, int n, BS & out);
+	static void EncodeBinaryMaskingNull(int v, int n, BS & out);
+	static void EncodeBinaryStrictNull(int v, int n, BS & out);
+	static void EncodeBinaryZeroNull(int v, int n, BS & out);
+
+	static void EncodeCategoricalExplicitNull(int v, int n, BS & out);
+	static void EncodeCategoricalImplicitNull(int v, int n, BS & out);
+	static void EncodeCategoricalMaskingNull(int v, int n, BS & out);
+	static void EncodeCategoricalStrictNull(int v, int n, BS & out);
+	static void EncodeCategoricalZeroNull(int v, int n, BS & out);
+
+	static void EncodeExpnormExplicitNull(int v, int vmax, double slope, BS & out);
+	static void EncodeExpnormMaskingNull(int v, int vmax, double slope, BS & out);
+	static void EncodeExpnormStrictNull(int v, int vmax, double slope, BS & out);
+	static void EncodeExpnormZeroNull(int v, int vmax, double slope, BS & out);
+
+	static void EncodeLinnormExplicitNull(int v, int vmax, BS & out);
+	static void EncodeLinnormMaskingNull(int v, int vmax, BS & out);
+	static void EncodeLinnormStrictNull(int v, int vmax, BS & out);
+	static void EncodeLinnormZeroNull(int v, int vmax, BS & out);
+
+	static float CalcExpnorm(int v, int vmax, double slope);
+	static float CalcLinnorm(int v, int vmax);
+
+private:
+	static void EncodeAccumulating(int v, int n, BS & out);
+	static void EncodeBinary(int v, int n, BS & out);
+	static void EncodeCategorical(int v, int n, BS & out);
+	static void EncodeExpnorm(int v, int vmax, double slope, BS & out);
+	static void EncodeLinnorm(int v, int vmax, BS & out);
+};
+}

+ 78 - 0
AI/MMAI/BAI/v13/global_stats.cpp

@@ -0,0 +1,78 @@
+/*
+ * global_stats.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 "BAI/v13/global_stats.h"
+#include "schema/v13/constants.h"
+#include "schema/v13/types.h"
+
+namespace MMAI::BAI::V13
+{
+namespace S13 = Schema::V13;
+using Side = Schema::Side;
+using GA = Schema::V13::GlobalAttribute;
+
+static_assert(EI(Side::LEFT) == EI(BattleSide::LEFT_SIDE));
+static_assert(EI(Side::RIGHT) == EI(BattleSide::RIGHT_SIDE));
+
+GlobalStats::GlobalStats(BattleSide side, int value, int hp)
+{
+	// Fill with NA to guard against "forgotten" attrs
+	// (all attrs are strict so encoder will throw if NAs are found)
+	attrs.fill(S13::NULL_VALUE_UNENCODED);
+
+	static_assert(EI(GA::_count) == 10, "whistleblower in case attributes change");
+
+	setattr(GA::BATTLE_WINNER, S13::NULL_VALUE_UNENCODED);
+	setattr(GA::BATTLE_SIDE, EI(side));
+	setattr(GA::BATTLE_SIDE_ACTIVE_PLAYER, S13::NULL_VALUE_UNENCODED);
+	setattr(GA::BFIELD_VALUE_START_ABS, value);
+	setattr(GA::BFIELD_VALUE_NOW_ABS, value);
+	setattr(GA::BFIELD_VALUE_NOW_REL0, 1000);
+	setattr(GA::BFIELD_HP_START_ABS, hp);
+	setattr(GA::BFIELD_HP_NOW_ABS, hp);
+	setattr(GA::BFIELD_HP_NOW_REL0, 1000);
+	setattr(GA::ACTION_MASK, 0);
+}
+
+static_assert(EI(GlobalAction::_count) == 2); // RETREAT, WAIT
+
+void GlobalStats::update(BattleSide side, CombatResult res, int value, int hp, bool canWait)
+{
+	(res == CombatResult::NONE) ? setattr(GA::BATTLE_WINNER, S13::NULL_VALUE_UNENCODED) : setattr(GA::BATTLE_WINNER, EI(res));
+
+	(side == BattleSide::NONE) ? setattr(GA::BATTLE_SIDE_ACTIVE_PLAYER, S13::NULL_VALUE_UNENCODED) : setattr(GA::BATTLE_SIDE_ACTIVE_PLAYER, EI(side));
+
+	// ll (long long) ensures long is 64-bit even on 32-bit systems
+	setattr(GA::BFIELD_VALUE_NOW_ABS, value);
+	setattr(GA::BFIELD_VALUE_NOW_REL0, 1000LL * value / attr(GA::BFIELD_VALUE_START_ABS));
+	setattr(GA::BFIELD_HP_NOW_ABS, hp);
+	setattr(GA::BFIELD_HP_NOW_REL0, 1000LL * hp / attr(GA::BFIELD_HP_START_ABS));
+
+	canWait ? actmask.set(EI(GlobalAction::WAIT)) : actmask.reset(EI(GlobalAction::WAIT));
+
+	setattr(GA::ACTION_MASK, actmask.to_ulong());
+}
+
+int GlobalStats::getAttr(GA a) const
+{
+	return attr(a);
+}
+
+int GlobalStats::attr(GA a) const
+{
+	return attrs.at(EI(a));
+};
+
+void GlobalStats::setattr(GA a, int value)
+{
+	attrs.at(EI(a)) = value;
+};
+}

+ 38 - 0
AI/MMAI/BAI/v13/global_stats.h

@@ -0,0 +1,38 @@
+/*
+ * global_stats.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 "battle/BattleSide.h"
+#include "schema/v13/types.h"
+
+namespace MMAI::BAI::V13
+{
+
+using CombatResult = Schema::V13::CombatResult;
+using GlobalAction = Schema::V13::GlobalAction;
+using GlobalAttribute = Schema::V13::GlobalAttribute;
+using GlobalAttrs = Schema::V13::GlobalAttrs;
+using IGlobalStats = Schema::V13::IGlobalStats;
+
+using GlobalActionMask = std::bitset<EI(GlobalAction::_count)>;
+
+class GlobalStats : public IGlobalStats
+{
+public:
+	GlobalStats(BattleSide side, int value, int hp);
+
+	int getAttr(GlobalAttribute a) const override;
+	int attr(GlobalAttribute a) const;
+	void update(BattleSide side, CombatResult res, int value, int hp, bool canWait);
+	void setattr(GlobalAttribute a, int value);
+	GlobalAttrs attrs = {};
+	GlobalActionMask actmask = 0; // for active stack only
+};
+}

+ 381 - 0
AI/MMAI/BAI/v13/hex.cpp

@@ -0,0 +1,381 @@
+/*
+ * hex.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 "vcmi/spells/Service.h"
+#include "vcmi/spells/Spell.h"
+
+#include "BAI/v13/hex.h"
+#include "common.h"
+#include "schema/v13/constants.h"
+
+namespace MMAI::BAI::V13
+{
+namespace S13 = Schema::V13;
+
+using HA = Schema::V13::HexAttribute;
+using HS = Schema::V13::HexState;
+using SA = Schema::V13::StackAttribute;
+
+constexpr HexStateMask S_PASSABLE = 1 << EI(HexState::PASSABLE);
+constexpr HexStateMask S_STOPPING = 1 << EI(HexState::STOPPING);
+constexpr HexStateMask S_DAMAGING_L = 1 << EI(HexState::DAMAGING_L);
+constexpr HexStateMask S_DAMAGING_R = 1 << EI(HexState::DAMAGING_R);
+constexpr HexStateMask S_DAMAGING_ALL = 1 << EI(HexState::DAMAGING_L) | 1 << EI(HexState::DAMAGING_R);
+
+// static
+int Hex::CalcId(const BattleHex & bh)
+{
+	ASSERT(bh.isAvailable(), "Hex unavailable: " + std::to_string(bh.toInt()));
+	return bh.getX() - 1 + (bh.getY() * 15);
+}
+
+// static
+std::pair<int, int> Hex::CalcXY(const BattleHex & bh)
+{
+	return {bh.getX() - 1, bh.getY()};
+}
+
+//
+// Return bh's neighbouring hexes for setting action mask
+//
+// return nearby hexes for "X":
+//
+//  . . . . . . . . . .
+// . . .11 5 0 6 . . .
+//  . .10 4 X 1 7 . . .
+// . . . 9 3 2 8 . . .
+//  . . . . . . . . . .
+//
+// NOTE:
+// The index of each hex in the returned array corresponds to a
+// the respective AMOVE_* HexAction w.r.t. "X" (see hexaction.h)
+//
+// static
+HexActionHex Hex::NearbyBattleHexes(const BattleHex & bh)
+{
+	static_assert(EI(HexAction::AMOVE_TR) == 0);
+	static_assert(EI(HexAction::AMOVE_R) == 1);
+	static_assert(EI(HexAction::AMOVE_BR) == 2);
+	static_assert(EI(HexAction::AMOVE_BL) == 3);
+	static_assert(EI(HexAction::AMOVE_L) == 4);
+	static_assert(EI(HexAction::AMOVE_TL) == 5);
+	static_assert(EI(HexAction::AMOVE_2TR) == 6);
+	static_assert(EI(HexAction::AMOVE_2R) == 7);
+	static_assert(EI(HexAction::AMOVE_2BR) == 8);
+	static_assert(EI(HexAction::AMOVE_2BL) == 9);
+	static_assert(EI(HexAction::AMOVE_2L) == 10);
+	static_assert(EI(HexAction::AMOVE_2TL) == 11);
+
+	auto nbhR = bh.cloneInDirection(BattleHex::EDir::RIGHT, false);
+	auto nbhL = bh.cloneInDirection(BattleHex::EDir::LEFT, false);
+
+	return HexActionHex{
+		// The 6 basic directions
+		bh.cloneInDirection(BattleHex::EDir::TOP_RIGHT, false),
+		nbhR,
+		bh.cloneInDirection(BattleHex::EDir::BOTTOM_RIGHT, false),
+		bh.cloneInDirection(BattleHex::EDir::BOTTOM_LEFT, false),
+		nbhL,
+		bh.cloneInDirection(BattleHex::EDir::TOP_LEFT, false),
+
+		// Extended directions for R-side wide creatures
+		nbhR.cloneInDirection(BattleHex::EDir::TOP_RIGHT, false),
+		nbhR.cloneInDirection(BattleHex::EDir::RIGHT, false),
+		nbhR.cloneInDirection(BattleHex::EDir::BOTTOM_RIGHT, false),
+
+		// Extended directions for L-side wide creatures
+		nbhL.cloneInDirection(BattleHex::EDir::BOTTOM_LEFT, false),
+		nbhL.cloneInDirection(BattleHex::EDir::LEFT, false),
+		nbhL.cloneInDirection(BattleHex::EDir::TOP_LEFT, false)
+	};
+}
+
+Hex::Hex(
+	const BattleHex & bhex_,
+	const EAccessibility accessibility,
+	const EGateState gatestate,
+	const std::vector<std::shared_ptr<const CObstacleInstance>> & obstacles,
+	const std::map<BattleHex, std::shared_ptr<Stack>> & hexstacks,
+	const std::shared_ptr<ActiveStackInfo> & astackinfo
+)
+	: bhex(bhex_), id(CalcId(bhex_))
+{
+	attrs.fill(S13::NULL_VALUE_UNENCODED);
+
+	auto [x, y] = CalcXY(bhex);
+	auto it = hexstacks.find(bhex);
+	stack = it == hexstacks.end() ? nullptr : it->second;
+
+	setattr(HA::Y_COORD, y);
+	setattr(HA::X_COORD, x);
+
+	// This is never N/A => set separately (not within the if below)
+	setattr(HA::IS_REAR, stack && bhex == stack->cstack->occupiedHex());
+
+	static_assert(EI(SA::_count) == 25, "whistleblower in case attributes change");
+
+	auto attrmap = std::map<HA, SA>{
+		{HA::STACK_SIDE,                  SA::SIDE                 },
+		{HA::STACK_SLOT,                  SA::SLOT                 },
+		{HA::STACK_QUANTITY,              SA::QUANTITY             },
+		{HA::STACK_ATTACK,                SA::ATTACK               },
+		{HA::STACK_DEFENSE,               SA::DEFENSE              },
+		{HA::STACK_SHOTS,                 SA::SHOTS                },
+		{HA::STACK_DMG_MIN,               SA::DMG_MIN              },
+		{HA::STACK_DMG_MAX,               SA::DMG_MAX              },
+		{HA::STACK_HP,                    SA::HP                   },
+		{HA::STACK_HP_LEFT,               SA::HP_LEFT              },
+		{HA::STACK_SPEED,                 SA::SPEED                },
+		{HA::STACK_QUEUE,                 SA::QUEUE                },
+		{HA::STACK_VALUE_ONE,             SA::VALUE_ONE            },
+		{HA::STACK_FLAGS1,                SA::FLAGS1               },
+		{HA::STACK_FLAGS2,                SA::FLAGS2               },
+
+		{HA::STACK_VALUE_REL,             SA::VALUE_REL            },
+		{HA::STACK_VALUE_REL0,            SA::VALUE_REL0           },
+		{HA::STACK_VALUE_KILLED_REL,      SA::VALUE_KILLED_REL     },
+		{HA::STACK_VALUE_KILLED_ACC_REL0, SA::VALUE_KILLED_ACC_REL0},
+		{HA::STACK_VALUE_LOST_REL,        SA::VALUE_LOST_REL       },
+		{HA::STACK_VALUE_LOST_ACC_REL0,   SA::VALUE_LOST_ACC_REL0  },
+		{HA::STACK_DMG_DEALT_REL,         SA::DMG_DEALT_REL        },
+		{HA::STACK_DMG_DEALT_ACC_REL0,    SA::DMG_DEALT_ACC_REL0   },
+		{HA::STACK_DMG_RECEIVED_REL,      SA::DMG_RECEIVED_REL     },
+		{HA::STACK_DMG_RECEIVED_ACC_REL0, SA::DMG_RECEIVED_ACC_REL0},
+	};
+
+	if(stack)
+	{
+		int i = 0;
+		for(const auto & [a, sa] : attrmap)
+		{
+			setattr(a, stack->attr(sa));
+			++i;
+		}
+
+		ASSERT(i == EI(SA::_count), "not all stack attributes encoded: i=" + std::to_string(i));
+	}
+
+	if(astackinfo)
+	{
+		setStateMask(accessibility, obstacles, astackinfo->stack->cstack->unitSide());
+		setActionMask(astackinfo, hexstacks);
+	}
+	else
+	{
+		setStateMask(accessibility, obstacles, BattleSide::ATTACKER);
+	}
+
+	finalize();
+}
+
+const HexAttrs & Hex::getAttrs() const
+{
+	return attrs;
+}
+
+int Hex::getID() const
+{
+	return id;
+}
+
+int Hex::getAttr(HexAttribute a) const
+{
+	return attr(a);
+}
+
+int Hex::attr(HexAttribute a) const
+{
+	return attrs.at(EI(a));
+};
+void Hex::setattr(HexAttribute a, int value)
+{
+	attrs.at(EI(a)) = value;
+};
+
+std::string Hex::name() const
+{
+	return "(" + std::to_string(attr(HA::Y_COORD)) + "," + std::to_string(attr(HA::X_COORD)) + ")";
+}
+
+void Hex::finalize()
+{
+	attrs.at(EI(HA::ACTION_MASK)) = actmask.to_ulong();
+	attrs.at(EI(HA::STATE_MASK)) = statemask.to_ulong();
+}
+
+const Stack * Hex::getStack() const
+{
+	return stack.get();
+}
+
+// private
+
+void Hex::setStateMask(const EAccessibility accessibility, const std::vector<std::shared_ptr<const CObstacleInstance>> & obstacles, BattleSide side)
+{
+	// First process obstacles
+	// XXX: set only non-PASSABLE flags
+	// (e.g. there may be a stack standing on the obstacle (firewall, moat))
+	// so the PASSABLE mask bit will set later
+	// XXX: moats are a weird obstacle:
+	//      * if dispellable (Tower mines?) => type=SPELL_CREATED
+	//      * otherwise => type=MOAT
+	//      * their trigger ability is a spell, as it seems
+	//        (which is not found in spells.json, neither is available as a SpellID constant)
+	//
+	//      Ref: Moat::placeObstacles()
+	//           BattleEvaluator::goTowardsNearest() // var triggerAbility
+	//
+
+	for(const auto & obstacle : obstacles)
+	{
+		switch(obstacle->obstacleType)
+		{
+			case CObstacleInstance::USUAL:
+			case CObstacleInstance::ABSOLUTE_OBSTACLE:
+				statemask &= ~S_PASSABLE;
+				break;
+			case CObstacleInstance::MOAT:
+				statemask |= (S_STOPPING | S_DAMAGING_ALL);
+				break;
+			case CObstacleInstance::SPELL_CREATED:
+				// XXX: the public Obstacle / Spell API does not seem to expose
+				//      any useful methods for checking if friendly creatures
+				//      would get damaged by an obstacle.
+				switch(SpellID(obstacle->ID))
+				{
+					case SpellID::QUICKSAND:
+						statemask |= S_STOPPING;
+						break;
+					case SpellID::LAND_MINE:
+						auto casterside = dynamic_cast<const SpellCreatedObstacle *>(obstacle.get())->casterSide;
+						// XXX: in practice, there is no situation where enemy
+						//      mines are visible (e.g. when our army has a stack
+						// 		which is native to the battlefield terrain),
+						// 		as the UI simply does not allow to cast the spell
+						// 		in this case .
+						if(side == casterside)
+							statemask |= (side == BattleSide::DEFENDER ? S_DAMAGING_L : S_DAMAGING_R);
+						else
+							statemask |= (side == BattleSide::DEFENDER ? S_DAMAGING_R : S_DAMAGING_L);
+				}
+				break;
+			default:
+				THROW_FORMAT("Unexpected obstacle type: %d", EI(obstacle->obstacleType));
+		}
+	}
+
+	switch(accessibility)
+	{
+		case EAccessibility::ACCESSIBLE:
+			ASSERT(!stack, "accessibility is ACCESSIBLE, but a stack was found on hex");
+			statemask |= S_PASSABLE;
+			break;
+		case EAccessibility::OBSTACLE:
+			ASSERT(!stack, "accessibility is OBSTACLE, but a stack was found on hex");
+			statemask &= ~S_PASSABLE;
+			break;
+		case EAccessibility::ALIVE_STACK:
+			// XXX: stack can be NULL if it was left out of the observation
+			// ASSERT(stack, "accessibility is ALIVE_STACK, but no stack was found on hex");
+			statemask &= ~S_PASSABLE;
+			break;
+		case EAccessibility::DESTRUCTIBLE_WALL:
+			// XXX: Destroyed walls become ACCESSIBLE.
+			ASSERT(!stack, "accessibility is DESTRUCTIBLE_WALL, but a stack was found on hex");
+			statemask &= ~S_PASSABLE;
+			break;
+		case EAccessibility::GATE:
+			// See BattleProcessor::updateGateState() for gate states
+			// See CBattleInfoCallback::getAccessibility() for accessibility on gate
+			//
+			// TL; DR:
+			// -> GATE means closed, non-blocked gate
+			// -> UNAVAILABLE means blocked
+			// -> ACCESSIBLE otherwise (open, destroyed)
+			//
+			// Regardless of the gate state, we always set the GATE flag
+			// purely based on the hex coordinates and not on the accessibility
+			// => not setting GATE flag here
+			//
+			// However, in case of GATE accessibility, we still need
+			// to set the PASSABLE flag accordingly.
+			side == BattleSide::DEFENDER ? statemask.set(EI(HS::PASSABLE)) : statemask.reset(EI(HS::PASSABLE));
+			break;
+		case EAccessibility::UNAVAILABLE:
+			statemask &= ~S_PASSABLE;
+			break;
+		default:
+			THROW_FORMAT("Unexpected hex accessibility for bhex %d: %d", bhex.toInt() % EI(accessibility));
+	}
+}
+
+void Hex::setActionMask(const std::shared_ptr<ActiveStackInfo> & astackinfo, const std::map<BattleHex, std::shared_ptr<Stack>> & hexstacks)
+{
+	const auto * astack = astackinfo->stack;
+
+	// XXX: for statehist, astack may be enemy stack
+	// in this case building the actmask is redundant
+
+	if(astackinfo->canshoot && stack && stack->cstack->unitSide() != astack->cstack->unitSide())
+		actmask.set(EI(HexAction::SHOOT));
+
+	// XXX: ReachabilityInfo::isReachable() must not be used as it
+	//      returns true even if speed is insufficient => use distances.
+	// NOTE: distances is 0 for the stack's main hex and 1 for its rear hex
+	//       (100000 if it can't fit there)
+	if(astackinfo->rinfo->distances.at(bhex.toInt()) <= astack->attr(SA::SPEED))
+		actmask.set(EI(HexAction::MOVE));
+	else
+		// astack can't MOVE here => AMOVE_* will never be possible
+		return;
+
+	const auto & nbhexes = NearbyBattleHexes(bhex);
+	const auto * const a_cstack = astack->cstack;
+
+	for(int i = 0; i < nbhexes.size(); ++i)
+	{
+		const auto & n_bhex = nbhexes.at(i);
+		if(!n_bhex.isAvailable())
+			continue;
+
+		auto it = hexstacks.find(n_bhex);
+		if(it == hexstacks.end())
+			continue;
+
+		const auto & n_cstack = it->second->cstack;
+		auto hexaction = static_cast<HexAction>(i);
+
+		if(n_cstack->unitSide() == a_cstack->unitSide())
+			return;
+
+		if(hexaction <= HexAction::AMOVE_TL)
+		{
+			ASSERT(CStack::isMeleeAttackPossible(a_cstack, n_cstack, bhex), "vcmi says melee attack is IMPOSSIBLE [1]");
+			actmask.set(i);
+		}
+		else if(hexaction > HexAction::AMOVE_2BR)
+		{
+			// only wide L stacks can perform 2TL/2L/2BL attacks
+			if(a_cstack->unitSide() == BattleSide::ATTACKER && a_cstack->doubleWide())
+			{
+				ASSERT(CStack::isMeleeAttackPossible(a_cstack, n_cstack, bhex), "vcmi says melee attack is IMPOSSIBLE");
+				actmask.set(i);
+			}
+		}
+		// only wide R stacks can perform 2TR/2R/2BR attacks
+		else if(a_cstack->unitSide() == BattleSide::DEFENDER && a_cstack->doubleWide())
+		{
+			ASSERT(CStack::isMeleeAttackPossible(a_cstack, n_cstack, bhex), "vcmi says melee attack is IMPOSSIBLE [2]");
+			actmask.set(i);
+		}
+	}
+}
+}

+ 89 - 0
AI/MMAI/BAI/v13/hex.h

@@ -0,0 +1,89 @@
+/*
+ * hex.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 "battle/AccessibilityInfo.h"
+#include "battle/BattleHex.h"
+#include "battle/CObstacleInstance.h"
+#include "battle/ReachabilityInfo.h"
+#include "constants/Enumerations.h"
+
+#include "BAI/v13/stack.h"
+#include "schema/v13/types.h"
+
+#include <memory>
+
+namespace MMAI::BAI::V13
+{
+using HexAction = Schema::V13::HexAction;
+using HexAttribute = Schema::V13::HexAttribute;
+using HexAttrs = Schema::V13::HexAttrs;
+using HexState = Schema::V13::HexState;
+
+using HexActionMask = std::bitset<EI(HexAction::_count)>;
+using HexStateMask = std::bitset<EI(HexState::_count)>;
+using HexActionHex = std::array<BattleHex, 12>;
+
+struct ActiveStackInfo
+{
+	const Stack * stack;
+	const bool canshoot;
+	const std::shared_ptr<ReachabilityInfo> rinfo;
+
+	ActiveStackInfo(const Stack * stack_, const bool canshoot_, const std::shared_ptr<ReachabilityInfo> & rinfo_)
+		: stack(stack_), canshoot(canshoot_), rinfo(rinfo_) {};
+};
+
+/*
+ * A wrapper around BattleHex. Differences:
+ *
+ * x is 0..14     (instead of 0..16),
+ * id is 0..164  (instead of 0..177)
+ */
+class Hex : public Schema::V13::IHex
+{
+public:
+	static int CalcId(const BattleHex & bh);
+	static std::pair<int, int> CalcXY(const BattleHex & bh);
+	static HexActionHex NearbyBattleHexes(const BattleHex & bh);
+
+	Hex(const BattleHex & bh,
+		EAccessibility accessibility,
+		EGateState gatestate,
+		const std::vector<std::shared_ptr<const CObstacleInstance>> & obstacles,
+		const std::map<BattleHex, std::shared_ptr<Stack>> & hexstacks,
+		const std::shared_ptr<ActiveStackInfo> & astackinfo);
+
+	// IHex impl
+	const HexAttrs & getAttrs() const override;
+	int getID() const override;
+	int getAttr(HexAttribute a) const override;
+	const Stack * getStack() const override;
+
+	const BattleHex bhex;
+	const int id;
+	std::shared_ptr<const Stack> stack = nullptr;
+	HexAttrs attrs = {};
+	HexActionMask actmask = 0; // for active stack only
+	HexStateMask statemask = 0; //
+
+	std::string name() const;
+	int attr(HexAttribute a) const;
+
+private:
+	void setattr(HexAttribute a, int value);
+	void finalize();
+
+	void setStateMask(EAccessibility accessibility, const std::vector<std::shared_ptr<const CObstacleInstance>> & obstacles, BattleSide side);
+
+	void setActionMask(const std::shared_ptr<ActiveStackInfo> & astackinfo, const std::map<BattleHex, std::shared_ptr<Stack>> & hexstacks);
+};
+}

+ 57 - 0
AI/MMAI/BAI/v13/hexaction.h

@@ -0,0 +1,57 @@
+/*
+ * hexaction.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 "battle/BattleHex.h"
+
+#include "schema/v13/constants.h"
+#include "schema/v13/types.h"
+
+// There is a cyclic dependency if those are placed in action.h:
+// action.h -> battlefield.h -> hex.h -> actmask.h -> action.h
+namespace MMAI::BAI::V13
+{
+using GlobalAction = Schema::V13::GlobalAction;
+using HexAction = Schema::V13::HexAction;
+
+static_assert(EI(HexAction::AMOVE_TR) == 0);
+static_assert(EI(HexAction::AMOVE_R) == 1);
+static_assert(EI(HexAction::AMOVE_BR) == 2);
+static_assert(EI(HexAction::AMOVE_BL) == 3);
+static_assert(EI(HexAction::AMOVE_L) == 4);
+static_assert(EI(HexAction::AMOVE_TL) == 5);
+static_assert(EI(HexAction::AMOVE_2TR) == 6);
+static_assert(EI(HexAction::AMOVE_2R) == 7);
+static_assert(EI(HexAction::AMOVE_2BR) == 8);
+static_assert(EI(HexAction::AMOVE_2BL) == 9);
+static_assert(EI(HexAction::AMOVE_2L) == 10);
+static_assert(EI(HexAction::AMOVE_2TL) == 11);
+
+constexpr auto AMOVE_TO_EDIR = std::array<BattleHex::EDir, 12>{
+	BattleHex::TOP_RIGHT,
+	BattleHex::RIGHT,
+	BattleHex::BOTTOM_RIGHT,
+	BattleHex::BOTTOM_LEFT,
+	BattleHex::LEFT,
+	BattleHex::TOP_LEFT,
+	BattleHex::TOP_RIGHT,
+	BattleHex::RIGHT,
+	BattleHex::BOTTOM_RIGHT,
+	BattleHex::BOTTOM_LEFT,
+	BattleHex::LEFT,
+	BattleHex::TOP_LEFT,
+};
+
+static_assert(EI(GlobalAction::_count) == Schema::V13::N_NONHEX_ACTIONS);
+static_assert(EI(HexAction::_count) == Schema::V13::N_HEX_ACTIONS);
+
+constexpr int N_ACTIONS = EI(GlobalAction::_count) + (EI(HexAction::_count) * 165);
+}

+ 37 - 0
AI/MMAI/BAI/v13/hexactmask.h

@@ -0,0 +1,37 @@
+/*
+ * hexactmask.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 "BAI/v13/hexaction.h"
+
+namespace MMAI::BAI::V13
+{
+/*
+  * A list of flags for a single hex (see HexAction)
+  */
+using HexActMask = std::bitset<EI(HexAction::_count)>;
+
+struct ActMask
+{
+	bool retreat = false;
+	bool wait = false;
+
+	/*
+      * A list of HexActMask objects
+      *
+      * [0] HexActMask for hex 0
+      * [1] HexActMask for hex 1
+      * ...
+      * [164] HexActMask for hex 164
+      */
+	std::array<HexActMask, 165> hexactmasks = {};
+};
+}

+ 44 - 0
AI/MMAI/BAI/v13/links.h

@@ -0,0 +1,44 @@
+/*
+ * links.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 "schema/v13/types.h"
+
+namespace MMAI::BAI::V13
+{
+class Links : public Schema::V13::ILinks
+{
+public:
+	std::vector<int64_t> srcIndex; // [src1, src2, ...]
+	std::vector<int64_t> dstIndex; // [dst1, dst2, ...]
+	std::vector<float> attributes; // [attr1, attr2, ...]
+
+	std::vector<int64_t> getSrcIndex() const override
+	{
+		return srcIndex;
+	}
+	std::vector<int64_t> getDstIndex() const override
+	{
+		return dstIndex;
+	}
+	std::vector<float> getAttributes() const override
+	{
+		return attributes;
+	}
+
+	void add(int src, int dst, float attr)
+	{
+		srcIndex.push_back(src);
+		dstIndex.push_back(dst);
+		attributes.push_back(attr);
+	}
+};
+}

+ 88 - 0
AI/MMAI/BAI/v13/player_stats.cpp

@@ -0,0 +1,88 @@
+/*
+ * player_stats.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 "BAI/v13/global_stats.h"
+#include "BAI/v13/player_stats.h"
+#include "schema/v13/constants.h"
+
+namespace MMAI::BAI::V13
+{
+
+namespace S13 = Schema::V13;
+using Side = Schema::Side;
+using GA = Schema::V13::GlobalAttribute;
+using A = Schema::V13::PlayerAttribute;
+
+static_assert(EI(Side::LEFT) == EI(BattleSide::LEFT_SIDE));
+static_assert(EI(Side::RIGHT) == EI(BattleSide::RIGHT_SIDE));
+
+PlayerStats::PlayerStats(BattleSide side, int value, int hp)
+{
+	// Fill with NA to guard against "forgotten" attrs
+	// (all attrs are strict so encoder will throw if NAs are found)
+	attrs.fill(S13::NULL_VALUE_UNENCODED);
+
+	static_assert(EI(A::_count) == 23, "whistleblower in case attributes change");
+
+	setattr(A::BATTLE_SIDE, EI(side));
+	setattr(A::VALUE_KILLED_ACC_ABS, 0);
+	setattr(A::VALUE_LOST_ACC_ABS, 0);
+	setattr(A::DMG_DEALT_ACC_ABS, 0);
+	setattr(A::DMG_RECEIVED_ACC_ABS, 0);
+};
+
+void PlayerStats::update(const GlobalStats * gstats, int value, int hp, int dmgDealt, int dmgReceived, int valueKilled, int valueLost)
+{
+	// ll (long long) ensures long is 64-bit even on 32-bit systems
+	setattr(A::ARMY_VALUE_NOW_ABS, value);
+	setattr(A::ARMY_VALUE_NOW_REL, 1000LL * value / gstats->attr(GA::BFIELD_VALUE_NOW_ABS));
+	setattr(A::ARMY_VALUE_NOW_REL0, 1000LL * value / gstats->attr(GA::BFIELD_VALUE_START_ABS));
+	setattr(A::ARMY_HP_NOW_ABS, hp);
+	setattr(A::ARMY_HP_NOW_REL, 1000LL * hp / gstats->attr(GA::BFIELD_HP_NOW_ABS));
+	setattr(A::ARMY_HP_NOW_REL0, 1000LL * hp / gstats->attr(GA::BFIELD_HP_START_ABS));
+	setattr(A::VALUE_KILLED_NOW_ABS, valueKilled);
+	setattr(A::VALUE_KILLED_NOW_REL, 1000LL * valueKilled / gstats->attr(GA::BFIELD_VALUE_NOW_ABS));
+	addattr(A::VALUE_KILLED_ACC_ABS, valueKilled);
+	setattr(A::VALUE_KILLED_ACC_REL0, 1000LL * attr(A::VALUE_KILLED_ACC_ABS) / gstats->attr(GA::BFIELD_VALUE_START_ABS));
+	setattr(A::VALUE_LOST_NOW_ABS, valueLost);
+	setattr(A::VALUE_LOST_NOW_REL, 1000LL * valueLost / gstats->attr(GA::BFIELD_VALUE_NOW_ABS));
+	addattr(A::VALUE_LOST_ACC_ABS, valueLost);
+	setattr(A::VALUE_LOST_ACC_REL0, 1000LL * attr(A::VALUE_LOST_ACC_ABS) / gstats->attr(GA::BFIELD_VALUE_START_ABS));
+	setattr(A::DMG_DEALT_NOW_ABS, dmgDealt);
+	setattr(A::DMG_DEALT_NOW_REL, 1000LL * dmgDealt / gstats->attr(GA::BFIELD_HP_NOW_ABS));
+	addattr(A::DMG_DEALT_ACC_ABS, dmgDealt);
+	setattr(A::DMG_DEALT_ACC_REL0, 1000LL * attr(A::DMG_DEALT_ACC_ABS) / gstats->attr(GA::BFIELD_HP_START_ABS));
+	setattr(A::DMG_RECEIVED_NOW_ABS, dmgReceived);
+	setattr(A::DMG_RECEIVED_NOW_REL, 1000LL * dmgReceived / gstats->attr(GA::BFIELD_HP_NOW_ABS));
+	addattr(A::DMG_RECEIVED_ACC_ABS, dmgReceived);
+	setattr(A::DMG_RECEIVED_ACC_REL0, 1000LL * attr(A::DMG_RECEIVED_ACC_ABS) / gstats->attr(GA::BFIELD_HP_START_ABS));
+}
+
+int PlayerStats::getAttr(PlayerAttribute a) const
+{
+	return attr(a);
+}
+
+int PlayerStats::attr(PlayerAttribute a) const
+{
+	return attrs.at(EI(a));
+};
+
+void PlayerStats::setattr(PlayerAttribute a, int value)
+{
+	attrs.at(EI(a)) = value;
+};
+
+void PlayerStats::addattr(PlayerAttribute a, int value)
+{
+	attrs.at(EI(a)) += value;
+};
+}

+ 35 - 0
AI/MMAI/BAI/v13/player_stats.h

@@ -0,0 +1,35 @@
+/*
+ * player_stats.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 "BAI/v13/global_stats.h"
+#include "battle/BattleSide.h"
+#include "schema/v13/types.h"
+
+namespace MMAI::BAI::V13
+{
+
+using IPlayerStats = Schema::V13::IPlayerStats;
+using PlayerAttribute = Schema::V13::PlayerAttribute;
+using PlayerAttrs = Schema::V13::PlayerAttrs;
+
+class PlayerStats : public IPlayerStats
+{
+public:
+	PlayerStats(BattleSide side, int value, int hp);
+
+	int getAttr(PlayerAttribute a) const override;
+	int attr(PlayerAttribute a) const;
+	void setattr(PlayerAttribute a, int value);
+	void addattr(PlayerAttribute a, int value);
+	void update(const GlobalStats * gstats, int value, int hp, int dmgDealt, int dmgReceived, int valueKilled, int valueLost);
+	PlayerAttrs attrs = {};
+};
+}

+ 1561 - 0
AI/MMAI/BAI/v13/render.cpp

@@ -0,0 +1,1561 @@
+/*
+ * render.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 "battle/AccessibilityInfo.h"
+#include "battle/BattleAttackInfo.h"
+#include "battle/CObstacleInstance.h"
+#include "battle/IBattleInfoCallback.h"
+#include "constants/EntityIdentifiers.h"
+#include "mapObjects/CGTownInstance.h"
+#include "vcmi/spells/Caster.h"
+
+#include "BAI/v13/hex.h"
+#include "BAI/v13/hexactmask.h"
+#include "BAI/v13/render.h"
+#include "common.h"
+
+#include <algorithm>
+
+#include "schema/v13/constants.h"
+#include "schema/v13/types.h"
+
+namespace MMAI::BAI::V13
+{
+
+namespace S13 = Schema::V13;
+using IHex = Schema::V13::IHex;
+using IStack = Schema::V13::IStack;
+using ISupplementaryData = Schema::V13::ISupplementaryData;
+using GA = Schema::V13::GlobalAttribute;
+using HA = Schema::V13::HexAttribute;
+using PA = Schema::V13::PlayerAttribute;
+using SA = StackAttribute;
+using SF1 = StackFlag1;
+using SF2 = StackFlag2;
+
+namespace
+{
+	std::string PadLeft(const std::string & input, size_t desiredLength, char paddingChar)
+	{
+		std::ostringstream ss;
+		ss << std::right << std::setfill(paddingChar) << std::setw(desiredLength) << input;
+		return ss.str();
+	}
+
+	// Plain message overload: expect(cond, "expectation failed");
+	inline void expect(bool exp, std::string_view message)
+	{
+		if(exp)
+			return;
+
+		throw std::runtime_error(std::string(message));
+	}
+
+	template<typename... Args>
+	inline void expect(bool exp, std::string_view format, const Args &... args)
+	requires(sizeof...(Args) > 0)
+	{
+		if(exp)
+			return;
+
+		boost::format f{std::string(format)};
+
+		// Fold expression: expands to (f % arg1, f % arg2, ...)
+		((f % args), ...);
+
+		throw std::runtime_error(f.str());
+	}
+}
+
+namespace
+{
+	struct Context
+	{
+		const CPlayerBattleCallback * battle{};
+		std::vector<const CStack *> allstacks;
+		std::array<const CStack *, 7> l_CStacks{};
+		std::array<const CStack *, 7> r_CStacks{};
+		std::vector<const CStack *> l_CStacksAll;
+		std::vector<const CStack *> r_CStacksAll;
+		std::vector<const CStack *> l_CStacksExtra;
+		std::vector<const CStack *> r_CStacksExtra;
+		std::vector<const CStack *> l_CStacksSummons;
+		std::vector<const CStack *> r_CStacksSummons;
+		std::vector<const CStack *> l_CStacksMachines;
+		std::vector<const CStack *> r_CStacksMachines;
+		std::array<const CStack *, 165> hexstacks{};
+
+		std::map<const CStack *, ReachabilityInfo> rinfos;
+	};
+
+	std::array<const CStack *, 7> getAllStacksForSide(const Context & ctx, bool side)
+	{
+		return side ? ctx.l_CStacks : ctx.r_CStacks;
+	}
+
+	// Return (attr == N/A), but after performing some checks
+	bool isNA(int v, const CStack * stack, const std::string_view attrname)
+	{
+		if(v == S13::NULL_VALUE_UNENCODED)
+		{
+			expect(!stack, "%s: N/A but stack != nullptr", attrname);
+			return true;
+		}
+		expect(stack, "%s: != N/A but stack = nullptr", attrname);
+		return false;
+	};
+
+	bool checkReachable(const Context & ctx, BattleHex bh, bool v, const CStack * stack)
+	{
+		auto distance = ctx.rinfos.at(stack).distances.at(bh.toInt());
+		auto canreach = (stack->getMovementRange() >= distance);
+
+		// XXX: if v=false, returns true when UNreachable
+		//      if v=true returns true when reachable
+		return v ? canreach : !canreach;
+	};
+
+	void ensureReachability(const Context & ctx, BattleHex bh, bool v, const CStack * stack, const char * attrname)
+	{
+		expect(checkReachable(ctx, bh, v, stack), "%s: (bhex=%d) reachability expected: %d", attrname, bh.toInt(), v);
+	};
+
+	void ensureValueMatch(int have, int want, const std::string_view attrname, const std::string & desc = "")
+	{
+		desc.empty() ? expect(have == want, "%s: have: %d, want: %d", attrname, have, want)
+					 : expect(have == want, "%s: have: %d, want: %d (%s)", attrname, have, want, desc.c_str());
+	};
+
+	void ensureStackNullOrMatch(HexAttribute a, const CStack * cstack, int have, auto wantfunc, const std::string_view attrname)
+	{
+		auto vmax = std::get<3>(S13::HEX_ENCODING.at(EI(a)));
+		if(isNA(have, cstack, attrname))
+			return;
+		int want = wantfunc();
+		want = std::min(want, vmax);
+		have = std::min(have, vmax); // this is usually done by the encoder
+		ensureValueMatch(have, want, attrname);
+	};
+
+	void ensureMeleeability(const Context & ctx, BattleHex bh, HexActMask mask, HexAction ha, const CStack * cstack, const char * attrname)
+	{
+		auto mv = mask.test(EI(ha));
+
+		// if AMOVE is allowed, we must be able to reach hex
+		// (no else -- we may still be able to reach it)
+		if(mv == 1)
+			ensureReachability(ctx, bh, true, cstack, attrname);
+
+		auto r_nbh = bh.cloneInDirection(BattleHex::EDir::RIGHT, false);
+		auto l_nbh = bh.cloneInDirection(BattleHex::EDir::LEFT, false);
+		auto nbh = BattleHex{};
+
+		switch(ha)
+		{
+			case HexAction::AMOVE_TR:
+				nbh = bh.cloneInDirection(BattleHex::EDir::TOP_RIGHT, false);
+				break;
+			case HexAction::AMOVE_R:
+				nbh = bh.cloneInDirection(BattleHex::EDir::RIGHT, false);
+				break;
+			case HexAction::AMOVE_BR:
+				nbh = bh.cloneInDirection(BattleHex::EDir::BOTTOM_RIGHT, false);
+				break;
+			case HexAction::AMOVE_BL:
+				nbh = bh.cloneInDirection(BattleHex::EDir::BOTTOM_LEFT, false);
+				break;
+			case HexAction::AMOVE_L:
+				nbh = bh.cloneInDirection(BattleHex::EDir::LEFT, false);
+				break;
+			case HexAction::AMOVE_TL:
+				nbh = bh.cloneInDirection(BattleHex::EDir::TOP_LEFT, false);
+				break;
+			case HexAction::AMOVE_2TR:
+				nbh = r_nbh.cloneInDirection(BattleHex::EDir::TOP_RIGHT, false);
+				break;
+			case HexAction::AMOVE_2R:
+				nbh = r_nbh.cloneInDirection(BattleHex::EDir::RIGHT, false);
+				break;
+			case HexAction::AMOVE_2BR:
+				nbh = r_nbh.cloneInDirection(BattleHex::EDir::BOTTOM_RIGHT, false);
+				break;
+			case HexAction::AMOVE_2BL:
+				nbh = l_nbh.cloneInDirection(BattleHex::EDir::BOTTOM_LEFT, false);
+				break;
+			case HexAction::AMOVE_2L:
+				nbh = l_nbh.cloneInDirection(BattleHex::EDir::LEFT, false);
+				break;
+			case HexAction::AMOVE_2TL:
+				nbh = l_nbh.cloneInDirection(BattleHex::EDir::TOP_LEFT, false);
+				break;
+			default:
+				THROW_FORMAT("Unexpected HexAction: %d", EI(ha));
+				break;
+		}
+
+		auto estacks = getAllStacksForSide(ctx, !EI(cstack->unitSide()));
+		const auto it = std::ranges::find_if( // NOLINT(readability-qualified-auto)
+			estacks,
+			[&nbh](const auto & stack)
+			{
+				return stack && stack->coversPos(nbh);
+			}
+		);
+
+		const auto * estack = it == estacks.end() ? nullptr : *it;
+
+		if(mv)
+		{
+			expect(estack, "%s: =1 (bhex %d, nbhex %d), but estack is nullptr", attrname, bh.toInt(), nbh.toInt());
+			// must not pass "nbh" for defender position, as it could be its rear hex
+			expect(
+				cstack->isMeleeAttackPossible(cstack, estack, bh),
+				"%s: =1 (bhex %d, nbhex %d), but VCMI says isMeleeAttackPossible=0",
+				attrname,
+				bh.toInt(),
+				nbh.toInt()
+			);
+		}
+	};
+
+	// as opposed to ensureHexShootableOrNA, this hexattr works with a mask
+	// values are 0 or 1 and this check requires a valid target
+	void ensureShootability(const Context & ctx, BattleHex bh, int v, const CStack * cstack, const char * attrname)
+	{
+		auto canshoot = ctx.battle->battleCanShoot(cstack);
+		auto estacks = getAllStacksForSide(ctx, !EI(cstack->unitSide()));
+
+		const auto it = std::ranges::find_if( // NOLINT(readability-qualified-auto)
+			estacks,
+			[&bh](auto estack)
+			{
+				return estack && estack->coversPos(bh);
+			}
+		);
+
+		const auto * estack = it == estacks.end() ? nullptr : *it;
+
+		// XXX: the estack on `bh` might be "hidden" from the state
+		//      in which case the mask for shooting will be 0 although
+		//      there IS a stack to shoot on this hex
+		if(v)
+		{
+			expect(estack, "%s: =%d, but estack is nullptr", attrname, bh.toInt());
+			expect(canshoot, "%s: =%d but canshoot=%d", attrname, v, canshoot);
+		}
+		else
+		{
+			// stack must be unable to shoot
+			// OR there must be no target at hex
+			expect(!canshoot || !estack, "%s: =%d but canshoot=%d and estack is not null", attrname, v, canshoot);
+		}
+	};
+
+	void ensureCorrectMaskOrNA(const Context & ctx, BattleHex bh, int v, const CStack * cstack, const std::string_view attrname)
+	{
+		if(isNA(v, cstack, attrname))
+			return;
+
+		auto basename = std::string(attrname);
+		auto mask = HexActMask(v);
+
+		ensureReachability(ctx, bh, mask.test(EI(HexAction::MOVE)), cstack, (basename + "{MOVE}").c_str());
+		ensureShootability(ctx, bh, mask.test(EI(HexAction::SHOOT)), cstack, (basename + "{SHOOT}").c_str());
+		ensureMeleeability(ctx, bh, mask, HexAction::AMOVE_TR, cstack, (basename + "{AMOVE_TR}").c_str());
+		ensureMeleeability(ctx, bh, mask, HexAction::AMOVE_R, cstack, (basename + "{AMOVE_R}").c_str());
+		ensureMeleeability(ctx, bh, mask, HexAction::AMOVE_BR, cstack, (basename + "{AMOVE_BR}").c_str());
+		ensureMeleeability(ctx, bh, mask, HexAction::AMOVE_BL, cstack, (basename + "{AMOVE_BL}").c_str());
+		ensureMeleeability(ctx, bh, mask, HexAction::AMOVE_L, cstack, (basename + "{AMOVE_L}").c_str());
+		ensureMeleeability(ctx, bh, mask, HexAction::AMOVE_TL, cstack, (basename + "{AMOVE_TL}").c_str());
+		ensureMeleeability(ctx, bh, mask, HexAction::AMOVE_2TR, cstack, (basename + "{AMOVE_2TR}").c_str());
+		ensureMeleeability(ctx, bh, mask, HexAction::AMOVE_2R, cstack, (basename + "{AMOVE_2R}").c_str());
+		ensureMeleeability(ctx, bh, mask, HexAction::AMOVE_2BR, cstack, (basename + "{AMOVE_2BR}").c_str());
+		ensureMeleeability(ctx, bh, mask, HexAction::AMOVE_2BL, cstack, (basename + "{AMOVE_2BL}").c_str());
+		ensureMeleeability(ctx, bh, mask, HexAction::AMOVE_2L, cstack, (basename + "{AMOVE_2L}").c_str());
+		ensureMeleeability(ctx, bh, mask, HexAction::AMOVE_2TL, cstack, (basename + "{AMOVE_2TL}").c_str());
+	};
+
+}
+
+// This function used during model development and is never called otherwise
+void Verify(const State * state) // NOSONAR - function used for debugging only
+{
+	const auto * battle = state->battle;
+	auto hexes = Hexes();
+	const CStack * astack = nullptr;
+
+	expect(battle, "no battle to verify");
+
+	Context ctx;
+	ctx.battle = battle;
+
+	ctx.allstacks = battle->battleGetStacks();
+	std::ranges::sort(
+		ctx.allstacks,
+		[](const CStack * a, const CStack * b)
+		{
+			return a->unitId() < b->unitId();
+		}
+	);
+
+	for(auto & cstack : ctx.allstacks)
+	{
+		if(cstack->unitId() == battle->battleActiveUnit()->unitId())
+			astack = cstack;
+
+		if(cstack->unitSlot() < 0)
+		{
+			if(cstack->unitSlot() == SlotID::SUMMONED_SLOT_PLACEHOLDER)
+				cstack->unitSide() == BattleSide::DEFENDER ? ctx.r_CStacksSummons.push_back(cstack) : ctx.l_CStacksSummons.push_back(cstack);
+			else if(cstack->unitSlot() == SlotID::WAR_MACHINES_SLOT)
+				cstack->unitSide() == BattleSide::DEFENDER ? ctx.r_CStacksMachines.push_back(cstack) : ctx.l_CStacksMachines.push_back(cstack);
+		}
+		else
+		{
+			cstack->unitSide() == BattleSide::DEFENDER ? ctx.r_CStacks.at(cstack->unitSlot()) = cstack : ctx.l_CStacks.at(cstack->unitSlot()) = cstack;
+		}
+
+		ctx.rinfos.try_emplace(cstack, battle->getReachability(cstack));
+
+		for(const auto & bh : cstack->getHexes())
+		{
+			if(!bh.isAvailable())
+				continue; // war machines rear hex, arrow towers
+			expect(!ctx.hexstacks.at(Hex::CalcId(bh)), "hex occupied by multiple stacks?");
+			ctx.hexstacks.at(Hex::CalcId(bh)) = cstack;
+		}
+	}
+
+	ctx.l_CStacksAll.insert(ctx.l_CStacksAll.end(), ctx.l_CStacks.begin(), ctx.l_CStacks.end());
+	ctx.l_CStacksAll.insert(ctx.l_CStacksAll.end(), ctx.l_CStacksSummons.begin(), ctx.l_CStacksSummons.end());
+	ctx.l_CStacksAll.insert(ctx.l_CStacksAll.end(), ctx.l_CStacksMachines.begin(), ctx.l_CStacksMachines.end());
+
+	ctx.r_CStacksAll.insert(ctx.r_CStacksAll.end(), ctx.r_CStacks.begin(), ctx.r_CStacks.end());
+	ctx.r_CStacksAll.insert(ctx.r_CStacksAll.end(), ctx.r_CStacksSummons.begin(), ctx.r_CStacksSummons.end());
+	ctx.r_CStacksAll.insert(ctx.r_CStacksAll.end(), ctx.r_CStacksMachines.begin(), ctx.r_CStacksMachines.end());
+
+	ctx.l_CStacksExtra.insert(ctx.l_CStacksExtra.end(), ctx.l_CStacksSummons.begin(), ctx.l_CStacksSummons.end());
+	ctx.l_CStacksExtra.insert(ctx.l_CStacksExtra.end(), ctx.l_CStacksMachines.begin(), ctx.l_CStacksMachines.end());
+
+	ctx.r_CStacksExtra.insert(ctx.r_CStacksExtra.end(), ctx.r_CStacksSummons.begin(), ctx.r_CStacksSummons.end());
+	ctx.r_CStacksExtra.insert(ctx.r_CStacksExtra.end(), ctx.r_CStacksMachines.begin(), ctx.r_CStacksMachines.end());
+
+	auto SideStacks = std::map<bool, std::vector<const CStack *> *>{
+		{false, &ctx.l_CStacksAll},
+        {true,  &ctx.r_CStacksAll}
+	};
+
+	auto ended = state->supdata->ended;
+
+	if(!astack)
+		expect(ended, "astack is NULL, but ended is not true");
+	else if(ended)
+	{
+		// at battle-end, activeStack is usually the ENEMY stack
+		// XXX: this expect will incorrectly throw if we retreated as a regular action
+		//      (in which case our stack will be active, but we would have lost the battle)
+		// expect(state->supdata->victory == (astack->getOwner() == battle->battleGetMySide()), "state->supdata->victory is %d, but astack->side=%d and myside=%d", state->supdata->victory, astack->getOwner(), battle->battleGetMySide());
+
+		// at battle-end, even regardless of the actual active stack,
+		// battlefield->astack must be nullptr
+		expect(!state->battlefield->astack, "ended, but battlefield->astack is not NULL");
+		expect(state->supdata->getIsBattleEnded(), "ended, but state->supdata->getIsBattleEnded() is false");
+	}
+
+	// XXX: good morale is NOT handled here for simplicity
+	//      See comments in Battlefield::GetQueue how to handle it.
+	auto tmp = std::vector<battle::Units>{};
+	battle->battleGetTurnOrder(tmp, S13::STACK_QUEUE_SIZE, 0);
+	auto queue = std::vector<const battle::Unit *>{};
+	for(auto & units : tmp)
+	{
+		for(auto & unit : units)
+		{
+			if(queue.size() < S13::STACK_QUEUE_SIZE)
+				queue.push_back(unit);
+			else
+				break;
+		}
+	}
+
+	const auto * gstats = state->supdata->getGlobalStats();
+	auto gmask = GlobalActionMask(gstats->getAttr(GA::ACTION_MASK));
+	ensureValueMatch(gmask.test(EI(GlobalAction::RETREAT)), false, "GA.ACTION_MASK[RETREAT]");
+
+	if(ended)
+	{
+		static_assert(EI(Side::LEFT) == EI(BattleSide::ATTACKER));
+		static_assert(EI(Side::RIGHT) == EI(BattleSide::DEFENDER));
+
+		auto fin = battle->battleIsFinished();
+
+		// XXX: The logic in battleIsFinished is flawed and returns no value
+		//      (i.e. "not finished") if both sides have units, which can
+		//      happen if the WE some has retreated as a regular action (not via reset).
+		// ASSERT(fin.has_value(), "ended, but battleIsFinished returns no value?");
+
+		if(fin.has_value())
+		{
+			// NONE means draw (no units on battlefield) -- our value will be null in this case
+			(fin == BattleSide::NONE) ? ensureValueMatch(gstats->getAttr(GA::BATTLE_WINNER), S13::NULL_VALUE_UNENCODED, "GA.BATTLE_WINNER (draw)")
+									  : ensureValueMatch(gstats->getAttr(GA::BATTLE_WINNER), EI(fin.value()), "GA.BATTLE_WINNER");
+		}
+		else
+		{
+			// we have retreated *as an action*
+			// There seems to be no way to ask vcmi "which side retreated"
+		}
+
+		ensureValueMatch(gstats->getAttr(GA::BATTLE_SIDE_ACTIVE_PLAYER), S13::NULL_VALUE_UNENCODED, "GA.BATTLE_SIDE_ACTIVE_PLAYER");
+		ensureValueMatch(gmask.test(EI(GlobalAction::WAIT)), false, "GA.ACTION_MASK[WAIT]");
+	}
+	else
+	{
+		static_assert(EI(Side::LEFT) == EI(BattleSide::LEFT_SIDE));
+		static_assert(EI(Side::RIGHT) == EI(BattleSide::RIGHT_SIDE));
+		ASSERT(astack != nullptr, "not ended, but no astack either");
+		ensureValueMatch(gstats->getAttr(GA::BATTLE_WINNER), S13::NULL_VALUE_UNENCODED, "GA.BATTLE_WINNER (battle ongoing)");
+		ensureValueMatch(gstats->getAttr(GA::BATTLE_SIDE_ACTIVE_PLAYER), EI(astack->unitSide()), "GA.BATTLE_SIDE_ACTIVE_PLAYER");
+		ensureValueMatch(gmask.test(EI(GlobalAction::WAIT)), !astack->waitedThisTurn, "GA.ACTION_MASK[WAIT]");
+	}
+	auto alogs = state->supdata->getAttackLogs();
+
+	for(int ihex = 0; ihex < 165; ihex++)
+	{
+		int x = ihex % 15;
+		int y = ihex / 15;
+		auto & hex = state->battlefield->hexes->at(y).at(x);
+		auto bh = hex->bhex;
+		expect(bh == BattleHex(x + 1, y), "hex->bhex mismatch");
+
+		auto ainfo = battle->getAccessibility();
+		auto aa = ainfo.at(bh.toInt());
+
+		for(int i = 0; i < EI(HexAttribute::_count); i++)
+		{
+			auto attr = static_cast<HexAttribute>(i);
+			auto v = hex->attrs.at(i);
+			const auto * cstack = ctx.hexstacks.at(ihex);
+
+			if(cstack)
+			{
+				expect(!!hex->stack, "cstack is present, but hex->stack is nullptr");
+				expect(hex->stack->cstack == cstack, "hex->cstack != cstack");
+			}
+			else
+			{
+				expect(!hex->stack, "cstack is nullptr, but hex->stack is present");
+			}
+
+			switch(attr)
+			{
+				case HA::Y_COORD:
+					expect(v == y, "HEX.Y_COORD: %d != %d", v, y);
+					break;
+				case HA::X_COORD:
+					expect(v == x, "HEX.X_COORD: %d != %d", v, x);
+					break;
+				case HA::STATE_MASK:
+				{
+					auto obstacles = battle->battleGetAllObstaclesOnPos(bh, false);
+					auto anyobstacle = [&obstacles](auto fn)
+					{
+						return std::any_of(
+							obstacles.begin(),
+							obstacles.end(),
+							[&fn](const std::shared_ptr<const CObstacleInstance> & obstacle)
+							{
+								return fn(obstacle.get());
+							}
+						);
+					};
+
+					auto mask = HexStateMask(v);
+					BattleSide side = astack ? astack->unitSide() : BattleSide::ATTACKER; // XXX: Hex defaults to 0 if there is no astack
+
+					if(mask.test(EI(HexState::PASSABLE)))
+					{
+						expect(
+							aa == EAccessibility::ACCESSIBLE || (EI(side) && aa == EAccessibility::GATE),
+							"HEX.STATE_MASK: PASSABLE bit is set, but accessibility is %d (side: %d)",
+							EI(aa),
+							EI(side)
+						);
+					}
+					else
+					{
+						if(aa == EAccessibility::OBSTACLE || aa == EAccessibility::ALIVE_STACK)
+							break;
+
+						switch(aa)
+						{
+							case EAccessibility::ACCESSIBLE:
+								throw std::runtime_error("HEX.STATE_MASK: PASSABLE bit not set, but accessibility is ACCESSIBLE");
+								break;
+							case EAccessibility::ALIVE_STACK:
+							case EAccessibility::OBSTACLE:
+							case EAccessibility::DESTRUCTIBLE_WALL:
+							case EAccessibility::GATE:
+								break;
+							case EAccessibility::UNAVAILABLE:
+								// only Fort and Boat battles can have unavailable hexes
+								expect(
+									battle->battleGetFortifications().wallsHealth > 0 || battle->battleTerrainType() == TerrainId::WATER,
+									"Found UNAVAILABLE accessibility on non-fort, non-boat battlefield: tertype=%d",
+									EI(battle->battleTerrainType())
+								);
+								break;
+							case EAccessibility::SIDE_COLUMN:
+								// side hexes should are not included in the observation
+								throw std::runtime_error("HEX.STATE_MASK: SIDE_COLUMN accessibility found");
+								break;
+							default:
+								throw std::runtime_error("Unexpected accessibility: " + std::to_string(EI(aa)));
+						}
+					}
+
+					if(mask.test(EI(HexState::STOPPING)))
+					{
+						auto stopping = anyobstacle(std::mem_fn(&CObstacleInstance::stopsMovement));
+						expect(stopping, "HEX.STATE_MASK: STOPPING bit is set, but no obstacle stops movement");
+					}
+
+					if(mask.test(EI(HexState::DAMAGING_L)))
+					{
+						auto damaging = anyobstacle(
+							[side](const CObstacleInstance * o)
+							{
+								if(o->obstacleType == CObstacleInstance::MOAT)
+									return true;
+								if(!o->triggersEffects())
+									return false;
+								auto s = SpellID(o->ID);
+								if(s == SpellID::FIRE_WALL)
+									return true;
+								if(s != SpellID::LAND_MINE)
+									return false;
+								const auto * so = dynamic_cast<const SpellCreatedObstacle *>(o);
+								auto bside = static_cast<bool>(side);
+								return (side == so->casterSide) ? bside : !bside;
+							}
+						);
+						expect(damaging, "HEX.STATE_MASK: DAMAGING bit is set, but no obstacle triggers a damaging effect");
+					}
+
+					if(mask.test(EI(HexState::DAMAGING_R)))
+					{
+						auto damaging = anyobstacle(
+							[side](const CObstacleInstance * o)
+							{
+								if(o->obstacleType == CObstacleInstance::MOAT)
+									return true;
+								if(!o->triggersEffects())
+									return false;
+								auto s = SpellID(o->ID);
+								if(s == SpellID::FIRE_WALL)
+									return true;
+								if(s != SpellID::LAND_MINE)
+									return false;
+								const auto * so = dynamic_cast<const SpellCreatedObstacle *>(o);
+								auto bside = static_cast<bool>(side);
+								return (side == so->casterSide) ? !bside : bside;
+							}
+						);
+						expect(damaging, "HEX.STATE_MASK: DAMAGING bit is set, but no obstacle triggers a damaging effect");
+					}
+				}
+				break;
+				case HA::ACTION_MASK:
+				{
+					if(ended)
+					{
+						expect(v == 0, "HEX.ACTION_MASK: battle ended, but action mask is %d", v);
+					}
+					else
+					{
+						ensureCorrectMaskOrNA(ctx, bh, v, astack, "HEX.ACTION_MASK");
+					}
+				}
+				break;
+				case HA::IS_REAR:
+				{
+					ensureValueMatch(v, cstack ? cstack->occupiedHex() == hex->bhex : 0, "HEX.IS_REAR");
+				}
+				break;
+				case HA::STACK_SIDE:
+					ensureStackNullOrMatch(
+						attr,
+						cstack,
+						v,
+						[&cstack]
+						{
+							return EI(cstack->unitSide());
+						},
+						"HA.STACK_SIDE"
+					);
+					break;
+				case HA::STACK_SLOT:
+				{
+					if(!cstack)
+						break;
+
+					auto want = static_cast<int>(cstack->unitSlot());
+					if(want == SlotID::WAR_MACHINES_SLOT)
+						want = S13::STACK_SLOT_WARMACHINES;
+					else if(want < 0 || want > 7)
+						want = S13::STACK_SLOT_SPECIAL;
+
+					ensureValueMatch(v, want, "HA.STACK_SLOT");
+				}
+				break;
+				case HA::STACK_QUANTITY:
+					ensureStackNullOrMatch(
+						attr,
+						cstack,
+						v,
+						[&cstack]
+						{
+							return std::round(S13::STACK_QTY_MAX * static_cast<float>(cstack->getCount()) / S13::STACK_QTY_MAX);
+						},
+						"HEX.STACK_QUANTITY"
+					);
+					break;
+				case HA::STACK_ATTACK:
+					ensureStackNullOrMatch(
+						attr,
+						cstack,
+						v,
+						[&cstack]
+						{
+							return cstack->getAttack(false);
+						},
+						"HEX.STACK_ATTACK"
+					);
+					break;
+				case HA::STACK_DEFENSE:
+					ensureStackNullOrMatch(
+						attr,
+						cstack,
+						v,
+						[&cstack]
+						{
+							return cstack->getDefense(false);
+						},
+						"HEX.STACK_DEFENSE"
+					);
+					break;
+				case HA::STACK_SHOTS:
+					ensureStackNullOrMatch(
+						attr,
+						cstack,
+						v,
+						[&cstack]
+						{
+							return cstack->shots.available();
+						},
+						"HEX.STACK_SHOTS"
+					);
+					break;
+				case HA::STACK_DMG_MIN:
+					ensureStackNullOrMatch(
+						attr,
+						cstack,
+						v,
+						[&cstack]
+						{
+							return cstack->getMinDamage(false);
+						},
+						"HEX.STACK_DMG_MIN"
+					);
+					break;
+				case HA::STACK_DMG_MAX:
+					ensureStackNullOrMatch(
+						attr,
+						cstack,
+						v,
+						[&cstack]
+						{
+							return cstack->getMaxDamage(false);
+						},
+						"HEX.STACK_DMG_MAX"
+					);
+					break;
+				case HA::STACK_HP:
+					ensureStackNullOrMatch(
+						attr,
+						cstack,
+						v,
+						[&cstack]
+						{
+							return cstack->getMaxHealth();
+						},
+						"HEX.STACK_HP"
+					);
+					break;
+				case HA::STACK_HP_LEFT:
+					ensureStackNullOrMatch(
+						attr,
+						cstack,
+						v,
+						[&cstack]
+						{
+							return cstack->getFirstHPleft();
+						},
+						"HEX.STACK_VALUE_REL"
+					);
+					break;
+				case HA::STACK_SPEED:
+					ensureStackNullOrMatch(
+						attr,
+						cstack,
+						v,
+						[&cstack]
+						{
+							return cstack->getMovementRange();
+						},
+						"HEX.STACK_SPEED"
+					);
+					break;
+				case HA::STACK_QUEUE:
+				{
+					// at battle end, queue is messed up
+					// (the stack that dealt the killing blow is still "active", but not on 0 pos)
+					if(ended || !cstack)
+						break;
+
+					auto qbits = std::bitset<S13::STACK_QUEUE_SIZE>(v);
+
+					for(int n = 0; n < S13::STACK_QUEUE_SIZE; ++n)
+					{
+						int have = qbits.test(n);
+						int want = (queue.at(n) == cstack);
+						ensureValueMatch(have, want, ("HEX.STACK_QUEUE[" + std::to_string(n) + "]"));
+					}
+				}
+				break;
+				case HA::STACK_VALUE_ONE:
+				{
+					ensureStackNullOrMatch(
+						attr,
+						cstack,
+						v,
+						[&cstack]
+						{
+							return Stack::GetValue(cstack->unitType());
+						},
+						"HEX.STACK_VALUE_ONE"
+					);
+				}
+				break;
+				case HA::STACK_VALUE_REL:
+				{
+					int tot = 0;
+					for(auto & s : ctx.allstacks)
+						tot += s->getCount() * Stack::GetValue(s->unitType());
+
+					ensureStackNullOrMatch(
+						attr,
+						cstack,
+						v,
+						[&cstack, &tot]
+						{
+							return 1000LL * cstack->getCount() * Stack::GetValue(cstack->unitType()) / tot;
+						},
+						"HEX.STACK_VALUE_REL"
+					);
+				}
+				break;
+				// These require historical information
+				// (CPlayerCallback does not provide such)
+				case HA::STACK_VALUE_REL0:
+				case HA::STACK_VALUE_KILLED_REL:
+				case HA::STACK_VALUE_KILLED_ACC_REL0:
+				case HA::STACK_VALUE_LOST_REL:
+				case HA::STACK_VALUE_LOST_ACC_REL0:
+				case HA::STACK_DMG_DEALT_REL:
+				case HA::STACK_DMG_DEALT_ACC_REL0:
+				case HA::STACK_DMG_RECEIVED_REL:
+				case HA::STACK_DMG_RECEIVED_ACC_REL0:
+					break;
+				case HA::STACK_FLAGS1:
+				{
+					if(isNA(v, cstack, "HEX.STACK_FLAGS"))
+						break;
+
+					for(int j = 0; j < EI(StackFlag1::_count); j++)
+					{
+						auto f = static_cast<StackFlag1>(j);
+						auto vf = hex->stack->flag(f);
+
+						switch(f)
+						{
+							case SF1::IS_ACTIVE:
+								// at battle end, queue is messed up
+								// (the stack that dealt the killing blow is still "active", but not on 0 pos)
+								if(ended)
+									break;
+
+								if(vf == 0)
+									expect(cstack != astack, "HEX.STACK_FLAGS1.IS_ACTIVE: =0 but cstack == astack");
+								else
+									expect(cstack == astack, "HEX.STACK_FLAGS1.IS_ACTIVE: =%d but cstack != astack", vf);
+								break;
+							case SF1::WILL_ACT:
+								ensureValueMatch(vf, cstack->willMove(), "HEX.STACK_FLAGS1.WILL_ACT");
+								break;
+							case SF1::CAN_WAIT:
+								ensureValueMatch(vf, cstack->willMove() && !cstack->waitedThisTurn, "HEX.STACK_FLAGS1.CAN_WAIT");
+								break;
+							case SF1::CAN_RETALIATE:
+								// XXX: ableToRetaliate() calls CAmmo's (i.e. CRetaliations's) canUse() method
+								//      which takes into account relevant bonuses (e.g. NO_RETALIATION from expert Blind)
+								//      It does *NOT* take into account the attacker's BLOCKS_RETALIATION bonus
+								//      (if it did, what would be the correct CAN_RETALIATE value for friendly units?)
+								ensureValueMatch(vf, cstack->ableToRetaliate(), "HEX.STACK_FLAGS1.CAN_RETALIATE");
+								break;
+							case SF1::SLEEPING:
+								cstack->unitType()->getId() == CreatureID::AMMO_CART
+									? ensureValueMatch(vf, false, "HEX.STACK_FLAGS1.SLEEPING")
+									: ensureValueMatch(vf, cstack->hasBonusOfType(BonusType::NOT_ACTIVE), "HEX.STACK_FLAGS1.SLEEPING");
+								break;
+							case SF1::BLOCKED:
+								ensureValueMatch(vf, cstack->canShoot() && battle->battleIsUnitBlocked(cstack), "HEX.STACK_FLAGS1.TWO_HEX_ATTACK_BREATH");
+								break;
+							case SF1::BLOCKING:
+							{
+								auto adjUnits = battle->battleAdjacentUnits(cstack);
+								bool want = std::ranges::any_of(
+									adjUnits,
+									[&battle, &cstack](const auto & adjstack)
+									{
+										return adjstack->unitSide() != cstack->unitSide() && adjstack->canShoot() && battle->battleIsUnitBlocked(adjstack)
+											&& !adjstack->hasBonusOfType(BonusType::FREE_SHOOTING) && !adjstack->hasBonusOfType(BonusType::SIEGE_WEAPON);
+									}
+								);
+
+								ensureValueMatch(vf, want, "HEX.STACK_FLAGS1.BLOCKING");
+							}
+							break;
+							case SF1::IS_WIDE:
+								ensureValueMatch(vf, cstack->occupiedHex().isAvailable(), "HEX.STACK_FLAGS1.IS_WIDE");
+								break;
+							case SF1::FLYING:
+								ensureValueMatch(vf, cstack->hasBonusOfType(BonusType::FLYING), "HEX.STACK_FLAGS1.FLYING");
+								break;
+							case SF1::ADDITIONAL_ATTACK:
+								ensureValueMatch(vf, cstack->hasBonusOfType(BonusType::ADDITIONAL_ATTACK), "HEX.STACK_FLAGS1.ADDITIONAL_ATTACK");
+								break;
+							case SF1::NO_MELEE_PENALTY:
+								ensureValueMatch(vf, cstack->hasBonusOfType(BonusType::NO_MELEE_PENALTY), "HEX.STACK_FLAGS1.NO_MELEE_PENALTY");
+								break;
+							case SF1::TWO_HEX_ATTACK_BREATH:
+								ensureValueMatch(vf, cstack->hasBonusOfType(BonusType::TWO_HEX_ATTACK_BREATH), "HEX.STACK_FLAGS1.TWO_HEX_ATTACK_BREATH");
+								break;
+							case SF1::BLOCKS_RETALIATION:
+								ensureValueMatch(vf, cstack->hasBonusOfType(BonusType::BLOCKS_RETALIATION), "HEX.STACK_FLAGS1.BLOCKS_RETALIATION");
+								break;
+							case SF1::SHOOTER:
+								ensureValueMatch(vf, cstack->hasBonusOfType(BonusType::SHOOTER), "HEX.STACK_FLAGS1.SHOOTER");
+								// canShoot returns false if ammo = 0
+								// ensureValueMatch(vf, cstack->canShoot(), "HEX.STACK_FLAGS1.SHOOTER (canShoot)");
+								break;
+							case SF1::NON_LIVING:
+							{
+								auto undead = cstack->hasBonusOfType(BonusType::UNDEAD);
+								auto nonliving = cstack->hasBonusOfType(BonusType::NON_LIVING);
+								ensureValueMatch(vf, undead || nonliving, "HEX.STACK_FLAGS1.NON_LIVING", cstack->getDescription());
+							}
+							break;
+							case SF1::WAR_MACHINE:
+								ensureValueMatch(vf, cstack->hasBonusOfType(BonusType::SIEGE_WEAPON), "HEX.STACK_FLAGS1.WAR_MACHINE");
+								break;
+							case SF1::FIREBALL:
+								ensureValueMatch(
+									vf, cstack->hasBonusOfType(BonusType::SPELL_LIKE_ATTACK, SpellID(SpellID::FIREBALL)), "HEX.STACK_FLAGS1.FIREBALL"
+								);
+								break;
+							case SF1::DEATH_CLOUD:
+								ensureValueMatch(
+									vf, cstack->hasBonusOfType(BonusType::SPELL_LIKE_ATTACK, SpellID(SpellID::DEATH_CLOUD)), "HEX.STACK_FLAGS1.DEATH_CLOUD"
+								);
+								break;
+							case SF1::THREE_HEADED_ATTACK:
+								ensureValueMatch(vf, cstack->hasBonusOfType(BonusType::THREE_HEADED_ATTACK), "HEX.STACK_FLAGS1.THREE_HEADED_ATTACK");
+								break;
+							case SF1::ALL_AROUND_ATTACK:
+								ensureValueMatch(vf, cstack->hasBonusOfType(BonusType::ATTACKS_ALL_ADJACENT), "HEX.STACK_FLAGS1.ALL_AROUND_ATTACK");
+								break;
+							case SF1::RETURN_AFTER_STRIKE:
+								ensureValueMatch(vf, cstack->hasBonusOfType(BonusType::RETURN_AFTER_STRIKE), "HEX.STACK_FLAGS1.RETURN_AFTER_STRIKE");
+								break;
+							case SF1::ENEMY_DEFENCE_REDUCTION:
+								ensureValueMatch(vf, cstack->hasBonusOfType(BonusType::ENEMY_DEFENCE_REDUCTION), "HEX.STACK_FLAGS1.ENEMY_DEFENCE_REDUCTION");
+								break;
+							case SF1::LIFE_DRAIN:
+								ensureValueMatch(vf, cstack->hasBonusOfType(BonusType::LIFE_DRAIN), "HEX.STACK_FLAGS1.LIFE_DRAIN");
+								break;
+							case SF1::DOUBLE_DAMAGE_CHANCE:
+								ensureValueMatch(vf, cstack->hasBonusOfType(BonusType::DOUBLE_DAMAGE_CHANCE), "HEX.STACK_FLAGS1.DOUBLE_DAMAGE_CHANCE");
+								break;
+							case SF1::DEATH_STARE:
+								ensureValueMatch(vf, cstack->hasBonusOfType(BonusType::DEATH_STARE), "HEX.STACK_FLAGS1.DEATH_STARE");
+								break;
+							default:
+								THROW_FORMAT("Unexpected StackFlag: %d", EI(f));
+						}
+					}
+				}
+				break;
+				case HA::STACK_FLAGS2:
+				{
+					if(!isNA(v, cstack, "HEX.STACK_FLAGS"))
+					{
+						for(int j = 0; j < EI(StackFlag2::_count); j++)
+						{
+							auto f = static_cast<StackFlag2>(j);
+							auto vf = hex->stack->flag(f);
+
+							switch(f)
+							{
+								case SF2::AGE:
+									ensureValueMatch(vf, cstack->hasBonusFrom(BonusSource::SPELL_EFFECT, SpellID(SpellID::AGE)), "HEX.STACK_FLAGS2.AGE");
+									break;
+								case SF2::AGE_ATTACK:
+									ensureValueMatch(
+										vf, cstack->hasBonusOfType(BonusType::SPELL_AFTER_ATTACK, SpellID(SpellID::AGE)), "HEX.STACK_FLAGS2.AGE_ATTACK"
+									);
+									break;
+								case SF2::BIND:
+									ensureValueMatch(vf, cstack->hasBonusFrom(BonusSource::SPELL_EFFECT, SpellID(SpellID::BIND)), "HEX.STACK_FLAGS2.BIND");
+									break;
+								case SF2::BIND_ATTACK:
+									ensureValueMatch(
+										vf, cstack->hasBonusOfType(BonusType::SPELL_AFTER_ATTACK, SpellID(SpellID::BIND)), "HEX.STACK_FLAGS2.BIND_ATTACK"
+									);
+									break;
+								case SF2::BLIND:
+								{
+									auto blind = cstack->hasBonusFrom(BonusSource::SPELL_EFFECT, SpellID(SpellID::BLIND));
+									auto paralyzed = cstack->hasBonusFrom(BonusSource::SPELL_EFFECT, SpellID(SpellID::PARALYZE));
+									ensureValueMatch(vf, blind || paralyzed, "HEX.STACK_FLAGS2.BLIND");
+								}
+								break;
+								case SF2::BLIND_ATTACK:
+								{
+									auto blind = cstack->hasBonusOfType(BonusType::SPELL_AFTER_ATTACK, SpellID(SpellID::BLIND));
+									auto paralyze = cstack->hasBonusOfType(BonusType::SPELL_AFTER_ATTACK, SpellID(SpellID::PARALYZE));
+									ensureValueMatch(vf, blind || paralyze, "HEX.STACK_FLAGS2.BLIND_ATTACK");
+								}
+								break;
+								case SF2::CURSE:
+									ensureValueMatch(vf, cstack->hasBonusFrom(BonusSource::SPELL_EFFECT, SpellID(SpellID::CURSE)), "HEX.STACK_FLAGS2.CURSE");
+									break;
+								case SF2::CURSE_ATTACK:
+									ensureValueMatch(
+										vf, cstack->hasBonusOfType(BonusType::SPELL_AFTER_ATTACK, SpellID(SpellID::CURSE)), "HEX.STACK_FLAGS2.CURSE_ATTACK"
+									);
+									break;
+								case SF2::DISPEL_ATTACK:
+									ensureValueMatch(
+										vf,
+										cstack->hasBonusOfType(BonusType::SPELL_AFTER_ATTACK, SpellID(SpellID::DISPEL_HELPFUL_SPELLS)),
+										"HEX.STACK_FLAGS2.DISPEL_ATTACK"
+									);
+									break;
+								case SF2::PETRIFY:
+									ensureValueMatch(
+										vf, cstack->hasBonusFrom(BonusSource::SPELL_EFFECT, SpellID(SpellID::STONE_GAZE)), "HEX.STACK_FLAGS2.PETRIFY"
+									);
+									break;
+								case SF2::PETRIFY_ATTACK:
+									ensureValueMatch(
+										vf,
+										cstack->hasBonusOfType(BonusType::SPELL_AFTER_ATTACK, SpellID(SpellID::STONE_GAZE)),
+										"HEX.STACK_FLAGS2.PETRIFY_ATTACK"
+									);
+									break;
+								case SF2::POISON:
+									ensureValueMatch(vf, cstack->hasBonusFrom(BonusSource::SPELL_EFFECT, SpellID(SpellID::POISON)), "HEX.STACK_FLAGS2.POISON");
+									break;
+								case SF2::POISON_ATTACK:
+									ensureValueMatch(
+										vf, cstack->hasBonusOfType(BonusType::SPELL_AFTER_ATTACK, SpellID(SpellID::POISON)), "HEX.STACK_FLAGS2.POISON_ATTACK"
+									);
+									break;
+								case SF2::WEAKNESS:
+									ensureValueMatch(
+										vf, cstack->hasBonusFrom(BonusSource::SPELL_EFFECT, SpellID(SpellID::WEAKNESS)), "HEX.STACK_FLAGS2.WEAKNESS"
+									);
+									break;
+								case SF2::WEAKNESS_ATTACK:
+									ensureValueMatch(
+										vf,
+										cstack->hasBonusOfType(BonusType::SPELL_AFTER_ATTACK, SpellID(SpellID::WEAKNESS)),
+										"HEX.STACK_FLAGS2.WEAKNESS_ATTACK"
+									);
+									break;
+								default:
+									THROW_FORMAT("Unexpected StackFlag2: %d", EI(f));
+							}
+						}
+					}
+				}
+				break;
+				default:
+					THROW_FORMAT("Unexpected HexAttribute: %d", EI(attr));
+			}
+		}
+	}
+
+	// Mask is undefined at battle end
+	if(ended)
+		return;
+}
+
+// This intentionally uses the IState interface to ensure that
+// the schema is properly exposing all needed informaton
+std::string Render(const Schema::IState * istate, const Action * action) // NOSONAR - function used for debugging only
+{
+	auto supdata_ = istate->getSupplementaryData();
+	expect(supdata_.has_value(), "supdata_ holds no value");
+	expect(supdata_.type() == typeid(const ISupplementaryData *), "supdata_ of unexpected type");
+	const auto * sup = std::any_cast<const ISupplementaryData *>(supdata_);
+	expect(sup, "sup holds a nullptr");
+	const auto * gstats = sup->getGlobalStats();
+	const auto * lpstats = sup->getLeftPlayerStats();
+	const auto * rpstats = sup->getRightPlayerStats();
+	const auto * mystats = gstats->getAttr(GA::BATTLE_SIDE) ? rpstats : lpstats;
+	auto hexes = sup->getHexes();
+	auto alogs = sup->getAttackLogs();
+
+	const IStack * astack = nullptr;
+
+	// find an active hex (i.e. with active stack on it)
+	for(auto & row : hexes)
+	{
+		for(auto & hex : row)
+		{
+			const auto * const stack = hex->getStack();
+			if(stack && stack->getFlag(SF1::IS_ACTIVE))
+			{
+				expect(!astack || astack == stack, "two active stacks found");
+				astack = stack;
+			}
+		}
+	}
+
+	auto ended = gstats->getAttr(GA::BATTLE_WINNER) != S13::NULL_VALUE_UNENCODED;
+
+	if(!astack && !ended)
+		logAi->error("could not find an active stack (battle has not ended).");
+
+	std::string nocol = "\033[0m";
+	std::string redcol = "\033[31m"; // red
+	std::string bluecol = "\033[34m"; // blue
+	std::string darkcol = "\033[90m";
+	std::string activemod = "\033[107m\033[7m"; // bold+reversed
+	std::string ukncol = "\033[7m"; // white
+
+	std::vector<std::stringstream> lines;
+
+	//
+	// 1. Add logs table:
+	//
+	// #1 attacks #5 for 16 dmg (1 killed)
+	// #5 attacks #1 for 4 dmg (0 killed)
+	// ...
+	//
+	for(auto & alog : alogs)
+	{
+		auto row = std::stringstream();
+		auto attcol = ukncol;
+		auto attalias = '?';
+		auto defcol = ukncol;
+		auto defalias = '?';
+
+		if(alog->getAttacker())
+		{
+			attcol = (alog->getAttacker()->getAttr(SA::SIDE) == 0) ? redcol : bluecol;
+			attalias = alog->getAttacker()->getAlias();
+		}
+
+		if(alog->getDefender())
+		{
+			defcol = (alog->getDefender()->getAttr(SA::SIDE) == 0) ? redcol : bluecol;
+			defalias = alog->getDefender()->getAlias();
+		}
+
+		row << attcol << "#" << attalias << nocol;
+		row << " attacks ";
+		row << defcol << "#" << defalias << nocol;
+		row << " for " << alog->getDamageDealt() << " dmg";
+		row << " (kills: " << alog->getUnitsKilled() << ", value: " << alog->getValueKilled() << " / " << alog->getValueKilledPermille() << "‰)";
+
+		lines.push_back(std::move(row));
+	}
+
+	//
+	// 2. Build ASCII table
+	//    (+populate aliveStacks var)
+	//    NOTE: the contents below look mis-aligned in some editors.
+	//          In (my) terminal, it all looks correct though.
+	//
+	//   ▕₁▕₂▕₃▕₄▕₅▕₆▕₇▕₈▕₉▕₀▕₁▕₂▕₃▕₄▕₅▕
+	//  ┃▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔┃
+	// ¹┨  1 ◌ ○ ○ ○ ○ ◌ ◌ ◌ ◌ ◌ ◌ ◌ ◌ 1 ┠¹
+	// ²┨ ◌ ○ ○ ○ ○ ○ ○ ◌ ◌ ◌ ◌ ◌ ◌ ◌ ◌  ┠²
+	// ³┨  ◌ ○ ○ ○ ○ ○ ○ ◌ ▦ ▦ ◌ ◌ ◌ ◌ ◌ ┠³
+	// ⁴┨ ◌ ○ ○ ○ ○ ○ ○ ○ ▦ ▦ ▦ ◌ ◌ ◌ ◌  ┠⁴
+	// ⁵┨  2 ◌ ○ ○ ▦ ▦ ◌ ○ ◌ ◌ ◌ ◌ ◌ ◌ 2 ┠⁵
+	// ⁶┨ ◌ ○ ○ ○ ▦ ▦ ◌ ○ ○ ◌ ◌ ◌ ◌ ◌ ◌  ┠⁶
+	// ⁷┨  3 3 ○ ○ ○ ▦ ◌ ○ ○ ◌ ◌ ▦ ◌ ◌ 3 ┠⁷
+	// ⁸┨ ◌ ○ ○ ○ ○ ○ ○ ○ ○ ◌ ◌ ▦ ▦ ◌ ◌  ┠⁸
+	// ⁹┨  ◌ ○ ○ ○ ○ ○ ○ ○ ◌ ◌ ◌ ◌ ◌ ◌ ◌ ┠⁹
+	// ⁰┨ ◌ ○ ○ ○ ○ ○ ○ ○ ◌ ◌ ◌ ◌ ◌ ◌ ◌  ┠⁰
+	// ¹┨  4 ◌ ○ ○ ○ ○ ○ ◌ ◌ ◌ ◌ ◌ ◌ ◌ 4 ┠¹
+	//  ┃▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁┃
+	//   ▕¹▕²▕³▕⁴▕⁵▕⁶▕⁷▕⁸▕⁹▕⁰▕¹▕²▕³▕⁴▕⁵▕
+	//
+
+	// s=special; can be any number, slot is always 7 (SPECIAL), visualized A,B,C...
+
+	auto tablestartrow = lines.size();
+
+	lines.emplace_back() << "    ₀▏₁▏₂▏₃▏₄▏₅▏₆▏₇▏₈▏₉▏₀▏₁▏₂▏₃▏₄";
+	lines.emplace_back() << " ┃▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔┃ ";
+
+	static const std::array<std::string, 10> nummap{"₀", "₁", "₂", "₃", "₄", "₅", "₆", "₇", "₈", "₉"};
+
+	bool addspace = true;
+
+	auto seenstacks = std::map<const IStack *, IHex *>{};
+	bool divlines = true;
+
+	// y even "▏"
+	// y odd "▕"
+
+	for(int y = 0; y < 11; y++)
+	{
+		for(int x = 0; x < 15; x++)
+		{
+			auto sym = std::string("?");
+			auto & hex = hexes.at(y).at(x);
+			const auto * stack = hex->getStack();
+
+			const char * spacer = (y % 2 == 0) ? " " : "";
+			auto & row = (x == 0) ? (lines.emplace_back() << nummap.at(y % 10) << "┨" << spacer) : lines.back();
+
+			if(addspace)
+			{
+				if(divlines && (x != 0))
+				{
+					row << darkcol << (y % 2 == 0 ? "▏" : "▕") << nocol;
+				}
+				else
+				{
+					row << " ";
+				}
+			}
+
+			addspace = true;
+
+			auto smask = HexStateMask(hex->getAttr(HA::STATE_MASK));
+			auto col = nocol;
+
+			// First put symbols based on hex state.
+			// If there's a stack on this hex, symbol will be overriden.
+			HexStateMask mpass = 1 << EI(HexState::PASSABLE);
+			HexStateMask mstop = 1 << EI(HexState::STOPPING);
+			HexStateMask mdmgl = 1 << EI(HexState::DAMAGING_L);
+			HexStateMask mdmgr = 1 << EI(HexState::DAMAGING_R);
+			HexStateMask mdefault = 0; // or mother :)
+
+			std::vector<std::tuple<std::string, std::string, HexStateMask>> symbols{
+				{"⨻", bluecol, mpass | mstop | mdmgl},
+				{"⨻", redcol,  mpass | mstop | mdmgr},
+				{"✶", bluecol, mpass | mdmgl        },
+				{"✶", redcol,  mpass | mdmgr        },
+				{"△", nocol,   mpass | mstop        },
+				{"○", nocol,   mpass                }, // changed to "◌" if unreachable
+				{"◼", nocol,   mdefault             }
+			};
+
+			for(auto & tuple : symbols)
+			{
+				const auto & [s, c, m] = tuple;
+				if((smask & m) == m)
+				{
+					sym = s;
+					col = c;
+					break;
+				}
+			}
+
+			auto amask = HexActMask(hex->getAttr(HA::ACTION_MASK));
+			if(col == nocol && !amask.test(EI(HexAction::MOVE)))
+			{ // || supdata->getIsBattleEnded()
+				col = darkcol;
+				sym = sym == "○" ? "◌" : sym;
+			}
+
+			if(stack)
+			{
+				auto seen = seenstacks.find(stack) != seenstacks.end();
+				// MSVC mandates constexpr `n` here
+				constexpr int n = std::get<2>(S13::HEX_ENCODING[EI(HA::STACK_FLAGS1)]);
+				auto flags = std::bitset<n>(stack->getAttr(SA::FLAGS1));
+				sym = std::string(1, stack->getAlias());
+				col = stack->getAttr(SA::SIDE) ? bluecol : redcol;
+
+				if(stack->getAttr(SA::QUEUE) & 1)
+					col += activemod;
+
+				if(flags.test(EI(SF1::IS_WIDE)) && !seen)
+				{
+					if(stack->getAttr(SA::SIDE) == 0)
+					{
+						sym += "↠";
+						addspace = false;
+					}
+					else if(stack->getAttr(SA::SIDE) == 1 && hex->getAttr(HA::X_COORD) < 14)
+					{
+						sym += "↞";
+						addspace = false;
+					}
+				}
+
+				if(!seen)
+					seenstacks.try_emplace(stack, hex);
+			}
+
+			row << col << sym << nocol;
+
+			if(x == 15 - 1)
+			{
+				row << (y % 2 == 0 ? " " : "  ") << "┠" << nummap.at(y % 10);
+			}
+		}
+	}
+
+	lines.emplace_back() << " ┃▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁┃";
+	lines.emplace_back() << "   ⁰▕¹▕²▕³▕⁴▕⁵▕⁶▕⁷▕⁸▕⁹▕⁰▕¹▕²▕³▕⁴";
+
+	//
+	// 3. Add side table stuff
+	//
+	//   ▕₁▕₂▕₃▕₄▕₅▕₆▕₇▕₈▕₉▕₀▕₁▕₂▕₃▕₄▕₅▕
+	//  ┃▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔┃         Player: RED
+	// ₁┨  ○ ○ ○ ○ ○ ◌ ◌ ◌ ◌ ◌ ◌ ◌ ◌ ◌ ◌ ┠₁    Last action:
+	// ₂┨ ○ ○ ○ ○ ○ ○ ◌ ◌ ◌ ◌ ◌ ◌ ◌ ◌ ◌  ┠₂      DMG dealt: 0
+	// ₃┨  1 ○ ○ ○ ○ ○ ◌ ◌ ▦ ▦ ◌ ◌ ◌ ◌ 1 ┠₃   Units killed: 0
+	// ...
+
+	for(int i = 0; i <= lines.size(); i++)
+	{
+		std::string name;
+		std::string value;
+		auto side = gstats->getAttr(GA::BATTLE_SIDE);
+
+		switch(i)
+		{
+			case 1:
+				name = "Player";
+				if(ended)
+					value = "";
+				else
+					value = side ? bluecol + "BLUE" + nocol : redcol + "RED" + nocol;
+				break;
+			case 2:
+				name = "Last action";
+				value = action ? action->name() + " [" + std::to_string(action->action) + "]" : "";
+				break;
+			case 3:
+				name = "DMG dealt";
+				value = boost::str(boost::format("%d (%d since start)") % mystats->getAttr(PA::DMG_DEALT_NOW_ABS) % mystats->getAttr(PA::DMG_DEALT_ACC_ABS));
+				break;
+			case 4:
+				name = "DMG received";
+				value =
+					boost::str(boost::format("%d (%d since start)") % mystats->getAttr(PA::DMG_RECEIVED_NOW_ABS) % mystats->getAttr(PA::DMG_RECEIVED_ACC_ABS));
+				break;
+			case 5:
+				name = "Value killed";
+				value =
+					boost::str(boost::format("%d (%d since start)") % mystats->getAttr(PA::VALUE_KILLED_NOW_ABS) % mystats->getAttr(PA::VALUE_KILLED_ACC_ABS));
+				break;
+			case 6:
+				name = "Value lost";
+				value = boost::str(boost::format("%d (%d since start)") % mystats->getAttr(PA::VALUE_LOST_NOW_ABS) % mystats->getAttr(PA::VALUE_LOST_ACC_ABS));
+				break;
+			case 7:
+			{
+				// XXX: if there's a draw, this text will be incorrect
+				auto restext = gstats->getAttr(GA::BATTLE_WINNER) ? (bluecol + "BLUE WINS") : (redcol + "RED WINS");
+
+				name = "Battle result";
+				value = ended ? (restext + nocol) : "";
+			}
+			break;
+			case 8:
+				name = "Army value (L)";
+				value = boost::str(
+					boost::format("%d (%.0f‰ of current BF value)") % lpstats->getAttr(PA::ARMY_VALUE_NOW_ABS) % lpstats->getAttr(PA::ARMY_VALUE_NOW_REL)
+				);
+				break;
+			case 9:
+				name = "Army value (R)";
+				value = boost::str(
+					boost::format("%d (%.0f‰ of current BF value)") % rpstats->getAttr(PA::ARMY_VALUE_NOW_ABS) % rpstats->getAttr(PA::ARMY_VALUE_NOW_REL)
+				);
+				break;
+			case 10:
+				name = "Current BF value";
+				value = boost::str(
+					boost::format("%d (%.0f‰ of starting BF value)") % gstats->getAttr(GA::BFIELD_VALUE_NOW_ABS) % gstats->getAttr(GA::BFIELD_VALUE_NOW_REL0)
+				);
+				break;
+			default:
+				continue;
+		}
+
+		lines.at(tablestartrow + i) << PadLeft(name, 17, ' ') << ": " << value;
+	}
+
+	lines.emplace_back() << "";
+
+	//
+	// 5. Add stacks table:
+	//
+	//          Stack # |   0   1   2   3   4   5   6   A   B   C   0   1   2   3   4   5   6   A   B   C
+	// -----------------+--------------------------------------------------------------------------------
+	//              Qty |   0  34   0   0   0   0   0   0   0   0   0  17   0   0   0   0   0   0   0   0
+	//           Attack |   0   8   0   0   0   0   0   0   0   0   0   6   0   0   0   0   0   0   0   0
+	//    ...10 more... | ...
+	// -----------------+--------------------------------------------------------------------------------
+	//
+	// table with 24 columns (1 header, 3 dividers, 10 stacks per side)
+	// Each row represents a separate attribute
+
+	using RowDef = std::tuple<StackAttribute, std::string>;
+
+	// max to show
+	constexpr int max_stacks_per_side = 10;
+
+	// All cell text is aligned right
+	auto colwidths = std::array<int, 4 + (2 * max_stacks_per_side)>{};
+	colwidths.fill(5); // default col width
+	colwidths.at(0) = 16; // header col
+
+	// Divider column indexes
+	auto divcolids = {1, max_stacks_per_side + 2, (2 * max_stacks_per_side) + 3};
+
+	for(int i : divcolids)
+		colwidths.at(i) = 2; // divider col
+
+	// {Attribute, name, colwidth}
+	const auto rowdefs = std::vector<RowDef>{
+		RowDef{SA::FLAGS1,    "Stack #"         }, // stack alias (1..7, S or M)
+		RowDef{SA::SIDE,      ""                }, // divider row
+		RowDef{SA::QUANTITY,  "Qty"             },
+		RowDef{SA::ATTACK,    "Attack"          },
+		RowDef{SA::DEFENSE,   "Defense"         },
+		RowDef{SA::SHOTS,     "Shots"           },
+		RowDef{SA::DMG_MIN,   "Dmg (min)"       },
+		RowDef{SA::DMG_MAX,   "Dmg (max)"       },
+		RowDef{SA::HP,        "HP"              },
+		RowDef{SA::HP_LEFT,   "HP left"         },
+		RowDef{SA::SPEED,     "Speed"           },
+		RowDef{SA::QUEUE,     "Queue"           },
+		RowDef{SA::VALUE_ONE, "Value (one)"     },
+		RowDef{SA::VALUE_REL, "       Value (‰)"}, // manually pad to 16 (unicode length issue)
+		// RowDef{SA::ESTIMATED_DMG, "Est. DMG%"},
+		RowDef{SA::FLAGS1,    "State"           }, // "WAR" = CAN_WAIT, WILL_ACT, CAN_RETAL
+		RowDef{SA::FLAGS1,    "Attack mods"     }, // "DB" = Double, Blinding
+		// 2 values per column to avoid too long table
+		RowDef{SA::FLAGS1,    "Blocked/ing"     },
+		RowDef{SA::FLAGS1,    "Fly/Sleep"       },
+		RowDef{SA::FLAGS1,    "NoRetal/NoMelee" },
+		RowDef{SA::FLAGS1,    "Wide/Breath"     },
+		RowDef{SA::SIDE,      ""                }, // divider row
+	};
+
+	// Table with nrows and ncells, each cell a 3-element tuple
+	// cell: color, width, txt
+	using TableCell = std::tuple<std::string, int, std::string>;
+	using TableRow = std::array<TableCell, colwidths.size()>;
+
+	auto table = std::vector<TableRow>{};
+
+	auto divrow = TableRow{};
+	for(int i = 0; i < colwidths.size(); i++)
+		divrow[i] = {nocol, colwidths.at(i), std::string(colwidths.at(i), '-')};
+
+	for(int i : divcolids)
+		divrow.at(i) = {nocol, colwidths.at(i), std::string(colwidths.at(i) - 1, '-') + "+"};
+
+	int specialcounter = 0;
+
+	// Attribute rows
+	for(const auto & [a, aname] : rowdefs)
+	{
+		if(a == SA::SIDE)
+		{ // divider row
+			table.push_back(divrow);
+			continue;
+		}
+
+		auto row = TableRow{};
+
+		// Header col
+		row.at(0) = {nocol, colwidths.at(0), aname};
+
+		// Div cols
+		for(int i : {1, 2 + max_stacks_per_side, static_cast<int>(colwidths.size() - 1)})
+			row.at(i) = {nocol, colwidths.at(i), "|"};
+
+		// Stack cols
+		for(auto side : {0, 1})
+		{
+			auto sidestacks = std::array<std::pair<const IStack *, IHex *>, max_stacks_per_side>{};
+			auto extracounter = 0;
+			for(const auto & [stack, hex] : seenstacks)
+			{
+				if(stack->getAttr(SA::SIDE) == side)
+				{
+					int slot = stack->getAlias() >= '0' && stack->getAlias() <= '6' ? stack->getAlias() - '0' : 7 + extracounter;
+
+					if(slot < max_stacks_per_side)
+						sidestacks.at(slot) = {stack, hex};
+
+					if(slot >= 7)
+						extracounter += 1;
+				}
+			}
+
+			for(int i = 0; i < sidestacks.size(); ++i)
+			{
+				auto & [stack, hex] = sidestacks.at(i);
+				auto colid = 2 + i + side + (max_stacks_per_side * side);
+
+				if(!stack)
+				{
+					row.at(colid) = {nocol, colwidths.at(colid), ""};
+					continue;
+				}
+
+				std::string value;
+
+				// MSVC mandates constexpr `n` here
+				constexpr int n1 = std::get<2>(S13::HEX_ENCODING[EI(HA::STACK_FLAGS1)]);
+				constexpr int n2 = std::get<2>(S13::HEX_ENCODING[EI(HA::STACK_FLAGS2)]);
+				auto flags1 = std::bitset<n1>(stack->getAttr(SA::FLAGS1));
+				auto flags2 = std::bitset<n2>(stack->getAttr(SA::FLAGS2));
+
+				auto color = stack->getAttr(SA::SIDE) ? bluecol : redcol;
+
+				if(a == SA::QUEUE)
+				{
+					auto qbits = std::bitset<S13::STACK_QUEUE_SIZE>(stack->getAttr(SA::QUEUE));
+					for(int n = 0; n < S13::STACK_QUEUE_SIZE; ++n)
+					{
+						if(qbits.test(n))
+						{
+							value = std::to_string(n);
+							break;
+						}
+					}
+				}
+				else if(a == SA::VALUE_ONE && stack->getAttr(a) >= 1000)
+				{
+					std::ostringstream oss;
+					oss << std::fixed << std::setprecision(1) << (stack->getAttr(a) / 1000.0);
+					value = oss.str();
+					value[value.size() - 2] = 'k';
+					if(value.rfind("K0") == (value.size() - 2))
+						value.resize(value.size() - 1);
+				}
+				else if(a == SA::FLAGS1)
+				{
+					auto fmt = boost::format("%d/%d");
+
+					switch(specialcounter)
+					{
+						case 0:
+							value = std::string(1, stack->getAlias());
+							break;
+						case 1:
+						{
+							value = std::string("");
+							value += flags1.test(EI(SF1::CAN_WAIT)) ? "W" : " ";
+							value += flags1.test(EI(SF1::WILL_ACT)) ? "A" : " ";
+							value += flags1.test(EI(SF1::CAN_RETALIATE)) ? "R" : " ";
+						}
+						break;
+						case 2:
+						{
+							value = std::string("");
+							value += flags1.test(EI(SF1::ADDITIONAL_ATTACK)) ? "D" : " ";
+							value += flags2.test(EI(SF2::BLIND_ATTACK)) ? "B" : " ";
+						}
+						break;
+						case 3:
+							value = boost::str(fmt % flags1.test(EI(SF1::BLOCKED)) % flags1.test(EI(SF1::BLOCKING)));
+							break;
+						case 4:
+							value = boost::str(fmt % flags1.test(EI(SF1::FLYING)) % flags1.test(EI(SF1::SLEEPING)));
+							break;
+						case 5:
+							value = boost::str(fmt % flags1.test(EI(SF1::BLOCKS_RETALIATION)) % flags1.test(EI(SF1::NO_MELEE_PENALTY)));
+							break;
+						case 6:
+							value = boost::str(fmt % flags1.test(EI(SF1::IS_WIDE)) % flags1.test(EI(SF1::TWO_HEX_ATTACK_BREATH)));
+							break;
+						default:
+							THROW_FORMAT("Unexpected specialcounter: %d", specialcounter);
+					}
+				}
+				else
+				{
+					value = std::to_string(stack->getAttr(a));
+				}
+
+				if((stack->getAttr(SA::QUEUE) & 1) && !ended)
+					color += activemod;
+
+				row.at(colid) = {color, colwidths.at(colid), value};
+			}
+		}
+
+		if(a == SA::FLAGS1)
+			++specialcounter;
+
+		table.push_back(row);
+	}
+
+	for(auto & r : table)
+	{
+		auto line = std::stringstream();
+		for(auto & [color, width, txt] : r)
+			line << color << PadLeft(txt, width, ' ') << nocol;
+
+		lines.push_back(std::move(line));
+	}
+
+	//
+	// 7. Join rows into a single string
+	//
+	std::string res = lines[0].str();
+	for(int i = 1; i < lines.size(); i++)
+		res += "\n" + lines[i].str();
+
+	return res;
+}
+}

+ 21 - 0
AI/MMAI/BAI/v13/render.h

@@ -0,0 +1,21 @@
+/*
+ * render.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 "BAI/v13/action.h"
+#include "BAI/v13/state.h"
+#include "schema/base.h"
+
+namespace MMAI::BAI::V13
+{
+std::string Render(const Schema::IState * istate, const Action * action);
+void Verify(const State * state);
+}

+ 545 - 0
AI/MMAI/BAI/v13/stack.cpp

@@ -0,0 +1,545 @@
+/*
+ * stack.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 "CCreatureHandler.h"
+#include "GameLibrary.h"
+#include "battle/IBattleInfoCallback.h"
+#include "bonuses/BonusEnum.h"
+#include "common.h"
+#include "constants/EntityIdentifiers.h"
+
+#include "BAI/v13/stack.h"
+#include "schema/v13/constants.h"
+#include "schema/v13/types.h"
+
+namespace MMAI::BAI::V13
+{
+namespace S13 = Schema::V13;
+using SA = Schema::V13::StackAttribute;
+using F1 = Schema::V13::StackFlag1;
+using F2 = Schema::V13::StackFlag2;
+using GA = Schema::V13::GlobalAttribute;
+using CreatureValues = std::map<CreatureID, int>;
+
+namespace
+{
+	int calculateSlot(const CStack * cstack)
+	{
+		int slot = cstack->unitSlot();
+		if(slot >= 0 && slot < 7)
+		{
+			return slot;
+		}
+		else if(slot == SlotID::WAR_MACHINES_SLOT)
+		{
+			return S13::STACK_SLOT_WARMACHINES;
+		}
+		else
+		{
+			// "special" slot, e.g. summoned, commander, etc.
+			return S13::STACK_SLOT_SPECIAL;
+		}
+	}
+
+	char calculateAlias(int slot)
+	{
+		switch(slot)
+		{
+			case S13::STACK_SLOT_SPECIAL:
+				return 'S';
+				break;
+			case S13::STACK_SLOT_WARMACHINES:
+				return 'M';
+				break;
+			default:
+				return static_cast<char>('0' + slot);
+		}
+	}
+
+	int calculateValue(const CCreature * cr)
+	{
+		/*
+		 * Formula:
+		 * 10 * (A + B) * C * D1 * D2 * ... * Dn
+		 *
+		 * A = <offensive factor>
+		 * B = <defensive factor>
+		 * C = <speed factor>
+		 * D* = <bonus factor>
+		 */
+
+		auto att = cr->getBaseAttack();
+		auto def = cr->getBaseDefense();
+		auto dmg = (cr->getBaseDamageMax() + cr->getBaseDamageMin()) / 2.0;
+		auto hp = cr->getBaseHitPoints();
+		auto spd = cr->getBaseSpeed();
+		auto shooter = cr->hasBonusOfType(BonusType::SHOOTER);
+		auto bonuses = cr->getAllBonuses(Selector::all);
+
+		auto a = 3 * dmg * (1 + std::min(4.0, 0.05 * att));
+		auto b = hp / (1 - std::min(0.7, 0.025 * def));
+		auto c = spd ? std::log(spd * 2) : 0.5;
+		auto d = shooter ? 1.5 : 1.0;
+
+		for(const auto & bonus : *bonuses)
+		{
+			switch(bonus->type)
+			{
+				case BonusType::ADDITIONAL_ATTACK:
+					d += (shooter ? 0.5 : 0.3);
+					break;
+				case BonusType::ADDITIONAL_RETALIATION:
+					d += (bonus->val * 0.1);
+					break;
+				case BonusType::ATTACKS_ALL_ADJACENT:
+					d += 0.2;
+					break;
+				case BonusType::BLOCKS_RETALIATION:
+					d += 0.3;
+					break;
+				case BonusType::DEATH_STARE:
+					d += (bonus->val * 0.02); // 10% = 0.2
+					break;
+				case BonusType::DOUBLE_DAMAGE_CHANCE:
+					d += (bonus->val * 0.005); // 20% = 0.1
+					break;
+				case BonusType::ENEMY_DEFENCE_REDUCTION:
+					d += (bonus->val * 0.0025); // 40% = 0.1
+					break;
+				case BonusType::FIRE_SHIELD:
+					d += (bonus->val * 0.003); // 20% = 0.1
+					break;
+				case BonusType::FLYING:
+					d += 0.1;
+					break;
+				case BonusType::LIFE_DRAIN:
+					d += (bonus->val * 0.003); // 100% = 0.3
+					break;
+				case BonusType::NO_DISTANCE_PENALTY:
+					d += 0.5;
+					break;
+				case BonusType::NO_MELEE_PENALTY:
+					d += 0.1;
+					break;
+				case BonusType::THREE_HEADED_ATTACK:
+					d += 0.05;
+					break;
+				case BonusType::TWO_HEX_ATTACK_BREATH:
+					d += 0.1;
+					break;
+				case BonusType::UNLIMITED_RETALIATIONS:
+					d += 0.2;
+					break;
+				case BonusType::SPELL_LIKE_ATTACK:
+					switch(bonus->subtype.as<SpellID>())
+					{
+						case SpellID::DEATH_CLOUD:
+							d += 0.2;
+					}
+					break;
+				case BonusType::SPELL_AFTER_ATTACK:
+					switch(bonus->subtype.as<SpellID>())
+					{
+						case SpellID::BLIND:
+						case SpellID::STONE_GAZE:
+						case SpellID::PARALYZE:
+							d += (bonus->val * 0.01); // 20% = 0.2
+							break;
+						case SpellID::BIND:
+							d += (bonus->val * 0.001); // 100% = 0.1
+							break;
+						case SpellID::WEAKNESS:
+							d += (bonus->val * 0.001); // 100% = 0.1
+							break;
+						case SpellID::AGE:
+							d += (bonus->val * 0.005); // 20% = 0.1
+							break;
+						case SpellID::CURSE:
+							d += (bonus->val * 0.0025); // 20% = 0.05
+					}
+			}
+		}
+
+		// Multiply by 10 to reduce the integer rounding for weak units
+		// (e.g. peasant 7.48 => 7 is a lot, 74.8 => 75 is OK)
+		auto res = static_cast<int>(std::round(10 * (a + b) * c * d));
+
+		if(MMAI_VERBOSE)
+		{
+			std::cout << "MMAI_VERBOSE: " << res << " " << cr->getId().toEntity(LIBRARY)->getJsonKey() << " (a=" << a << ", b=" << b << ", c=" << c
+					  << ", d=" << d << ")\n";
+		}
+
+		return res;
+	}
+
+	CreatureValues InitCreatureValues()
+	{
+		CreatureValues values;
+
+		for(const auto & creature : LIBRARY->creh->objects)
+			if(creature)
+				values.try_emplace(creature->getId(), calculateValue(creature.get()));
+
+		return values;
+	}
+}
+
+// static
+int Stack::GetValue(const CCreature * creature)
+{
+	static const CreatureValues CREATURE_VALUES = InitCreatureValues();
+
+	if(!creature)
+		throw std::runtime_error("GetValue: nullptr given");
+
+	const auto & it = CREATURE_VALUES.find(creature->getId());
+
+	if(it == CREATURE_VALUES.end())
+		throw std::runtime_error("GetValue: no value for creature with ID=" + std::to_string(creature->getId()));
+
+	return it->second;
+}
+
+// static
+std::pair<BitQueue, int> Stack::QBits(const CStack * cstack, const Queue & vec)
+{
+	BitQueue q;
+	int pos = -1;
+	if(vec.size() != S13::STACK_QUEUE_SIZE)
+		throw std::runtime_error("Unexpected queue size: " + std::to_string(vec.size()));
+
+	for(auto i = 0; i < vec.size(); ++i)
+	{
+		if(vec[i] == cstack->unitId())
+		{
+			q.set(i);
+			if(pos < 0)
+				pos = i;
+		}
+	}
+
+	return {q, pos};
+}
+
+Stack::Stack(
+	const CStack * cstack_,
+	const Queue & q,
+	const StatsContainer & statsContainer,
+	const ReachabilityInfo & rinfo_,
+	bool blocked,
+	bool blocking,
+	const DamageEstimation & estdmg
+)
+	: cstack(cstack_), rinfo(rinfo_)
+{
+	// XXX: NULL attrs are used only for non-existing stacks
+	// => don't fill with null here (as opposed to attrs in Hex)
+
+	const auto & oldgstats = statsContainer.oldgstats;
+	const auto & gstats = statsContainer.gstats;
+	const auto & stackStats = statsContainer.stackStats;
+
+	int slot = calculateSlot(cstack);
+	alias = calculateAlias(slot);
+
+	// queue needs to be set first to determine if stack is active
+	auto [qbits, pos] = QBits(cstack, q);
+	qposFirst = pos; // for comparing two positions
+
+	processBonuses();
+
+	if(cstack->willMove())
+	{
+		setflag(F1::WILL_ACT);
+		// XXX: do NOT use cstack->waited()
+		//      (it returns cstack->waiting, which becomes false after acting)
+		if(!cstack->waitedThisTurn)
+			setflag(F1::CAN_WAIT);
+	}
+
+	if(cstack->ableToRetaliate())
+		setflag(F1::CAN_RETALIATE);
+
+	if(blocked)
+		setflag(F1::BLOCKED);
+
+	if(blocking)
+		setflag(F1::BLOCKING);
+
+	if(cstack->occupiedHex().isAvailable())
+		setflag(F1::IS_WIDE);
+
+	// std::bitset's public semantics are architecture-independent.
+	// operator<< prints bits from MSB to LSB,
+	// e.g. std::bitset<8>(1) prints 00000001
+	// b[i] indexes from the least-significant bit: b[0] == 1, b[1..7] == 0
+	if(qbits.test(0))
+		setflag(F1::IS_ACTIVE);
+
+	shots = cstack->shots.available();
+
+	auto valueOne = GetValue(cstack->unitType());
+
+	// Force a lower value for summons (we don't care about them, they are not permanent)
+	if(cstack->unitSlot() == SlotID::SUMMONED_SLOT_PLACEHOLDER)
+		valueOne *= 0.2;
+
+	auto permille = [](int v1, int v2)
+	{
+		// LL (long long) ensures long is 64-bit even on 32-bit systems
+		return static_cast<int>((1000LL * v1) / v2);
+	};
+
+	auto bf_valueNow = gstats->attr(GA::BFIELD_VALUE_NOW_ABS);
+	auto bf_valuePrev = oldgstats->attr(GA::BFIELD_VALUE_NOW_ABS);
+	auto bf_valueStart = gstats->attr(GA::BFIELD_VALUE_START_ABS);
+	auto bf_hpPrev = oldgstats->attr(GA::BFIELD_HP_NOW_ABS);
+	auto bf_hpStart = gstats->attr(GA::BFIELD_HP_START_ABS);
+	auto value = valueOne * cstack->getCount();
+
+	setattr(SA::SIDE, EI(cstack->unitSide()));
+	setattr(SA::SLOT, slot);
+	setattr(SA::QUANTITY, cstack->getCount());
+	setattr(SA::ATTACK, cstack->getAttack(shots > 0));
+	setattr(SA::DEFENSE, cstack->getDefense(false));
+	setattr(SA::SHOTS, shots);
+	setattr(SA::DMG_MIN, cstack->getMinDamage(shots > 0));
+	setattr(SA::DMG_MAX, cstack->getMaxDamage(shots > 0));
+	setattr(SA::HP, cstack->getMaxHealth());
+	setattr(SA::HP_LEFT, cstack->getFirstHPleft());
+	setattr(SA::SPEED, cstack->getMovementRange());
+	setattr(SA::QUEUE, qbits.to_ulong());
+	setattr(SA::VALUE_ONE, valueOne);
+	setattr(SA::VALUE_REL, permille(value, bf_valueNow));
+	setattr(SA::VALUE_REL0, permille(value, bf_valueStart));
+	setattr(SA::VALUE_KILLED_REL, permille(stackStats.valueKilledNow, bf_valuePrev));
+	setattr(SA::VALUE_KILLED_ACC_REL0, permille(stackStats.valueKilledTotal, bf_valueStart));
+	setattr(SA::VALUE_LOST_REL, permille(stackStats.valueLostNow, bf_valuePrev));
+	setattr(SA::VALUE_LOST_ACC_REL0, permille(stackStats.valueLostTotal, bf_valueStart));
+	setattr(SA::DMG_DEALT_REL, permille(stackStats.dmgDealtNow, bf_hpPrev));
+	setattr(SA::DMG_DEALT_ACC_REL0, permille(stackStats.dmgDealtTotal, bf_hpStart));
+	setattr(SA::DMG_RECEIVED_REL, permille(stackStats.dmgReceivedNow, bf_hpPrev));
+	setattr(SA::DMG_RECEIVED_ACC_REL0, permille(stackStats.dmgReceivedTotal, bf_hpStart));
+
+	// The attrs set above must match the total count -2 (which are the FLAGS1 and FLAGS2)
+	static_assert(EI(SA::_count) == 23 + 2, "whistleblower in case attributes change");
+
+	finalize();
+}
+
+void Stack::processBonuses()
+{
+	auto bonuses = cstack->getAllBonuses(Selector::all);
+
+	// XXX: config/creatures/<faction>.json is misleading
+	//      (for example, no creature has NO_MELEE_PENALTY bonus there)
+	//      The source of truth is the original H3 data files
+	//      (referred to as CRTRAITS.TXT file, see CCreatureHandler::loadLegacyData)
+	for(const auto & bonus : *bonuses)
+	{
+		switch(bonus->type)
+		{
+			case BonusType::FLYING:
+				setflag(F1::FLYING);
+				break;
+			case BonusType::SHOOTER:
+				setflag(F1::SHOOTER);
+				break;
+			case BonusType::UNDEAD:
+				setflag(F1::NON_LIVING);
+				break;
+			case BonusType::NON_LIVING:
+				setflag(F1::NON_LIVING);
+				break;
+			case BonusType::SIEGE_WEAPON:
+				setflag(F1::WAR_MACHINE);
+				break;
+			case BonusType::BLOCKS_RETALIATION:
+				setflag(F1::BLOCKS_RETALIATION);
+				break;
+			case BonusType::NO_MELEE_PENALTY:
+				setflag(F1::NO_MELEE_PENALTY);
+				break;
+			case BonusType::TWO_HEX_ATTACK_BREATH:
+				setflag(F1::TWO_HEX_ATTACK_BREATH);
+				break;
+			case BonusType::ADDITIONAL_ATTACK:
+				setflag(F1::ADDITIONAL_ATTACK);
+				break;
+			case BonusType::SPELL_AFTER_ATTACK:
+				switch(bonus->subtype.as<SpellID>())
+				{
+					case SpellID::BLIND:
+						setflag(F2::BLIND_ATTACK);
+						break;
+					case SpellID::PARALYZE:
+						setflag(F2::BLIND_ATTACK);
+						break;
+					case SpellID::STONE_GAZE:
+						setflag(F2::PETRIFY_ATTACK);
+						break;
+					case SpellID::BIND:
+						setflag(F2::BIND_ATTACK);
+						break;
+					case SpellID::WEAKNESS:
+						setflag(F2::WEAKNESS_ATTACK);
+						break;
+					case SpellID::DISPEL:
+						setflag(F2::DISPEL_ATTACK);
+						break;
+					case SpellID::DISPEL_HELPFUL_SPELLS:
+						setflag(F2::DISPEL_ATTACK);
+						break;
+					case SpellID::POISON:
+						setflag(F2::POISON_ATTACK);
+						break;
+					case SpellID::CURSE:
+						setflag(F2::CURSE_ATTACK);
+						break;
+					case SpellID::AGE:
+						setflag(F2::AGE_ATTACK);
+				}
+				break;
+			case BonusType::SPELL_LIKE_ATTACK:
+				switch(bonus->subtype.as<SpellID>())
+				{
+					case SpellID::FIREBALL:
+						setflag(F1::FIREBALL);
+						break;
+					case SpellID::DEATH_CLOUD:
+						setflag(F1::DEATH_CLOUD);
+				}
+				break;
+			case BonusType::THREE_HEADED_ATTACK:
+				setflag(F1::THREE_HEADED_ATTACK);
+				break;
+			case BonusType::ATTACKS_ALL_ADJACENT:
+				setflag(F1::ALL_AROUND_ATTACK);
+				break;
+			case BonusType::RETURN_AFTER_STRIKE:
+				setflag(F1::RETURN_AFTER_STRIKE);
+				break;
+			case BonusType::ENEMY_DEFENCE_REDUCTION:
+				setflag(F1::ENEMY_DEFENCE_REDUCTION);
+				break;
+			case BonusType::LIFE_DRAIN:
+				setflag(F1::LIFE_DRAIN);
+				break;
+			case BonusType::DOUBLE_DAMAGE_CHANCE:
+				setflag(F1::DOUBLE_DAMAGE_CHANCE);
+				break;
+			case BonusType::DEATH_STARE:
+				setflag(F1::DEATH_STARE);
+				break;
+			case BonusType::NOT_ACTIVE:
+				if(cstack->unitType()->getId() != CreatureID::AMMO_CART)
+					setflag(F1::SLEEPING);
+		}
+
+		if(bonus->source == BonusSource::SPELL_EFFECT)
+		{
+			switch(bonus->sid.as<SpellID>())
+			{
+				case SpellID::AGE:
+					setflag(F2::AGE);
+					break;
+				case SpellID::BIND:
+					setflag(F2::BIND);
+					break;
+				case SpellID::BLIND:
+					setflag(F2::BLIND);
+					break;
+				case SpellID::CURSE:
+					setflag(F2::CURSE);
+					break;
+				case SpellID::PARALYZE:
+					setflag(F2::BLIND);
+					break;
+				case SpellID::POISON:
+					setflag(F2::POISON);
+					break;
+				case SpellID::STONE_GAZE:
+					setflag(F2::PETRIFY);
+					break;
+				case SpellID::WEAKNESS:
+					setflag(F2::WEAKNESS);
+			}
+		}
+	}
+}
+
+const StackAttrs & Stack::getAttrs() const
+{
+	return attrs;
+}
+
+char Stack::getAlias() const
+{
+	return alias;
+}
+
+int Stack::getAttr(StackAttribute a) const
+{
+	return attr(a);
+}
+
+int Stack::getFlag(StackFlag1 sf) const
+{
+	return flag(sf);
+}
+
+int Stack::getFlag(StackFlag2 sf) const
+{
+	return flag(sf);
+}
+
+bool Stack::flag(StackFlag1 f) const
+{
+	return flags1.test(EI(f));
+};
+
+bool Stack::flag(StackFlag2 f) const
+{
+	return flags2.test(EI(f));
+};
+
+int Stack::attr(StackAttribute a) const
+{
+	return attrs.at(EI(a));
+};
+
+void Stack::setflag(StackFlag1 f)
+{
+	flags1.set(EI(f));
+};
+
+void Stack::setflag(StackFlag2 f)
+{
+	flags2.set(EI(f));
+};
+
+void Stack::setattr(StackAttribute a, int value)
+{
+	attrs.at(EI(a)) = value;
+};
+
+void Stack::addattr(StackAttribute a, int value)
+{
+	attrs.at(EI(a)) += value;
+};
+
+void Stack::finalize()
+{
+	setattr(SA::FLAGS1, flags1.to_ulong());
+	setattr(SA::FLAGS2, flags2.to_ulong());
+}
+}

+ 107 - 0
AI/MMAI/BAI/v13/stack.h

@@ -0,0 +1,107 @@
+/*
+ * stack.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 "CStack.h"
+#include "battle/IBattleInfoCallback.h"
+
+#include "BAI/v13/global_stats.h"
+#include "battle/ReachabilityInfo.h"
+#include "schema/v13/constants.h"
+#include "schema/v13/types.h"
+
+namespace MMAI::BAI::V13
+{
+
+namespace S13 = Schema::V13;
+using StackAttribute = Schema::V13::StackAttribute;
+using StackAttrs = Schema::V13::StackAttrs;
+using StackFlag1 = Schema::V13::StackFlag1;
+using StackFlag2 = Schema::V13::StackFlag2;
+using StackFlags1 = Schema::V13::StackFlags1;
+using StackFlags2 = Schema::V13::StackFlags2;
+
+using Queue = std::vector<uint32_t>; // item=unit id
+using BitQueue = std::bitset<S13::STACK_QUEUE_SIZE>;
+
+static_assert(1 << S13::STACK_QUEUE_SIZE < std::numeric_limits<int>::max(), "BitQueue must be convertible to int");
+
+/*
+ * A wrapper around CStack
+ */
+class Stack : public Schema::V13::IStack
+{
+public:
+	static int GetValue(const CCreature * creature);
+
+	// not the quantum version :)
+	static std::pair<BitQueue, int> QBits(const CStack *, const Queue &);
+
+	struct Stats
+	{
+		int dmgDealtNow = 0;
+		int dmgDealtTotal = 0;
+		int dmgReceivedNow = 0;
+		int dmgReceivedTotal = 0;
+		int valueKilledNow = 0;
+		int valueKilledTotal = 0;
+		int valueLostNow = 0;
+		int valueLostTotal = 0;
+	};
+
+	// struct for reducing constructor args to avoid sonarcloud warning...
+	struct StatsContainer
+	{
+		const GlobalStats * oldgstats;
+		const GlobalStats * gstats;
+		const Stats stackStats;
+	};
+
+	Stack(
+		const CStack * cstack,
+		const Queue & q,
+		const StatsContainer & statsContainer,
+		const ReachabilityInfo & rinfo,
+		bool blocked,
+		bool blocking,
+		const DamageEstimation & estdmg
+	);
+
+	// IStack impl
+	const StackAttrs & getAttrs() const override;
+	int getAttr(StackAttribute a) const override;
+	int getFlag(StackFlag1 sf) const override;
+	int getFlag(StackFlag2 sf) const override;
+	char getAlias() const override;
+	char alias;
+
+	const CStack * const cstack;
+	const ReachabilityInfo rinfo;
+	StackAttrs attrs = {};
+	StackFlags1 flags1 = 0; //
+	StackFlags2 flags2 = 0; //
+
+	int attr(StackAttribute a) const;
+	bool flag(StackFlag1 f) const;
+	bool flag(StackFlag2 f) const;
+	int shots;
+	int qposFirst;
+
+private:
+	void setflag(StackFlag1 f);
+	void setflag(StackFlag2 f);
+	void setattr(StackAttribute a, int value);
+	void addattr(StackAttribute a, int value);
+	void finalize();
+
+	void processBonuses();
+};
+}

+ 556 - 0
AI/MMAI/BAI/v13/state.cpp

@@ -0,0 +1,556 @@
+/*
+ * state.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 "battle/CPlayerBattleCallback.h"
+#include "networkPacks/PacksForClientBattle.h"
+
+#include "BAI/v13/encoder.h"
+#include "BAI/v13/hexaction.h"
+#include "BAI/v13/state.h"
+#include "BAI/v13/supplementary_data.h"
+#include "common.h"
+#include "schema/v13/constants.h"
+
+#include <algorithm>
+#include <memory>
+
+namespace MMAI::BAI::V13
+{
+namespace S13 = Schema::V13;
+using GA = Schema::V13::GlobalAttribute;
+using PA = Schema::V13::PlayerAttribute;
+using HA = Schema::V13::HexAttribute;
+using SA = Schema::V13::StackAttribute;
+
+//
+// Prevent human errors caused by the Stack / Hex attr overlap
+//
+static_assert(EI(HA::STACK_SIDE) == EI(SA::SIDE) + S13::STACK_ATTR_OFFSET);
+static_assert(EI(HA::STACK_SLOT) == EI(SA::SLOT) + S13::STACK_ATTR_OFFSET);
+static_assert(EI(HA::STACK_QUANTITY) == EI(SA::QUANTITY) + S13::STACK_ATTR_OFFSET);
+static_assert(EI(HA::STACK_ATTACK) == EI(SA::ATTACK) + S13::STACK_ATTR_OFFSET);
+static_assert(EI(HA::STACK_DEFENSE) == EI(SA::DEFENSE) + S13::STACK_ATTR_OFFSET);
+static_assert(EI(HA::STACK_SHOTS) == EI(SA::SHOTS) + S13::STACK_ATTR_OFFSET);
+static_assert(EI(HA::STACK_DMG_MIN) == EI(SA::DMG_MIN) + S13::STACK_ATTR_OFFSET);
+static_assert(EI(HA::STACK_DMG_MAX) == EI(SA::DMG_MAX) + S13::STACK_ATTR_OFFSET);
+static_assert(EI(HA::STACK_HP) == EI(SA::HP) + S13::STACK_ATTR_OFFSET);
+static_assert(EI(HA::STACK_HP_LEFT) == EI(SA::HP_LEFT) + S13::STACK_ATTR_OFFSET);
+static_assert(EI(HA::STACK_SPEED) == EI(SA::SPEED) + S13::STACK_ATTR_OFFSET);
+static_assert(EI(HA::STACK_QUEUE) == EI(SA::QUEUE) + S13::STACK_ATTR_OFFSET);
+static_assert(EI(HA::STACK_VALUE_ONE) == EI(SA::VALUE_ONE) + S13::STACK_ATTR_OFFSET);
+static_assert(EI(HA::STACK_FLAGS1) == EI(SA::FLAGS1) + S13::STACK_ATTR_OFFSET);
+static_assert(EI(HA::STACK_FLAGS2) == EI(SA::FLAGS2) + S13::STACK_ATTR_OFFSET);
+static_assert(EI(HA::STACK_VALUE_REL) == EI(SA::VALUE_REL) + S13::STACK_ATTR_OFFSET);
+static_assert(EI(HA::STACK_VALUE_REL0) == EI(SA::VALUE_REL0) + S13::STACK_ATTR_OFFSET);
+static_assert(EI(HA::STACK_VALUE_KILLED_REL) == EI(SA::VALUE_KILLED_REL) + S13::STACK_ATTR_OFFSET);
+static_assert(EI(HA::STACK_VALUE_KILLED_ACC_REL0) == EI(SA::VALUE_KILLED_ACC_REL0) + S13::STACK_ATTR_OFFSET);
+static_assert(EI(HA::STACK_VALUE_LOST_REL) == EI(SA::VALUE_LOST_REL) + S13::STACK_ATTR_OFFSET);
+static_assert(EI(HA::STACK_VALUE_LOST_ACC_REL0) == EI(SA::VALUE_LOST_ACC_REL0) + S13::STACK_ATTR_OFFSET);
+static_assert(EI(HA::STACK_DMG_DEALT_REL) == EI(SA::DMG_DEALT_REL) + S13::STACK_ATTR_OFFSET);
+static_assert(EI(HA::STACK_DMG_DEALT_ACC_REL0) == EI(SA::DMG_DEALT_ACC_REL0) + S13::STACK_ATTR_OFFSET);
+static_assert(EI(HA::STACK_DMG_RECEIVED_REL) == EI(SA::DMG_RECEIVED_REL) + S13::STACK_ATTR_OFFSET);
+static_assert(EI(HA::STACK_DMG_RECEIVED_ACC_REL0) == EI(SA::DMG_RECEIVED_ACC_REL0) + S13::STACK_ATTR_OFFSET);
+static_assert(EI(StackAttribute::_count) == 25, "whistleblower in case attributes change");
+
+// static
+std::vector<float> State::InitNullStack()
+{
+	auto res = std::vector<float>{};
+	for(int i = 0; i < EI(StackAttribute::_count); ++i)
+		Encoder::Encode(static_cast<HA>(S13::STACK_ATTR_OFFSET + i), S13::NULL_VALUE_UNENCODED, res);
+	return res;
+};
+
+namespace
+{
+	std::tuple<int, int, int, int> CalcGlobalStats(const CPlayerBattleCallback * battle)
+	{
+		int lv = 0;
+		int lh = 0;
+		int rv = 0;
+		int rh = 0;
+
+		for(auto & stack : battle->battleGetStacks())
+		{
+			auto v = stack->getCount() * Stack::GetValue(stack->unitType());
+			auto h = stack->getAvailableHealth();
+
+			if(stack->unitSide() == BattleSide::ATTACKER)
+			{
+				lv += v;
+				lh += h;
+			}
+			else
+			{
+				rv += v;
+				rh += h;
+			}
+		}
+
+		return {lv, lh, rv, rh};
+	}
+
+	struct AttackLogAggregateData
+	{
+		int ldd = 0; // left damage dealt
+		int ldr = 0; // left damage received
+		int lvk = 0; // left value killed
+		int lvl = 0; // left value lost
+		int rdd = 0; // right damage dealt
+		int rdr = 0; // right damage received
+		int rvk = 0; // right value killed
+		int rvl = 0; // right value lost
+	};
+
+	AttackLogAggregateData ProcessAttackLogs(const std::vector<std::shared_ptr<AttackLog>> & attackLogs, std::map<const CStack *, Stack::Stats> sstats)
+	{
+		auto res = AttackLogAggregateData{};
+		for(auto & [cstack, ss] : sstats)
+		{
+			ss.dmgDealtNow = 0;
+			ss.dmgReceivedNow = 0;
+			ss.valueKilledNow = 0;
+			ss.valueLostNow = 0;
+		}
+
+		for(const auto & al : attackLogs)
+		{
+			const auto & ald = al->data;
+			if(ald.cattacker)
+			{
+				sstats[ald.cattacker].dmgDealtNow += ald.dmg;
+				sstats[ald.cattacker].dmgDealtTotal += ald.dmg;
+				sstats[ald.cattacker].valueKilledNow += ald.value;
+				sstats[ald.cattacker].valueKilledTotal += ald.value;
+
+				if(ald.cattacker->unitSide() == BattleSide::LEFT_SIDE)
+				{
+					res.ldd += ald.dmg;
+					res.lvk += ald.value;
+				}
+				else
+				{
+					res.rdd += ald.dmg;
+					res.rvk += ald.value;
+				}
+			}
+
+			ASSERT(ald.cdefender, "AttackLog cdefender is nullptr!");
+			sstats[ald.cdefender].dmgReceivedNow += ald.dmg;
+			sstats[ald.cdefender].dmgReceivedTotal += ald.dmg;
+			sstats[ald.cdefender].valueLostNow += ald.value;
+			sstats[ald.cdefender].valueLostTotal += ald.value;
+
+			if(ald.cdefender->unitSide() == BattleSide::LEFT_SIDE)
+			{
+				res.ldr += ald.dmg;
+				res.lvl += ald.value;
+			}
+			else
+			{
+				res.rdr += ald.dmg;
+				res.rvl += ald.value;
+			}
+		}
+
+		return res;
+	}
+
+}
+
+State::State(int version_, const std::string & colorname, const CPlayerBattleCallback * battle, bool enableTransitions)
+	: version_(version_)
+	, battle(battle)
+	, enableTransitions(enableTransitions)
+	, colorname(colorname)
+	, side(battle->battleGetMySide())
+	, nullstack(InitNullStack())
+{
+	auto [lv, lh, rv, rh] = CalcGlobalStats(battle);
+	gstats = std::make_unique<GlobalStats>(battle->battleGetMySide(), lv + rv, lh + rh);
+	lpstats = std::make_unique<PlayerStats>(BattleSide::LEFT_SIDE, lv, lh);
+	rpstats = std::make_unique<PlayerStats>(BattleSide::RIGHT_SIDE, rv, rh);
+
+	battlefield = Battlefield::Create(battle, nullptr, gstats.get(), gstats.get(), sstats, false);
+	bfstate.reserve(S13::BATTLEFIELD_STATE_SIZE);
+	actmask.reserve(S13::N_ACTIONS);
+}
+
+void State::onActiveStack(const CStack * astack, CombatResult result, bool recording, bool fastpath)
+{
+	logAi->debug("onActiveStack: result=%d, recording=%d, fastpath=%d", EI(result), recording, fastpath);
+	const auto & [lv, lh, rv, rh] = CalcGlobalStats(battle);
+	const auto & [ldd, ldr, lvk, lvl, rdd, rdr, rvk, rvl] = ProcessAttackLogs(attackLogs, sstats);
+	auto ogstats = *gstats; // a copy of the "old" gstats
+
+	(result == CombatResult::NONE) ? gstats->update(astack->unitSide(), result, lv + rv, lh + rh, !astack->waitedThisTurn)
+								   : gstats->update(BattleSide::NONE, result, lv + rv, lh + rh, false);
+	lpstats->update(&ogstats, lv, lh, ldd, ldr, lvk, lvl);
+	rpstats->update(&ogstats, rv, rh, rdd, rdr, rvk, rvl);
+
+	if(fastpath)
+	{
+		// means we are done with onActiveStack, and we can safely clear transitions now
+		transitions.clear();
+		persistentAttackLogs.clear();
+	}
+	else
+	{
+		if(enableTransitions)
+			persistentAttackLogs.insert(persistentAttackLogs.end(), attackLogs.begin(), attackLogs.end());
+
+		battlefield = Battlefield::Create(battle, astack, &ogstats, gstats.get(), sstats, isMorale);
+		bfstate.clear();
+		actmask.clear();
+
+		for(int i = 0; i < EI(GlobalAction::_count); i++)
+		{
+			switch(static_cast<GlobalAction>(i))
+			{
+				case GlobalAction::RETREAT:
+					actmask.push_back(battle->battleCanFlee());
+					break;
+				case GlobalAction::WAIT:
+					actmask.push_back(battlefield->astack && !battlefield->astack->cstack->waitedThisTurn);
+					break;
+				default:
+					THROW_FORMAT("Unexpected GlobalAction: %d", i);
+			}
+		}
+
+		encodeGlobal(result);
+		encodePlayer(lpstats.get());
+		encodePlayer(rpstats.get());
+
+		for(auto & hexrow : *battlefield->hexes)
+			for(auto & hex : hexrow)
+				encodeHex(hex.get());
+
+		// Links are not part of the state
+		// They are handled separately by the connector
+		// for (auto &link : battlefield->links)
+		//     encodeLink(link);
+
+		verify();
+	}
+
+	isMorale = false;
+
+	supdata = std::make_unique<SupplementaryData>(
+		colorname,
+		static_cast<Side>(side),
+		gstats.get(),
+		lpstats.get(),
+		rpstats.get(),
+		battlefield.get(),
+		enableTransitions ? persistentAttackLogs : attackLogs, // store the logs since OUR last turn
+		transitions, // store the states since last turn
+		result
+	);
+
+	if(recording)
+	{
+		ASSERT(startedAction >= 0, "unexpected startedAction: " + std::to_string(startedAction));
+		// NOTE: this creates a copy of bfstate (which is what we want)
+		transitions.emplace_back(startedAction, std::make_shared<Schema::ActionMask>(actmask), std::make_shared<Schema::BattlefieldState>(bfstate));
+	}
+	else
+	{
+		actingStack = astack; // for fastpath, see onActionStarted
+		startedAction = -1;
+		// XXX: must NOT clear transitions here (can do it only after BAI's activeStack completes)
+		// transitions.clear();
+	}
+
+	attackLogs.clear(); // accumulate new logs until next turn
+}
+
+void State::_onActionStarted(const BattleAction & ba)
+{
+	if(!ba.isUnitAction())
+	{
+		logAi->warn("Got non-unit action of type: %d", EI(ba.actionType));
+		return;
+	}
+
+	auto stacks = battle->battleGetStacks();
+
+	// Case A: << ENEMY TURN >>
+	// 1. StupidAI makes action; vcmi calls ->
+	// 2. State::onActionStart() calls ->                           // actingStack is nullptr
+	// 3. onActiveStack(recording=true) builds bf and returns to ->
+	// 4. State::onActionStart() clears actingStack
+	//
+	// Case B: << OUR TURN >>
+	// 1. BAI::activeStack() calls ->
+	// 2. State::onActiveStack(recording=false) builds bf, sets actingStack and returns to ->
+	// 3. BAI::activeStack() makes action; vcmi calls ->
+	// 4. State::onActionStart() sets fastpath=true and calls ->    //  actingStack already present
+	// 5. onActiveStack(recording=true) **skips building bf** and returns to ->
+	// 6. State::onActionStart() clears actingStack
+
+	//
+	// no need to create battlefield in 5, as it's the same as in 2.
+	bool fastpath = false;
+	bool found = false;
+	for(const auto * cstack : battle->battleGetAllStacks(true))
+	{
+		if(cstack->unitId() == ba.stackNumber)
+		{
+			if(actingStack)
+			{
+				// XXX: actingStack is already set here only if it was set in onActiveStack() i.e. on our turn
+				// We could check only the unit's side, but since there are
+				// auto-acting units, comparing the exact unit seems safer.
+				fastpath = true;
+				if(cstack != actingStack)
+				{
+					THROW_FORMAT(
+						"actingStack was already set to %s, but does not match the real acting stack %s",
+						actingStack->getDescription() % cstack->getDescription()
+					);
+				}
+			}
+
+			actingStack = cstack;
+			found = true;
+			break;
+		}
+	}
+	ASSERT(found, "could not find cstack with unitId: " + std::to_string(ba.stackNumber));
+
+	if(actingStack->creatureId() == CreatureID::FIRST_AID_TENT || actingStack->creatureId() == CreatureID::CATAPULT
+	   || actingStack->creatureId() == CreatureID::ARROW_TOWERS)
+	{
+		// These are auto-acting for BAI
+		// Cannot build state in this case
+		return;
+	}
+
+	switch(ba.actionType)
+	{
+		case EActionType::WAIT:
+			startedAction = S13::ACTION_WAIT;
+			break;
+		case EActionType::SHOOT:
+		{
+			auto bh = ba.target.at(0).hexValue;
+			auto id = Hex::CalcId(bh);
+			startedAction = S13::N_NONHEX_ACTIONS + id * EI(HexAction::_count) + EI(HexAction::SHOOT);
+		}
+		break;
+		case EActionType::DEFEND:
+		{
+			auto bh = actingStack->getPosition();
+			auto id = Hex::CalcId(bh);
+			startedAction = S13::N_NONHEX_ACTIONS + id * EI(HexAction::_count) + EI(HexAction::MOVE);
+		}
+		break;
+		case EActionType::WALK:
+		{
+			auto bh = ba.target.at(0).hexValue;
+			auto id = Hex::CalcId(bh);
+			startedAction = S13::N_NONHEX_ACTIONS + id * EI(HexAction::_count) + EI(HexAction::MOVE);
+		}
+		break;
+		case EActionType::WALK_AND_ATTACK:
+		{
+			auto bhMove = ba.target.at(0).hexValue;
+			auto bhTarget = ba.target.at(1).hexValue;
+			auto idMove = Hex::CalcId(bhMove);
+
+			// Can't use `battlefield` (old state)
+			auto it = std::ranges::find_if(
+				stacks,
+				[&bhTarget](const CStack * cstack)
+				{
+					return cstack->coversPos(bhTarget);
+				}
+			);
+
+			if(it == stacks.end())
+			{
+				THROW_FORMAT("Could not find stack for target bhex: %d", bhTarget.toInt());
+			}
+
+			const auto * targetStack = *it;
+
+			if(!CStack::isMeleeAttackPossible(actingStack, targetStack, bhMove))
+			{
+				THROW_FORMAT("Melee attack not possible from bh=%d to bh=%d (to %s)", bhMove.toInt() % bhTarget.toInt() % targetStack->getDescription());
+			}
+
+			const auto & nbhexes = Hex::NearbyBattleHexes(bhMove);
+
+			for(int i = 0; i < nbhexes.size(); ++i)
+			{
+				const auto & n_bhex = nbhexes.at(i);
+				if(n_bhex == bhTarget)
+				{
+					startedAction = S13::N_NONHEX_ACTIONS + idMove * EI(HexAction::_count) + i;
+					break;
+				}
+			}
+
+			ASSERT(
+				startedAction >= 0, "failed to determine startedAction"
+
+			);
+		}
+		break;
+		case EActionType::MONSTER_SPELL:
+			logAi->warn("Got MONSTER_SPELL action (use cursed ground to prevent this)");
+			return;
+			break;
+		default:
+			// Don't record a state diff for the other actions
+			// (most are irrelevant or should never occur during training,
+			//  except for MONSTER_SPELL, which can be fixed via cursed ground)
+			logAi->debug("Not recording actionType=%d", EI(ba.actionType));
+			return;
+	}
+
+	logAi->debug("Recording actionType=%d", EI(ba.actionType));
+	onActiveStack(actingStack, CombatResult::NONE, true, fastpath);
+}
+
+void State::encodeGlobal(CombatResult result)
+{
+	for(int i = 0; i < EI(GA::_count); ++i)
+	{
+		Encoder::Encode(static_cast<GA>(i), gstats->attrs.at(i), bfstate);
+	}
+}
+
+void State::encodePlayer(const PlayerStats * pstats)
+{
+	for(int i = 0; i < EI(PA::_count); ++i)
+	{
+		Encoder::Encode(static_cast<PA>(i), pstats->attrs.at(i), bfstate);
+	}
+}
+
+void State::encodeHex(const Hex * hex)
+{
+	// Battlefield state
+	for(int i = 0; i < EI(HA::_count); ++i)
+		Encoder::Encode(static_cast<HA>(i), hex->attrs.at(i), bfstate);
+
+	// Action mask
+	for(int m = 0; m < hex->actmask.size(); ++m)
+		actmask.push_back(hex->actmask.test(m));
+}
+
+void State::verify() const
+{
+	ASSERT(bfstate.size() == S13::BATTLEFIELD_STATE_SIZE, "unexpected bfstate.size(): " + std::to_string(bfstate.size()));
+	ASSERT(actmask.size() == N_ACTIONS, "unexpected actmask.size(): " + std::to_string(actmask.size()));
+}
+
+void State::onBattleStacksAttacked(const std::vector<BattleStackAttacked> & bsa)
+{
+	auto stacks = battlefield->stacks;
+
+	for(const auto & elem : bsa)
+	{
+		const auto * cdefender = battle->battleGetStackByID(elem.stackAttacked, false);
+		const auto * cattacker = battle->battleGetStackByID(elem.attackerID, false);
+
+		ASSERT(cdefender, "defender cannot be NULL");
+
+		const auto defender = std::ranges::find_if(
+			stacks,
+			[&cdefender](const std::shared_ptr<Stack> & stack)
+			{
+				return cdefender == stack->cstack;
+			}
+		);
+
+		if(defender == stacks.end())
+		{
+			logAi->info("defender cstack '%s' not found in stacks. Maybe it was just summoned/resurrected?", cdefender->getDescription());
+		}
+
+		const auto attacker = std::ranges::find_if(
+			stacks,
+			[&cattacker](const std::shared_ptr<Stack> & stack)
+			{
+				return cattacker == stack->cstack;
+			}
+		);
+
+		auto bf_valueNow = gstats->attr(GA::BFIELD_VALUE_NOW_ABS);
+		auto bf_hpNow = gstats->attr(GA::BFIELD_HP_NOW_ABS);
+		auto value = elem.killedAmount * Stack::GetValue(cdefender->unitType());
+
+		// XXX: attacker can be NULL when an effect does dmg (eg. Acid)
+		// XXX: attacker or defender can be NULL if it did not exist
+		//      when `stacks` was built (e.g. during our last turn),
+		//      Can happen if the enemy has now summonned/resurrected it.
+		auto ald = AttackLogData{
+			.attacker = (attacker != stacks.end() ? *attacker : nullptr),
+			.defender = (defender != stacks.end() ? *defender : nullptr),
+			.cattacker = cattacker,
+			.cdefender = cdefender,
+			.dmg = static_cast<int>(elem.damageAmount),
+			.dmgPermille = static_cast<int>(1000 * elem.damageAmount / bf_hpNow),
+			.units = static_cast<int>(elem.killedAmount),
+			.value = static_cast<int>(value),
+			.valuePermille = static_cast<int>(1000 * value / bf_valueNow)
+		};
+
+		attackLogs.push_back(std::make_shared<AttackLog>(std::move(ald)));
+	}
+}
+
+void State::onBattleTriggerEffect(const BattleTriggerEffect & bte)
+{
+	if(bte.effect != BonusType::MORALE)
+		return;
+
+	isMorale = true;
+}
+
+void State::onActionFinished(const BattleAction & ba)
+{
+	// XXX: assuming action was OK (no server error about failed/fishy action)
+}
+
+/*
+ * !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+ * !!!!!! IMPORTANT: `battlefield` must not be used here (old state) !!!!!!
+ * !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+ */
+void State::onActionStarted(const BattleAction & ba)
+{
+	if(!enableTransitions)
+		return;
+
+	_onActionStarted(ba);
+	actingStack = nullptr;
+}
+
+void State::onBattleEnd(const BattleResult * br)
+{
+	switch(br->winner)
+	{
+		case BattleSide::LEFT_SIDE:
+			onActiveStack(nullptr, CombatResult::LEFT_WINS);
+			break;
+		case BattleSide::RIGHT_SIDE:
+			onActiveStack(nullptr, CombatResult::RIGHT_WINS);
+			break;
+		default:
+			onActiveStack(nullptr, CombatResult::DRAW);
+	}
+}
+};

+ 107 - 0
AI/MMAI/BAI/v13/state.h

@@ -0,0 +1,107 @@
+/*
+ * state.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 "battle/CBattleInfoEssentials.h"
+#include "battle/CPlayerBattleCallback.h"
+#include "networkPacks/PacksForClientBattle.h"
+
+#include "BAI/v13/action.h"
+#include "BAI/v13/attack_log.h"
+#include "BAI/v13/battlefield.h"
+#include "BAI/v13/global_stats.h"
+#include "BAI/v13/player_stats.h"
+#include "BAI/v13/supplementary_data.h"
+#include "schema/base.h"
+#include "schema/v13/types.h"
+
+namespace MMAI::BAI::V13
+{
+using BS = Schema::BattlefieldState;
+
+static const auto DUMMY_ATTNMASK = Schema::AttentionMask();
+
+class State : public Schema::IState
+{
+public:
+	// IState impl
+	const Schema::ActionMask * getActionMask() const override
+	{
+		return &actmask;
+	};
+	const Schema::AttentionMask * getAttentionMask() const override
+	{
+		return &DUMMY_ATTNMASK;
+	}
+	const Schema::BattlefieldState * getBattlefieldState() const override
+	{
+		return &bfstate;
+	}
+	std::any getSupplementaryData() const override
+	{
+		return static_cast<const MMAI::Schema::V13::ISupplementaryData *>(supdata.get());
+	}
+	int version() const override
+	{
+		return version_;
+	}
+
+	State() = delete;
+	State(
+		int version_,
+		const std::string & colorname,
+		const CPlayerBattleCallback * battle,
+		bool enableTransitions = false // disabled for performance
+	);
+
+	void onActiveStack(const CStack * astack, CombatResult result = CombatResult::NONE, bool recording = false, bool fastpath = false);
+	void onBattleStacksAttacked(const std::vector<BattleStackAttacked> & bsa);
+	void onBattleTriggerEffect(const BattleTriggerEffect & bte);
+	void onActionStarted(const BattleAction & ba);
+	void _onActionStarted(const BattleAction & ba);
+	void onActionFinished(const BattleAction & ba);
+	void onBattleEnd(const BattleResult * br);
+
+	// Subsequent versions may override this if they only change
+	// the data type of encoded values (i.e. have their own HEX_ENCODING)
+	void encodeGlobal(CombatResult result);
+	void encodePlayer(const PlayerStats * pstats);
+	void encodeHex(const Hex * hex);
+	void verify() const;
+
+	const int version_;
+	const CPlayerBattleCallback * const battle;
+	bool enableTransitions;
+
+	Schema::BattlefieldState bfstate;
+	Schema::ActionMask actmask;
+	std::unique_ptr<SupplementaryData> supdata = nullptr;
+	std::vector<std::shared_ptr<AttackLog>> attackLogs;
+	std::vector<std::shared_ptr<AttackLog>> persistentAttackLogs;
+	std::vector<std::tuple<Schema::Action, std::shared_ptr<Schema::ActionMask>, std::shared_ptr<Schema::BattlefieldState>>> transitions;
+	std::unique_ptr<Action> action = nullptr;
+	std::unique_ptr<GlobalStats> gstats = nullptr;
+	std::unique_ptr<PlayerStats> lpstats = nullptr;
+	std::unique_ptr<PlayerStats> rpstats = nullptr;
+	std::map<const CStack *, Stack::Stats> sstats;
+	const std::pair<int, int> initialArmyValues;
+	const std::string colorname;
+	const BattleSide side;
+	std::shared_ptr<const Battlefield> battlefield;
+	bool isMorale = false;
+
+	int previousAction = -1;
+	int startedAction = -1;
+	const CStack * actingStack = nullptr;
+
+	static std::vector<float> InitNullStack();
+	const std::vector<float> nullstack;
+};
+}

+ 84 - 0
AI/MMAI/BAI/v13/supplementary_data.cpp

@@ -0,0 +1,84 @@
+/*
+ * supplementary_data.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 "BAI/v13/supplementary_data.h"
+#include "common.h"
+
+namespace MMAI::BAI::V13
+{
+Schema::V13::Hexes SupplementaryData::getHexes() const
+{
+	ASSERT(battlefield, "getHexes() called when battlefield is null");
+	auto res = Schema::V13::Hexes{};
+
+	for(int y = 0; y < battlefield->hexes->size(); ++y)
+	{
+		auto & hexrow = battlefield->hexes->at(y);
+		auto & resrow = res.at(y);
+		for(int x = 0; x < hexrow.size(); ++x)
+		{
+			resrow.at(x) = hexrow.at(x).get();
+		}
+	}
+
+	return res;
+}
+
+Schema::V13::Stacks SupplementaryData::getStacks() const
+{
+	ASSERT(battlefield, "getStacks() called when battlefield is null");
+	auto res = Schema::V13::Stacks{};
+
+	for(const auto & stack : battlefield->stacks)
+	{
+		res.push_back(stack.get());
+	}
+
+	return res;
+}
+
+Schema::V13::AllLinks SupplementaryData::getAllLinks() const
+{
+	ASSERT(battlefield, "getAllLinks() called when battlefield is null");
+	auto res = Schema::V13::AllLinks{};
+
+	for(const auto & [lt, links] : battlefield->allLinks)
+	{
+		res[lt] = links.get();
+	}
+
+	return res;
+}
+
+Schema::V13::AttackLogs SupplementaryData::getAttackLogs() const
+{
+	auto res = Schema::V13::AttackLogs{};
+	res.reserve(attackLogs.size());
+
+	for(const auto & al : attackLogs)
+		res.push_back(al.get());
+
+	return res;
+}
+
+Schema::V13::StateTransitions SupplementaryData::getStateTransitions() const
+{
+	auto res = Schema::V13::StateTransitions{};
+	res.reserve(transitions.size());
+
+	for(const auto & [action, actmask, transition] : transitions)
+		res.emplace_back(action, actmask.get(), transition.get());
+
+	return res;
+}
+
+}

+ 122 - 0
AI/MMAI/BAI/v13/supplementary_data.h

@@ -0,0 +1,122 @@
+/*
+ * supplementary_data.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 "BAI/v13/attack_log.h"
+#include "BAI/v13/battlefield.h"
+#include "BAI/v13/global_stats.h"
+#include "BAI/v13/player_stats.h"
+#include "schema/v13/types.h"
+
+namespace MMAI::BAI::V13
+{
+using Side = Schema::Side;
+using ErrorCode = Schema::V13::ErrorCode;
+
+// match sides for convenience when determining winner (see `victory`)
+static_assert(EI(CombatResult::LEFT_WINS) == EI(Side::LEFT));
+static_assert(EI(CombatResult::RIGHT_WINS) == EI(Side::RIGHT));
+
+class SupplementaryData : public Schema::V13::ISupplementaryData
+{
+public:
+	SupplementaryData() = delete;
+
+	// Called on activeStack (complete battlefield info)
+	SupplementaryData(
+		const std::string & colorname_,
+		Side side_,
+		const GlobalStats * gstats_,
+		const PlayerStats * lpstats_,
+		const PlayerStats * rpstats_,
+		const Battlefield * battlefield_,
+		const std::vector<std::shared_ptr<AttackLog>> & attackLogs_,
+		const std::vector<std::tuple<Schema::Action, std::shared_ptr<Schema::ActionMask>, std::shared_ptr<Schema::BattlefieldState>>> & transitions_,
+		CombatResult result
+	)
+		: colorname(colorname_)
+		, side(side_)
+		, battlefield(battlefield_)
+		, gstats(gstats_)
+		, lpstats(lpstats_)
+		, rpstats(rpstats_)
+		, attackLogs(attackLogs_)
+		, transitions(transitions_)
+		, ended(result != CombatResult::NONE)
+		, victory(EI(result) == EI(side)) {};
+
+	// impl ISupplementaryData
+	Type getType() const override
+	{
+		return type;
+	};
+	Side getSide() const override
+	{
+		return side;
+	};
+	std::string getColor() const override
+	{
+		return colorname;
+	};
+	ErrorCode getErrorCode() const override
+	{
+		return errcode;
+	};
+
+	bool getIsBattleEnded() const override
+	{
+		return ended;
+	};
+	bool getIsVictorious() const override
+	{
+		return victory;
+	};
+
+	Schema::V13::Stacks getStacks() const override;
+	Schema::V13::Hexes getHexes() const override;
+	Schema::V13::AllLinks getAllLinks() const override;
+	Schema::V13::AttackLogs getAttackLogs() const override;
+	Schema::V13::StateTransitions getStateTransitions() const override;
+	const Schema::V13::IGlobalStats * getGlobalStats() const override
+	{
+		return gstats;
+	}
+	const Schema::V13::IPlayerStats * getLeftPlayerStats() const override
+	{
+		return lpstats;
+	}
+	const Schema::V13::IPlayerStats * getRightPlayerStats() const override
+	{
+		return rpstats;
+	}
+	std::string getAnsiRender() const override
+	{
+		return ansiRender;
+	}
+
+	const std::string colorname;
+	const Side side;
+	const Battlefield * const battlefield;
+	const GlobalStats * const gstats;
+	const PlayerStats * const lpstats;
+	const PlayerStats * const rpstats;
+	const std::vector<std::shared_ptr<AttackLog>> attackLogs;
+	const std::vector<std::tuple<Schema::Action, std::shared_ptr<Schema::ActionMask>, std::shared_ptr<Schema::BattlefieldState>>> transitions;
+	const bool ended = false;
+	const bool victory = false;
+
+	// Optionally modified (during activeStack if action was invalid)
+	ErrorCode errcode = ErrorCode::OK;
+
+	// Optionally modified (during activeStack if action was RENDER)
+	Type type = Type::REGULAR;
+	std::string ansiRender;
+};
+}

+ 137 - 0
AI/MMAI/CMakeLists.txt

@@ -0,0 +1,137 @@
+cmake_minimum_required(VERSION 3.24)
+
+set(MMAI_FILES
+  BAI/base.cpp
+  BAI/base.h
+  BAI/router.cpp
+  BAI/router.h
+  BAI/model/ScriptedModel.h
+  BAI/model/ScriptedModel.cpp
+  BAI/model/NNModel.h
+  BAI/model/NNModel.cpp
+  BAI/model/util/bucketing.cpp
+  BAI/model/util/bucketing.h
+  BAI/model/util/common.h
+  BAI/model/util/sampling.cpp
+  BAI/model/util/sampling.h
+
+  BAI/v13/BAI.cpp
+  BAI/v13/BAI.h
+  BAI/v13/action.cpp
+  BAI/v13/action.h
+  BAI/v13/attack_log.h
+  BAI/v13/battlefield.cpp
+  BAI/v13/battlefield.h
+  BAI/v13/encoder.cpp
+  BAI/v13/encoder.h
+  BAI/v13/global_stats.cpp
+  BAI/v13/global_stats.h
+  BAI/v13/hex.cpp
+  BAI/v13/hex.h
+  BAI/v13/hexaction.h
+  BAI/v13/hexactmask.h
+  BAI/v13/links.h
+  BAI/v13/player_stats.cpp
+  BAI/v13/player_stats.h
+  BAI/v13/render.cpp
+  BAI/v13/render.h
+  BAI/v13/stack.cpp
+  BAI/v13/stack.h
+  BAI/v13/state.cpp
+  BAI/v13/state.h
+  BAI/v13/supplementary_data.cpp
+  BAI/v13/supplementary_data.h
+
+  schema/schema.h
+  schema/v13/constants.h
+  schema/v13/schema.h
+  schema/v13/types.h
+
+  MMAI.h
+  StdInc.h
+  common.h
+)
+
+option(ENABLE_MMAI_TEST "Compile tests" OFF)
+
+set(MMAI_INCLUDES ${CMAKE_CURRENT_SOURCE_DIR})
+
+add_definitions(-DUSING_ONNX=1)
+assign_source_group(${MMAI_FILES})
+
+if(ENABLE_STATIC_LIBS)
+  add_library(MMAI STATIC ${MMAI_FILES})
+else()
+  add_library(MMAI SHARED ${MMAI_FILES} main.cpp StdInc.cpp)
+  install(TARGETS MMAI RUNTIME DESTINATION ${AI_LIB_DIR} LIBRARY DESTINATION ${AI_LIB_DIR})
+
+  # This is needed to allow MMAI to link against BattleAI
+  # For windows, this is handled at runtime instead via LoadLibraryExW(..., LOAD_WITH_ALTERED_SEARCH_PATH)
+  if(APPLE)
+    set_target_properties(MMAI PROPERTIES INSTALL_RPATH "@loader_path")
+  elseif(UNIX)
+    set_target_properties(MMAI PROPERTIES INSTALL_RPATH "$ORIGIN")
+  endif()
+endif()
+
+target_link_libraries(MMAI PRIVATE vcmi)
+
+if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
+  set(ONNXRUNTIME_ROOT "/opt/onnxruntime" CACHE PATH "Path to onnxruntime containing lib/onnxruntime.so and include/onnxruntime_cxx_api.h")
+
+  message(STATUS "Looking for a system onnxruntime")
+  find_path(ONNXRUNTIME_INCLUDE_DIR
+    NAMES onnxruntime_cxx_api.h
+    PATH_SUFFIXES onnxruntime include
+  )
+
+  find_library(ONNXRUNTIME_LIBRARY NAMES onnxruntime libonnxruntime)
+
+  if(ONNXRUNTIME_INCLUDE_DIR AND ONNXRUNTIME_LIBRARY)
+    message(STATUS "Using system onnxruntime:")
+    message(STATUS "  include: ${ONNXRUNTIME_INCLUDE_DIR}")
+    message(STATUS "  library: ${ONNXRUNTIME_LIBRARY}")
+
+    target_include_directories(MMAI PRIVATE "${ONNXRUNTIME_INCLUDE_DIR}")
+    target_link_libraries(MMAI PRIVATE "${ONNXRUNTIME_LIBRARY}")
+  else()
+    message(STATUS "System onnxruntime not found, falling back to ${ONNXRUNTIME_ROOT}")
+    target_include_directories(MMAI PRIVATE "${ONNXRUNTIME_ROOT}/include")
+    target_link_libraries(MMAI PRIVATE "${ONNXRUNTIME_ROOT}/lib/libonnxruntime.so")
+  endif()
+else()
+  find_package(onnxruntime CONFIG REQUIRED)
+  target_link_libraries(MMAI PRIVATE onnxruntime::onnxruntime)
+endif()
+
+# for fallback & spell casting
+add_dependencies(MMAI BattleAI)
+target_link_libraries(MMAI PRIVATE BattleAI)
+
+# Used in schema/base.h to determine if it is imported by MMAI or vcmi-gym
+set_target_properties(MMAI PROPERTIES COMPILE_DEFINITIONS "MMAI_DLL=1")
+target_include_directories(MMAI PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
+
+if(ENABLE_MMAI_TEST)
+  include(GoogleTest)
+  include(CheckCXXCompilerFlag)
+  enable_testing()
+
+  target_link_libraries(MMAI PUBLIC gtest gtest_main)
+
+  target_include_directories(MMAI PRIVATE "${CMAKE_SOURCE_DIR}/test/googletest/googletest/include")
+  add_subdirectory(${CMAKE_SOURCE_DIR}/test/googletest ${CMAKE_SOURCE_DIR}/test/googletest/build EXCLUDE_FROM_ALL)
+  add_executable(MMAI_test test/encoder_test.cpp)
+  target_link_libraries(MMAI_test PRIVATE MMAI)
+  gtest_discover_tests(MMAI_test)
+
+  # default visibility is needed for testing
+  set_target_properties(MMAI PROPERTIES CXX_VISIBILITY_PRESET "default")
+  set_target_properties(MMAI_test PROPERTIES CXX_VISIBILITY_PRESET "default")
+
+  # Run tests with:
+  # ctest --test-dir build/AI/MMAI/
+endif()
+
+vcmi_set_output_dir(MMAI "AI")
+enable_pch(MMAI)

+ 13 - 0
AI/MMAI/MMAI.h

@@ -0,0 +1,13 @@
+/*
+ * MMAI.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 "BAI/router.h"

+ 12 - 0
AI/MMAI/README.md

@@ -0,0 +1,12 @@
+# Overview
+
+A [VCMI](https://github.com/vcmi/vcmi) AI library which uses pre-trained
+models for commanding a hero's army in battle.
+
+During gameplay, MMAI extracts various features from the battlefield,
+feeds it to a pre-trained model and executes an action based on the model's
+output.
+
+MMAI is also essential during model training, where the collected data is sent
+to [`vcmi-gym`](https://github.com/smanolloff/vcmi-gym) (a Reinforcement
+Learning environment designed for VCMI).

+ 12 - 0
AI/MMAI/StdInc.cpp

@@ -0,0 +1,12 @@
+/*
+ * StdInc.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
+ *
+ */
+
+// Creates the precompiled header
+#include "StdInc.h"

+ 18 - 0
AI/MMAI/StdInc.h

@@ -0,0 +1,18 @@
+/*
+ * StdInc.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 "../../Global.h"
+
+// This header should be treated as a pre compiled header file(PCH) in the compiler building settings.
+
+// Here you can add specific libraries and macros which are specific to this project.
+
+VCMI_LIB_USING_NAMESPACE

+ 33 - 0
AI/MMAI/common.h

@@ -0,0 +1,33 @@
+/*
+ * common.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 "StdInc.h"
+
+namespace MMAI
+{
+// Enum-to-int need C++23 to use std::to_underlying
+// https://en.cppreference.com/w/cpp/utility/to_underlying
+#define EI(enum_value) static_cast<int>(enum_value)
+
+#define ASSERT(cond, msg) \
+	if(!(cond))           \
+	throw std::runtime_error(std::string("Assertion failed in ") + boost::filesystem::path(__FILE__).filename().string() + ": " + msg)
+
+#define THROW_FORMAT(message, formatting_elems) throw std::runtime_error(boost::str(boost::format(message) % formatting_elems))
+
+static const bool MMAI_VERBOSE = []()
+{
+	const char * envvar = std::getenv("MMAI_VERBOSE");
+	return envvar != nullptr && std::strcmp(envvar, "1") == 0;
+}();
+
+}

+ 33 - 0
AI/MMAI/main.cpp

@@ -0,0 +1,33 @@
+/*
+ * main.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 "MMAI.h"
+
+#ifdef __GNUC__
+#	define strcpy_s(a, b, c) strncpy(a, c, b)
+#endif
+
+static const char * const g_cszAiName = "MMAI";
+
+extern "C" DLL_EXPORT int GetGlobalAiVersion()
+{
+	return AI_INTERFACE_VER;
+}
+
+extern "C" DLL_EXPORT void GetAiName(char * name)
+{
+	strcpy_s(name, strlen(g_cszAiName) + 1, g_cszAiName);
+}
+
+extern "C" DLL_EXPORT void GetNewBattleAI(std::shared_ptr<CBattleGameInterface> & out)
+{
+	out = std::make_shared<MMAI::BAI::Router>();
+}

+ 125 - 0
AI/MMAI/schema/base.h

@@ -0,0 +1,125 @@
+/*
+ * base.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 <any>
+#include <string>
+#include <vector>
+
+#include <boost/core/demangle.hpp>
+#include <boost/format.hpp>
+
+// Import + Export macro declarations
+// If MMAI_DLL is defined => this header is imported from VCMI's MMAI lib.
+// Otherwise, it is imported from vcmi-gym.
+#if defined(_WIN32)
+#	if defined(__GNUC__)
+#		define MMAI_IMPORT __attribute__((dllimport))
+#		define MMAI_EXPORT __attribute__((dllexport))
+#	else
+#		define MMAI_IMPORT __declspec(dllimport)
+#		define MMAI_EXPORT __declspec(dllexport)
+#	endif
+#	ifndef ELF_VISIBILITY
+#		define ELF_VISIBILITY
+#	endif
+#else
+#	ifdef __GNUC__
+#		define MMAI_IMPORT __attribute__((visibility("default")))
+#		define MMAI_EXPORT __attribute__((visibility("default")))
+#		ifndef ELF_VISIBILITY
+#			define ELF_VISIBILITY __attribute__((visibility("default")))
+#		endif
+#	endif
+#endif
+
+#ifdef MMAI_DLL
+#	define MMAI_DLL_LINKAGE MMAI_EXPORT
+#else
+#	define MMAI_DLL_LINKAGE MMAI_IMPORT
+#endif
+
+namespace MMAI::Schema
+{
+#define EI(enum_value) static_cast<int>(enum_value)
+
+using Action = int;
+using BattlefieldState = std::vector<float>;
+using ActionMask = std::vector<bool>;
+using AttentionMask = std::vector<float>;
+
+// Same control actions for all versions
+constexpr Action ACTION_RETREAT = 0;
+constexpr Action ACTION_RESET = -1;
+constexpr Action ACTION_RENDER_ANSI = -2;
+
+class IState
+{
+public:
+	virtual const ActionMask * getActionMask() const = 0;
+	virtual const AttentionMask * getAttentionMask() const = 0;
+	virtual const BattlefieldState * getBattlefieldState() const = 0;
+
+	// Supplementary data may differ across versions => expose it as std::any
+	// XXX: ensure the real data type has MMAI_DLL_LINKAGE to prevent std::any_cast errors
+	virtual std::any getSupplementaryData() const = 0;
+
+	virtual int version() const = 0;
+	virtual ~IState() = default;
+};
+
+enum class ModelType : int
+{
+	SCRIPTED, // e.g. BattleAI, StupidAI
+	NN, // pre-trained models stored in a file
+	_count
+};
+
+enum class Side : int
+{
+	LEFT, // BattleSide::LEFT
+	RIGHT, // BattleSide::RIGHT
+	BOTH // for models able to play as either left or right
+};
+
+class IModel
+{
+public:
+	virtual ModelType getType() = 0;
+	virtual std::string getName() = 0;
+	virtual int getVersion() = 0;
+	virtual int getAction(const IState *) = 0;
+	virtual double getValue(const IState *) = 0;
+	virtual Side getSide() = 0;
+
+	virtual ~IModel() = default;
+};
+
+// Convenience formatter for std::any cast errors
+inline std::string AnyCastError(const std::any & any, const std::type_info & t)
+{
+	if(!any.has_value())
+	{
+		return "no value";
+	}
+	else if(any.type() != t)
+	{
+		return boost::str(
+			boost::format("type mismatch: want: %s/%u, have: %s/%u") % boost::core::demangle(t.name()) % t.hash_code()
+			% boost::core::demangle(any.type().name()) % any.type().hash_code()
+		);
+	}
+	else
+	{
+		return "";
+	}
+}
+}

+ 22 - 0
AI/MMAI/schema/schema.h

@@ -0,0 +1,22 @@
+/*
+ * schema.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
+
+/*
+ * THIS FILE LIVES IN:
+ *
+ * vcmi/AI/MMAI/export/export.h
+ *
+ */
+
+#include "schema/base.h"
+
+#include "schema/v13/schema.h"

+ 298 - 0
AI/MMAI/schema/v13/constants.h

@@ -0,0 +1,298 @@
+/*
+ * constants.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 <array>
+#include <stdexcept>
+#include <string>
+#include <tuple>
+
+#include "schema/base.h"
+#include "schema/v13/types.h"
+#include "schema/v13/util.h"
+
+namespace MMAI::Schema::V13
+{
+constexpr int N_NONHEX_ACTIONS = 2;
+constexpr Action ACTION_RETREAT = 0;
+constexpr Action ACTION_WAIT = 1;
+constexpr int N_HEX_ACTIONS = EI(HexAction::_count);
+constexpr int N_ACTIONS = N_NONHEX_ACTIONS + (165 * N_HEX_ACTIONS);
+constexpr int STACK_ATTR_OFFSET = EI(HexAttribute::_count) - EI(StackAttribute::_count);
+
+// Control actions (not part of the regular action space)
+constexpr Action ACTION_UNSET = -666;
+constexpr Action ACTION_RESET = -1;
+constexpr Action ACTION_RENDER_ANSI = -2;
+
+// Value used when masking NULL values during encoding
+constexpr int NULL_VALUE_ENCODED = -1;
+constexpr int NULL_VALUE_UNENCODED = -1;
+
+// Convenience definitions which do not need to be exported
+namespace X
+{
+	inline constexpr auto AE = Encoding::ACCUMULATING_EXPLICIT_NULL;
+	inline constexpr auto AI = Encoding::ACCUMULATING_IMPLICIT_NULL;
+	inline constexpr auto AM = Encoding::ACCUMULATING_MASKING_NULL;
+	inline constexpr auto AS = Encoding::ACCUMULATING_STRICT_NULL;
+	inline constexpr auto AZ = Encoding::ACCUMULATING_ZERO_NULL;
+
+	inline constexpr auto BE = Encoding::BINARY_EXPLICIT_NULL;
+	inline constexpr auto BM = Encoding::BINARY_MASKING_NULL;
+	inline constexpr auto BS = Encoding::BINARY_STRICT_NULL;
+	inline constexpr auto BZ = Encoding::BINARY_ZERO_NULL;
+
+	inline constexpr auto CE = Encoding::CATEGORICAL_EXPLICIT_NULL;
+	inline constexpr auto CI = Encoding::CATEGORICAL_IMPLICIT_NULL;
+	inline constexpr auto CM = Encoding::CATEGORICAL_MASKING_NULL;
+	inline constexpr auto CS = Encoding::CATEGORICAL_STRICT_NULL;
+	inline constexpr auto CZ = Encoding::CATEGORICAL_ZERO_NULL;
+
+	inline constexpr auto EE = Encoding::EXPNORM_EXPLICIT_NULL;
+	inline constexpr auto EM = Encoding::EXPNORM_MASKING_NULL;
+	inline constexpr auto ES = Encoding::EXPNORM_STRICT_NULL;
+	inline constexpr auto EZ = Encoding::EXPNORM_ZERO_NULL;
+
+	inline constexpr auto LE = Encoding::LINNORM_EXPLICIT_NULL;
+	inline constexpr auto LM = Encoding::LINNORM_MASKING_NULL;
+	inline constexpr auto LS = Encoding::LINNORM_STRICT_NULL;
+	inline constexpr auto LZ = Encoding::LINNORM_ZERO_NULL;
+
+	inline constexpr auto RAW = Encoding::RAW;
+
+	using GA = GlobalAttribute;
+	using PA = PlayerAttribute;
+	using HA = HexAttribute;
+
+	/*
+	 * The encoding schema `{a, e, n, vmax, p}`, where:
+	 * a=attribute
+	 * e=encoding
+	 * n=size
+	 * vmax=max_value
+	 * p=param (encoding-specific)
+	 */
+	using E5G = std::tuple<GlobalAttribute, Encoding, int, int, double>;
+	using E5P = std::tuple<PlayerAttribute, Encoding, int, int, double>;
+	using E5H = std::tuple<HexAttribute, Encoding, int, int, double>;
+}
+
+using GlobalEncoding = std::array<X::E5G, EI(GlobalAttribute::_count)>;
+using PlayerEncoding = std::array<X::E5P, EI(PlayerAttribute::_count)>;
+using HexEncoding = std::array<X::E5H, EI(HexAttribute::_count)>;
+
+/*
+ * Compile-time constructor for E5H and E5S tuples
+ * https://stackoverflow.com/a/23784921
+ */
+template<typename T>
+constexpr std::tuple<T, Encoding, int, int, double> E5(T a, Encoding e, int vmax, double slope = -1, int bins = -1)
+{
+	switch(e)
+	{
+		// "0" is a value => vmax+1 values
+		case X::AE:
+			return {a, e, vmax + 2, vmax, -1};
+		case X::AI:
+		case X::AM:
+		case X::AS:
+		case X::AZ:
+			return {a, e, vmax + 1, vmax, -1};
+
+		// Log2(8)=3 (2^3), but if vmax=8 then 4 bits will be required
+		// => Log2(9)=4
+		case X::BE:
+			return {a, e, static_cast<int>(Log2(vmax + 1)) + 1, vmax, -1};
+		case X::BM:
+		case X::BS:
+		case X::BZ:
+			return {a, e, static_cast<int>(Log2(vmax + 1)), vmax, -1};
+
+		// "0" is a category => vmax+1 categories
+		case X::CE:
+			return {a, e, vmax + 2, vmax, -1};
+		case X::CI:
+		case X::CM:
+		case X::CS:
+		case X::CZ:
+			return {a, e, vmax + 1, vmax, -1};
+
+		case X::LE:
+			return {a, e, 2, vmax, -1};
+		case X::LM:
+		case X::LS:
+		case X::LZ:
+			return {a, e, 1, vmax, -1};
+
+		case X::EE:
+			return {a, e, 2, vmax, slope};
+		case X::EM:
+		case X::ES:
+		case X::EZ:
+			return {a, e, 1, vmax, slope};
+
+		case X::RAW:
+			return {a, e, 1, vmax, -1};
+		default:
+			throw std::runtime_error("Unexpected encoding: " + std::to_string(EI(e)));
+	}
+}
+
+// 0-6 regular; 7=war machines; 8=other (summoned, commander, etc.)
+constexpr int STACK_SLOT_WARMACHINES = 7;
+constexpr int STACK_SLOT_SPECIAL = 8;
+
+// Values above MAX are simply capped
+constexpr int STACK_QUEUE_SIZE = 30;
+constexpr int CREATURE_ID_MAX = 149; // H3 core has creature IDs 0..149
+constexpr int STACK_SLOT_MAX = 8;
+
+// NOTE: the generated maps use old AIValue() which is 4-6x LOWER
+//       than the one calculated by MMAI (in Stack::CalcValue())
+//       => a map with 100K pools corresponds to 400K..600K pools now
+// The biggest pools are:
+//   (1) 4x1096  => 500K pools (old)  => 3000K (new) => total = 6000K  (new)
+//   (2) 8x64    => 800K pools (old)  => 4800K (new) => total = 9600K  (new)
+//   (3) 8x64    => 1600K pools (old) => 9600K (new) => total = 19200K (new)
+// Since (1) is used for training while (2) and (3) are for evaluation, we
+// set max=10000K=10M (new) in order to:
+// - test higher-than-trained values via (2), but within limits,
+// - test higher-than-trained values via (3), but outside limits
+//
+// XXX: THIS IS NOW LEFT UNUSED (switched to relative values instead)
+// constexpr int ARMY_VALUE_MAX = 10 * 1000 * 1000; // 10M
+
+constexpr auto BFIELD_VALUE_MAX = static_cast<int>(10e6); // 4.2M max for 4x1024.vmap
+constexpr auto BFIELD_VALUE_SLOPE = 5;
+
+constexpr auto BFIELD_HP_MAX = static_cast<int>(200e3); // 90k max for 4x1024.vmap
+constexpr auto BFIELD_HP_SLOPE = 7.5;
+
+constexpr GlobalEncoding GLOBAL_ENCODING{
+	E5(X::GA::BATTLE_SIDE, X::CS, 1),
+	E5(X::GA::BATTLE_SIDE_ACTIVE_PLAYER, X::CE, 1), // NULL means no battle
+	E5(X::GA::BATTLE_WINNER, X::CE, 1), // NULL means ongoing battle
+	E5(X::GA::BFIELD_VALUE_START_ABS, X::ES, BFIELD_VALUE_MAX, BFIELD_VALUE_SLOPE),
+	E5(X::GA::BFIELD_VALUE_NOW_ABS, X::ES, BFIELD_VALUE_MAX, BFIELD_VALUE_SLOPE),
+	E5(X::GA::BFIELD_VALUE_NOW_REL0, X::LS, 1000), // bfield_value_now / bfield_value_at_start
+	E5(X::GA::BFIELD_HP_START_ABS, X::ES, BFIELD_HP_MAX, BFIELD_HP_SLOPE),
+	E5(X::GA::BFIELD_HP_NOW_ABS, X::ES, BFIELD_HP_MAX, BFIELD_HP_SLOPE),
+	E5(X::GA::BFIELD_HP_NOW_REL0, X::LS, 1000), // bfield_hp_now / bfield_hp_at_start
+	E5(X::GA::ACTION_MASK, X::BS, (1 << EI(GlobalAction::_count)) - 1)
+};
+
+// 100 Ghost dragons => ~4K base dmg
+// vs. Grand Elf = 8K dmg (+22 attack advantage)
+// => 667 kills * 1.8k value = 1.2M value killed
+constexpr auto VALUE_KILLED_NOW_MAX = static_cast<int>(2e6);
+constexpr auto VALUE_KILLED_NOW_NBINS = 50;
+constexpr auto VALUE_KILLED_NOW_SLOPE = 7.5; // granularity at low values OK (1 imp = 213)
+
+constexpr auto DMG_DEALT_NOW_MAX = static_cast<int>(20e3);
+constexpr auto DMG_DEALT_NOW_NBINS = 50;
+constexpr auto DMG_DEALT_NOW_SLOPE = 6.5;
+
+constexpr PlayerEncoding PLAYER_ENCODING{
+	E5(X::PA::BATTLE_SIDE, X::CS, 1),
+	E5(X::PA::ARMY_VALUE_NOW_ABS, X::ES, BFIELD_VALUE_MAX, BFIELD_VALUE_SLOPE),
+	E5(X::PA::ARMY_VALUE_NOW_REL, X::LS, 1000), //     (army_value_now / global_value_now)
+	E5(X::PA::ARMY_VALUE_NOW_REL0, X::LS, 1000), //    (army_value_now / global_value_at_start)
+	E5(X::PA::ARMY_HP_NOW_ABS, X::ES, BFIELD_HP_MAX, BFIELD_HP_SLOPE),
+	E5(X::PA::ARMY_HP_NOW_REL, X::LS, 1000), //        (army_hp_now / global_hp_now)
+	E5(X::PA::ARMY_HP_NOW_REL0, X::LS, 1000), //       (army_hp_now / global_hp_at_start)
+	E5(X::PA::VALUE_KILLED_NOW_ABS, X::ES, VALUE_KILLED_NOW_MAX, VALUE_KILLED_NOW_SLOPE),
+	E5(X::PA::VALUE_KILLED_NOW_REL, X::LS, 1000), //   (value_killed_this_turn / global_value_last_turn)
+	E5(X::PA::VALUE_KILLED_ACC_ABS, X::ES, BFIELD_VALUE_MAX, BFIELD_VALUE_SLOPE),
+	E5(X::PA::VALUE_KILLED_ACC_REL0, X::LS, 1000), //  (value_killed_lifetime / global_value_at_start)
+	E5(X::PA::VALUE_LOST_NOW_ABS, X::ES, VALUE_KILLED_NOW_MAX, VALUE_KILLED_NOW_SLOPE),
+	E5(X::PA::VALUE_LOST_NOW_REL, X::LS, 1000), //     (value_lost_this_turn / global_value_last_turn)
+	E5(X::PA::VALUE_LOST_ACC_ABS, X::ES, BFIELD_VALUE_MAX, BFIELD_VALUE_SLOPE),
+	E5(X::PA::VALUE_LOST_ACC_REL0, X::LS, 1000), //    (value_lost_lifetime / global_value_at_start)
+	E5(X::PA::DMG_DEALT_NOW_ABS, X::ES, DMG_DEALT_NOW_MAX, DMG_DEALT_NOW_SLOPE),
+	E5(X::PA::DMG_DEALT_NOW_REL, X::LS, 1000), //      (dmg_dealt_this_turn / global_hp_last_turn)
+	E5(X::PA::DMG_DEALT_ACC_ABS, X::ES, BFIELD_HP_MAX, BFIELD_HP_SLOPE),
+	E5(X::PA::DMG_DEALT_ACC_REL0, X::LS, 1000), //     (dmg_dealt_lifetime / global_hp_at_start)
+	E5(X::PA::DMG_RECEIVED_NOW_ABS, X::ES, DMG_DEALT_NOW_MAX, DMG_DEALT_NOW_SLOPE),
+	E5(X::PA::DMG_RECEIVED_NOW_REL, X::LS, 1000), //   (dmg_received_this_turn / global_hp_last_turn)
+	E5(X::PA::DMG_RECEIVED_ACC_ABS, X::ES, BFIELD_HP_MAX, BFIELD_HP_SLOPE),
+	E5(X::PA::DMG_RECEIVED_ACC_REL0, X::LS, 1000), //  (dmg_received_lifetime / global_hp_at_start)
+};
+
+// Visualise on https://www.desmos.com/calculator:
+// ln(1 + (x/M) * (exp(S)-1))/S
+// Add slider "S" (slope) and "M" (vmax).
+// Play with the sliders to see the nonlinearity (use M=1 for best view)
+// XXX: slope cannot be 0
+
+constexpr auto STACK_QTY_MAX = 1500;
+constexpr auto STACK_QTY_SLOPE = 5;
+
+constexpr auto STACK_HP_MAX = 1000;
+constexpr auto STACK_HP_SLOPE = 6;
+
+constexpr auto STACK_VALUE_MAX = 200e3; // titan 55k, crystal dr. 113k, azure 180k...
+constexpr auto STACK_VALUE_NBINS = 20;
+constexpr auto STACK_VALUE_SLOPE = 6.5;
+
+constexpr HexEncoding HEX_ENCODING{
+	E5(X::HA::Y_COORD, X::CS, 10),
+	E5(X::HA::X_COORD, X::CS, 14),
+	E5(X::HA::STATE_MASK, X::BS, (1 << EI(HexState::_count)) - 1),
+	E5(X::HA::ACTION_MASK, X::BZ, (1 << EI(HexAction::_count)) - 1),
+	E5(X::HA::IS_REAR, X::CZ, 1), // 1=this is the rear hex of a stack
+	E5(X::HA::STACK_SIDE, X::CE, 1), // 0=attacker, 1=defender
+	E5(X::HA::STACK_SLOT, X::CE, STACK_SLOT_MAX),
+	E5(X::HA::STACK_QUANTITY, X::EZ, STACK_QTY_MAX, STACK_QTY_SLOPE),
+	E5(X::HA::STACK_ATTACK, X::LZ, 80),
+	E5(X::HA::STACK_DEFENSE, X::LZ, 80), // azure dragon is 60 when defending
+	E5(X::HA::STACK_SHOTS, X::LZ, 32), // sharpshooter is 32
+	E5(X::HA::STACK_DMG_MIN, X::LZ, 100),
+	E5(X::HA::STACK_DMG_MAX, X::LZ, 100),
+	E5(X::HA::STACK_HP, X::EZ, STACK_HP_MAX, STACK_HP_SLOPE),
+	E5(X::HA::STACK_HP_LEFT, X::EZ, STACK_HP_MAX, STACK_HP_SLOPE),
+	E5(X::HA::STACK_SPEED, X::CE, 20),
+	E5(X::HA::STACK_QUEUE, X::BZ, (1 << STACK_QUEUE_SIZE) - 1), // 0..14, 0=active stack
+	E5(X::HA::STACK_VALUE_ONE, X::EZ, STACK_VALUE_MAX, STACK_VALUE_SLOPE),
+	E5(X::HA::STACK_FLAGS1, X::BZ, (1 << EI(StackFlag1::_count)) - 1),
+	E5(X::HA::STACK_FLAGS2, X::BZ, (1 << EI(StackFlag2::_count)) - 1),
+
+	E5(X::HA::STACK_VALUE_REL, X::LZ, 1000),
+	E5(X::HA::STACK_VALUE_REL0, X::LZ, 1000),
+	E5(X::HA::STACK_VALUE_KILLED_REL, X::LZ, 1000),
+	E5(X::HA::STACK_VALUE_KILLED_ACC_REL0, X::LZ, 1000),
+	E5(X::HA::STACK_VALUE_LOST_REL, X::LZ, 1000),
+	E5(X::HA::STACK_VALUE_LOST_ACC_REL0, X::LZ, 1000),
+	E5(X::HA::STACK_DMG_DEALT_REL, X::LZ, 1000),
+	E5(X::HA::STACK_DMG_DEALT_ACC_REL0, X::LZ, 1000),
+	E5(X::HA::STACK_DMG_RECEIVED_REL, X::LZ, 1000),
+	E5(X::HA::STACK_DMG_RECEIVED_ACC_REL0, X::LZ, 1000),
+};
+
+// Dedining encodings for each attribute by hand is error-prone
+// The below compile-time asserts are essential.
+static_assert(UninitializedEncodingAttributes(GLOBAL_ENCODING) == 0, "Found uninitialized elements");
+static_assert(UninitializedEncodingAttributes(PLAYER_ENCODING) == 0, "Found uninitialized elements");
+static_assert(UninitializedEncodingAttributes(HEX_ENCODING) == 0, "Found uninitialized elements");
+static_assert(DisarrayedEncodingAttributeIndex(GLOBAL_ENCODING) == -1, "Found wrong element at this index");
+static_assert(DisarrayedEncodingAttributeIndex(PLAYER_ENCODING) == -1, "Found wrong element at this index");
+static_assert(DisarrayedEncodingAttributeIndex(HEX_ENCODING) == -1, "Found wrong element at this index");
+static_assert(MisconfiguredExpnormSlopeIndex(GLOBAL_ENCODING) == -1, "Found miscalculated binary vmax element at this index");
+static_assert(MisconfiguredExpnormSlopeIndex(PLAYER_ENCODING) == -1, "Found miscalculated binary vmax element at this index");
+static_assert(MisconfiguredExpnormSlopeIndex(HEX_ENCODING) == -1, "Found miscalculated binary vmax element at this index");
+
+constexpr int BATTLEFIELD_STATE_SIZE_GLOBAL = EncodedSize(GLOBAL_ENCODING);
+constexpr int BATTLEFIELD_STATE_SIZE_ONE_PLAYER = EncodedSize(PLAYER_ENCODING);
+constexpr int BATTLEFIELD_STATE_SIZE_ONE_HEX = EncodedSize(HEX_ENCODING);
+constexpr int BATTLEFIELD_STATE_SIZE_ALL_HEXES = 165 * BATTLEFIELD_STATE_SIZE_ONE_HEX;
+constexpr int BATTLEFIELD_STATE_SIZE =
+	BATTLEFIELD_STATE_SIZE_GLOBAL + BATTLEFIELD_STATE_SIZE_ONE_PLAYER + BATTLEFIELD_STATE_SIZE_ONE_PLAYER + BATTLEFIELD_STATE_SIZE_ALL_HEXES;
+}

+ 14 - 0
AI/MMAI/schema/v13/schema.h

@@ -0,0 +1,14 @@
+/*
+ * schema.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 "schema/v13/constants.h"
+#include "schema/v13/types.h"

+ 640 - 0
AI/MMAI/schema/v13/types.h

@@ -0,0 +1,640 @@
+/*
+ * types.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 <array>
+#include <bitset>
+#include <map>
+#include <string>
+#include <tuple>
+#include <vector>
+
+#include "schema/base.h"
+
+namespace MMAI::Schema::V13
+{
+enum class Encoding : int
+{
+	/*
+	 * Represent `v` as `n` bits, where `bits[1..v+1]=1`.
+	 * If `v=-1` (a.k.a. "NULL"), only the bit at index 0 will be `1`.
+	 *
+	 * Examples:
+	 * * `v=3`,  `n=5` => `[0,1,1,1,1]`
+	 * * `v=0`,  `n=5` => `[0,1,0,0,0]`
+	 * * `v=-1`, `n=5` => `[1,0,0,0,0]`
+	 */
+	ACCUMULATING_EXPLICIT_NULL,
+
+	/*
+	 * Represent `v` as `n` bits, where `bits[0..v]=1`.
+	 * If `v=-1` (a.k.a. "NULL"), all bits will be `0`.
+	 *
+	 * Examples:
+	 * * `v=3`,  `n=5` => `[1,1,1,1,0]`
+	 * * `v=0`,  `n=5` => `[1,0,0,0,0]`
+	 * * `v=-1`, `n=5` => `[0,0,0,0,0]`
+	 */
+	ACCUMULATING_IMPLICIT_NULL,
+	/*
+	 * Represent `v` as `n` bits, where `bits[0..v]=1`.
+	 * If `v=-1` (a.k.a. "NULL"), all bits will be `-1`.
+	 *
+	 * Examples:
+	 * * `v=3`,  `n=5` => `[1,1,1,1,0]`
+	 * * `v=0`,  `n=5` => `[1,0,0,0,0]`
+	 * * `v=-1`, `n=5` => `[-1,-1,-1,-1,-1]`
+	 */
+	ACCUMULATING_MASKING_NULL,
+
+	/*
+	 * Represent `v` as `n` bits, where `bits[0..v]=1`.
+	 * If `v=-1` (a.k.a. "NULL"), an error will be thrown.
+	 *
+	 * Examples:
+	 * * `v=3`,  `n=5` => `[1,1,1,1,0]`
+	 * * `v=0`,  `n=5` => `[1,0,0,0,0]`
+	 * * `v=-1`, `n=5` => (error)
+	 */
+	ACCUMULATING_STRICT_NULL,
+
+	/*
+	 * Represent `v` as `n` bits, where `bits[0..v]=1`.
+	 * If `v=-1` (a.k.a. "NULL"), it will be treated as `0`.
+	 *
+	 * Examples:
+	 * * `v=3`,  `n=5` => `[1,1,1,1,0]`
+	 * * `v=0`,  `n=5` => `[1,0,0,0,0]`
+	 * * `v=-1`, `n=5` => `[1,0,0,0,0]`
+	 */
+	ACCUMULATING_ZERO_NULL,
+
+	/*
+	 * Represent `v<<1` as `n`-bit binary (unsigned, LSB at index 0).
+	 * If `v=-1` (a.k.a. "NULL"), only the bit at index 0 will be `1`.
+	 *
+	 * Examples:
+	 * * `v=3`,  `n=5` => `[0,1,1,0,0]`
+	 * * `v=0`,  `n=5` => `[0,0,0,0,0]`
+	 * * `v=-1`, `n=5` => `[1,0,0,0,0]`
+	 */
+	BINARY_EXPLICIT_NULL,
+
+	/*
+	 * Represent `v` as an `n`-bit binary (LSB at index 0)
+	 * If `v=-1` (a.k.a. "NULL"), all bits will be `-1`.
+	 *
+	 * Examples:
+	 * * `v=3`,  `n=5` => `[1,1,0,0,0]`
+	 * * `v=0`,  `n=5` => `[0,0,0,0,0]`
+	 * * `v=-1`, `n=5` => `[-1,-1,-1,-1,-1]`
+	 */
+	BINARY_MASKING_NULL,
+
+	/*
+	 * Represent `v` as an `n`-bit binary (LSB at index 0)
+	 * If `v=-1` (a.k.a. "NULL"), an error will be thrown.
+	 *
+	 * Examples:
+	 * * `v=3`,  `n=5` => `[1,1,0,0,0]`
+	 * * `v=0`,  `n=5` => `[0,0,0,0,0]`
+	 * * `v=-1`, `n=5` => (error)
+	 */
+	BINARY_STRICT_NULL,
+
+	/*
+	 * Represent `v` as `n`-bit binary (unsigned, LSB at index 0).
+	 * If `v=-1` (a.k.a. "NULL"), it will be treated as `0`.
+	 *
+	 * Examples:
+	 * * `v=3`,  `n=5` => `[1,1,0,0,0]`
+	 * * `v=0`,  `n=5` => `[0,0,0,0,0]`
+	 * * `v=-1`, `n=5` => `[0,0,0,0,0]`
+	 */
+	BINARY_ZERO_NULL,
+	// XXX: BINARY_ZERO_NULL obsoletes BINARY_IMPLICIT_NULL
+
+	/*
+	 * Represent `v` as `n` bits, where `bits[v+1]=1`.
+	 *
+	 * Examples:
+	 * * `v=3`,  `n=5` => `[0,0,0,0,1]`
+	 * * `v=0`,  `n=5` => `[0,1,0,0,0]`
+	 * * `v=-1`, `n=5` => `[1,0,0,0,0]`
+	 */
+	CATEGORICAL_EXPLICIT_NULL,
+
+	/*
+	 * Represent `v` as `n` bits, where `bits[v]=1`.
+	 * If `v=-1` (a.k.a. "NULL"), all bits will be `0`.
+	 *
+	 * Examples:
+	 * * `v=3`,  `n=5` => `[0,0,0,1,0]`
+	 * * `v=0`,  `n=5` => `[1,0,0,0,0]`
+	 * * `v=-1`, `n=5` => `[0,0,0,0,0]`
+	 */
+	CATEGORICAL_IMPLICIT_NULL,
+
+	/*
+	 * Represent `v` as `n` bits, where `bits[v]=1`.
+	 * If `v=-1` (a.k.a. "NULL"), all bits will be `-1`.
+	 *
+	 * Examples:
+	 * * `v=3`,  `n=5` => `[0,0,0,1,0]`
+	 * * `v=0`,  `n=5` => `[1,0,0,0,0]`
+	 * * `v=-1`, `n=5` => `[-1,-1,-1,-1,-1]`
+	 */
+	CATEGORICAL_MASKING_NULL,
+
+	/*
+	 * Represent `v` as `n` bits, where `bits[v]=1`.
+	 * If `v=-1` (a.k.a. "NULL"), an error will be thrown.
+	 *
+	 * Examples:
+	 * * `v=3`,  `n=5` => `[0,0,0,1,0]`
+	 * * `v=0`,  `n=5` => `[1,0,0,0,0]`
+	 * * `v=-1`, `n=5` => (error)
+	 */
+	CATEGORICAL_STRICT_NULL,
+
+	/*
+	 * Represent `v` as `n` bits, where `bits[v]=1`.
+	 * If `v=-1` (a.k.a. "NULL"), it will be treated as `0`.
+	 *
+	 * Examples:
+	 * * `v=3`,  `n=5` => `[0,0,0,1,0]`
+	 * * `v=0`,  `n=5` => `[1,0,0,0,0]`
+	 * * `v=-1`, `n=5` => `[1,0,0,0,0]`
+	 */
+	CATEGORICAL_ZERO_NULL,
+
+	/*
+	 * Normalize `v+1` exponentially with base `vmax`
+	 * and represent it as `[0, vnorm]`.
+	 * If `v=-1` (a.k.a. "NULL"), the result will be `[1, 0]
+	 *
+	 * Examples:
+	 * * `v=3`,  `vmax=10` => `[0, 0.6]`
+	 * * `v=0`,  `vmax=10` => `[0, 0]`
+	 * * `v=-1`, `vmax=10` => `[1, 0]`
+	 */
+	EXPNORM_EXPLICIT_NULL,
+
+	/*
+	 * Normalize `v+1` exponentially with base `vmax`.
+	 * If `v=0`, en error will be thrown.
+	 * If `v=-1` (a.k.a. "NULL"), it will not be normalized.
+	 *
+	 * Examples:
+	 * * `v=3`,  `vmax=10` => `0.6`
+	 * * `v=0`,  `vmax=10` => `0`
+	 * * `v=-1`, `vmax=10` => `-1`
+	 */
+	EXPNORM_MASKING_NULL,
+
+	/*
+	 * Normalize `v+1` exponentially with base `vmax`.
+	 * If `v=-1` (a.k.a. "NULL"), an error will be thrown.
+	 *
+	 * Examples:
+	 * * `v=3`,  `vmax=10` => `0.6`
+	 * * `v=0`,  `vmax=10` => `0`
+	 * * `v=-1`, `vmax=10` => (error)
+	 */
+	EXPNORM_STRICT_NULL,
+
+	/*
+	 * Normalize `v+1` exponentially with base `vmax`.
+	 * If `v=0`, en error will be thrown.
+	 * If `v=-1` (a.k.a. "NULL"), it will be treated as `0`.
+	 *
+	 * Examples:
+	 * * `v=3`,  `vmax=10` => `0.6`
+	 * * `v=0`,  `vmax=10` => `0`
+	 * * `v=-1`, `vmax=10` => `0`
+	 */
+	EXPNORM_ZERO_NULL,
+	// XXX: NORMALIZED_ZERO_NULL obsoletes NORMALIZED_IMPLICIT_NULL
+
+	/*
+	 * Normalize `v` linearly in the range `(0, vmax)`.
+	 * and represent it as `[0, vnorm]`.
+	 * If `v=-1` (a.k.a. "NULL"), the result will be `[1, 0]
+	 *
+	 * Examples:
+	 * * `v=3`,  `vmax=10` => `[0, 0.3]`
+	 * * `v=0`,  `vmax=10` => `[0, 0]`
+	 * * `v=-1`, `vmax=10` => `[1, 0]`
+	 */
+	LINNORM_EXPLICIT_NULL,
+
+	/*
+	 * Normalize `v` linearly in the range `(0, vmax)`.
+	 * If `v=-1` (a.k.a. "NULL"), it will not be normalized.
+	 *
+	 * Examples:
+	 * * `v=3`,  `vmax=10` => `0.3`
+	 * * `v=0`,  `vmax=10` => `0`
+	 * * `v=-1`, `vmax=10` => `-1`
+	 */
+	LINNORM_MASKING_NULL,
+
+	/*
+	 * Normalize `v` linearly in the range `(0, vmax)`.
+	 * If `v=-1` (a.k.a. "NULL"), an error will be thrown.
+	 *
+	 * Examples:
+	 * * `v=3`,  `vmax=10` => `0.3`
+	 * * `v=0`,  `vmax=10` => `0`
+	 * * `v=-1`, `vmax=10` => (error)
+	 */
+	LINNORM_STRICT_NULL,
+
+	/*
+	 * Normalize `v` linearly in the range `(0, vmax)`.
+	 * If `v=-1` (a.k.a. "NULL"), it will be treated as `0`.
+	 *
+	 * Examples:
+	 * * `v=3`,  `vmax=10` => `0.6`
+	 * * `v=0`,  `vmax=10` => `0`
+	 * * `v=-1`, `vmax=10` => `0`
+	 */
+	LINNORM_ZERO_NULL,
+	// XXX: NORMALIZED_ZERO_NULL obsoletes NORMALIZED_IMPLICIT_NULL
+
+	/*
+	 * Don't normalize, use as-is.
+	 */
+	RAW,
+};
+
+enum class CombatResult
+{
+	LEFT_WINS,
+	RIGHT_WINS,
+	DRAW,
+	NONE,
+
+	_count
+};
+
+enum class StackActState : int
+{
+	READY, //   will act this turn, not waited
+	WAITING, // will act this turn, already waited
+	DONE, //    will not act this turn
+	_count
+};
+
+enum class HexState : int
+{
+	// IMPASSABLE, // obstacle/stack/gate(closed,attacker)
+	PASSABLE, //      empty/mine/firewall/gate(open)/gate(closed,defender), ...
+	STOPPING, //      moat/quicksand
+	DAMAGING_L, //    moat/mine/firewall
+	DAMAGING_R, //    moat/mine/firewall
+	// GATE, //       XXX: redundant? Always set during siege (regardless of gate state)
+	_count
+};
+
+enum class HexAction : int
+{
+	AMOVE_TR, //  = Move to (*) + attack at hex 0..11:
+	AMOVE_R, //    . . . . . . . . . 5 0 . . . .
+	AMOVE_BR, //  . 1-hex:  . . . . 4 * 1 . . .
+	AMOVE_BL, //   . . . . . . . . . 3 2 . . . .
+	AMOVE_L, //   . . . . . . . . . . . . . . .
+	AMOVE_TL, //   . . . . . . . . . 5 0 6 . . .
+	AMOVE_2TR, // . 2-hex (R):  . . 4 * # 7 . .
+	AMOVE_2R, //   . . . . . . . . . 3 2 8 . . .
+	AMOVE_2BR, // . . . . . . . . . . . . . . .
+	AMOVE_2BL, //  . . . . . . . .11 5 0 . . . .
+	AMOVE_2L, //  . 2-hex (L):  .10 # * 1 . . .
+	AMOVE_2TL, //  . . . . . . . . 9 3 2 . . . .
+	MOVE, //      = Move to (defend if current hex)
+	SHOOT, //     = shoot at
+	_count
+};
+
+enum class GlobalAction : int
+{
+	RETREAT,
+	WAIT,
+	_count
+};
+
+enum class GlobalAttribute : int
+{
+	BATTLE_SIDE, //               0=left, 1=right (does not change during battle)
+	BATTLE_SIDE_ACTIVE_PLAYER, // 0=left, 1=right (NA = battle finished)
+	BATTLE_WINNER, //             0=left, 1=right (NA = battle not finished)
+	BFIELD_VALUE_START_ABS, //    global_value_at_start
+	BFIELD_VALUE_NOW_ABS, //      global_value_now
+	BFIELD_VALUE_NOW_REL0, //     global_value_now / global_value_at_start
+	BFIELD_HP_START_ABS, //       global_hp_at_start
+	BFIELD_HP_NOW_ABS, //         global_hp_now
+	BFIELD_HP_NOW_REL0, //        global_hp_now / global_hp_at_start
+	ACTION_MASK, //               mask for global actions (retreat, wait)
+
+	_count
+};
+
+enum class PlayerAttribute : int
+{
+	BATTLE_SIDE, //           0=left, 1=right
+	ARMY_VALUE_NOW_ABS,
+	ARMY_VALUE_NOW_REL, //    side_army_value_now / global_value_now
+	ARMY_VALUE_NOW_REL0, //   side_army_value_now / global_value_at_start
+	ARMY_HP_NOW_ABS,
+	ARMY_HP_NOW_REL, //       side_army_hp_now / global_hp_now
+	ARMY_HP_NOW_REL0, //      side_army_hp_now / global_hp_at_start
+	VALUE_KILLED_NOW_ABS,
+	VALUE_KILLED_NOW_REL, //  left_value_killed_this_turn / global_value_last_turn
+	VALUE_KILLED_ACC_ABS,
+	VALUE_KILLED_ACC_REL0, // left_value_killed_lifetime / global_value_at_start
+	VALUE_LOST_NOW_ABS,
+	VALUE_LOST_NOW_REL, //    left_value_lost_this_turn / global_value_last_turn
+	VALUE_LOST_ACC_ABS,
+	VALUE_LOST_ACC_REL0, //   left_value_lost_lifetime / global_value_at_start
+	DMG_DEALT_NOW_ABS,
+	DMG_DEALT_NOW_REL, //     left_dmg_dealt_this_turn / global_hp_last_turn
+	DMG_DEALT_ACC_ABS,
+	DMG_DEALT_ACC_REL0, //    left_dmg_dealt_lifetime / global_hp_at_start
+	DMG_RECEIVED_NOW_ABS,
+	DMG_RECEIVED_NOW_REL, //  left_dmg_taken_this_turn / global_hp_last_turn
+	DMG_RECEIVED_ACC_ABS,
+	DMG_RECEIVED_ACC_REL0, // left_dmg_taken_lifetime / global_hp_at_start
+
+	_count
+};
+
+// For description on each attribute, see the comments for HEX_ENCODING
+enum class HexAttribute : int
+{
+	Y_COORD,
+	X_COORD,
+	STATE_MASK,
+	ACTION_MASK,
+	IS_REAR, // is this hex the rear hex of a stack
+	STACK_SIDE,
+	// STACK_CREATURE_ID,
+	STACK_SLOT,
+	STACK_QUANTITY,
+	STACK_ATTACK,
+	STACK_DEFENSE,
+	STACK_SHOTS,
+	STACK_DMG_MIN,
+	STACK_DMG_MAX,
+	STACK_HP,
+	STACK_HP_LEFT,
+	STACK_SPEED,
+	STACK_QUEUE,
+	// STACK_ESTIMATED_DMG,
+	STACK_VALUE_ONE,
+	STACK_FLAGS1,
+	STACK_FLAGS2,
+
+	STACK_VALUE_REL,
+	STACK_VALUE_REL0,
+	STACK_VALUE_KILLED_REL,
+	STACK_VALUE_KILLED_ACC_REL0,
+	STACK_VALUE_LOST_REL,
+	STACK_VALUE_LOST_ACC_REL0,
+	STACK_DMG_DEALT_REL,
+	STACK_DMG_DEALT_ACC_REL0,
+	STACK_DMG_RECEIVED_REL,
+	STACK_DMG_RECEIVED_ACC_REL0,
+
+	_count
+};
+
+enum class StackAttribute : int
+{
+	SIDE,
+	SLOT,
+	QUANTITY,
+	ATTACK,
+	DEFENSE,
+	SHOTS,
+	DMG_MIN,
+	DMG_MAX,
+	HP,
+	HP_LEFT,
+	SPEED,
+	QUEUE,
+	// ESTIMATED_DMG,
+	VALUE_ONE,
+	FLAGS1,
+	FLAGS2,
+
+	// RELATIVE values
+	VALUE_REL, //             stack_value_now / global_value_now
+	VALUE_REL0, //            stack_value_now / global_value_at_start
+	VALUE_KILLED_REL, //      value_killed_this_turn / global_value_last_turn
+	VALUE_KILLED_ACC_REL0, // value_killed_lifetime / global_value_at_start
+	VALUE_LOST_REL, //        value_lost_this_turn / global_value_last_turn
+	VALUE_LOST_ACC_REL0, //   value_lost_lifetime / global_value_at_start
+	DMG_DEALT_REL, //         dmg_dealt_this_turn / global_hp_last_turn
+	DMG_DEALT_ACC_REL0, //    dmg_dealt_lifetime / global_hp_at_start
+	DMG_RECEIVED_REL, //      dmg_received_this_turn / global_hp_last_turn
+	DMG_RECEIVED_ACC_REL0, // dmg_received_lifetime / global_hp_at_start
+	_count
+};
+
+// flags are split into two, as they can't fit in a single int
+enum class StackFlag1 : int
+{
+	IS_ACTIVE,
+	WILL_ACT,
+	CAN_WAIT,
+	CAN_RETALIATE,
+	SLEEPING,
+	BLOCKED,
+	BLOCKING,
+	IS_WIDE,
+	FLYING,
+	ADDITIONAL_ATTACK,
+	NO_MELEE_PENALTY,
+	TWO_HEX_ATTACK_BREATH,
+	BLOCKS_RETALIATION,
+	SHOOTER,
+	NON_LIVING,
+	WAR_MACHINE,
+	FIREBALL,
+	DEATH_CLOUD,
+	THREE_HEADED_ATTACK,
+	ALL_AROUND_ATTACK,
+	RETURN_AFTER_STRIKE,
+	ENEMY_DEFENCE_REDUCTION,
+	LIFE_DRAIN,
+	DOUBLE_DAMAGE_CHANCE,
+	DEATH_STARE,
+
+	_count
+};
+
+enum class StackFlag2 : int
+{
+	AGE,
+	AGE_ATTACK,
+	BIND,
+	BIND_ATTACK,
+	BLIND,
+	BLIND_ATTACK,
+	CURSE,
+	CURSE_ATTACK,
+	DISPEL_ATTACK,
+	PETRIFY,
+	PETRIFY_ATTACK,
+	POISON,
+	POISON_ATTACK,
+	WEAKNESS,
+	WEAKNESS_ATTACK,
+	_count
+};
+
+enum class LinkType : int
+{
+	// XXX: types are sorted by frequency (desc)
+
+	// ACTION, //          need to link it with v=action (SRC=active stack)
+	ADJACENT,
+	REACH, //              i.e. "can move to"
+	RANGED_MOD, //         v=0.25 / 0.5 / 1
+	ACTS_BEFORE, //        v=num of actions (e.g. 2 if waited)
+	// BLOCKS, //          adds further attention to units blocking a shooter
+	MELEE_DMG_REL, //      v=frac. of DST stack HP
+	RETAL_DMG_REL, //      v=frac. of SRC stack HP after hypothetical attack
+	RANGED_DMG_REL, //     v=frac. of DST stack HP
+	// BLOCKED_BY,
+	// REAR_HEX, //        DST=rear hex of wide unit
+	_count
+};
+
+enum class ErrorCode : int
+{
+	OK,
+	ALREADY_WAITED,
+	MOVE_SELF,
+	HEX_UNREACHABLE,
+	HEX_BLOCKED,
+	HEX_MELEE_NA,
+	STACK_NA,
+	STACK_DEAD,
+	STACK_INVALID,
+	CANNOT_SHOOT,
+	FRIENDLY_FIRE,
+	INVALID_DIR,
+};
+
+class IGlobalStats
+{
+public:
+	virtual int getAttr(GlobalAttribute) const = 0;
+	virtual ~IGlobalStats() = default;
+};
+
+class IPlayerStats
+{
+public:
+	virtual int getAttr(PlayerAttribute) const = 0;
+	virtual ~IPlayerStats() = default;
+};
+
+using GlobalAttrs = std::array<int, static_cast<int>(GlobalAttribute::_count)>;
+using PlayerAttrs = std::array<int, static_cast<int>(PlayerAttribute::_count)>;
+using HexAttrs = std::array<int, static_cast<int>(HexAttribute::_count)>;
+using StackAttrs = std::array<int, static_cast<int>(StackAttribute::_count)>;
+using StackFlags1 = std::bitset<EI(StackFlag1::_count)>;
+using StackFlags2 = std::bitset<EI(StackFlag2::_count)>;
+
+class IStack
+{
+public:
+	virtual const StackAttrs & getAttrs() const = 0;
+	virtual int getAttr(StackAttribute) const = 0;
+	virtual int getFlag(StackFlag1) const = 0;
+	virtual int getFlag(StackFlag2) const = 0;
+	virtual char getAlias() const = 0;
+	virtual ~IStack() = default;
+};
+
+class IHex
+{
+public:
+	virtual const HexAttrs & getAttrs() const = 0;
+	virtual int getID() const = 0;
+	virtual int getAttr(HexAttribute) const = 0;
+	virtual const IStack * getStack() const = 0;
+	virtual ~IHex() = default;
+};
+
+class IAttackLog
+{
+public:
+	// NOTE: each of those can be nullptr if cstack was just resurrected/summoned
+	virtual IStack * getAttacker() const = 0;
+	virtual IStack * getDefender() const = 0;
+
+	virtual int getDamageDealt() const = 0;
+	virtual int getDamageDealtPermille() const = 0;
+	virtual int getUnitsKilled() const = 0;
+	virtual int getValueKilled() const = 0;
+	virtual int getValueKilledPermille() const = 0;
+	virtual ~IAttackLog() = default;
+};
+
+class ILinks
+{
+public:
+	virtual std::vector<int64_t> getSrcIndex() const = 0;
+	virtual std::vector<int64_t> getDstIndex() const = 0;
+	virtual std::vector<float> getAttributes() const = 0;
+	virtual ~ILinks() = default;
+};
+
+using AttackLogs = std::vector<IAttackLog *>;
+using Stacks = std::vector<IStack *>;
+using Hexes = std::array<std::array<IHex *, 15>, 11>;
+using AllLinks = std::map<LinkType, ILinks *>;
+
+using StateTransition = std::tuple<Action, ActionMask *, BattlefieldState *>;
+using StateTransitions = std::vector<StateTransition>;
+
+// This is returned as std::any by IState
+// => MMAI_DLL_LINKAGE is needed to ensure std::any_cast sees the same symbol
+class MMAI_DLL_LINKAGE ISupplementaryData
+{
+public:
+	enum class Type : int
+	{
+		REGULAR,
+		ANSI_RENDER
+	};
+
+	virtual Type getType() const = 0;
+	virtual Side getSide() const = 0;
+	virtual std::string getColor() const = 0;
+	virtual ErrorCode getErrorCode() const = 0;
+	virtual bool getIsBattleEnded() const = 0;
+	virtual bool getIsVictorious() const = 0;
+	virtual const IGlobalStats * getGlobalStats() const = 0;
+	virtual const IPlayerStats * getLeftPlayerStats() const = 0;
+	virtual const IPlayerStats * getRightPlayerStats() const = 0;
+	virtual Stacks getStacks() const = 0;
+	virtual Hexes getHexes() const = 0;
+	virtual AllLinks getAllLinks() const = 0;
+	virtual AttackLogs getAttackLogs() const = 0;
+	virtual std::string getAnsiRender() const = 0;
+	virtual StateTransitions getStateTransitions() const = 0;
+	virtual ~ISupplementaryData() = default;
+};
+}

+ 197 - 0
AI/MMAI/schema/v13/util.h

@@ -0,0 +1,197 @@
+/*
+ * util.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 "schema/v13/types.h"
+
+namespace MMAI::Schema::V13
+{
+/*
+ * Compile time int(sqrt(x))
+ * https://stackoverflow.com/a/27709195
+ */
+template<typename T>
+constexpr T Sqrt(T x, T lo, T hi)
+{
+	if(lo == hi)
+		return lo;
+	const T mid = (lo + hi + 1) / 2;
+	return (x / mid < mid) ? Sqrt<T>(x, lo, mid - 1) : Sqrt(x, mid, hi);
+}
+template<typename T>
+constexpr T CTSqrt(T x)
+{
+	return Sqrt<T>(x, 0, (x / 2) + 1);
+}
+
+/*
+ * Compile time int(log(x, 2))
+ * https://stackoverflow.com/a/23784921
+ */
+constexpr unsigned Log2(unsigned n)
+{
+	return n <= 1 ? 0 : 1 + Log2((n + 1) / 2);
+}
+
+/*
+ * Compile-time checks for misconfigured `HEX_ENCODING`/`STACK_ENCODING`.
+ * The index of the uninitialized element is returned.
+ */
+template<typename T>
+constexpr int UninitializedEncodingAttributes(T elems)
+{
+	// E5S / E5H:
+	using E5Type = typename T::value_type;
+
+	// Stack Attribute / HexAttribute:
+	using EnumType = typename std::tuple_element<0, E5Type>::type;
+
+	for(int i = 0; i < EI(EnumType::_count); i++)
+	{
+		if(elems.at(i) == E5Type{})
+			return EI(EnumType::_count) - i;
+	}
+
+	return 0;
+}
+
+/*
+ * Compile-time checks for elements in `HEX_ENCODING` and `STACK_ENCODING`
+ * which are out-of-order compared to the `Attribute` enum values.
+ * The index at which the order is violated is returned.
+ */
+template<typename T>
+constexpr int DisarrayedEncodingAttributeIndex(T elems)
+{
+	// E5S / E5H:
+	using E5Type = typename T::value_type;
+
+	// Stack Attribute / HexAttribute:
+	using EnumType = typename std::tuple_element<0, E5Type>::type;
+
+	for(int i = 0; i < EI(EnumType::_count); i++)
+	{
+		if(std::get<0>(elems.at(i)) != static_cast<EnumType>(i))
+			return i;
+	}
+
+	return -1;
+}
+
+/*
+ * Compile-time calculator for the number of unused values
+ * in a (potentially sub-optimal) BINARY encoding definition.
+ * Thue number of unuxed values is returned.
+ *
+ * Example:
+ * `vmax=130` means that 8 bits will be needed for the necoding (`n=8`).
+ * The maximum number of values which can be encoded with 8 bits is 255
+ * so there are 255-131=125 unused values => `125` is returned.
+ */
+constexpr int BinaryAttributeUnusedValues(Encoding e, int n, int vmax)
+{
+	switch(e)
+	{
+		case Encoding::BINARY_EXPLICIT_NULL:
+			return ((1 << (n - 1)) - 1 - vmax);
+			break;
+		case Encoding::BINARY_MASKING_NULL:
+			return ((1 << n) - 1 - vmax);
+			break;
+		case Encoding::BINARY_STRICT_NULL:
+			return ((1 << n) - 1 - vmax);
+			break;
+		case Encoding::BINARY_ZERO_NULL:
+			return ((1 << n) - 1 - vmax);
+		default:
+			return 0;
+	}
+	return 0;
+}
+
+template<typename T>
+constexpr int MiscalculatedBinaryAttributeUnusedValues(T elems)
+{
+	int i = MiscalculatedBinaryAttributeIndex(elems);
+	if(i == -1)
+		return 0;
+	auto [_, e, n, vmax, _p] = elems.at(i);
+	return BinaryAttributeUnusedValues(e, n, vmax);
+}
+
+/*
+ * Compile-time locator of misconfigured EXPNORM encodings:
+ * * checks if p <= 0 (must be positive)
+ */
+template<typename T>
+constexpr int MisconfiguredExpnormSlopeIndex(T elems)
+{
+	using E5Type = typename T::value_type;
+	using EnumType = typename std::tuple_element<0, E5Type>::type;
+
+	for(int i = 0; i < EI(EnumType::_count); i++)
+	{
+		auto [_, e, _n, vmax, p] = elems.at(i);
+		switch(e)
+		{
+			case Encoding::EXPNORM_EXPLICIT_NULL:
+			case Encoding::EXPNORM_MASKING_NULL:
+			case Encoding::EXPNORM_STRICT_NULL:
+			case Encoding::EXPNORM_ZERO_NULL:
+				if(p <= 0)
+					return i;
+				break;
+			default:
+				break;
+		}
+	}
+
+	return -1;
+}
+
+/*
+ * Compile-time locator of sub-optimal BINARY encodings.
+ * (see BinaryAttributeUnusedValues())
+ * The index of the sub-optimal BINARY encoding is returned.
+ */
+template<typename T>
+constexpr int MiscalculatedBinaryAttributeIndex(T elems)
+{
+	using E5Type = typename T::value_type;
+	using EnumType = typename std::tuple_element<0, E5Type>::type;
+
+	for(int i = 0; i < EI(EnumType::_count); i++)
+	{
+		auto [_, e, n, vmax, _p] = elems.at(i);
+		if(BinaryAttributeUnusedValues(e, n, vmax) > 0)
+			return i;
+	}
+
+	return -1;
+}
+
+/*
+ * Compile-time calculation for the encoded size of hexes and stacks
+ */
+template<typename T>
+constexpr int EncodedSize(T elems)
+{
+	using E5Type = typename T::value_type;
+	using EnumType = typename std::tuple_element<0, E5Type>::type;
+	int ret = 0;
+	for(int i = 0; i < EI(EnumType::_count); i++)
+	{
+		ret += std::get<2>(elems.at(i));
+	}
+	return ret;
+}
+
+}

+ 554 - 0
AI/MMAI/test/encoder_test.cpp

@@ -0,0 +1,554 @@
+/*
+ * encoder_test.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 "BAI/v13/encoder.h"
+#include "schema/v13/constants.h"
+#include "schema/v13/types.h"
+#include "test/googletest/googletest/include/gtest/gtest.h"
+#include <stdexcept>
+
+using Encoder = MMAI::BAI::V13::Encoder;
+namespace SV = Schema::V13;
+
+TEST(Encoder, Encode)
+{
+	{
+		constexpr auto a = SV::HexAttribute::Y_COORD;
+		constexpr auto e = std::get<1>(SV::HEX_ENCODING.at(int(a)));
+		constexpr auto n = std::get<2>(SV::HEX_ENCODING.at(int(a)));
+		static_assert(e == SV::Encoding::CATEGORICAL_STRICT_NULL, "test needs to be updated");
+		static_assert(n == 11, "test needs to be updated");
+
+		{
+			auto have = std::vector<float>{};
+			auto want = std::vector<float>{1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0};
+			Encoder::Encode(a, 0, have);
+			ASSERT_EQ(want, have);
+		}
+		{
+			auto have = std::vector<float>{};
+			auto want = std::vector<float>{0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0};
+			Encoder::Encode(a, 9, have);
+			ASSERT_EQ(want, have);
+		}
+		{
+			auto have = std::vector<float>{};
+			ASSERT_THROW(Encoder::Encode(a, -1, have), std::runtime_error);
+		}
+
+		// This no longer throws (emits warning instead)
+		// {
+		//     auto have = std::vector<float> {};
+		//     ASSERT_THROW(Encoder::Encode(a, 666, have), std::runtime_error);
+		// }
+	}
+}
+
+TEST(Encoder, AccumulatingExplicitNull)
+{
+	static_assert(EI(SV::Encoding::ACCUMULATING_EXPLICIT_NULL) == 0, "Encoding list has changed");
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{0, 1, 1, 1, 1};
+		Encoder::EncodeAccumulatingExplicitNull(3, 5, have);
+		ASSERT_EQ(want, have);
+	}
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{0, 1, 0, 0, 0};
+		Encoder::EncodeAccumulatingExplicitNull(0, 5, have);
+		ASSERT_EQ(want, have);
+	}
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{1, 0, 0, 0, 0};
+		Encoder::EncodeAccumulatingExplicitNull(-1, 5, have);
+		ASSERT_EQ(want, have);
+	}
+}
+
+TEST(Encoder, AccumulatingImplicitNull)
+{
+	static_assert(EI(SV::Encoding::ACCUMULATING_IMPLICIT_NULL) == 1 + EI(SV::Encoding::ACCUMULATING_EXPLICIT_NULL), "Encoding list has changed");
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{1, 1, 1, 1, 0};
+		Encoder::EncodeAccumulatingImplicitNull(3, 5, have);
+		ASSERT_EQ(want, have);
+	}
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{1, 0, 0, 0, 0};
+		Encoder::EncodeAccumulatingImplicitNull(0, 5, have);
+		ASSERT_EQ(want, have);
+	}
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{0, 0, 0, 0, 0};
+		Encoder::EncodeAccumulatingImplicitNull(-1, 5, have);
+		ASSERT_EQ(want, have);
+	}
+}
+
+TEST(Encoder, AccumulatingMaskingNull)
+{
+	static_assert(EI(SV::Encoding::ACCUMULATING_MASKING_NULL) == 1 + EI(SV::Encoding::ACCUMULATING_IMPLICIT_NULL), "Encoding list has changed");
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{1, 1, 1, 1, 0};
+		Encoder::EncodeAccumulatingMaskingNull(3, 5, have);
+		ASSERT_EQ(want, have);
+	}
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{1, 0, 0, 0, 0};
+		Encoder::EncodeAccumulatingMaskingNull(0, 5, have);
+		ASSERT_EQ(want, have);
+	}
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{-1, -1, -1, -1, -1};
+		Encoder::EncodeAccumulatingMaskingNull(-1, 5, have);
+		ASSERT_EQ(want, have);
+	}
+}
+
+TEST(Encoder, AccumulatingStrictNull)
+{
+	static_assert(EI(SV::Encoding::ACCUMULATING_STRICT_NULL) == 1 + EI(SV::Encoding::ACCUMULATING_MASKING_NULL), "Encoding list has changed");
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{1, 1, 1, 1, 0};
+		Encoder::EncodeAccumulatingStrictNull(3, 5, have);
+		ASSERT_EQ(want, have);
+	}
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{1, 0, 0, 0, 0};
+		Encoder::EncodeAccumulatingStrictNull(0, 5, have);
+		ASSERT_EQ(want, have);
+	}
+	{
+		auto have = std::vector<float>{};
+		ASSERT_THROW(Encoder::EncodeAccumulatingStrictNull(-1, 5, have), std::runtime_error);
+	}
+}
+
+TEST(Encoder, AccumulatingZeroNull)
+{
+	static_assert(EI(SV::Encoding::ACCUMULATING_ZERO_NULL) == 1 + EI(SV::Encoding::ACCUMULATING_STRICT_NULL), "Encoding list has changed");
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{1, 1, 1, 1, 0};
+		Encoder::EncodeAccumulatingZeroNull(3, 5, have);
+		ASSERT_EQ(want, have);
+	}
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{1, 0, 0, 0, 0};
+		Encoder::EncodeAccumulatingZeroNull(0, 5, have);
+		ASSERT_EQ(want, have);
+	}
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{1, 0, 0, 0, 0};
+		Encoder::EncodeAccumulatingZeroNull(-1, 5, have);
+		ASSERT_EQ(want, have);
+	}
+}
+
+TEST(Encoder, BinaryExplicitNull)
+{
+	static_assert(EI(SV::Encoding::BINARY_EXPLICIT_NULL) == 1 + EI(SV::Encoding::ACCUMULATING_ZERO_NULL), "Encoding list has changed");
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{0, 1, 1, 0, 0};
+		Encoder::EncodeBinaryExplicitNull(0b11, 5, have);
+		ASSERT_EQ(want, have);
+	}
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{0, 0, 0, 0, 0};
+		Encoder::EncodeBinaryExplicitNull(0, 5, have);
+		ASSERT_EQ(want, have);
+	}
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{1, 0, 0, 0, 0};
+		Encoder::EncodeBinaryExplicitNull(-1, 5, have);
+		ASSERT_EQ(want, have);
+	}
+}
+
+TEST(Encoder, BinaryMaskingNull)
+{
+	static_assert(EI(SV::Encoding::BINARY_MASKING_NULL) == 1 + EI(SV::Encoding::BINARY_EXPLICIT_NULL), "Encoding list has changed");
+
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{1, 1, 0, 0, 0};
+		Encoder::EncodeBinaryMaskingNull(0b11, 5, have);
+		ASSERT_EQ(want, have);
+	}
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{0, 0, 0, 0, 0};
+		Encoder::EncodeBinaryMaskingNull(0, 5, have);
+		ASSERT_EQ(want, have);
+	}
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{-1, -1, -1, -1, -1};
+		Encoder::EncodeBinaryMaskingNull(-1, 5, have);
+		ASSERT_EQ(want, have);
+	}
+}
+
+TEST(Encoder, BinaryStrictNull)
+{
+	static_assert(EI(SV::Encoding::BINARY_STRICT_NULL) == 1 + EI(SV::Encoding::BINARY_MASKING_NULL), "Encoding list has changed");
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{1, 1, 0, 0, 0};
+		Encoder::EncodeBinaryStrictNull(0b11, 5, have);
+		ASSERT_EQ(want, have);
+	}
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{0, 0, 0, 0, 0};
+		Encoder::EncodeBinaryStrictNull(0, 5, have);
+		ASSERT_EQ(want, have);
+	}
+	{
+		auto have = std::vector<float>{};
+		ASSERT_THROW(Encoder::EncodeBinaryStrictNull(-1, 5, have), std::runtime_error);
+	}
+}
+
+TEST(Encoder, BinaryZeroNull)
+{
+	static_assert(EI(SV::Encoding::BINARY_ZERO_NULL) == 1 + EI(SV::Encoding::BINARY_STRICT_NULL), "Encoding list has changed");
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{1, 1, 0, 0, 0};
+		Encoder::EncodeBinaryZeroNull(0b11, 5, have);
+		ASSERT_EQ(want, have);
+	}
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{0, 0, 0, 0, 0};
+		Encoder::EncodeBinaryZeroNull(0, 5, have);
+		ASSERT_EQ(want, have);
+	}
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{0, 0, 0, 0, 0};
+		Encoder::EncodeBinaryZeroNull(-1, 5, have);
+		ASSERT_EQ(want, have);
+	}
+}
+
+TEST(Encoder, CategoricalExplicitNull)
+{
+	static_assert(EI(SV::Encoding::CATEGORICAL_EXPLICIT_NULL) == 1 + EI(SV::Encoding::BINARY_ZERO_NULL), "Encoding list has changed");
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{0, 0, 0, 0, 1};
+		Encoder::EncodeCategoricalExplicitNull(3, 5, have);
+		ASSERT_EQ(want, have);
+	}
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{0, 1, 0, 0, 0};
+		Encoder::EncodeCategoricalExplicitNull(0, 5, have);
+		ASSERT_EQ(want, have);
+	}
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{1, 0, 0, 0, 0};
+		Encoder::EncodeCategoricalExplicitNull(-1, 5, have);
+		ASSERT_EQ(want, have);
+	}
+}
+
+TEST(Encoder, CategoricalImplicitNull)
+{
+	static_assert(EI(SV::Encoding::CATEGORICAL_IMPLICIT_NULL) == 1 + EI(SV::Encoding::CATEGORICAL_EXPLICIT_NULL), "Encoding list has changed");
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{0, 0, 0, 1, 0};
+		Encoder::EncodeCategoricalImplicitNull(3, 5, have);
+		ASSERT_EQ(want, have);
+	}
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{1, 0, 0, 0, 0};
+		Encoder::EncodeCategoricalImplicitNull(0, 5, have);
+		ASSERT_EQ(want, have);
+	}
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{0, 0, 0, 0, 0};
+		Encoder::EncodeCategoricalImplicitNull(-1, 5, have);
+		ASSERT_EQ(want, have);
+	}
+}
+
+TEST(Encoder, CategoricalMaskingNull)
+{
+	static_assert(EI(SV::Encoding::CATEGORICAL_MASKING_NULL) == 1 + EI(SV::Encoding::CATEGORICAL_IMPLICIT_NULL), "Encoding list has changed");
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{0, 0, 0, 1, 0};
+		Encoder::EncodeCategoricalMaskingNull(3, 5, have);
+		ASSERT_EQ(want, have);
+	}
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{1, 0, 0, 0, 0};
+		Encoder::EncodeCategoricalMaskingNull(0, 5, have);
+		ASSERT_EQ(want, have);
+	}
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{-1, -1, -1, -1, -1};
+		Encoder::EncodeCategoricalMaskingNull(-1, 5, have);
+		ASSERT_EQ(want, have);
+	}
+}
+
+TEST(Encoder, CategoricalStrictNull)
+{
+	static_assert(EI(SV::Encoding::CATEGORICAL_STRICT_NULL) == 1 + EI(SV::Encoding::CATEGORICAL_MASKING_NULL), "Encoding list has changed");
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{0, 0, 0, 1, 0};
+		Encoder::EncodeCategoricalStrictNull(3, 5, have);
+		ASSERT_EQ(want, have);
+	}
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{1, 0, 0, 0, 0};
+		Encoder::EncodeCategoricalStrictNull(0, 5, have);
+		ASSERT_EQ(want, have);
+	}
+	{
+		auto have = std::vector<float>{};
+		ASSERT_THROW(Encoder::EncodeCategoricalStrictNull(-1, 5, have), std::runtime_error);
+	}
+}
+
+TEST(Encoder, CategoricalZeroNull)
+{
+	static_assert(EI(SV::Encoding::CATEGORICAL_ZERO_NULL) == 1 + EI(SV::Encoding::CATEGORICAL_STRICT_NULL), "Encoding list has changed");
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{0, 0, 0, 1, 0};
+		Encoder::EncodeCategoricalZeroNull(3, 5, have);
+		ASSERT_EQ(want, have);
+	}
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{1, 0, 0, 0, 0};
+		Encoder::EncodeCategoricalZeroNull(0, 5, have);
+		ASSERT_EQ(want, have);
+	}
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{1, 0, 0, 0, 0};
+		Encoder::EncodeCategoricalZeroNull(-1, 5, have);
+		ASSERT_EQ(want, have);
+	}
+}
+
+TEST(Encoder, NormalizedExpExplicitNull)
+{
+	static_assert(EI(SV::Encoding::EXPNORM_EXPLICIT_NULL) == 1 + EI(SV::Encoding::CATEGORICAL_ZERO_NULL), "Encoding list has changed");
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{0, 0.876};
+		Encoder::EncodeExpnormExplicitNull(3, 5, 4, have);
+		ASSERT_EQ(have.size(), 2);
+		ASSERT_EQ(want.at(0), have.at(0));
+		ASSERT_NEAR(want.at(1), have.at(1), 1e-3);
+	}
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{0, 0};
+		Encoder::EncodeExpnormExplicitNull(0, 5, 4, have);
+		ASSERT_EQ(want, have);
+	}
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{1, 0};
+		Encoder::EncodeExpnormExplicitNull(-1, 5, 4, have);
+		ASSERT_EQ(want, have);
+	}
+}
+
+TEST(Encoder, NormalizedExpMaskingNull)
+{
+	static_assert(EI(SV::Encoding::EXPNORM_MASKING_NULL) == 1 + EI(SV::Encoding::EXPNORM_EXPLICIT_NULL), "Encoding list has changed");
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{0.876};
+		Encoder::EncodeExpnormMaskingNull(3, 5, 4, have);
+		ASSERT_EQ(have.size(), 1);
+		ASSERT_NEAR(want.at(0), have.at(0), 1e-3);
+	}
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{0};
+		Encoder::EncodeExpnormMaskingNull(0, 5, 4, have);
+		ASSERT_EQ(want, have);
+	}
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{-1};
+		Encoder::EncodeExpnormMaskingNull(-1, 5, 4, have);
+		ASSERT_EQ(want, have);
+	}
+}
+
+TEST(Encoder, NormalizedExpStrictNull)
+{
+	static_assert(EI(SV::Encoding::EXPNORM_STRICT_NULL) == 1 + EI(SV::Encoding::EXPNORM_MASKING_NULL), "Encoding list has changed");
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{0.876};
+		Encoder::EncodeExpnormStrictNull(3, 5, 4, have);
+		ASSERT_EQ(have.size(), 1);
+		ASSERT_NEAR(want.at(0), have.at(0), 1e-3);
+	}
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{0};
+		Encoder::EncodeExpnormStrictNull(0, 5, 4, have);
+		ASSERT_EQ(want, have);
+	}
+	{
+		auto have = std::vector<float>{};
+		ASSERT_THROW(Encoder::EncodeExpnormStrictNull(-1, 5, 4, have), std::runtime_error);
+	}
+}
+
+TEST(Encoder, NormalizedExpZeroNull)
+{
+	static_assert(EI(SV::Encoding::EXPNORM_ZERO_NULL) == 1 + EI(SV::Encoding::EXPNORM_STRICT_NULL), "Encoding list has changed");
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{0.876};
+		Encoder::EncodeExpnormZeroNull(3, 5, 4, have);
+		ASSERT_EQ(have.size(), 1);
+		ASSERT_NEAR(want.at(0), have.at(0), 1e-3);
+	}
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{0};
+		Encoder::EncodeExpnormZeroNull(0, 5, 4, have);
+		ASSERT_EQ(want, have);
+	}
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{0};
+		Encoder::EncodeExpnormZeroNull(-1, 5, 4, have);
+		ASSERT_EQ(want, have);
+	}
+}
+
+TEST(Encoder, NormalizedLinExplicitNull)
+{
+	static_assert(EI(SV::Encoding::LINNORM_EXPLICIT_NULL) == 1 + EI(SV::Encoding::EXPNORM_ZERO_NULL), "Encoding list has changed");
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{0, 0.6};
+		Encoder::EncodeLinnormExplicitNull(3, 5, have);
+		ASSERT_EQ(want, have);
+	}
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{0, 0};
+		Encoder::EncodeLinnormExplicitNull(0, 5, have);
+		ASSERT_EQ(want, have);
+	}
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{1, 0};
+		Encoder::EncodeLinnormExplicitNull(-1, 5, have);
+		ASSERT_EQ(want, have);
+	}
+}
+
+TEST(Encoder, NormalizedMaskingNull)
+{
+	static_assert(EI(SV::Encoding::LINNORM_MASKING_NULL) == 1 + EI(SV::Encoding::LINNORM_EXPLICIT_NULL), "Encoding list has changed");
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{0.6};
+		Encoder::EncodeLinnormMaskingNull(3, 5, have);
+		ASSERT_EQ(want, have);
+	}
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{0};
+		Encoder::EncodeLinnormMaskingNull(0, 5, have);
+		ASSERT_EQ(want, have);
+	}
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{-1};
+		Encoder::EncodeLinnormMaskingNull(-1, 5, have);
+		ASSERT_EQ(want, have);
+	}
+}
+
+TEST(Encoder, NormalizedStrictNull)
+{
+	static_assert(EI(SV::Encoding::LINNORM_STRICT_NULL) == 1 + EI(SV::Encoding::LINNORM_MASKING_NULL), "Encoding list has changed");
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{0.6};
+		Encoder::EncodeLinnormStrictNull(3, 5, have);
+		ASSERT_EQ(want, have);
+	}
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{0};
+		Encoder::EncodeLinnormStrictNull(0, 5, have);
+		ASSERT_EQ(want, have);
+	}
+	{
+		auto have = std::vector<float>{};
+		ASSERT_THROW(Encoder::EncodeLinnormStrictNull(-1, 5, have), std::runtime_error);
+	}
+}
+
+TEST(Encoder, NormalizedZeroNull)
+{
+	static_assert(EI(SV::Encoding::LINNORM_ZERO_NULL) == 1 + EI(SV::Encoding::LINNORM_STRICT_NULL), "Encoding list has changed");
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{0.6};
+		Encoder::EncodeLinnormZeroNull(3, 5, have);
+		ASSERT_EQ(want, have);
+	}
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{0};
+		Encoder::EncodeLinnormZeroNull(0, 5, have);
+		ASSERT_EQ(want, have);
+	}
+	{
+		auto have = std::vector<float>{};
+		auto want = std::vector<float>{0};
+		Encoder::EncodeLinnormZeroNull(-1, 5, have);
+		ASSERT_EQ(want, have);
+	}
+}

+ 4 - 1
AI/Nullkiller/Engine/PriorityEvaluator.cpp

@@ -1061,7 +1061,10 @@ public:
 		auto hero = clusterGoal.hero;
 		auto role = evaluationContext.evaluator.ai->heroManager->getHeroRole(hero);
 
-		std::vector<std::pair<ObjectInstanceID, ClusterObjectInfo>> objects(cluster->objects.begin(), cluster->objects.end());
+		std::vector<std::pair<ObjectInstanceID, ClusterObjectInfo>> objects;
+		objects.reserve(cluster->objects.size());
+		for (const auto& obj : cluster->objects)
+			objects.emplace_back(obj.first, obj.second);
 
 		std::sort(objects.begin(), objects.end(), [](std::pair<ObjectInstanceID, ClusterObjectInfo> o1, std::pair<ObjectInstanceID, ClusterObjectInfo> o2) -> bool
 		{

+ 4 - 1
AI/Nullkiller2/Engine/PriorityEvaluator.cpp

@@ -1062,7 +1062,10 @@ public:
 		auto hero = clusterGoal.hero;
 		auto role = evaluationContext.evaluator.aiNk->heroManager->getHeroRoleOrDefaultInefficient(hero);
 
-		std::vector<std::pair<ObjectInstanceID, ClusterObjectInfo>> objects(cluster->objects.begin(), cluster->objects.end());
+		std::vector<std::pair<ObjectInstanceID, ClusterObjectInfo>> objects;
+		objects.reserve(cluster->objects.size());
+		for (const auto& obj : cluster->objects)
+			objects.emplace_back(obj.first, obj.second);
 
 		std::sort(objects.begin(), objects.end(), [](std::pair<ObjectInstanceID, ClusterObjectInfo> o1, std::pair<ObjectInstanceID, ClusterObjectInfo> o2) -> bool
 		{

+ 28 - 10
AI/StupidAI/StupidAI.cpp

@@ -125,6 +125,7 @@ void CStupidAI::activeStack(const BattleID & battleID, const CStack * stack)
 	std::vector<EnemyInfo> enemiesShootable;
 	std::vector<EnemyInfo> enemiesReachable;
 	std::vector<EnemyInfo> enemiesUnreachable;
+	std::vector<EnemyInfo> enemiesInvincible;
 
 	if(stack->creatureId() == CreatureID::CATAPULT)
 	{
@@ -147,7 +148,11 @@ void CStupidAI::activeStack(const BattleID & battleID, const CStack * stack)
 
 	for (const CStack *s : cb->getBattle(battleID)->battleGetStacks(CBattleInfoEssentials::ONLY_ENEMY))
 	{
-		if(cb->getBattle(battleID)->battleCanShoot(stack, s->getPosition()))
+		if (s->isInvincible())
+		{
+			enemiesInvincible.push_back(s);
+		}
+		else if(cb->getBattle(battleID)->battleCanShoot(stack, s->getPosition()))
 		{
 			enemiesShootable.push_back(s);
 		}
@@ -197,22 +202,35 @@ void CStupidAI::activeStack(const BattleID & battleID, const CStack * stack)
 	}
 	else if(enemiesUnreachable.size()) //due to #955 - a buggy battle may occur when there are no enemies
 	{
-		auto closestEnemy = vstd::minElementByFun(enemiesUnreachable, [&](const EnemyInfo & ei) -> int
-		{
-			return dists.distToNearestNeighbour(stack, ei.s);
-		});
-
-		if(dists.distToNearestNeighbour(stack, closestEnemy->s) < GameConstants::BFIELD_SIZE)
-		{
-			cb->battleMakeUnitAction(battleID, goTowards(battleID, stack, closestEnemy->s->getAttackableHexes(stack)));
+		if(moveStackToClosestEnemy(battleID, stack, dists, enemiesUnreachable))
+			return;
+	}
+	else if(enemiesInvincible.size())
+	{
+		if(moveStackToClosestEnemy(battleID, stack, dists, enemiesInvincible))
 			return;
-		}
 	}
 
 	cb->battleMakeUnitAction(battleID, BattleAction::makeDefend(stack));
 	return;
 }
 
+bool CStupidAI::moveStackToClosestEnemy(const BattleID & battleID, const CStack * stack, const ReachabilityInfo & dists, const std::vector<EnemyInfo> &enemyInfos)
+{
+	auto closestEnemy = vstd::minElementByFun(enemyInfos,[&](const EnemyInfo & ei) -> int
+		{
+			return dists.distToNearestNeighbour(stack, ei.s);
+		}
+	);
+
+	if(dists.distToNearestNeighbour(stack, closestEnemy->s) < GameConstants::BFIELD_SIZE)
+	{
+		cb->battleMakeUnitAction(battleID, goTowards(battleID, stack, closestEnemy->s->getAttackableHexes(stack)));
+		return true;
+	}
+	return false;
+}
+
 void CStupidAI::battleAttack(const BattleID & battleID, const BattleAttack *ba)
 {
 	print("battleAttack called");

+ 1 - 0
AI/StupidAI/StupidAI.h

@@ -51,5 +51,6 @@ public:
 
 private:
 	BattleAction goTowards(const BattleID & battleID, const CStack * stack, BattleHexArray hexes) const;
+	bool moveStackToClosestEnemy(const BattleID & battleID, const CStack * stack, const ReachabilityInfo & dists, const std::vector<EnemyInfo> & enemyInfos);
 };
 

+ 1 - 0
AUTHORS.h

@@ -40,6 +40,7 @@ const std::vector<std::vector<std::string>> contributors = {
 	{ "Developing", "Rickard Westerlund" , "Onion Knight"       , "[email protected]"         },
 	{ "Developing", ""                   , "rilian-la-te"       , ""                             },
 	{ "Developing", ""                   , "SoundSSGood"        , ""                             },
+	{ "Developing", "Simeon Manolov"     , "smanolloff"         , "[email protected]"        },
 	{ "Developing", "Stefan Pavlov"      , "Ste"                , "[email protected]"            },
 	{ "Developing", "Tom Zielinski"      , "Warmonger"          , "[email protected]"              },
 	{ "Developing", "Trevor Standley"    , "tstandley"          , ""                             },

+ 7 - 0
CI/before_install/linux_common.sh

@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+
+# https://github.com/microsoft/onnxruntime/discussions/6489
+ONNXRUNTIME_URL=https://github.com/microsoft/onnxruntime/releases/download/v1.18.1/onnxruntime-linux-x64-1.18.1.tgz
+ONNXRUNTIME_ROOT=/opt/onnxruntime
+sudo mkdir -p "$ONNXRUNTIME_ROOT"
+curl -fsSL "$ONNXRUNTIME_URL" | sudo tar -xzv --strip-components=1 -C "$ONNXRUNTIME_ROOT"

+ 2 - 0
CI/before_install/linux_qt5.sh

@@ -3,6 +3,8 @@
 set -euo pipefail
 export DEBIAN_FRONTEND=noninteractive
 
+source $(dirname "${BASH_SOURCE[0]}")/linux_common.sh
+
 APT_CACHE="${APT_CACHE:-${RUNNER_TEMP:-/tmp}/apt-cache}"
 sudo mkdir -p "$APT_CACHE"
 

+ 2 - 0
CI/before_install/linux_qt6.sh

@@ -3,6 +3,8 @@
 set -euo pipefail
 export DEBIAN_FRONTEND=noninteractive
 
+source $(dirname "${BASH_SOURCE[0]}")/linux_common.sh
+
 APT_CACHE="${APT_CACHE:-${RUNNER_TEMP:-/tmp}/apt-cache}"
 sudo mkdir -p "$APT_CACHE"
 

+ 5 - 0
CMakeLists.txt

@@ -55,6 +55,7 @@ option(ENABLE_NULLKILLER_AI "Enable compilation of NullkillerAI library" ON)
 option(ENABLE_NULLKILLER2_AI "Enable compilation of Nullkiller2AI library" ON)
 option(ENABLE_STUPID_AI "Enable compilation of StupidAI library" ON)
 option(ENABLE_BATTLE_AI "Enable compilation of BattleAI library" ON)
+option(ENABLE_MMAI "Enable compilation of MMAI AI library" ON)
 
 # Compilation options
 
@@ -302,6 +303,10 @@ endif()
 if(ENABLE_BATTLE_AI)
 	add_definitions(-DENABLE_BATTLE_AI)
 endif()
+if(ENABLE_MMAI)
+	add_definitions(-DENABLE_MMAI)
+endif()
+
 
 if(IOS)
 	set(CMAKE_MACOSX_RPATH 1)

+ 2 - 1
CMakePresets.json

@@ -42,7 +42,8 @@
             "inherits" : "default-release",
             "hidden": true,
             "cacheVariables": {
-                "CMAKE_INSTALL_PREFIX" : "/usr/local"
+                "CMAKE_INSTALL_PREFIX" : "/usr/local",
+                "ONNXRUNTIME_ROOT": "/opt/onnxruntime"
             }
         },
         {

二進制
Mods/vcmi/Content/Sprites/lobby/battle-normal.png


二進制
Mods/vcmi/Content/Sprites/lobby/battle-pressed.png


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

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

二進制
Mods/vcmi/Content/Sprites/lobby/dropdownNormal.png


二進制
Mods/vcmi/Content/Sprites/lobby/dropdownPressed.png


+ 4 - 2
Mods/vcmi/Content/config/english.json

@@ -141,7 +141,8 @@
 	"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.battleOnlyMode" : "Battle Mode",
+	"vcmi.lobby.battleOnlyMode.help" : "Play a simple battle without adventure map",
 	"vcmi.lobby.battleOnlyModeSubTitle" : "Select heroes, army, skills, artifact and battleground for simple battle without adventure map",
 	"vcmi.lobby.battleOnlyModeBattlefield" : "Battlefield",
 	"vcmi.lobby.battleOnlyModeBattlefieldSelect" : "Select Battlefield",
@@ -884,7 +885,7 @@
 	"vcmi.optionsTab.turnTime.chess.2"    : "Chess: 02:00 + 01:00 + 00:15 + 00:00",
 	"vcmi.optionsTab.turnTime.chess.1"    : "Chess: 01:00 + 01:00 + 00:00 + 00:00",
 
-	"vcmi.optionsTab.simturns.select"         : "Select simultaneous turns preset",
+	"vcmi.optionsTab.simturns.select"         : "Select sim. turns preset",
 	"vcmi.optionsTab.simturns.none"           : "No simultaneous turns",
 	"vcmi.optionsTab.simturns.tillContactMax" : "Simturns: Until contact",
 	"vcmi.optionsTab.simturns.tillContact1"   : "Simturns: 1 week, break on contact",
@@ -1112,6 +1113,7 @@
 	"core.bonus.UNDEAD.description" : "{Undead}\nCreature is Undead and is immune to effects that only affect living",
 	"core.bonus.UNLIMITED_RETALIATIONS.description" : "{Unlimited retaliations}\nThis unit can retaliate against an unlimited number of attacks",
 	"core.bonus.WIDE_BREATH.description" : "{Wide breath}\nThis unit attacks all units around its target",
+	"core.bonus.DAMAGE_RECEIVED_CAP.description" : "{Damage limit (${val}%) }\nNo single attack can deal more damage than ${val}% of max health",
 
 	"spell.core.castleMoat.name" : "Moat",
 	"spell.core.castleMoatTrigger.name" : "Moat",

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

@@ -140,7 +140,8 @@
 	"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.battleOnlyMode" : "Kampfmodus",
+	"vcmi.lobby.battleOnlyMode.help" : "Spiele einen einfachen Kampf ohne Abenteuerkarte",
 	"vcmi.lobby.battleOnlyModeSubTitle" : "Wähle Helden, Armeen, Skills, Artefakte und ein Schlachtfeld für einen Kampf ohne Abenteuerkarte",
 	"vcmi.lobby.battleOnlyModeBattlefield" : "Schlachtfeld",
 	"vcmi.lobby.battleOnlyModeBattlefieldSelect" : "Schlachtfeld auswählen",
@@ -853,7 +854,7 @@
 	"vcmi.optionsTab.simturnsMax.help" : "Spielt gleichzeitig für eine bestimmte Anzahl von Tagen oder bis zum Kontakt mit einem anderen Spieler",
 	"vcmi.optionsTab.simturnsAI.help" : "{Simultane KI Züge}\nExperimentelle Option. Ermöglicht es den KI-Spielern, gleichzeitig mit dem menschlichen Spieler zu agieren, wenn simultane Spielzüge aktiviert sind.",
 
-	"vcmi.optionsTab.turnTime.select"     : "Spielzug-Timer-Voreinst. wählen",
+	"vcmi.optionsTab.turnTime.select"     : "Timer-Voreinstellung wählen",
 	"vcmi.optionsTab.turnTime.unlimited"  : "Unbegrenzter Spielzug-Timer",
 	"vcmi.optionsTab.turnTime.classic.1"  : "Klassischer Timer: 1 Minute",
 	"vcmi.optionsTab.turnTime.classic.2"  : "Klassischer Timer: 2 Minuten",
@@ -868,15 +869,15 @@
 	"vcmi.optionsTab.turnTime.chess.2"    : "Schach: 02:00 01:00 00:15 00:00",
 	"vcmi.optionsTab.turnTime.chess.1"    : "Schach: 01:00 01:00 00:00 00:00",
 
-	"vcmi.optionsTab.simturns.select"         : "Voreinst. für simultane Züge wählen",
+	"vcmi.optionsTab.simturns.select"         : "Voreinst. für Züge wählen",
 	"vcmi.optionsTab.simturns.none"           : "Keine simultanen Züge",
 	"vcmi.optionsTab.simturns.tillContactMax" : "Simzüge: Bis zum Kontakt",
 	"vcmi.optionsTab.simturns.tillContact1"   : "Simzüge: 1 Woche, Stop bei Kontakt",
 	"vcmi.optionsTab.simturns.tillContact2"   : "Simzüge: 2 Wochen, Stop bei Kontakt",
 	"vcmi.optionsTab.simturns.tillContact4"   : "Simzüge: 1 Monat, Stop bei Kontakt",
-	"vcmi.optionsTab.simturns.blocked1"       : "Simzüge: 1 Woche, Kontakte block.",
-	"vcmi.optionsTab.simturns.blocked2"       : "Simzüge: 2 Wochen, Kontakte block.",
-	"vcmi.optionsTab.simturns.blocked4"       : "Simzüge: 1 Monat, Kontakte block.",
+	"vcmi.optionsTab.simturns.blocked1"       : "Simzüge: 1 Woche, Kontakte blockieren",
+	"vcmi.optionsTab.simturns.blocked2"       : "Simzüge: 2 Wochen, Kontakte blockieren",
+	"vcmi.optionsTab.simturns.blocked4"       : "Simzüge: 1 Monat, Kontakte blockieren",
 
 	"vcmi.campaignSet.chronicles" : "Heroes Chronicles",
 	"vcmi.campaignSet.hota" : "Horn of the Abyss",

+ 0 - 1
android/vcmi-app/src/main/java/eu/vcmi/vcmi/NativeMethods.java

@@ -27,7 +27,6 @@ public class NativeMethods
 
     public static native void initClassloader();
     public static native void heroesDataUpdate();
-    public static native boolean tryToSaveTheGame();
 
     public static void setupMsg(final Messenger msg)
     {

+ 3 - 16
android/vcmi-app/src/main/java/eu/vcmi/vcmi/VcmiSDLActivity.java

@@ -114,25 +114,12 @@ public class VcmiSDLActivity extends SDLActivity
     @Override
     protected void onDestroy()
     {
-        try
-        {
-            // since android can kill the activity unexpectedly (e.g. memory is low or device is inactive for some time), let's try creating
-            // an autosave so user might be able to resume the game; this isn't a very good impl (we shouldn't really sleep here and hope that the
-            // save is created, but for now it might suffice
-            // (better solution: listen for game's confirmation that the save has been created -- this would allow us to inform the users
-            // on the next app launch that there is an automatic save that they can use)
-            if (NativeMethods.tryToSaveTheGame())
-            {
-                Thread.sleep(1000L);
-            }
-        }
-        catch (final InterruptedException ignored)
-        {
-        }
-
         unbindServer();
 
         super.onDestroy();
+
+        finishAffinity();
+        System.exit(0);
     }
 
     private void initService()

+ 3 - 0
client/CMakeLists.txt

@@ -476,6 +476,9 @@ if(NOT ENABLE_STATIC_LIBS)
 		add_dependencies(vcmiclientcommon BattleAI)
 	endif()
 
+	if(ENABLE_MMAI)
+		add_dependencies(vcmiclientcommon MMAI)
+	endif()
 endif()
 if(IOS)
 	if(ENABLE_ERM)

+ 14 - 9
client/CPlayerInterface.cpp

@@ -668,15 +668,7 @@ void CPlayerInterface::battleStart(const BattleID & battleID, const CCreatureSet
 
 	if ((replayAllowed && useQuickCombat) || forceQuickCombat)
 	{
-		autofightingAI = CDynLibHandler::getNewBattleAI(settings["server"]["friendlyAI"].String());
-
-		AutocombatPreferences autocombatPreferences = AutocombatPreferences();
-		autocombatPreferences.enableSpellsUsage = settings["battle"]["enableAutocombatSpells"].Bool();
-
-		autofightingAI->initBattleInterface(env, cb, autocombatPreferences);
-		autofightingAI->battleStart(battleID, army1, army2, tile, hero1, hero2, side, false);
-		isAutoFightOn = true;
-		registerBattleInterface(autofightingAI);
+		prepareAutoFightingAI(battleID, army1, army2, tile, hero1, hero2, side);
 	}
 
 	waitForAllDialogs();
@@ -1877,6 +1869,19 @@ bool CPlayerInterface::capturedAllEvents()
 	return false;
 }
 
+void CPlayerInterface::prepareAutoFightingAI(const BattleID &bid, const CCreatureSet *army1, const CCreatureSet *army2, int3 tile, const CGHeroInstance *hero1, const CGHeroInstance *hero2, BattleSide side)
+{
+	autofightingAI = CDynLibHandler::getNewBattleAI(settings["server"]["friendlyAI"].String());
+
+	AutocombatPreferences autocombatPreferences = AutocombatPreferences();
+	autocombatPreferences.enableSpellsUsage = settings["battle"]["enableAutocombatSpells"].Bool();
+
+	autofightingAI->initBattleInterface(env, cb, autocombatPreferences);
+	autofightingAI->battleStart(bid, army1, army2, tile, hero1, hero2, side, false);
+	isAutoFightOn = true;
+	registerBattleInterface(autofightingAI);
+}
+
 void CPlayerInterface::showWorldViewEx(const std::vector<ObjectPosInfo>& objectPositions, bool showTerrain)
 {
 	EVENT_HANDLER_CALLED_BY_CLIENT;

+ 2 - 0
client/CPlayerInterface.h

@@ -221,6 +221,8 @@ public: // public interface for use by client via GAME->interface() access
 	void registerBattleInterface(std::shared_ptr<CBattleGameInterface> battleEvents);
 	void unregisterBattleInterface(std::shared_ptr<CBattleGameInterface> battleEvents);
 
+	void prepareAutoFightingAI(const BattleID &bid, const CCreatureSet *army1, const CCreatureSet *army2, int3 tile, const CGHeroInstance *hero1, const CGHeroInstance *hero2, BattleSide side);
+
 	CPlayerInterface(PlayerColor Player);
 	~CPlayerInterface();
 

+ 0 - 23
client/Client.cpp

@@ -513,29 +513,6 @@ void CClient::removeGUI() const
 	GAME->setInterfaceInstance(nullptr);
 }
 
-#ifdef VCMI_ANDROID
-extern "C" JNIEXPORT jboolean JNICALL Java_eu_vcmi_vcmi_NativeMethods_tryToSaveTheGame(JNIEnv * env, jclass cls)
-{
-	std::scoped_lock interfaceLock(ENGINE->interfaceMutex);
-
-	logGlobal->info("Received emergency save game request");
-	if(!GAME->interface() || !GAME->interface()->cb)
-	{
-		logGlobal->info("... but no active player interface found!");
-		return false;
-	}
-
-	if (!GAME->server().logicConnection)
-	{
-		logGlobal->info("... but no active connection found!");
-		return false;
-	}
-
-	GAME->interface()->cb->save("Saves/_Android_Autosave");
-	return true;
-}
-#endif
-
 void CClient::registerBattleInterface(std::shared_ptr<IBattleEventsReceiver> battleEvents, PlayerColor color)
 {
 	additionalBattleInts[color].push_back(battleEvents);

+ 3 - 0
client/GameEngineUser.h

@@ -23,6 +23,9 @@ public:
 	/// Called when app shutdown has been requested in any way - exit button, Alt-F4, etc
 	virtual void onShutdownRequested(bool askForConfirmation) = 0;
 
+	/// Called when mobile app pauses
+	virtual void onAppPaused() = 0;
+
 	/// Returns true if all input events should be captured and ignored
 	virtual bool capturedAllEvents() = 0;
 };

+ 32 - 0
client/GameInstance.cpp

@@ -20,6 +20,7 @@
 
 #include "../lib/CConfigHandler.h"
 #include "../lib/GameLibrary.h"
+#include "../lib/callback/CCallback.h"
 #include "../lib/texts/CGeneralTextHandler.h"
 
 std::unique_ptr<GameInstance> GAME = nullptr;
@@ -112,3 +113,34 @@ void GameInstance::onShutdownRequested(bool ask)
 			CInfoWindow::showYesNoDialog(LIBRARY->generaltexth->allTexts[69], {}, doQuit, {}, PlayerColor(1));
 	}
 }
+
+void GameInstance::onAppPaused()
+{
+	pauseAutoSave();
+}
+
+void GameInstance::pauseAutoSave()
+{
+	const std::string autoSaveName = "Saves/PauseAutosave";
+
+	logGlobal->info("Received pause save game request");
+	if(!GAME->interface() || !GAME->interface()->cb)
+	{
+		logGlobal->info("... but no active player interface found!");
+		return;
+	}
+
+	if (!GAME->server().logicConnection)
+	{
+		logGlobal->info("... but no active connection found!");
+		return;
+	}
+
+	if(!GAME->interface()->cb->getActiveBattles().empty())
+	{
+		logGlobal->info("... but player is in battle, skipping autosave!");
+		return;
+	}
+
+	GAME->interface()->cb->save(autoSaveName);
+}

+ 3 - 0
client/GameInstance.h

@@ -37,6 +37,8 @@ class GameInstance final : boost::noncopyable, public IGameEngineUser
 	std::shared_ptr<CMainMenu> mainMenuInstance;
 	CPlayerInterface * interfaceInstance;
 
+	void pauseAutoSave();
+
 public:
 	GameInstance();
 	~GameInstance();
@@ -55,6 +57,7 @@ public:
 	void onUpdate() final;
 	bool capturedAllEvents() final;
 	void onShutdownRequested(bool askForConfirmation) final;
+	void onAppPaused() final;
 };
 
 extern std::unique_ptr<GameInstance> GAME;

+ 3 - 1
client/adventureMap/AdventureMapInterface.cpp

@@ -603,7 +603,9 @@ void AdventureMapInterface::onTileHovered(const int3 &targetPosition)
 {
 	if(!shortcuts->optionMapViewActive())
 		return;
-
+	//if the player is not ingame (loser, winner, wrong) we are in a shutdown process
+	if (!GAME->interface()->cb || GAME->interface()->cb->getPlayerStatus(GAME->interface()->playerID) != EPlayerStatus::INGAME)
+		return;
 	//may occur just at the start of game (fake move before full initialization)
 	if(!GAME->interface()->localState->getCurrentArmy())
 		return;

+ 2 - 19
client/battle/BattleWindow.cpp

@@ -656,15 +656,7 @@ void BattleWindow::bAutofightf()
 		owner.curInt->isAutoFightOn = true;
 		blockUI(true);
 
-		auto ai = CDynLibHandler::getNewBattleAI(settings["server"]["friendlyAI"].String());
-
-		AutocombatPreferences autocombatPreferences = AutocombatPreferences();
-		autocombatPreferences.enableSpellsUsage = settings["battle"]["enableAutocombatSpells"].Bool();
-
-		ai->initBattleInterface(owner.curInt->env, owner.curInt->cb, autocombatPreferences);
-		ai->battleStart(owner.getBattleID(), owner.army1, owner.army2, int3(0,0,0), owner.attackingHeroInstance, owner.defendingHeroInstance, owner.getBattle()->battleGetMySide(), false);
-		owner.curInt->registerBattleInterface(ai);
-
+		owner.curInt->prepareAutoFightingAI(owner.getBattleID(), owner.army1, owner.army2, int3(0,0,0), owner.attackingHeroInstance, owner.defendingHeroInstance, owner.getBattle()->battleGetMySide());
 		owner.requestAutofightingAIToTakeAction();
 	}
 }
@@ -833,17 +825,8 @@ void BattleWindow::endWithAutocombat()
 		[this]()
 		{
 			owner.curInt->isAutoFightEndBattle = true;
+			owner.curInt->prepareAutoFightingAI(owner.getBattleID(), owner.army1, owner.army2, int3(0,0,0), owner.attackingHeroInstance, owner.defendingHeroInstance, owner.getBattle()->battleGetMySide());
 
-			auto ai = CDynLibHandler::getNewBattleAI(settings["server"]["friendlyAI"].String());
-
-			AutocombatPreferences autocombatPreferences = AutocombatPreferences();
-			autocombatPreferences.enableSpellsUsage = settings["battle"]["enableAutocombatSpells"].Bool();
-
-			ai->initBattleInterface(owner.curInt->env, owner.curInt->cb, autocombatPreferences);
-			ai->battleStart(owner.getBattleID(), owner.army1, owner.army2, int3(0,0,0), owner.attackingHeroInstance, owner.defendingHeroInstance, owner.getBattle()->battleGetMySide(), false);
-
-			owner.curInt->isAutoFightOn = true;
-			owner.curInt->registerBattleInterface(ai);
 			owner.requestAutofightingAIToTakeAction();
 
 			close();

+ 6 - 0
client/eventsSDL/InputHandler.cpp

@@ -217,6 +217,12 @@ void InputHandler::preprocessEvent(const SDL_Event & ev)
 #endif
 		return;
 	}
+	else if(ev.type == SDL_APP_WILLENTERBACKGROUND)
+	{
+		std::scoped_lock interfaceLock(ENGINE->interfaceMutex);
+		ENGINE->user().onAppPaused();
+		return;
+	}
 	else if(ev.type == SDL_KEYDOWN)
 	{
 		if(ev.key.keysym.sym == SDLK_F4 && (ev.key.keysym.mod & KMOD_ALT))

+ 10 - 6
client/lobby/CLobbyScreen.cpp

@@ -58,8 +58,12 @@ CLobbyScreen::CLobbyScreen(ESelectionScreen screenType, bool hideScreen)
 		buttonOptions = std::make_shared<CButton>(Point(411, 510), AnimationPath::builtin("GSPBUTT.DEF"), LIBRARY->generaltexth->zelp[46], std::bind(&CLobbyScreen::toggleTab, this, tabOpt), EShortcut::LOBBY_ADDITIONAL_OPTIONS);
 		if(settings["general"]["enableUiEnhancements"].Bool())
 		{
-			buttonTurnOptions = std::make_shared<CButton>(Point(619, 105), AnimationPath::builtin("GSPBUT2.DEF"), LIBRARY->generaltexth->zelp[46], std::bind(&CLobbyScreen::toggleTab, this, tabTurnOptions), EShortcut::LOBBY_TURN_OPTIONS);
-			buttonExtraOptions = std::make_shared<CButton>(Point(619, 510), AnimationPath::builtin("GSPBUT2.DEF"), LIBRARY->generaltexth->zelp[46], std::bind(&CLobbyScreen::toggleTab, this, tabExtraOptions), EShortcut::LOBBY_EXTRA_OPTIONS);
+			if(screenType == ESelectionScreen::newGame)
+				buttonBattleMode = std::make_shared<CButton>(Point(619, 105), AnimationPath::builtin("GSPButton2Arrow"), CButton::tooltip("", LIBRARY->generaltexth->translate("vcmi.lobby.battleOnlyMode.help")), [this](){
+					updateAfterStateChange(); // creates tabBattleOnlyMode -> cannot created by init of object because GAME->server().isGuest() isn't valid at that point
+					toggleTab(tabBattleOnlyMode);
+				}, EShortcut::LOBBY_BATTLE_MODE);
+			buttonExtraOptions = std::make_shared<CButton>(Point(619, 510), AnimationPath::builtin("GSPButton2Arrow"), LIBRARY->generaltexth->zelp[46], std::bind(&CLobbyScreen::toggleTab, this, tabExtraOptions), EShortcut::LOBBY_EXTRA_OPTIONS);
 		}
 	};
 
@@ -225,8 +229,8 @@ void CLobbyScreen::toggleMode(bool host)
 	buttonSelect->setTextOverlay("  " + LIBRARY->generaltexth->allTexts[500], FONT_SMALL, buttonColor);
 	buttonOptions->setTextOverlay(LIBRARY->generaltexth->allTexts[501], FONT_SMALL, buttonColor);
 
-	if (buttonTurnOptions)
-		buttonTurnOptions->setTextOverlay(LIBRARY->generaltexth->translate("vcmi.optionsTab.turnOptions.hover"), FONT_SMALL, buttonColor);
+	if (buttonBattleMode)
+		buttonBattleMode->setTextOverlay(LIBRARY->generaltexth->translate("vcmi.lobby.battleOnlyMode"), FONT_SMALL, buttonColor);
 
 	if (buttonExtraOptions)
 		buttonExtraOptions->setTextOverlay(LIBRARY->generaltexth->translate("vcmi.optionsTab.extraOptions.hover"), FONT_SMALL, buttonColor);
@@ -239,8 +243,8 @@ void CLobbyScreen::toggleMode(bool host)
 	buttonSelect->block(!host);
 	buttonOptions->block(!host);
 
-	if (buttonTurnOptions)
-		buttonTurnOptions->block(!host);
+	if (buttonBattleMode)
+		buttonBattleMode->block(!host);
 
 	if (buttonExtraOptions)
 		buttonExtraOptions->block(!host);

+ 1 - 1
client/lobby/CSelectionBase.h

@@ -66,7 +66,7 @@ public:
 	std::shared_ptr<CButton> buttonSelect;
 	std::shared_ptr<CButton> buttonRMG;
 	std::shared_ptr<CButton> buttonOptions;
-	std::shared_ptr<CButton> buttonTurnOptions;
+	std::shared_ptr<CButton> buttonBattleMode;
 	std::shared_ptr<CButton> buttonExtraOptions;
 	std::shared_ptr<CButton> buttonStart;
 	std::shared_ptr<CButton> buttonBack;

+ 13 - 0
client/lobby/OptionsTabBase.cpp

@@ -10,6 +10,8 @@
 #include "StdInc.h"
 #include "OptionsTabBase.h"
 #include "CSelectionBase.h"
+#include "TurnOptionsTab.h"
+#include "CLobbyScreen.h"
 
 #include "../widgets/ComboBox.h"
 #include "../widgets/CTextInput.h"
@@ -79,6 +81,12 @@ OptionsTabBase::OptionsTabBase(const JsonPath & configPath)
 		GAME->server().setSimturnsInfo(getSimturnsPresets().at(index));
 	};
 
+	addCallback("tabTurnOptions", [&](int)
+	{
+		auto lobby = (static_cast<CLobbyScreen *>(parent));
+		lobby->toggleTab(lobby->tabTurnOptions);
+	});
+
 	addCallback("setTimerPreset", setTimerPresetCallback);
 	addCallback("setSimturnPreset", setSimturnsPresetCallback);
 
@@ -428,6 +436,11 @@ void OptionsTabBase::recreate(bool campaign)
 		buttonUnlimitedReplay->block(GAME->server().isGuest());
 	}
 
+	if(auto buttonTurnOptions = widget<CButton>("buttonTurnOptions"))
+	{
+		buttonTurnOptions->block(GAME->server().isGuest() || campaign);
+	}
+
 	if(auto textureCampaignOverdraw = widget<CFilledTexture>("textureCampaignOverdraw"))
 		textureCampaignOverdraw->setEnabled(campaign);
 }

+ 0 - 10
client/lobby/SelectionTab.cpp

@@ -242,14 +242,6 @@ 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")), [this](){
-				auto lobby = static_cast<CLobbyScreen *>(parent);
-				lobby->toggleTab(lobby->tabBattleOnlyMode);
-			}, EShortcut::LOBBY_BATTLE_MODE);
-		}
-
 		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](){
@@ -324,8 +316,6 @@ void SelectionTab::toggleMode()
 	{
 		if(slider)
 			slider->block(true);
-		if(buttonBattleOnlyMode)
-			buttonBattleOnlyMode->block(true);
 	}
 	else
 	{

+ 0 - 2
client/lobby/SelectionTab.h

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

+ 3 - 11
client/mainmenu/CPrologEpilogVideo.cpp

@@ -30,18 +30,10 @@ CPrologEpilogVideo::CPrologEpilogVideo(CampaignScenarioPrologEpilog _spe, std::f
 
 	backgroundAroundMenu = std::make_shared<CFilledTexture>(ImagePath::builtin("DIBOXBCK"), Rect(-pos.x, -pos.y, ENGINE->screenDimensions().x, ENGINE->screenDimensions().y));
 
-	//TODO: remove hardcoded paths. Some of campaigns video actually consist from 2 parts
-	// however, currently our campaigns format expects only	a single video file
-	static const std::map<VideoPath, VideoPath> pairedVideoFiles = {
-		{ VideoPath::builtin("EVIL2AP1"),  VideoPath::builtin("EVIL2AP2") },
-		{ VideoPath::builtin("H3ABdb4"),   VideoPath::builtin("H3ABdb4b") },
-		{ VideoPath::builtin("H3x2_RNe1"), VideoPath::builtin("H3x2_RNe2") },
-	};
-
-	if (pairedVideoFiles.count(spe.prologVideo))
-		videoPlayer = std::make_shared<VideoWidget>(Point(0, 0), spe.prologVideo, pairedVideoFiles.at(spe.prologVideo), true);
+	if (!spe.prologVideo.second.empty())
+		videoPlayer = std::make_shared<VideoWidget>(Point(0, 0), spe.prologVideo.first, spe.prologVideo.second, true);
 	else
-		videoPlayer = std::make_shared<VideoWidget>(Point(0, 0), spe.prologVideo, true);
+		videoPlayer = std::make_shared<VideoWidget>(Point(0, 0), spe.prologVideo.first, true);
 
 	//some videos are 800x600 in size while some are 800x400
 	if (videoPlayer->pos.h == 400)

+ 25 - 0
client/render/AssetGenerator.cpp

@@ -92,6 +92,7 @@ void AssetGenerator::initialize()
 	animationFiles[AnimationPath::builtin("SPRITES/adventureLayersButton")] = createAdventureMapButton(ImagePath::builtin("adventureLayers.png"), true);
 	
 	animationFiles[AnimationPath::builtin("SPRITES/GSPButtonClear")] = createGSPButtonClear();
+	animationFiles[AnimationPath::builtin("SPRITES/GSPButton2Arrow")] = createGSPButton2Arrow();
 
 	for (PlayerColor color(-1); color < PlayerColor::PLAYER_LIMIT; ++color)
 	{
@@ -1009,6 +1010,30 @@ AssetGenerator::AnimationLayoutMap AssetGenerator::createGSPButtonClear()
 	return layout;
 }
 
+AssetGenerator::AnimationLayoutMap AssetGenerator::createGSPButton2Arrow()
+{
+	auto baseImg = ENGINE->renderHandler().loadAnimation(AnimationPath::builtin("GSPBUT2"), EImageBlitMode::OPAQUE);
+	auto overlayImg = ENGINE->renderHandler().loadAnimation(AnimationPath::builtin("GSPBUTT"), EImageBlitMode::OPAQUE);
+
+	AnimationLayoutMap layout;
+	for(int i = 0; i < 4; i++)
+	{
+		ImagePath spriteName = ImagePath::builtin("GSPButton2Arrow" + std::to_string(i) + ".png");
+
+		imageFiles[spriteName] = [baseImg, overlayImg, i](){
+			auto newImg = ENGINE->renderHandler().createImage(baseImg->getImage(i)->dimensions(), CanvasScalingPolicy::IGNORE);
+			auto canvas = newImg->getCanvas();
+			canvas.draw(baseImg->getImage(i), Point(0, 0));
+			canvas.draw(overlayImg->getImage(i), Point(0, 0), Rect(0, 0, 20, 20));
+			return newImg;
+		};
+
+		layout[0].push_back(ImageLocator(spriteName, EImageBlitMode::SIMPLE));
+	}
+
+	return layout;
+}
+
 AssetGenerator::CanvasPtr AssetGenerator::createGateListColored(PlayerColor color, PlayerColor backColor) const
 {
 	auto locator = ImageLocator(ImagePath::builtin("TpGate"), EImageBlitMode::COLORKEY);

+ 1 - 0
client/render/AssetGenerator.h

@@ -67,6 +67,7 @@ private:
 	CanvasPtr createCreatureInfoPanelElement(CreatureInfoPanelElement element) const;
 	CanvasPtr createQuestWindow() const;
 	AnimationLayoutMap createGSPButtonClear();
+	AnimationLayoutMap createGSPButton2Arrow();
 	CanvasPtr createGateListColored(PlayerColor color, PlayerColor backColor) const;
 	CanvasPtr createHeroSlotsColored(PlayerColor backColor) const;
 

+ 1 - 1
client/widgets/CComponent.cpp

@@ -109,7 +109,7 @@ void CComponent::init(ComponentType Type, ComponentSubType Subtype, std::optiona
 		for(std::istringstream iss(s); iss >> s; )
 			longestWordLen = std::max(longestWordLen, fontPtr->getStringWidth(s));
 
-		max = std::max<int>(max, longestWordLen + 8);
+		max = std::min<int>(max, longestWordLen + 8);
 	}
 
 	std::vector<std::string> textLines = CMessage::breakText(getSubtitle(), std::max<int>(max, pos.w), font);

+ 18 - 4
client/windows/CCreatureWindow.cpp

@@ -837,6 +837,7 @@ void CStackWindow::init()
 
 	if(!info->stackNode)
 	{
+		//does not contain any propagated bonuses
 		fakeNode = std::make_unique<CStackInstance>(nullptr, info->creature->getId(), 1, true);
 		info->stackNode = fakeNode.get();
 	}
@@ -856,14 +857,26 @@ void CStackWindow::init()
 
 void CStackWindow::initBonusesList()
 {
-	BonusList receivedBonuses = *info->stackNode->getBonuses(CSelector(Bonus::Permanent));
+	const IBonusBearer * bonusSource = (info->stack && !info->stack->base) 
+    ? static_cast<const IBonusBearer*>(info->stack)  // Use CStack for war machines
+    : static_cast<const IBonusBearer*>(info->stackNode);  // Use CStackInstance for regular units
+
+	auto bonusToString = [bonusSource](const std::shared_ptr<Bonus> & bonus) -> std::string
+	{
+		if(!bonus->description.empty())
+			return bonus->description.toString();
+		else
+			return LIBRARY->getBth()->bonusToString(bonus, bonusSource);
+	};
+
+	BonusList receivedBonuses = *bonusSource->getBonuses(CSelector(Bonus::Permanent));
 	BonusList abilities = info->creature->getExportedBonusList();
 
 	// remove all bonuses that are not propagated away
 	// such bonuses should be present in received bonuses, and if not - this means that they are behind inactive limiter, such as inactive stack experience bonuses
 	abilities.remove_if([](const Bonus* b){ return b->propagator == nullptr;});
 
-	const auto & bonusSortingPredicate = [this](const std::shared_ptr<Bonus> & v1, const std::shared_ptr<Bonus> & v2){
+	const auto & bonusSortingPredicate = [bonusToString](const std::shared_ptr<Bonus> & v1, const std::shared_ptr<Bonus> & v2){
 		if (v1->source != v2->source)
 		{
 			int priorityV1 = v1->source == BonusSource::CREATURE_ABILITY ? -1 : static_cast<int>(v1->source);
@@ -871,7 +884,7 @@ void CStackWindow::initBonusesList()
 			return priorityV1 < priorityV2;
 		}
 		else
-			return  info->stackNode->bonusToString(v1) < info->stackNode->bonusToString(v2);
+			return bonusToString(v1) < bonusToString(v2);
 	};
 
 	receivedBonuses.remove_if([](const Bonus* b)
@@ -939,7 +952,8 @@ void CStackWindow::initBonusesList()
 	BonusInfo bonusInfo;
 	for(auto b : visibleBonuses)
 	{
-		bonusInfo.description = info->stackNode->bonusToString(b);
+		bonusInfo.description = bonusToString(b);
+		//FIXME: we possibly use fakeNode, which does not have the correct bonus value
 		bonusInfo.imagePath = info->stackNode->bonusToGraphics(b);
 		bonusInfo.bonusSource = b->source;
 

+ 5 - 1
config/bonuses.json

@@ -205,7 +205,11 @@
 			"bonusSubtype.damageTypeMelee" : null,
 		}
 	},
-	
+
+	"DAMAGE_RECEIVED_CAP":
+	{
+	},
+
 	"GENERATE_RESOURCE":
 	{
 		"blockDescriptionPropagation": true

部分文件因文件數量過多而無法顯示