diff --git a/AI/Nullkiller2/CMakeLists.txt b/AI/Nullkiller2/CMakeLists.txt index 6ca04d473..235ca6ef4 100644 --- a/AI/Nullkiller2/CMakeLists.txt +++ b/AI/Nullkiller2/CMakeLists.txt @@ -50,6 +50,7 @@ set(Nullkiller2_SRCS Engine/Nullkiller.cpp Engine/DeepDecomposer.cpp Engine/PriorityEvaluator.cpp + Engine/ResourceTrader.cpp Analyzers/DangerHitMapAnalyzer.cpp Analyzers/BuildAnalyzer.cpp Analyzers/ObjectClusterizer.cpp @@ -126,6 +127,7 @@ set(Nullkiller2_HEADERS Engine/Nullkiller.h Engine/DeepDecomposer.h Engine/PriorityEvaluator.h + Engine/ResourceTrader.h Analyzers/DangerHitMapAnalyzer.h Analyzers/BuildAnalyzer.h Analyzers/ObjectClusterizer.h diff --git a/AI/Nullkiller2/Engine/Nullkiller.cpp b/AI/Nullkiller2/Engine/Nullkiller.cpp index 4d23ee218..59c3d4674 100644 --- a/AI/Nullkiller2/Engine/Nullkiller.cpp +++ b/AI/Nullkiller2/Engine/Nullkiller.cpp @@ -26,6 +26,7 @@ #include "../Behaviors/StayAtTownBehavior.h" #include "../Goals/Invalid.h" #include "Goals/RecruitHero.h" +#include "ResourceTrader.h" #include namespace NK2AI @@ -479,7 +480,7 @@ void Nullkiller::makeTurn() } } - hasAnySuccess |= handleTrading(); + hasAnySuccess |= ResourceTrader::trade(buildAnalyzer, cc, getFreeResources()); if(!hasAnySuccess) { logAi->trace("Nothing was done this turn. Ending turn."); @@ -606,160 +607,6 @@ void Nullkiller::lockResources(const TResources & res) lockedResources += res; } -bool Nullkiller::handleTrading() -{ - // TODO: Mircea: Maybe include based on how close danger is: X as default + proportion of close danger or something around that - constexpr float ARMY_GOLD_RATIO_PER_MAKE_TURN_PASS = 0.1f; - constexpr float EXPENDABLE_BULK_RATIO = 0.25f; - bool haveTraded = false; - ObjectInstanceID marketId; - - // TODO: Mircea: What about outside town markets that have better rates than a single town for example? - // Are those used anywhere? To inspect. - for (const auto * const town : cc->getTownsInfo()) - { - if (town->hasBuiltSomeTradeBuilding()) - { - marketId = town->id; - break; - } - } - - if (!marketId.hasValue()) - return false; - - const CGObjectInstance * obj = cc->getObj(marketId, false); - assert(obj); - // if (!obj) - // return false; - - const auto * market = dynamic_cast(obj); - assert(market); - // if (!market) - // return false; - - bool shouldTryToTrade = true; - while(shouldTryToTrade) - { - shouldTryToTrade = false; - buildAnalyzer->update(); - - // if we favor getResourcesRequiredNow is better on short term, if we favor getTotalResourcesRequired is better on long term - TResources missingNow = buildAnalyzer->getMissingResourcesNow(ARMY_GOLD_RATIO_PER_MAKE_TURN_PASS); - if(missingNow.empty()) - break; - - TResources income = buildAnalyzer->getDailyIncome(); - // We don't want to sell something that's necessary later on, though that could make short term a bit harder sometimes - TResources freeAfterMissingTotal = buildAnalyzer->getFreeResourcesAfterMissingTotal(ARMY_GOLD_RATIO_PER_MAKE_TURN_PASS); - -#if NK2AI_TRACE_LEVEL >= 2 - logAi->info("Nullkiller::handleTrading Free %s. FreeAfterMissingTotal %s. MissingNow %s", getFreeResources().toString(), freeAfterMissingTotal.toString(), missingNow.toString()); -#endif - - constexpr int EMPTY = -1; - int mostWanted = EMPTY; - TResource mostWantedScoreNeg = std::numeric_limits::max(); - int mostExpendable = EMPTY; - TResource mostExpendableAmountPos = 0; - - // Find the most wanted resource - for(int i = 0; i < missingNow.size(); ++i) - { - if(missingNow[i] == 0) - continue; - - const TResource score = income[i] - missingNow[i]; - if(score < mostWantedScoreNeg) - { - mostWanted = i; - mostWantedScoreNeg = score; - } - } - - // Find the most expendable resource - for(int i = 0; i < missingNow.size(); ++i) - { - const TResource amountToSell = freeAfterMissingTotal[i]; - if (amountToSell == 0) - continue; - - bool okToSell = false; - if(i == GameResID::GOLD) - { - // TODO: Mircea: Check if we should negate isGoldPressureOverMax() instead - if(income[GameResID::GOLD] > 0 && !buildAnalyzer->isGoldPressureOverMax()) - okToSell = true; - } - else - { - okToSell = true; - } - - if(amountToSell > mostExpendableAmountPos && okToSell) - { - mostExpendable = i; - mostExpendableAmountPos = amountToSell; - } - } - -#if NK2AI_TRACE_LEVEL >= 2 - logAi->trace( - "Nullkiller::handleTrading mostWanted: %d, mostWantedScoreNeg %d, mostExpendable: %d, mostExpendableAmountPos %d", - mostWanted, - mostWantedScoreNeg, - mostExpendable, - mostExpendableAmountPos - ); -#endif - - if(mostExpendable == mostWanted || mostWanted == EMPTY || mostExpendable == EMPTY) - break; - - int givenPerUnit; - int receivedUnits; - market->getOffer(mostExpendable, mostWanted, givenPerUnit, receivedUnits, EMarketMode::RESOURCE_RESOURCE); - if (!givenPerUnit || !receivedUnits) - { - logGlobal->error( - "Nullkiller::handleTrading No offer for %d of %d, given %d, received %d. Should never happen", - mostExpendable, - mostWanted, - givenPerUnit, - receivedUnits - ); - break; - } - - // TODO: Mircea: if 15 wood and 14 gems, gems can be used a lot more for buying other things - if (givenPerUnit > mostExpendableAmountPos) - break; - - TResource multiplier = std::min(static_cast(mostExpendableAmountPos * EXPENDABLE_BULK_RATIO / givenPerUnit), - missingNow[mostWanted] / receivedUnits); // for gold we have to / receivedUnits, because 1 ore gives many gold units - if(multiplier == 0) // could happen for very small values due to EXPENDABLE_BULK_RATIO - multiplier = 1; - - const TResource givenMultiplied = givenPerUnit * multiplier; - if(givenMultiplied > freeAfterMissingTotal[mostExpendable]) - { - logGlobal->error( - "Nullkiller::handleTrading Something went wrong with the multiplier %d", - multiplier - ); - break; - } - - cc->trade(market->getObjInstanceID(), EMarketMode::RESOURCE_RESOURCE, GameResID(mostExpendable), GameResID(mostWanted), givenMultiplied); -#if NK2AI_TRACE_LEVEL >= 2 - logAi->info("Nullkiller::handleTrading Traded %d of %s for %d of %s at %s", givenMultiplied, mostExpendable, receivedUnits, mostWanted, obj->getObjectName()); -#endif - haveTraded = true; - shouldTryToTrade = true; - } - return haveTraded; -} - std::shared_ptr Nullkiller::getPathsInfo(const CGHeroInstance * h) const { return pathfinderCache->getPathsInfo(h); diff --git a/AI/Nullkiller2/Engine/Nullkiller.h b/AI/Nullkiller2/Engine/Nullkiller.h index 11d02d17f..2b5c7b62b 100644 --- a/AI/Nullkiller2/Engine/Nullkiller.h +++ b/AI/Nullkiller2/Engine/Nullkiller.h @@ -136,9 +136,7 @@ public: ScanDepth getScanDepth() const { return scanDepth; } bool isOpenMap() const { return openMap; } bool isObjectGraphAllowed() const { return useObjectGraph; } - bool handleTrading(); void invalidatePathfinderData(); - std::shared_ptr getPathsInfo(const CGHeroInstance * h) const; void invalidatePaths(); std::map getHeroesForPathfinding() const; diff --git a/AI/Nullkiller2/Engine/ResourceTrader.cpp b/AI/Nullkiller2/Engine/ResourceTrader.cpp new file mode 100644 index 000000000..496b0743a --- /dev/null +++ b/AI/Nullkiller2/Engine/ResourceTrader.cpp @@ -0,0 +1,181 @@ +/* +* ResourceTrader.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 "ResourceTrader.h" + +namespace NK2AI +{ +bool ResourceTrader::trade(const std::unique_ptr & buildAnalyzer, std::shared_ptr cc, TResources freeResources) +{ + // TODO: Mircea: Maybe include based on how close danger is: X as default + proportion of close danger or something around that + constexpr float ARMY_GOLD_RATIO_PER_MAKE_TURN_PASS = 0.1f; + constexpr float EXPENDABLE_BULK_RATIO = 0.3f; + bool haveTraded = false; + ObjectInstanceID marketId; + + // TODO: Mircea: What about outside town markets that have better rates than a single town for example? + // Are those used anywhere? To inspect. + for (const auto * const town : cc->getTownsInfo()) + { + if (town->hasBuiltSomeTradeBuilding()) + { + marketId = town->id; + break; + } + } + + if (!marketId.hasValue()) + return false; + + const CGObjectInstance * obj = cc->getObj(marketId, false); + assert(obj); + // if (!obj) + // return false; + + const auto * market = dynamic_cast(obj); + assert(market); + // if (!market) + // return false; + + bool shouldTryToTrade = true; + while(shouldTryToTrade) + { + shouldTryToTrade = false; + buildAnalyzer->update(); + + // if we favor getResourcesRequiredNow is better on short term, if we favor getTotalResourcesRequired is better on long term + TResources missingNow = buildAnalyzer->getMissingResourcesNow(ARMY_GOLD_RATIO_PER_MAKE_TURN_PASS); + if(missingNow.empty()) + break; + + TResources income = buildAnalyzer->getDailyIncome(); + // We don't want to sell something that's necessary later on, though that could make short term a bit harder sometimes + TResources freeAfterMissingTotal = buildAnalyzer->getFreeResourcesAfterMissingTotal(ARMY_GOLD_RATIO_PER_MAKE_TURN_PASS); + +#if NK2AI_TRACE_LEVEL >= 2 + logAi->info("ResourceTrader: Free %s. FreeAfterMissingTotal %s. MissingNow %s", freeResources.toString(), freeAfterMissingTotal.toString(), missingNow.toString()); +#endif + + if(ResourceTrader::tradeHelper(EXPENDABLE_BULK_RATIO, market, missingNow, income, freeAfterMissingTotal, buildAnalyzer, cc)) + { + haveTraded = true; + shouldTryToTrade = true; + } + } + return haveTraded; +} + +bool ResourceTrader::tradeHelper( + float EXPENDABLE_BULK_RATIO, + const IMarket * market, + TResources missingNow, + TResources income, + TResources freeAfterMissingTotal, + const std::unique_ptr & buildAnalyzer, + std::shared_ptr cc +) +{ + constexpr int EMPTY = -1; + int mostWanted = EMPTY; + TResource mostWantedScoreNeg = std::numeric_limits::max(); + int mostExpendable = EMPTY; + TResource mostExpendableAmountPos = 0; + + // Find the most wanted resource + for(int i = 0; i < missingNow.size(); ++i) + { + if(missingNow[i] == 0) + continue; + + const TResource score = income[i] - missingNow[i]; + if(score < mostWantedScoreNeg) + { + mostWanted = i; + mostWantedScoreNeg = score; + } + } + + // Find the most expendable resource + for(int i = 0; i < missingNow.size(); ++i) + { + const TResource amountToSell = freeAfterMissingTotal[i]; + if(amountToSell == 0) + continue; + + bool okToSell = false; + if(i == GameResID::GOLD) + { + // TODO: Mircea: Check if we should negate isGoldPressureOverMax() instead + if(income[GameResID::GOLD] > 0 && !buildAnalyzer->isGoldPressureOverMax()) + okToSell = true; + } + else + { + okToSell = true; + } + + if(amountToSell > mostExpendableAmountPos && okToSell) + { + mostExpendable = i; + mostExpendableAmountPos = amountToSell; + } + } + +#if NK2AI_TRACE_LEVEL >= 2 + logAi->trace( + "ResourceTrader: mostWanted: %d, mostWantedScoreNeg %d, mostExpendable: %d, mostExpendableAmountPos %d", + mostWanted, + mostWantedScoreNeg, + mostExpendable, + mostExpendableAmountPos + ); +#endif + + if(mostExpendable == mostWanted || mostWanted == EMPTY || mostExpendable == EMPTY) + return false; + + int givenPerUnit; + int receivedPerUnit; + market->getOffer(mostExpendable, mostWanted, givenPerUnit, receivedPerUnit, EMarketMode::RESOURCE_RESOURCE); + if(!givenPerUnit || !receivedPerUnit) + { + logGlobal->error( + "ResourceTrader: No offer for %d of %d, given %d, received %d. Should never happen", + mostExpendable, + mostWanted, + givenPerUnit, + receivedPerUnit + ); + return false; + } + + // TODO: Mircea: if 15 wood and 14 gems, gems can be used a lot more for buying other things + if(givenPerUnit > mostExpendableAmountPos) + return false; + + TResource multiplier = std::min( + static_cast(mostExpendableAmountPos * EXPENDABLE_BULK_RATIO / givenPerUnit), + missingNow[mostWanted] / receivedPerUnit + ); // for gold we have to / receivedUnits, because 1 ore gives many gold units + if(multiplier == 0) // could happen for very small values due to EXPENDABLE_BULK_RATIO + multiplier = 1; + + const TResource givenMultiplied = givenPerUnit * multiplier; + if(givenMultiplied > freeAfterMissingTotal[mostExpendable]) + { + logGlobal->error("ResourceTrader: Something went wrong with the multiplier %d", multiplier); + return false; + } + + cc->trade(market->getObjInstanceID(), EMarketMode::RESOURCE_RESOURCE, GameResID(mostExpendable), GameResID(mostWanted), givenMultiplied); +#if NK2AI_TRACE_LEVEL >= 2 + logAi->info("ResourceTrader: Traded %d of %s for %d receivedPerUnit of %s", givenMultiplied, mostExpendable, receivedPerUnit, mostWanted); +#endif + return true; +} +} \ No newline at end of file diff --git a/AI/Nullkiller2/Engine/ResourceTrader.h b/AI/Nullkiller2/Engine/ResourceTrader.h new file mode 100644 index 000000000..8a46eb020 --- /dev/null +++ b/AI/Nullkiller2/Engine/ResourceTrader.h @@ -0,0 +1,31 @@ +/* +* ResourceTrader.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 "Nullkiller.h" + +namespace NK2AI +{ + +class ResourceTrader +{ +public: + static bool trade(const std::unique_ptr & buildAnalyzer, std::shared_ptr cc, TResources freeResources); + static bool tradeHelper( + float EXPENDABLE_BULK_RATIO, + const IMarket * market, + TResources missingNow, + TResources income, + TResources freeAfterMissingTotal, + const std::unique_ptr & buildAnalyzer, + std::shared_ptr cc + ); +}; + +} diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index a23ecfeac..e556dbab1 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -148,6 +148,7 @@ if(ENABLE_NULLKILLER_AI) list(APPEND test_SRCS ${NULLKILLER2_TEST_SRCS} nullkiller2/Behaviors/RecruitHeroBehaviorTest.cpp + nullkiller2/Engine/ResourceTraderTest.cpp ) list(APPEND test_HEADERS diff --git a/test/nullkiller2/Behaviors/RecruitHeroBehaviorTest.cpp b/test/nullkiller2/Behaviors/RecruitHeroBehaviorTest.cpp index f1146aaed..e4e5ad3ce 100644 --- a/test/nullkiller2/Behaviors/RecruitHeroBehaviorTest.cpp +++ b/test/nullkiller2/Behaviors/RecruitHeroBehaviorTest.cpp @@ -1,13 +1,11 @@ /* - * PriorityEvaluatorTest.cpp, part of VCMI engine + * RecruitHeroBehaviorTest.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 "Global.h" #include "gtest/gtest.h" #include "gmock/gmock.h" diff --git a/test/nullkiller2/Engine/ResourceTraderTest.cpp b/test/nullkiller2/Engine/ResourceTraderTest.cpp new file mode 100644 index 000000000..7c0060f80 --- /dev/null +++ b/test/nullkiller2/Engine/ResourceTraderTest.cpp @@ -0,0 +1,66 @@ +/* + * RecruitHeroBehaviorTest.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 "AI/Nullkiller2/Behaviors/RecruitHeroBehavior.h" +#include "AI/Nullkiller2/Engine/Nullkiller.h" +#include "Global.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" + +class MockMarket : public IMarket +{ +public: + explicit MockMarket(IGameInfoCallback * cb) + : IMarket(cb) + { + } + ~MockMarket() override = default; + MOCK_METHOD(bool, getOffer, (int id1, int id2, int & val1, int & val2, EMarketMode mode), ()); + ObjectInstanceID getObjInstanceID() const override; + int getMarketEfficiency() const override; + std::set availableModes() const override; +}; + +TEST(Nullkiller2_Engine_ResourceTrader, tradeHelper) +{ + // auto * const market = new MockMarket(nullptr); + // EXPECT_CALL(*market, getOffer(testing::internal::Any, testing::internal::Any, testing::internal::Any, testing::internal::Any, EMarketMode::RESOURCE_RESOURCE)).Times(1); + // market->getOffer(0, 0, 0, 0, EMarketMode::RESOURCE_RESOURCE); + // delete market; +} + +TResources res(const int wood, const int mercury, const int ore, const int sulfur, const int crystals, const int gems, const int gold, const int mithril) +{ + TResources resources; + resources[0] = wood; + resources[1] = mercury; + resources[2] = ore; + resources[3] = sulfur; + resources[4] = crystals; + resources[5] = gems; + resources[6] = gold; + resources[7] = mithril; + return resources; +} + +// Nullkiller::handleTrading Free [13919, 13883, 13921, 13857, 13792, 13883, 14, 0]. FreeAfterMissingTotal [13859, 13819, 13891, 13833, 13718, 13763, 0, 0]. MissingNow [0, 0, 0, 0, 0, 0, 193445, 0] +// Nullkiller::handleTrading Traded 1547 of 2 for 125 of 6 +// Nullkiller::handleTrading Free [13919, 13883, 13921, 13857, 13792, 13883, 14, 0]. FreeAfterMissingTotal [13859, 13819, 12344, 13833, 13718, 13763, 0, 0]. MissingNow [0, 0, 0, 0, 0, 0, 70, 0] +// Nullkiller::handleTrading Traded 1 of 0 for 125 of 6 +// Nullkiller::handleTrading Free [13908, 13883, 12374, 13857, 13722, 13883, 414, 0]. FreeAfterMissingTotal [13848, 13819, 12344, 13833, 13648, 13763, 0, 0]. MissingNow [0, 0, 0, 0, 0, 0, 193075, 0] +// Nullkiller::handleTrading Traded 1544 of 0 for 125 of 6 +// Nullkiller::handleTrading Free [13908, 13883, 12374, 13857, 13722, 13883, 414, 0]. FreeAfterMissingTotal [12304, 13819, 12344, 13833, 13648, 13763, 0, 0]. MissingNow [0, 0, 0, 0, 0, 0, 75, 0] +// Nullkiller::handleTrading Traded 1 of 3 for 250 of 6 +// Nullkiller::handleTrading Free [12364, 13883, 12374, 13841, 13722, 13883, 24, 0]. FreeAfterMissingTotal [12304, 13819, 12344, 13817, 13648, 13763, 0, 0]. MissingNow [0, 0, 0, 0, 0, 0, 193465, 0] +// Nullkiller::handleTrading Traded 773 of 1 for 250 of 6 +// Nullkiller::handleTrading Free [12364, 13883, 12374, 13841, 13722, 13883, 24, 0]. FreeAfterMissingTotal [12304, 13046, 12344, 13817, 13648, 13763, 0, 0]. MissingNow [0, 0, 0, 0, 0, 0, 215, 0] +// Nullkiller::handleTrading Traded 1 of 3 for 250 of 6 +// Nullkiller::handleTrading Free [12364, 13110, 12374, 13837, 13722, 13883, 52524, 0]. FreeAfterMissingTotal [12304, 13046, 12344, 13813, 13648, 13763, 0, 0]. MissingNow [0, 0, 0, 0, 0, 0, 140965, 0] +// Nullkiller::handleTrading Traded 563 of 3 for 250 of 6 +// Nullkiller::handleTrading Free [12364, 13110, 12374, 13837, 13722, 13883, 52524, 0]. FreeAfterMissingTotal [12304, 13046, 12344, 13250, 13648, 13763, 0, 0]. MissingNow [0, 0, 0, 0, 0, 0, 215, 0] +// Nullkiller::handleTrading Traded 1 of 5 for 250 of 6