Răsfoiți Sursa

extract trading logic into ResourceTrader, test preparation WIP

Mircea TheHonestCTO 3 luni în urmă
părinte
comite
aea43de201

+ 2 - 0
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

+ 2 - 155
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 <boost/range/algorithm/sort.hpp>
 
 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<const IMarket *>(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<TResource>::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<int>(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<const CPathsInfo> Nullkiller::getPathsInfo(const CGHeroInstance * h) const
 {
 	return pathfinderCache->getPathsInfo(h);

+ 0 - 2
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<const CPathsInfo> getPathsInfo(const CGHeroInstance * h) const;
 	void invalidatePaths();
 	std::map<const CGHeroInstance *, HeroRole> getHeroesForPathfinding() const;

+ 181 - 0
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> & buildAnalyzer, std::shared_ptr<CCallback> 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<const IMarket *>(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> & buildAnalyzer,
+	std::shared_ptr<CCallback> cc
+)
+{
+	constexpr int EMPTY = -1;
+	int mostWanted = EMPTY;
+	TResource mostWantedScoreNeg = std::numeric_limits<TResource>::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<int>(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;
+}
+}

+ 31 - 0
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> & buildAnalyzer, std::shared_ptr<CCallback> cc, TResources freeResources);
+	static bool tradeHelper(
+		float EXPENDABLE_BULK_RATIO,
+		const IMarket * market,
+		TResources missingNow,
+		TResources income,
+		TResources freeAfterMissingTotal,
+		const std::unique_ptr<BuildAnalyzer> & buildAnalyzer,
+		std::shared_ptr<CCallback> cc
+	);
+};
+
+}

+ 1 - 0
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

+ 1 - 3
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"

+ 66 - 0
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<EMarketMode> 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