From 172484f7044c1c6f379e88e3174a02711253a3fd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 16:20:15 +0000 Subject: [PATCH 1/5] MMAI Router: route battle callbacks by BattleID Replace the single delegated BAI pointer with a per-BattleID map so simultaneous neutral battles each keep their own BAI instance. - Add getBattleAI() with explicit errors for unknown BattleIDs - Reject duplicate battleStart for the same BattleID - Erase only the ended battle in battleEnd() - Extract createDelegatedBAI() to preserve fallback and NN paths Co-authored-by: Dmitry Starkov --- AI/MMAI/BAI/router.cpp | 189 +++++++++++++++++++++++++++++------------ AI/MMAI/BAI/router.h | 20 ++++- 2 files changed, 154 insertions(+), 55 deletions(-) diff --git a/AI/MMAI/BAI/router.cpp b/AI/MMAI/BAI/router.cpp index 1f97831ba72..edba6208d4d 100644 --- a/AI/MMAI/BAI/router.cpp +++ b/AI/MMAI/BAI/router.cpp @@ -123,6 +123,13 @@ namespace return it->second.get(); } + + std::string MakeAddrStr(const CBattleGameInterface * bai) + { + std::ostringstream oss; + oss << static_cast(bai); + return oss.str(); + } } Router::Router() @@ -138,8 +145,16 @@ Router::Router() Router::~Router() { info("--- destructor ---"); - cb->waitTillRealize = wasWaitingForRealize; + if(cb) + cb->waitTillRealize = wasWaitingForRealize; +} + +#ifdef ENABLE_MMAI_TEST +void Router::setTestBattleAIFactory(TestBattleAIFactory factory) +{ + testBattleAIFactory = std::move(factory); } +#endif void Router::initBattleInterface(std::shared_ptr ENV, std::shared_ptr CB) { @@ -150,7 +165,7 @@ void Router::initBattleInterface(std::shared_ptr ENV, std::shared_p wasWaitingForRealize = cb->waitTillRealize; cb->waitTillRealize = false; - bai.reset(); + battles.clear(); } void Router::initBattleInterface(std::shared_ptr ENV, std::shared_ptr CB, AutocombatPreferences prefs) @@ -159,83 +174,167 @@ void Router::initBattleInterface(std::shared_ptr ENV, std::shared_p initBattleInterface(ENV, CB); } +CBattleGameInterface & Router::getBattleAI(const BattleID & bid) +{ + const auto it = battles.find(bid); + if(it == battles.end()) + { + const auto message = "MMAI Router: no BAI for BattleID " + std::to_string(bid.getNum()) + ", active battles: [" + formatActiveBattleIds() + "]"; + error(message); + throw std::runtime_error(message); + } + + if(!it->second.bai) + { + const auto message = "MMAI Router: null BAI for BattleID " + std::to_string(bid.getNum()); + error(message); + throw std::runtime_error(message); + } + + trace("BattleID " + std::to_string(bid.getNum()) + " -> BAI " + MakeAddrStr(it->second.bai.get())); + return *it->second.bai; +} + +std::string Router::formatActiveBattleIds() const +{ + std::string result; + for(const auto & [battleId, context] : battles) + { + if(!result.empty()) + result += ", "; + result += std::to_string(battleId.getNum()); + if(context.bai) + result += "@" + MakeAddrStr(context.bai.get()); + } + if(result.empty()) + return "none"; + return result; +} + +std::shared_ptr Router::createDelegatedBAI(BattleSide side) +{ + const std::string modelkey = side == BattleSide::ATTACKER ? "attacker" : "defender"; + Schema::IModel * model = GetModel(modelkey); + + auto modelside = model->getSide(); + auto realside = static_cast(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") + { + auto bai = CDynLibHandler::getNewBattleAI("StupidAI"); + bai->initBattleInterface(env, cb, autocombatPreferences); + return bai; + } + if(model->getName() == "BattleAI") + { + auto bai = CDynLibHandler::getNewBattleAI("BattleAI"); + bai->initBattleInterface(env, cb, autocombatPreferences); + return bai; + } + THROW_FORMAT("Unexpected scripted model name: %s", model->getName()); + case Schema::ModelType::NN: + // XXX: must not call initBattleInterface here + return Base::Create(model, env, cb, autocombatPreferences.enableSpellsUsage); + default: + THROW_FORMAT("Unexpected model type: %d", EI(model->getType())); + } +} + /* * Delegated methods */ void Router::actionFinished(const BattleID & bid, const BattleAction & action) { - bai->actionFinished(bid, action); + getBattleAI(bid).actionFinished(bid, action); } void Router::actionStarted(const BattleID & bid, const BattleAction & action) { - bai->actionStarted(bid, action); + getBattleAI(bid).actionStarted(bid, action); } void Router::activeStack(const BattleID & bid, const CStack * astack) { - bai->activeStack(bid, astack); + getBattleAI(bid).activeStack(bid, astack); } void Router::battleAttack(const BattleID & bid, const BattleAttack * ba) { - bai->battleAttack(bid, ba); + getBattleAI(bid).battleAttack(bid, ba); } void Router::battleCatapultAttacked(const BattleID & bid, const CatapultAttack & ca) { - bai->battleCatapultAttacked(bid, ca); + getBattleAI(bid).battleCatapultAttacked(bid, ca); } void Router::battleEnd(const BattleID & bid, const BattleResult * br, QueryID queryID) { + const auto it = battles.find(bid); + if(it == battles.end()) + { + const auto message = "MMAI Router: battleEnd for unknown BattleID " + std::to_string(bid.getNum()) + ", active battles: [" + formatActiveBattleIds() + "]"; + error(message); + throw std::runtime_error(message); + } + + debug("battleEnd bid=" + std::to_string(bid.getNum()) + " -> BAI " + MakeAddrStr(it->second.bai.get())); + auto bai = it->second.bai; + battles.erase(it); bai->battleEnd(bid, br, queryID); + debug("remaining battles: [" + formatActiveBattleIds() + "]"); } void Router::battleGateStateChanged(const BattleID & bid, const EGateState state) { - bai->battleGateStateChanged(bid, state); + getBattleAI(bid).battleGateStateChanged(bid, state); }; void Router::battleLogMessage(const BattleID & bid, const std::vector & lines) { - bai->battleLogMessage(bid, lines); + getBattleAI(bid).battleLogMessage(bid, lines); }; void Router::battleNewRound(const BattleID & bid) { - bai->battleNewRound(bid); + getBattleAI(bid).battleNewRound(bid); } void Router::battleNewRoundFirst(const BattleID & bid) { - bai->battleNewRoundFirst(bid); + getBattleAI(bid).battleNewRoundFirst(bid); } void Router::battleObstaclesChanged(const BattleID & bid, const std::vector & obstacles) { - bai->battleObstaclesChanged(bid, obstacles); + getBattleAI(bid).battleObstaclesChanged(bid, obstacles); }; void Router::battleSpellCast(const BattleID & bid, const BattleSpellCast * sc) { - bai->battleSpellCast(bid, sc); + getBattleAI(bid).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); + getBattleAI(bid).battleStackMoved(bid, stack, dest, distance, teleport); } void Router::battleStacksAttacked(const BattleID & bid, const std::vector & bsa, bool ranged) { - bai->battleStacksAttacked(bid, bsa, ranged); + getBattleAI(bid).battleStacksAttacked(bid, bsa, ranged); } void Router::battleStacksEffectsSet(const BattleID & bid, const SetStackEffect & sse) { - bai->battleStacksEffectsSet(bid, sse); + getBattleAI(bid).battleStacksEffectsSet(bid, sse); } void Router::battleStart( @@ -249,59 +348,41 @@ void Router::battleStart( 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(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()) + if(battles.contains(bid)) { - 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())); + const auto message = "MMAI Router: duplicate battleStart for BattleID " + std::to_string(bid.getNum()) + ", active battles: [" + formatActiveBattleIds() + "]"; + error(message); + throw std::runtime_error(message); } - bai->battleStart(bid, army1, army2, tile, hero1, hero2, side, replayAllowed); +#ifdef ENABLE_MMAI_TEST + std::shared_ptr newBai; + if(testBattleAIFactory) + newBai = testBattleAIFactory(bid, side); + else + newBai = createDelegatedBAI(side); +#else + auto newBai = createDelegatedBAI(side); +#endif + + debug("battleStart bid=" + std::to_string(bid.getNum()) + " -> BAI " + MakeAddrStr(newBai.get())); + battles.emplace(bid, BattleContext{newBai}); + newBai->battleStart(bid, army1, army2, tile, hero1, hero2, side, replayAllowed); } void Router::battleTriggerEffect(const BattleID & bid, const BattleTriggerEffect & bte) { - bai->battleTriggerEffect(bid, bte); + getBattleAI(bid).battleTriggerEffect(bid, bte); } void Router::battleUnitsChanged(const BattleID & bid, const std::vector & changes) { - bai->battleUnitsChanged(bid, changes); + getBattleAI(bid).battleUnitsChanged(bid, changes); } void Router::yourTacticPhase(const BattleID & bid, int distance) { - bai->yourTacticPhase(bid, distance); + getBattleAI(bid).yourTacticPhase(bid, distance); } /* diff --git a/AI/MMAI/BAI/router.h b/AI/MMAI/BAI/router.h index edf4ad882ab..688ef3b82b5 100644 --- a/AI/MMAI/BAI/router.h +++ b/AI/MMAI/BAI/router.h @@ -63,16 +63,34 @@ class Router : public CBattleGameInterface void battleUnitsChanged(const BattleID & bid, const std::vector & changes) override; void yourTacticPhase(const BattleID & bid, int distance) override; +#ifdef ENABLE_MMAI_TEST + using TestBattleAIFactory = std::function(const BattleID &, BattleSide side)>; + void setTestBattleAIFactory(TestBattleAIFactory factory); +#endif + private: + struct BattleContext + { + std::shared_ptr bai; + }; + std::shared_ptr env; std::shared_ptr cb; - std::shared_ptr bai; // calls will be delegated to this object + std::map battles; bool wasWaitingForRealize = false; AutocombatPreferences autocombatPreferences; std::string addrstr = "?"; std::string colorname = "?"; +#ifdef ENABLE_MMAI_TEST + TestBattleAIFactory testBattleAIFactory; +#endif + + CBattleGameInterface & getBattleAI(const BattleID & bid); + std::shared_ptr createDelegatedBAI(BattleSide side); + std::string formatActiveBattleIds() const; + void error(const std::string & text) const; void warn(const std::string & text) const; void info(const std::string & text) const; From dcddcc8cf541a547034b3caa78103f2c912d23a0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 16:20:16 +0000 Subject: [PATCH 2/5] Add MMAI Router regression tests for concurrent battles Introduce MMAI_router_test with mock BAI instances verifying that callbacks are routed by BattleID, battleEnd cleanup is isolated, and invalid/duplicate BattleIDs fail with clear errors. Co-authored-by: Dmitry Starkov --- AI/MMAI/CMakeLists.txt | 8 ++ AI/MMAI/test/router_test.cpp | 228 +++++++++++++++++++++++++++++++++++ 2 files changed, 236 insertions(+) create mode 100644 AI/MMAI/test/router_test.cpp diff --git a/AI/MMAI/CMakeLists.txt b/AI/MMAI/CMakeLists.txt index 45b94c3fc7d..9d9bc1eb06a 100644 --- a/AI/MMAI/CMakeLists.txt +++ b/AI/MMAI/CMakeLists.txt @@ -130,6 +130,8 @@ if(ENABLE_MMAI_TEST) include(CheckCXXCompilerFlag) enable_testing() + target_compile_definitions(MMAI PRIVATE ENABLE_MMAI_TEST) + target_link_libraries(MMAI PUBLIC gtest gtest_main) target_include_directories(MMAI PRIVATE "${CMAKE_SOURCE_DIR}/test/googletest/googletest/include") @@ -138,6 +140,12 @@ if(ENABLE_MMAI_TEST) target_link_libraries(MMAI_test PRIVATE MMAI) gtest_discover_tests(MMAI_test) + add_executable(MMAI_router_test test/router_test.cpp StdInc.cpp) + target_compile_definitions(MMAI_router_test PRIVATE ENABLE_MMAI_TEST) + target_link_libraries(MMAI_router_test PRIVATE MMAI vcmi gtest gtest_main) + enable_pch(MMAI_router_test) + gtest_discover_tests(MMAI_router_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") diff --git a/AI/MMAI/test/router_test.cpp b/AI/MMAI/test/router_test.cpp new file mode 100644 index 00000000000..c22073ce22a --- /dev/null +++ b/AI/MMAI/test/router_test.cpp @@ -0,0 +1,228 @@ +/* + * router_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 "StdInc.h" + +#include "BAI/router.h" +#include "networkPacks/PacksForClientBattle.h" +#include + +#include +#include +#include +#include + +namespace +{ +class RecordingBattleAI : public CBattleGameInterface +{ +public: + explicit RecordingBattleAI(int instanceId) + : instanceId(instanceId) + { + } + + int getInstanceId() const + { + return instanceId; + } + + const std::vector & getEvents() const + { + return events; + } + + void activeStack(const BattleID & bid, const CStack * stack) override + { + (void)stack; + record("activeStack", bid); + } + + void yourTacticPhase(const BattleID & bid, int distance) override + { + (void)distance; + record("yourTacticPhase", bid); + } + + void actionFinished(const BattleID & bid, const BattleAction & action) override + { + (void)action; + record("actionFinished", bid); + } + + void actionStarted(const BattleID & bid, const BattleAction & action) override + { + (void)action; + record("actionStarted", bid); + } + + void battleStacksAttacked(const BattleID & bid, const std::vector & bsa, bool ranged) override + { + (void)bsa; + (void)ranged; + record("battleStacksAttacked", bid); + } + + void battleEnd(const BattleID & bid, const BattleResult * br, QueryID queryID) override + { + (void)br; + (void)queryID; + record("battleEnd", bid); + } + + 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)army1; + (void)army2; + (void)tile; + (void)hero1; + (void)hero2; + (void)side; + (void)replayAllowed; + record("battleStart", bid); + } + +private: + int instanceId = 0; + std::vector events; + + void record(const std::string & callback, const BattleID & bid) + { + events.push_back(callback + ":" + std::to_string(bid.getNum()) + ":id=" + std::to_string(instanceId)); + } +}; + +BattleID makeBattleId(int32_t num) +{ + BattleID bid; + bid.setNum(num); + return bid; +} + +class RouterRoutingTest : public ::testing::Test +{ +protected: + void SetUp() override + { + instances.clear(); + nextInstanceId = 0; + router = std::make_unique(); + router->setTestBattleAIFactory( + [this](const BattleID & bid, BattleSide side) -> std::shared_ptr + { + (void)side; + auto ai = std::make_shared(++nextInstanceId); + instances[bid.getNum()] = ai; + return ai; + } + ); + } + + RecordingBattleAI & ai(int32_t battleNum) + { + return *instances.at(battleNum); + } + + std::unique_ptr router; + std::map> instances; + int nextInstanceId = 0; +}; + +TEST_F(RouterRoutingTest, SequentialBattles) +{ + const BattleID battleA = makeBattleId(17); + const BattleID battleB = makeBattleId(18); + + router->battleStart(battleA, nullptr, nullptr, int3(), nullptr, nullptr, BattleSide::DEFENDER, false); + router->activeStack(battleA, nullptr); + router->battleStacksAttacked(battleA, {}, false); + router->battleEnd(battleA, nullptr, QueryID(-1)); + + router->battleStart(battleB, nullptr, nullptr, int3(), nullptr, nullptr, BattleSide::DEFENDER, false); + router->activeStack(battleB, nullptr); + router->battleEnd(battleB, nullptr, QueryID(-1)); + + ASSERT_NE(ai(17).getInstanceId(), ai(18).getInstanceId()); + ASSERT_EQ(ai(17).getEvents().back(), "battleEnd:17:id=1"); + ASSERT_EQ(ai(18).getEvents().back(), "battleEnd:18:id=2"); +} + +TEST_F(RouterRoutingTest, ConcurrentBattlesRouteToMatchingInstance) +{ + const BattleID battleA = makeBattleId(17); + const BattleID battleB = makeBattleId(18); + + router->battleStart(battleA, nullptr, nullptr, int3(), nullptr, nullptr, BattleSide::DEFENDER, false); + router->battleStart(battleB, nullptr, nullptr, int3(), nullptr, nullptr, BattleSide::DEFENDER, false); + + ASSERT_NE(ai(17).getInstanceId(), ai(18).getInstanceId()); + + router->activeStack(battleA, nullptr); + router->battleStacksAttacked(battleB, {}, false); + router->actionStarted(battleA, BattleAction()); + + ASSERT_EQ(ai(17).getEvents().back(), "actionStarted:17:id=1"); + ASSERT_EQ(ai(18).getEvents().back(), "battleStacksAttacked:18:id=2"); +} + +TEST_F(RouterRoutingTest, EndFirstBattleLeavesSecondActive) +{ + const BattleID battleA = makeBattleId(17); + const BattleID battleB = makeBattleId(18); + + router->battleStart(battleA, nullptr, nullptr, int3(), nullptr, nullptr, BattleSide::DEFENDER, false); + router->battleStart(battleB, nullptr, nullptr, int3(), nullptr, nullptr, BattleSide::DEFENDER, false); + + router->battleEnd(battleA, nullptr, QueryID(-1)); + router->activeStack(battleB, nullptr); + + ASSERT_EQ(ai(17).getEvents().back(), "battleEnd:17:id=1"); + ASSERT_EQ(ai(18).getEvents().back(), "activeStack:18:id=2"); +} + +TEST_F(RouterRoutingTest, EndSecondBattleLeavesFirstActive) +{ + const BattleID battleA = makeBattleId(17); + const BattleID battleB = makeBattleId(18); + + router->battleStart(battleA, nullptr, nullptr, int3(), nullptr, nullptr, BattleSide::DEFENDER, false); + router->battleStart(battleB, nullptr, nullptr, int3(), nullptr, nullptr, BattleSide::DEFENDER, false); + + router->battleEnd(battleB, nullptr, QueryID(-1)); + router->activeStack(battleA, nullptr); + + ASSERT_EQ(ai(18).getEvents().back(), "battleEnd:18:id=2"); + ASSERT_EQ(ai(17).getEvents().back(), "activeStack:17:id=1"); +} + +TEST_F(RouterRoutingTest, UnknownBattleIdFailsClearly) +{ + EXPECT_THROW(router->activeStack(makeBattleId(999), nullptr), std::runtime_error); +} + +TEST_F(RouterRoutingTest, DuplicateBattleStartFailsClearly) +{ + const BattleID battleA = makeBattleId(17); + router->battleStart(battleA, nullptr, nullptr, int3(), nullptr, nullptr, BattleSide::DEFENDER, false); + EXPECT_THROW( + router->battleStart(battleA, nullptr, nullptr, int3(), nullptr, nullptr, BattleSide::DEFENDER, false), + std::runtime_error + ); +} +} From 4e431f533c3c12a9b461989f564f0be86108973a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 17:53:45 +0000 Subject: [PATCH 3/5] Link MMAI_router_test against the vcmi facade MMAI is an OBJECT library on develop, so the router test cannot link MMAI/vcmiMain objects directly. Defer creating MMAI_router_test until the facade exists and link libvcmi instead. Co-authored-by: Dmitry Starkov --- AI/MMAI/CMakeLists.txt | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/AI/MMAI/CMakeLists.txt b/AI/MMAI/CMakeLists.txt index e1413f7b98f..1ef5948f6ee 100644 --- a/AI/MMAI/CMakeLists.txt +++ b/AI/MMAI/CMakeLists.txt @@ -127,15 +127,20 @@ if(ENABLE_MMAI_TEST) target_link_libraries(MMAI_test PRIVATE MMAI) gtest_discover_tests(MMAI_test) - add_executable(MMAI_router_test test/router_test.cpp) - target_compile_definitions(MMAI_router_test PRIVATE ENABLE_MMAI_TEST) - target_link_libraries(MMAI_router_test PRIVATE MMAI) - gtest_discover_tests(MMAI_router_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") - set_target_properties(MMAI_router_test PROPERTIES CXX_VISIBILITY_PRESET "default") + + # The vcmi facade is created after AI/, so defer this target until it exists. + function(vcmi_add_mmai_router_test) + add_executable(MMAI_router_test ${CMAKE_SOURCE_DIR}/AI/MMAI/test/router_test.cpp) + target_compile_definitions(MMAI_router_test PRIVATE ENABLE_MMAI_TEST) + target_include_directories(MMAI_router_test PRIVATE ${CMAKE_SOURCE_DIR}/AI/MMAI) + target_link_libraries(MMAI_router_test PRIVATE vcmi gtest gtest_main) + gtest_discover_tests(MMAI_router_test) + set_target_properties(MMAI_router_test PROPERTIES CXX_VISIBILITY_PRESET "default") + endfunction() + cmake_language(DEFER DIRECTORY ${CMAKE_SOURCE_DIR} CALL vcmi_add_mmai_router_test) # Run tests with: # ctest --test-dir build/AI/MMAI/ From aaf495bae564c9202810be801a72631a74ffde3a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 18:15:52 +0000 Subject: [PATCH 4/5] Harden MMAI Router concurrent-battle dispatch Stop throwing on unknown BattleIDs in callback paths so a post-battleEnd event cannot abort the host; log the missing ID and leave other battles alone. Keep duplicate battleStart as an invariant failure. Use per-battle log tags locally instead of mutating Router-wide logtag. Strengthen routing tests to assert full event traces, no cross-talk after end, and empty Router state when both battles finish. Co-authored-by: Dmitry Starkov --- AI/MMAI/BAI/router.cpp | 178 ++++++++++++++++++++--------------- AI/MMAI/BAI/router.h | 8 +- AI/MMAI/CMakeLists.txt | 2 +- AI/MMAI/test/router_test.cpp | 85 ++++++++++++++--- 4 files changed, 185 insertions(+), 88 deletions(-) diff --git a/AI/MMAI/BAI/router.cpp b/AI/MMAI/BAI/router.cpp index c721805e4b4..435f2ea0c5c 100644 --- a/AI/MMAI/BAI/router.cpp +++ b/AI/MMAI/BAI/router.cpp @@ -160,9 +160,9 @@ namespace } -#define MMAI_LOG_TAG LogTag _(logtag + "." + __func__) +#define MMAI_LOG_TAG LogTag _(basetag + "." + __func__) -Router::Router() : addrstr(MakeAddrStr(this)), basetag(addrstr + ":MMAI"), logtag(basetag) {} +Router::Router() : addrstr(MakeAddrStr(this)), basetag(addrstr + ":MMAI") {} Router::~Router() { @@ -175,6 +175,11 @@ void Router::setTestBattleAIFactory(TestBattleAIFactory factory) { testBattleAIFactory = std::move(factory); } + +size_t Router::activeBattleCount() const +{ + return battles.size(); +} #endif void Router::initBattleInterface(std::shared_ptr ENV, std::shared_ptr CB) @@ -195,26 +200,37 @@ void Router::initBattleInterface(std::shared_ptr ENV, std::shared_p initBattleInterface(ENV, CB); } -CBattleGameInterface & Router::getBattleAI(const BattleID & bid) +const Router::BattleContext * Router::findBattle(const BattleID & bid) const { const auto it = battles.find(bid); if(it == battles.end()) - { - const auto message = "MMAI Router: no BAI for BattleID " + std::to_string(bid.getNum()) + ", active battles: [" + formatActiveBattleIds() + "]"; - logAi->error("%s", message); - throw std::runtime_error(message); - } + return nullptr; + return &it->second; +} + +void Router::logMissingBattle(const BattleID & bid, const char * func) const +{ + logAi->error("MMAI Router: no BAI for BattleID %d in %s, active battles: [%s]", bid.getNum(), func, formatActiveBattleIds()); +} + +std::string Router::battleLogTag(const BattleContext & ctx) const +{ + return ctx.logtag.empty() ? basetag : ctx.logtag; +} - if(!it->second.bai) +template +void Router::withBattle(const BattleID & bid, const char * func, Fn && fn) +{ + const auto * ctx = findBattle(bid); + if(!ctx || !ctx->bai) { - const auto message = "MMAI Router: null BAI for BattleID " + std::to_string(bid.getNum()); - logAi->error("%s", message); - throw std::runtime_error(message); + logMissingBattle(bid, func); + return; } - logtag = it->second.logtag.empty() ? basetag : it->second.logtag; - logAi->trace("MMAI Router: BattleID %d -> BAI %s", bid.getNum(), MakeAddrStr(it->second.bai.get())); - return *it->second.bai; + LogTag _(battleLogTag(*ctx) + "." + func); + logAi->trace("MMAI Router: %s BattleID %d -> BAI %s", func, bid.getNum(), MakeAddrStr(ctx->bai.get())); + fn(*ctx->bai); } std::string Router::formatActiveBattleIds() const @@ -287,51 +303,54 @@ Router::BattleContext Router::createDelegatedBAI(const BattleID & bid, BattleSid void Router::actionFinished(const BattleID & bid, const BattleAction & action) { - auto & ai = getBattleAI(bid); - MMAI_LOG_TAG; - ai.actionFinished(bid, action); + withBattle(bid, __func__, [&](CBattleGameInterface & ai) + { + ai.actionFinished(bid, action); + }); } void Router::actionStarted(const BattleID & bid, const BattleAction & action) { - auto & ai = getBattleAI(bid); - MMAI_LOG_TAG; - ai.actionStarted(bid, action); + withBattle(bid, __func__, [&](CBattleGameInterface & ai) + { + ai.actionStarted(bid, action); + }); } void Router::activeStack(const BattleID & bid, const CStack * astack) { - auto & ai = getBattleAI(bid); - MMAI_LOG_TAG; - ai.activeStack(bid, astack); + withBattle(bid, __func__, [&](CBattleGameInterface & ai) + { + ai.activeStack(bid, astack); + }); } void Router::battleAttack(const BattleID & bid, const BattleAttack * ba) { - auto & ai = getBattleAI(bid); - MMAI_LOG_TAG; - ai.battleAttack(bid, ba); + withBattle(bid, __func__, [&](CBattleGameInterface & ai) + { + ai.battleAttack(bid, ba); + }); } void Router::battleCatapultAttacked(const BattleID & bid, const CatapultAttack & ca) { - auto & ai = getBattleAI(bid); - MMAI_LOG_TAG; - ai.battleCatapultAttacked(bid, ca); + withBattle(bid, __func__, [&](CBattleGameInterface & ai) + { + ai.battleCatapultAttacked(bid, ca); + }); } void Router::battleEnd(const BattleID & bid, const BattleResult * br, QueryID queryID) { const auto it = battles.find(bid); - if(it == battles.end()) + if(it == battles.end() || !it->second.bai) { - const auto message = "MMAI Router: battleEnd for unknown BattleID " + std::to_string(bid.getNum()) + ", active battles: [" + formatActiveBattleIds() + "]"; - logAi->error("%s", message); - throw std::runtime_error(message); + logMissingBattle(bid, __func__); + return; } - logtag = it->second.logtag.empty() ? basetag : it->second.logtag; - MMAI_LOG_TAG; + LogTag _(battleLogTag(it->second) + "." + __func__); logAi->debug("MMAI Router: battleEnd bid=%d -> BAI %s", bid.getNum(), MakeAddrStr(it->second.bai.get())); auto bai = it->second.bai; battles.erase(it); @@ -341,65 +360,74 @@ void Router::battleEnd(const BattleID & bid, const BattleResult * br, QueryID qu void Router::battleGateStateChanged(const BattleID & bid, const EGateState state) { - auto & ai = getBattleAI(bid); - MMAI_LOG_TAG; - ai.battleGateStateChanged(bid, state); + withBattle(bid, __func__, [&](CBattleGameInterface & ai) + { + ai.battleGateStateChanged(bid, state); + }); }; void Router::battleLogMessage(const BattleID & bid, const std::vector & lines) { - auto & ai = getBattleAI(bid); - MMAI_LOG_TAG; - ai.battleLogMessage(bid, lines); + withBattle(bid, __func__, [&](CBattleGameInterface & ai) + { + ai.battleLogMessage(bid, lines); + }); }; void Router::battleNewRound(const BattleID & bid) { - auto & ai = getBattleAI(bid); - MMAI_LOG_TAG; - ai.battleNewRound(bid); + withBattle(bid, __func__, [&](CBattleGameInterface & ai) + { + ai.battleNewRound(bid); + }); } void Router::battleNewRoundFirst(const BattleID & bid) { - auto & ai = getBattleAI(bid); - MMAI_LOG_TAG; - ai.battleNewRoundFirst(bid); + withBattle(bid, __func__, [&](CBattleGameInterface & ai) + { + ai.battleNewRoundFirst(bid); + }); } void Router::battleObstaclesChanged(const BattleID & bid, const ObstacleChanges & obstacle) { - auto & ai = getBattleAI(bid); - MMAI_LOG_TAG; - ai.battleObstaclesChanged(bid, obstacle); + withBattle(bid, __func__, [&](CBattleGameInterface & ai) + { + ai.battleObstaclesChanged(bid, obstacle); + }); }; void Router::battleSpellCast(const BattleID & bid, const BattleSpellCast * sc) { - auto & ai = getBattleAI(bid); - MMAI_LOG_TAG; - ai.battleSpellCast(bid, sc); + withBattle(bid, __func__, [&](CBattleGameInterface & ai) + { + ai.battleSpellCast(bid, sc); + }); } void Router::battleStackMoved(const BattleID & bid, const CStack * stack, const BattleHexArray & dest, int distance, bool teleport) { - auto & ai = getBattleAI(bid); - MMAI_LOG_TAG; - ai.battleStackMoved(bid, stack, dest, distance, teleport); + withBattle(bid, __func__, [&](CBattleGameInterface & ai) + { + ai.battleStackMoved(bid, stack, dest, distance, teleport); + }); } void Router::battleStacksAttacked(const BattleID & bid, const std::vector & bsa, bool ranged) { - auto & ai = getBattleAI(bid); - MMAI_LOG_TAG; - ai.battleStacksAttacked(bid, bsa, ranged); + withBattle(bid, __func__, [&](CBattleGameInterface & ai) + { + ai.battleStacksAttacked(bid, bsa, ranged); + }); } void Router::battleStacksEffectsSet(const BattleID & bid, const SetStackEffect & sse) { - auto & ai = getBattleAI(bid); - MMAI_LOG_TAG; - ai.battleStacksEffectsSet(bid, sse); + withBattle(bid, __func__, [&](CBattleGameInterface & ai) + { + ai.battleStacksEffectsSet(bid, sse); + }); } void Router::battleStart( @@ -434,8 +462,7 @@ void Router::battleStart( ctx = createDelegatedBAI(bid, side); #endif - logtag = ctx.logtag.empty() ? basetag : ctx.logtag; - LogTag _2(logtag + "." + __func__); + LogTag _2(battleLogTag(ctx) + "." + __func__); logAi->debug("MMAI Router: battleStart bid=%d -> BAI %s", bid.getNum(), MakeAddrStr(ctx.bai.get())); battles.emplace(bid, ctx); ctx.bai->battleStart(bid, army1, army2, tile, hero1, hero2, side, replayAllowed); @@ -443,22 +470,25 @@ void Router::battleStart( void Router::battleTriggerEffect(const BattleID & bid, const BattleTriggerEffect & bte) { - auto & ai = getBattleAI(bid); - MMAI_LOG_TAG; - ai.battleTriggerEffect(bid, bte); + withBattle(bid, __func__, [&](CBattleGameInterface & ai) + { + ai.battleTriggerEffect(bid, bte); + }); } void Router::battleUnitsChanged(const BattleID & bid, const std::vector & changes) { - auto & ai = getBattleAI(bid); - MMAI_LOG_TAG; - ai.battleUnitsChanged(bid, changes); + withBattle(bid, __func__, [&](CBattleGameInterface & ai) + { + ai.battleUnitsChanged(bid, changes); + }); } void Router::yourTacticPhase(const BattleID & bid, int distance) { - auto & ai = getBattleAI(bid); - MMAI_LOG_TAG; - ai.yourTacticPhase(bid, distance); + withBattle(bid, __func__, [&](CBattleGameInterface & ai) + { + ai.yourTacticPhase(bid, distance); + }); } } diff --git a/AI/MMAI/BAI/router.h b/AI/MMAI/BAI/router.h index 43feb2b6ccf..6ae6c6e41ec 100644 --- a/AI/MMAI/BAI/router.h +++ b/AI/MMAI/BAI/router.h @@ -75,6 +75,7 @@ class Router : public CBattleGameInterface #ifdef ENABLE_MMAI_TEST using TestBattleAIFactory = std::function(const BattleID &, BattleSide side)>; void setTestBattleAIFactory(TestBattleAIFactory factory); + size_t activeBattleCount() const; #endif private: @@ -93,14 +94,17 @@ class Router : public CBattleGameInterface std::string addrstr = "?"; std::string colorname = "?"; const std::string basetag = "?"; - std::string logtag = "?"; #ifdef ENABLE_MMAI_TEST TestBattleAIFactory testBattleAIFactory; #endif - CBattleGameInterface & getBattleAI(const BattleID & bid); + const BattleContext * findBattle(const BattleID & bid) const; + void logMissingBattle(const BattleID & bid, const char * func) const; + template + void withBattle(const BattleID & bid, const char * func, Fn && fn); BattleContext createDelegatedBAI(const BattleID & bid, BattleSide side); std::string formatActiveBattleIds() const; + std::string battleLogTag(const BattleContext & ctx) const; }; } diff --git a/AI/MMAI/CMakeLists.txt b/AI/MMAI/CMakeLists.txt index 1ef5948f6ee..2529226a577 100644 --- a/AI/MMAI/CMakeLists.txt +++ b/AI/MMAI/CMakeLists.txt @@ -143,7 +143,7 @@ if(ENABLE_MMAI_TEST) cmake_language(DEFER DIRECTORY ${CMAKE_SOURCE_DIR} CALL vcmi_add_mmai_router_test) # Run tests with: - # ctest --test-dir build/AI/MMAI/ + # ./MMAI_router_test --gtest_filter='RouterRoutingTest.*' endif() vcmi_set_output_dir(MMAI "AI") diff --git a/AI/MMAI/test/router_test.cpp b/AI/MMAI/test/router_test.cpp index c22073ce22a..4026f7a467d 100644 --- a/AI/MMAI/test/router_test.cpp +++ b/AI/MMAI/test/router_test.cpp @@ -153,14 +153,22 @@ TEST_F(RouterRoutingTest, SequentialBattles) router->activeStack(battleA, nullptr); router->battleStacksAttacked(battleA, {}, false); router->battleEnd(battleA, nullptr, QueryID(-1)); + ASSERT_EQ(router->activeBattleCount(), 0u); router->battleStart(battleB, nullptr, nullptr, int3(), nullptr, nullptr, BattleSide::DEFENDER, false); router->activeStack(battleB, nullptr); router->battleEnd(battleB, nullptr, QueryID(-1)); ASSERT_NE(ai(17).getInstanceId(), ai(18).getInstanceId()); - ASSERT_EQ(ai(17).getEvents().back(), "battleEnd:17:id=1"); - ASSERT_EQ(ai(18).getEvents().back(), "battleEnd:18:id=2"); + EXPECT_EQ( + ai(17).getEvents(), + (std::vector{"battleStart:17:id=1", "activeStack:17:id=1", "battleStacksAttacked:17:id=1", "battleEnd:17:id=1"}) + ); + EXPECT_EQ( + ai(18).getEvents(), + (std::vector{"battleStart:18:id=2", "activeStack:18:id=2", "battleEnd:18:id=2"}) + ); + ASSERT_EQ(router->activeBattleCount(), 0u); } TEST_F(RouterRoutingTest, ConcurrentBattlesRouteToMatchingInstance) @@ -170,15 +178,21 @@ TEST_F(RouterRoutingTest, ConcurrentBattlesRouteToMatchingInstance) router->battleStart(battleA, nullptr, nullptr, int3(), nullptr, nullptr, BattleSide::DEFENDER, false); router->battleStart(battleB, nullptr, nullptr, int3(), nullptr, nullptr, BattleSide::DEFENDER, false); - + ASSERT_EQ(router->activeBattleCount(), 2u); ASSERT_NE(ai(17).getInstanceId(), ai(18).getInstanceId()); router->activeStack(battleA, nullptr); router->battleStacksAttacked(battleB, {}, false); router->actionStarted(battleA, BattleAction()); - ASSERT_EQ(ai(17).getEvents().back(), "actionStarted:17:id=1"); - ASSERT_EQ(ai(18).getEvents().back(), "battleStacksAttacked:18:id=2"); + EXPECT_EQ( + ai(17).getEvents(), + (std::vector{"battleStart:17:id=1", "activeStack:17:id=1", "actionStarted:17:id=1"}) + ); + EXPECT_EQ( + ai(18).getEvents(), + (std::vector{"battleStart:18:id=2", "battleStacksAttacked:18:id=2"}) + ); } TEST_F(RouterRoutingTest, EndFirstBattleLeavesSecondActive) @@ -190,10 +204,17 @@ TEST_F(RouterRoutingTest, EndFirstBattleLeavesSecondActive) router->battleStart(battleB, nullptr, nullptr, int3(), nullptr, nullptr, BattleSide::DEFENDER, false); router->battleEnd(battleA, nullptr, QueryID(-1)); + ASSERT_EQ(router->activeBattleCount(), 1u); router->activeStack(battleB, nullptr); - ASSERT_EQ(ai(17).getEvents().back(), "battleEnd:17:id=1"); - ASSERT_EQ(ai(18).getEvents().back(), "activeStack:18:id=2"); + EXPECT_EQ( + ai(17).getEvents(), + (std::vector{"battleStart:17:id=1", "battleEnd:17:id=1"}) + ); + EXPECT_EQ( + ai(18).getEvents(), + (std::vector{"battleStart:18:id=2", "activeStack:18:id=2"}) + ); } TEST_F(RouterRoutingTest, EndSecondBattleLeavesFirstActive) @@ -205,15 +226,55 @@ TEST_F(RouterRoutingTest, EndSecondBattleLeavesFirstActive) router->battleStart(battleB, nullptr, nullptr, int3(), nullptr, nullptr, BattleSide::DEFENDER, false); router->battleEnd(battleB, nullptr, QueryID(-1)); + ASSERT_EQ(router->activeBattleCount(), 1u); router->activeStack(battleA, nullptr); - ASSERT_EQ(ai(18).getEvents().back(), "battleEnd:18:id=2"); - ASSERT_EQ(ai(17).getEvents().back(), "activeStack:17:id=1"); + EXPECT_EQ( + ai(18).getEvents(), + (std::vector{"battleStart:18:id=2", "battleEnd:18:id=2"}) + ); + EXPECT_EQ( + ai(17).getEvents(), + (std::vector{"battleStart:17:id=1", "activeStack:17:id=1"}) + ); } -TEST_F(RouterRoutingTest, UnknownBattleIdFailsClearly) +TEST_F(RouterRoutingTest, UnknownBattleIdDoesNotAffectOtherBattles) { - EXPECT_THROW(router->activeStack(makeBattleId(999), nullptr), std::runtime_error); + const BattleID battleA = makeBattleId(17); + router->battleStart(battleA, nullptr, nullptr, int3(), nullptr, nullptr, BattleSide::DEFENDER, false); + const auto before = ai(17).getEvents(); + + EXPECT_NO_THROW(router->activeStack(makeBattleId(999), nullptr)); + EXPECT_NO_THROW(router->battleStacksAttacked(makeBattleId(999), {}, false)); + EXPECT_EQ(ai(17).getEvents(), before); + ASSERT_EQ(router->activeBattleCount(), 1u); +} + +TEST_F(RouterRoutingTest, CallbackAfterEndDoesNotReachOtherBattle) +{ + const BattleID battleA = makeBattleId(17); + const BattleID battleB = makeBattleId(18); + + router->battleStart(battleA, nullptr, nullptr, int3(), nullptr, nullptr, BattleSide::DEFENDER, false); + router->battleStart(battleB, nullptr, nullptr, int3(), nullptr, nullptr, BattleSide::DEFENDER, false); + router->battleEnd(battleA, nullptr, QueryID(-1)); + + EXPECT_NO_THROW(router->activeStack(battleA, nullptr)); + EXPECT_NO_THROW(router->battleEnd(battleA, nullptr, QueryID(-1))); + EXPECT_EQ( + ai(17).getEvents(), + (std::vector{"battleStart:17:id=1", "battleEnd:17:id=1"}) + ); + EXPECT_EQ(ai(18).getEvents(), (std::vector{"battleStart:18:id=2"})); + + router->activeStack(battleB, nullptr); + EXPECT_EQ( + ai(18).getEvents(), + (std::vector{"battleStart:18:id=2", "activeStack:18:id=2"}) + ); + router->battleEnd(battleB, nullptr, QueryID(-1)); + ASSERT_EQ(router->activeBattleCount(), 0u); } TEST_F(RouterRoutingTest, DuplicateBattleStartFailsClearly) @@ -224,5 +285,7 @@ TEST_F(RouterRoutingTest, DuplicateBattleStartFailsClearly) router->battleStart(battleA, nullptr, nullptr, int3(), nullptr, nullptr, BattleSide::DEFENDER, false), std::runtime_error ); + ASSERT_EQ(router->activeBattleCount(), 1u); + EXPECT_EQ(ai(17).getEvents(), (std::vector{"battleStart:17:id=1"})); } } From fce89c8bd8cd0d9bc41937056b380e21c9a023c7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 09:27:26 +0000 Subject: [PATCH 5/5] Keep MMAI Router layout identical across translation units The test factory member was gated on ENABLE_MMAI_TEST only in MMAI, so AIFactory's make_shared() allocated a smaller object than the MMAI constructor wrote. Always declare the factory hook on Router and stop defining ENABLE_MMAI_TEST on the MMAI target. Add a factory construction regression test via createBattleAI("MMAI"). Co-authored-by: Dmitry Starkov --- AI/MMAI/BAI/router.cpp | 6 ------ AI/MMAI/BAI/router.h | 4 ---- AI/MMAI/CMakeLists.txt | 3 --- AI/MMAI/test/router_test.cpp | 10 ++++++++++ 4 files changed, 10 insertions(+), 13 deletions(-) diff --git a/AI/MMAI/BAI/router.cpp b/AI/MMAI/BAI/router.cpp index 435f2ea0c5c..e36d0ce897a 100644 --- a/AI/MMAI/BAI/router.cpp +++ b/AI/MMAI/BAI/router.cpp @@ -170,7 +170,6 @@ Router::~Router() cb->waitTillRealize = wasWaitingForRealize; } -#ifdef ENABLE_MMAI_TEST void Router::setTestBattleAIFactory(TestBattleAIFactory factory) { testBattleAIFactory = std::move(factory); @@ -180,7 +179,6 @@ size_t Router::activeBattleCount() const { return battles.size(); } -#endif void Router::initBattleInterface(std::shared_ptr ENV, std::shared_ptr CB) { @@ -450,7 +448,6 @@ void Router::battleStart( } BattleContext ctx; -#ifdef ENABLE_MMAI_TEST if(testBattleAIFactory) { ctx.bai = testBattleAIFactory(bid, side); @@ -458,9 +455,6 @@ void Router::battleStart( } else ctx = createDelegatedBAI(bid, side); -#else - ctx = createDelegatedBAI(bid, side); -#endif LogTag _2(battleLogTag(ctx) + "." + __func__); logAi->debug("MMAI Router: battleStart bid=%d -> BAI %s", bid.getNum(), MakeAddrStr(ctx.bai.get())); diff --git a/AI/MMAI/BAI/router.h b/AI/MMAI/BAI/router.h index 6ae6c6e41ec..5aca48766dc 100644 --- a/AI/MMAI/BAI/router.h +++ b/AI/MMAI/BAI/router.h @@ -72,11 +72,9 @@ class Router : public CBattleGameInterface void battleUnitsChanged(const BattleID & bid, const std::vector & changes) override; void yourTacticPhase(const BattleID & bid, int distance) override; -#ifdef ENABLE_MMAI_TEST using TestBattleAIFactory = std::function(const BattleID &, BattleSide side)>; void setTestBattleAIFactory(TestBattleAIFactory factory); size_t activeBattleCount() const; -#endif private: struct BattleContext @@ -95,9 +93,7 @@ class Router : public CBattleGameInterface std::string colorname = "?"; const std::string basetag = "?"; -#ifdef ENABLE_MMAI_TEST TestBattleAIFactory testBattleAIFactory; -#endif const BattleContext * findBattle(const BattleID & bid) const; void logMissingBattle(const BattleID & bid, const char * func) const; diff --git a/AI/MMAI/CMakeLists.txt b/AI/MMAI/CMakeLists.txt index 2529226a577..edbbeba3462 100644 --- a/AI/MMAI/CMakeLists.txt +++ b/AI/MMAI/CMakeLists.txt @@ -117,8 +117,6 @@ if(ENABLE_MMAI_TEST) include(CheckCXXCompilerFlag) enable_testing() - target_compile_definitions(MMAI PRIVATE ENABLE_MMAI_TEST) - target_link_libraries(MMAI PUBLIC gtest gtest_main) target_include_directories(MMAI PRIVATE "${CMAKE_SOURCE_DIR}/test/googletest/googletest/include") @@ -134,7 +132,6 @@ if(ENABLE_MMAI_TEST) # The vcmi facade is created after AI/, so defer this target until it exists. function(vcmi_add_mmai_router_test) add_executable(MMAI_router_test ${CMAKE_SOURCE_DIR}/AI/MMAI/test/router_test.cpp) - target_compile_definitions(MMAI_router_test PRIVATE ENABLE_MMAI_TEST) target_include_directories(MMAI_router_test PRIVATE ${CMAKE_SOURCE_DIR}/AI/MMAI) target_link_libraries(MMAI_router_test PRIVATE vcmi gtest gtest_main) gtest_discover_tests(MMAI_router_test) diff --git a/AI/MMAI/test/router_test.cpp b/AI/MMAI/test/router_test.cpp index 4026f7a467d..5c7e5f76c60 100644 --- a/AI/MMAI/test/router_test.cpp +++ b/AI/MMAI/test/router_test.cpp @@ -11,6 +11,7 @@ #include "StdInc.h" #include "BAI/router.h" +#include "callback/AIFactory.h" #include "networkPacks/PacksForClientBattle.h" #include @@ -288,4 +289,13 @@ TEST_F(RouterRoutingTest, DuplicateBattleStartFailsClearly) ASSERT_EQ(router->activeBattleCount(), 1u); EXPECT_EQ(ai(17).getEvents(), (std::vector{"battleStart:17:id=1"})); } + +TEST(RouterFactoryConstruction, CreateBattleAIMMAI) +{ + auto ai = AIFactory::createBattleAI("MMAI"); + ASSERT_NE(ai, nullptr); + auto * router = dynamic_cast(ai.get()); + ASSERT_NE(router, nullptr); + EXPECT_EQ(router->activeBattleCount(), 0u); +} }