From 1d79946825fb6799e418d4a63b941b9175b535cb Mon Sep 17 00:00:00 2001 From: Xilmi Date: Sun, 1 Dec 2024 23:00:35 +0100 Subject: [PATCH 01/21] Fix for AI not defending in some cases Instead of skipping the defense of other towns entirely when hiring a hero was considered as a way of defending, now only hiring the same hero twice will be avoided. --- AI/Nullkiller/Behaviors/DefenceBehavior.cpp | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/AI/Nullkiller/Behaviors/DefenceBehavior.cpp b/AI/Nullkiller/Behaviors/DefenceBehavior.cpp index 86bd7e77c..08875cb37 100644 --- a/AI/Nullkiller/Behaviors/DefenceBehavior.cpp +++ b/AI/Nullkiller/Behaviors/DefenceBehavior.cpp @@ -41,9 +41,6 @@ Goals::TGoalVec DefenceBehavior::decompose(const Nullkiller * ai) const for(auto town : ai->cb->getTownsInfo()) { evaluateDefence(tasks, town, ai); - //Let's do only one defence-task per pass since otherwise it can try to hire the same hero twice - if (!tasks.empty()) - break; } return tasks; @@ -422,6 +419,18 @@ void DefenceBehavior::evaluateRecruitingHero(Goals::TGoalVec & tasks, const HitM if(hero->getTotalStrength() < threat.danger) continue; + bool heroAlreadyHiredInOtherTown = false; + for (const auto& task : tasks) + { + if (auto recruitGoal = dynamic_cast(task.get())) + { + if (recruitGoal->getHero() == hero) + heroAlreadyHiredInOtherTown = true; + } + } + if (heroAlreadyHiredInOtherTown) + continue; + auto myHeroes = ai->cb->getHeroesInfo(); #if NKAI_TRACE_LEVEL >= 1 From 2df9861076da7c5dd9745cc98703594aa62d0d4e Mon Sep 17 00:00:00 2001 From: Xilmi Date: Sun, 1 Dec 2024 23:05:56 +0100 Subject: [PATCH 02/21] Update DefenceBehavior.cpp Stop iterating over tasks when the curent hero has been found in one. --- AI/Nullkiller/Behaviors/DefenceBehavior.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/AI/Nullkiller/Behaviors/DefenceBehavior.cpp b/AI/Nullkiller/Behaviors/DefenceBehavior.cpp index 08875cb37..a76008523 100644 --- a/AI/Nullkiller/Behaviors/DefenceBehavior.cpp +++ b/AI/Nullkiller/Behaviors/DefenceBehavior.cpp @@ -425,7 +425,10 @@ void DefenceBehavior::evaluateRecruitingHero(Goals::TGoalVec & tasks, const HitM if (auto recruitGoal = dynamic_cast(task.get())) { if (recruitGoal->getHero() == hero) + { heroAlreadyHiredInOtherTown = true; + break; + } } } if (heroAlreadyHiredInOtherTown) From 34b8123fbafa5577a79ce21469410c4b06ebd88e Mon Sep 17 00:00:00 2001 From: Xilmi Date: Mon, 2 Dec 2024 15:11:39 +0100 Subject: [PATCH 03/21] Hero hiring adjustments The AI is now a lot more likely to buy a hero early on when that hero's army is worth more than half the cost of the hero, regardless of other circumstances. --- AI/Nullkiller/Analyzers/ObjectClusterizer.cpp | 2 +- AI/Nullkiller/Behaviors/RecruitHeroBehavior.cpp | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/AI/Nullkiller/Analyzers/ObjectClusterizer.cpp b/AI/Nullkiller/Analyzers/ObjectClusterizer.cpp index 5b9e0a12b..c6ed3cc91 100644 --- a/AI/Nullkiller/Analyzers/ObjectClusterizer.cpp +++ b/AI/Nullkiller/Analyzers/ObjectClusterizer.cpp @@ -505,7 +505,7 @@ void ObjectClusterizer::clusterizeObject( else if (priority <= 0) continue; - bool interestingObject = path.turn() <= 2 || priority > 0.5f; + bool interestingObject = path.turn() <= 2 || priority > ai->settings->isUseFuzzy() ? 0.5f : 0; if(interestingObject) { diff --git a/AI/Nullkiller/Behaviors/RecruitHeroBehavior.cpp b/AI/Nullkiller/Behaviors/RecruitHeroBehavior.cpp index 16c19ed62..fd87347a6 100644 --- a/AI/Nullkiller/Behaviors/RecruitHeroBehavior.cpp +++ b/AI/Nullkiller/Behaviors/RecruitHeroBehavior.cpp @@ -124,6 +124,7 @@ Goals::TGoalVec RecruitHeroBehavior::decompose(const Nullkiller * ai) const { if (ai->cb->getHeroesInfo().size() == 0 || treasureSourcesCount > ai->cb->getHeroesInfo().size() * 5 + || bestHeroToHire->getArmyCost() > GameConstants::HERO_GOLD_COST / 2.0 || (ai->getFreeResources()[EGameResID::GOLD] > 10000 && !ai->buildAnalyzer->isGoldPressureHigh() && haveCapitol) || (ai->getFreeResources()[EGameResID::GOLD] > 30000 && !ai->buildAnalyzer->isGoldPressureHigh())) { From c007bbbcd87834e03b0ec98b946cf9b4bdbb59ad Mon Sep 17 00:00:00 2001 From: Xilmi Date: Tue, 3 Dec 2024 23:09:13 +0100 Subject: [PATCH 04/21] AI improvements AI will no longer skip turns with heroes that are waiting for a delivery that takes more than one turn. Instead they will do something until their delivery is close enough to get it at the same turn. Fixed an issue where a bunch of heroes all tried to do the same tasks: Tasks that involve no fighting will now always be performed by the closest eligible hero while all other heroes look for something else to do. --- AI/Nullkiller/Engine/PriorityEvaluator.cpp | 31 ++++++++++++++-------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/AI/Nullkiller/Engine/PriorityEvaluator.cpp b/AI/Nullkiller/Engine/PriorityEvaluator.cpp index 92faf4bd8..31622aa6d 100644 --- a/AI/Nullkiller/Engine/PriorityEvaluator.cpp +++ b/AI/Nullkiller/Engine/PriorityEvaluator.cpp @@ -1019,12 +1019,20 @@ public: evaluationContext.involvesSailing = true; } + float highestCostForSingleHero = 0; for(auto pair : costsPerHero) { auto role = evaluationContext.evaluator.ai->heroManager->getHeroRole(pair.first); - evaluationContext.movementCostByRole[role] += pair.second; + if (pair.second > highestCostForSingleHero) + highestCostForSingleHero = pair.second; } + if (highestCostForSingleHero > 1 && costsPerHero.size() > 1) + { + //Chains that involve more than 1 hero doing something for more than a turn are too expensive in my book. They often involved heroes doing nothing just standing there waiting to fulfill their part of the chain. + return; + } + evaluationContext.movementCost *= costsPerHero.size(); //further deincentivise chaining as it often involves bringing back the army afterwards auto hero = task->hero; bool checkGold = evaluationContext.danger == 0; @@ -1387,12 +1395,11 @@ float PriorityEvaluator::evaluate(Goals::TSubgoal task, int priorityTier) if (evaluationContext.turn > 0) return 0; if(evaluationContext.conquestValue > 0) - score = 1000; + score = evaluationContext.armyInvolvement; if (vstd::isAlmostZero(score) || (evaluationContext.enemyHeroDangerRatio > 1 && (evaluationContext.turn > 0 || evaluationContext.isExchange) && !ai->cb->getTownsInfo().empty())) return 0; if (maxWillingToLose - evaluationContext.armyLossPersentage < 0) return 0; - score *= evaluationContext.closestWayRatio; if (evaluationContext.movementCost > 0) score /= evaluationContext.movementCost; break; @@ -1403,7 +1410,6 @@ float PriorityEvaluator::evaluate(Goals::TSubgoal task, int priorityTier) score = evaluationContext.armyInvolvement; if (evaluationContext.isEnemy && maxWillingToLose - evaluationContext.armyLossPersentage < 0) return 0; - score *= evaluationContext.closestWayRatio; break; } case PriorityTier::KILL: //Take towns / kill heroes that are further away @@ -1413,7 +1419,7 @@ float PriorityEvaluator::evaluate(Goals::TSubgoal task, int priorityTier) if (arriveNextWeek && evaluationContext.isEnemy) return 0; if (evaluationContext.conquestValue > 0) - score = 1000; + score = evaluationContext.armyInvolvement; if (vstd::isAlmostZero(score) || (evaluationContext.enemyHeroDangerRatio > 1 && (evaluationContext.turn > 0 || evaluationContext.isExchange) && !ai->cb->getTownsInfo().empty())) return 0; if (maxWillingToLose - evaluationContext.armyLossPersentage < 0) @@ -1431,8 +1437,9 @@ float PriorityEvaluator::evaluate(Goals::TSubgoal task, int priorityTier) return 0; if (maxWillingToLose - evaluationContext.armyLossPersentage < 0) return 0; + if (evaluationContext.armyLossPersentage == 0 && evaluationContext.closestWayRatio < 1.0) + return 0; score = 1000; - score *= evaluationContext.closestWayRatio; if (evaluationContext.movementCost > 0) score /= evaluationContext.movementCost; break; @@ -1445,8 +1452,9 @@ float PriorityEvaluator::evaluate(Goals::TSubgoal task, int priorityTier) return 0; if (maxWillingToLose - evaluationContext.armyLossPersentage < 0) return 0; + if (evaluationContext.armyLossPersentage == 0 && evaluationContext.closestWayRatio < 1.0) + return 0; score = 1000; - score *= evaluationContext.closestWayRatio; if (evaluationContext.movementCost > 0) score /= evaluationContext.movementCost; break; @@ -1467,6 +1475,8 @@ float PriorityEvaluator::evaluate(Goals::TSubgoal task, int priorityTier) return 0; if (maxWillingToLose - evaluationContext.armyLossPersentage < 0) return 0; + if (evaluationContext.armyLossPersentage == 0 && evaluationContext.closestWayRatio < 1.0) + return 0; score += evaluationContext.strategicalValue * 1000; score += evaluationContext.goldReward; score += evaluationContext.skillReward * evaluationContext.armyInvolvement * (1 - evaluationContext.armyLossPersentage) * 0.05; @@ -1477,7 +1487,6 @@ float PriorityEvaluator::evaluate(Goals::TSubgoal task, int priorityTier) if (score > 0) { score = 1000; - score *= evaluationContext.closestWayRatio; if (evaluationContext.movementCost > 0) score /= evaluationContext.movementCost; } @@ -1491,8 +1500,9 @@ float PriorityEvaluator::evaluate(Goals::TSubgoal task, int priorityTier) return 0; if (maxWillingToLose - evaluationContext.armyLossPersentage < 0) return 0; + if (evaluationContext.closestWayRatio < 1.0) + return 0; score = 1000; - score *= evaluationContext.closestWayRatio; if (evaluationContext.movementCost > 0) score /= evaluationContext.movementCost; break; @@ -1502,8 +1512,7 @@ float PriorityEvaluator::evaluate(Goals::TSubgoal task, int priorityTier) if (evaluationContext.enemyHeroDangerRatio > 1 && evaluationContext.isExchange) return 0; if (evaluationContext.isDefend || evaluationContext.isArmyUpgrade) - score = 1000; - score *= evaluationContext.closestWayRatio; + score = evaluationContext.armyInvolvement; score /= (evaluationContext.turn + 1); break; } From 66bc6c0d522840bcc8244687c05f230de72406a1 Mon Sep 17 00:00:00 2001 From: Xilmi Date: Wed, 4 Dec 2024 14:57:46 +0100 Subject: [PATCH 05/21] Update ObjectClusterizer.cpp Fixed warning that prevents compilation on GitHub. --- AI/Nullkiller/Analyzers/ObjectClusterizer.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AI/Nullkiller/Analyzers/ObjectClusterizer.cpp b/AI/Nullkiller/Analyzers/ObjectClusterizer.cpp index c6ed3cc91..0607fd756 100644 --- a/AI/Nullkiller/Analyzers/ObjectClusterizer.cpp +++ b/AI/Nullkiller/Analyzers/ObjectClusterizer.cpp @@ -505,7 +505,7 @@ void ObjectClusterizer::clusterizeObject( else if (priority <= 0) continue; - bool interestingObject = path.turn() <= 2 || priority > ai->settings->isUseFuzzy() ? 0.5f : 0; + bool interestingObject = path.turn() <= 2 || (priority > ai->settings->isUseFuzzy() ? 0.5f : 0); if(interestingObject) { From 7da5c08f744eaea05329f7407a7c2150fca2fca3 Mon Sep 17 00:00:00 2001 From: Xilmi Date: Wed, 4 Dec 2024 15:07:13 +0100 Subject: [PATCH 06/21] Update ObjectClusterizer.cpp Actually fix the warning now. --- AI/Nullkiller/Analyzers/ObjectClusterizer.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AI/Nullkiller/Analyzers/ObjectClusterizer.cpp b/AI/Nullkiller/Analyzers/ObjectClusterizer.cpp index 0607fd756..dd7173b74 100644 --- a/AI/Nullkiller/Analyzers/ObjectClusterizer.cpp +++ b/AI/Nullkiller/Analyzers/ObjectClusterizer.cpp @@ -505,7 +505,7 @@ void ObjectClusterizer::clusterizeObject( else if (priority <= 0) continue; - bool interestingObject = path.turn() <= 2 || (priority > ai->settings->isUseFuzzy() ? 0.5f : 0); + bool interestingObject = path.turn() <= 2 || priority > (ai->settings->isUseFuzzy() ? 0.5f : 0); if(interestingObject) { From df21a77857b64af72002a55d5a135de4c4ed4ba5 Mon Sep 17 00:00:00 2001 From: Xilmi Date: Thu, 5 Dec 2024 21:09:24 +0100 Subject: [PATCH 07/21] Battle-AI-improvements When defending the AI is now much smarter to use their defensive-structures like walls, towers and the moat to their advantage instead of allowing them to be lured out and killed in the open. A penalty-multiplier is now applied when deciding which units to walk towards. If an ally is closer than us to the enemy unit in question, we reduce our score for walking towards that unit too. This shall help against baiting a whole flock of AI-stacks to overcommit on chasing an inferior stack of the enemy. --- AI/BattleAI/BattleEvaluator.cpp | 99 +++++++++++++++++++++++++-- AI/BattleAI/BattleEvaluator.h | 2 + AI/BattleAI/BattleExchangeVariant.cpp | 34 ++++++++- AI/BattleAI/BattleExchangeVariant.h | 3 +- 4 files changed, 128 insertions(+), 10 deletions(-) diff --git a/AI/BattleAI/BattleEvaluator.cpp b/AI/BattleAI/BattleEvaluator.cpp index d591b8d7d..a9f32affd 100644 --- a/AI/BattleAI/BattleEvaluator.cpp +++ b/AI/BattleAI/BattleEvaluator.cpp @@ -119,6 +119,58 @@ std::vector BattleEvaluator::getBrokenWallMoatHexes() const return result; } +std::vector BattleEvaluator::getCastleHexes() +{ + std::vector result; + + // Loop through all wall parts + + std::vector wallHexes; + wallHexes.push_back(50); + wallHexes.push_back(183); + wallHexes.push_back(182); + wallHexes.push_back(130); + wallHexes.push_back(78); + wallHexes.push_back(29); + wallHexes.push_back(12); + wallHexes.push_back(97); + wallHexes.push_back(45); + wallHexes.push_back(62); + wallHexes.push_back(112); + wallHexes.push_back(147); + wallHexes.push_back(165); + + for (BattleHex wallHex : wallHexes) { + // Get the starting x-coordinate of the wall hex + int startX = wallHex.getX(); + + // Initialize current hex with the wall hex + BattleHex currentHex = wallHex; + while (currentHex.isValid()) { + // Check if the x-coordinate has wrapped (smaller than the starting x) + if (currentHex.getX() < startX) { + break; + } + + // Add the hex to the result + result.push_back(currentHex); + + // Move to the next hex to the right + currentHex = currentHex.cloneInDirection(BattleHex::RIGHT, false); + } + } + + return result; +} + +bool BattleEvaluator::hasWorkingTowers() const +{ + bool keepIntact = cb->getBattle(battleID)->battleGetWallState(EWallPart::KEEP) != EWallState::NONE && cb->getBattle(battleID)->battleGetWallState(EWallPart::KEEP) != EWallState::DESTROYED; + bool upperIntact = cb->getBattle(battleID)->battleGetWallState(EWallPart::UPPER_TOWER) != EWallState::NONE && cb->getBattle(battleID)->battleGetWallState(EWallPart::UPPER_TOWER) != EWallState::DESTROYED; + bool bottomIntact = cb->getBattle(battleID)->battleGetWallState(EWallPart::BOTTOM_TOWER) != EWallState::NONE && cb->getBattle(battleID)->battleGetWallState(EWallPart::BOTTOM_TOWER) != EWallState::DESTROYED; + return keepIntact || upperIntact || bottomIntact; +} + std::optional BattleEvaluator::findBestCreatureSpell(const CStack *stack) { //TODO: faerie dragon type spell should be selected by server @@ -161,6 +213,19 @@ BattleAction BattleEvaluator::selectStackAction(const CStack * stack) auto moveTarget = scoreEvaluator.findMoveTowardsUnreachable(stack, *targets, damageCache, hb); float score = EvaluationResult::INEFFECTIVE_SCORE; + auto enemyMellee = hb->getUnitsIf([this](const battle::Unit* u) -> bool + { + return u->unitSide() == BattleSide::ATTACKER && !hb->battleCanShoot(u); + }); + bool siegeDefense = stack->unitSide() == BattleSide::DEFENDER + && !stack->canShoot() + && hasWorkingTowers() + && !enemyMellee.empty(); + std::vector castleHexes = getCastleHexes(); + for (auto hex : castleHexes) + { + logAi->trace("Castlehex ID: %d Y: %d X: %d", hex, hex.getY(), hex.getX()); + } if(targets->possibleAttacks.empty() && bestSpellcast.has_value()) { @@ -174,7 +239,7 @@ BattleAction BattleEvaluator::selectStackAction(const CStack * stack) logAi->trace("Evaluating attack for %s", stack->getDescription()); #endif - auto evaluationResult = scoreEvaluator.findBestTarget(stack, *targets, damageCache, hb); + auto evaluationResult = scoreEvaluator.findBestTarget(stack, *targets, damageCache, hb, siegeDefense); auto & bestAttack = evaluationResult.bestAttack; cachedAttack.ap = bestAttack; @@ -227,15 +292,13 @@ BattleAction BattleEvaluator::selectStackAction(const CStack * stack) return BattleAction::makeDefend(stack); } - auto enemyMellee = hb->getUnitsIf([this](const battle::Unit * u) -> bool - { - return u->unitSide() == BattleSide::ATTACKER && !hb->battleCanShoot(u); + bool isTargetOutsideFort = std::none_of(castleHexes.begin(), castleHexes.end(), + [&](const BattleHex& hex) { + return hex == bestAttack.from; }); - - bool isTargetOutsideFort = bestAttack.dest.getY() < GameConstants::BFIELD_WIDTH - 4; bool siegeDefense = stack->unitSide() == BattleSide::DEFENDER && !bestAttack.attack.shooting - && hb->battleGetFortifications().hasMoat + && hasWorkingTowers() && !enemyMellee.empty() && isTargetOutsideFort; @@ -349,6 +412,28 @@ BattleAction BattleEvaluator::goTowardsNearest(const CStack * stack, std::vector auto reachability = cb->getBattle(battleID)->getReachability(stack); auto avHexes = cb->getBattle(battleID)->battleGetAvailableHexes(reachability, stack, false); + auto enemyMellee = hb->getUnitsIf([this](const battle::Unit* u) -> bool + { + return u->unitSide() == BattleSide::ATTACKER && !hb->battleCanShoot(u); + }); + + bool siegeDefense = stack->unitSide() == BattleSide::DEFENDER + && hasWorkingTowers() + && !enemyMellee.empty(); + + if (siegeDefense) + { + vstd::erase_if(avHexes, [&](const BattleHex& hex) { + std::vector castleHexes = getCastleHexes(); + + bool isOutsideWall = std::none_of(castleHexes.begin(), castleHexes.end(), + [&](const BattleHex& checkhex) { + return checkhex == hex; + }); + return isOutsideWall; + }); + } + if(!avHexes.size() || !hexes.size()) //we are blocked or dest is blocked { return BattleAction::makeDefend(stack); diff --git a/AI/BattleAI/BattleEvaluator.h b/AI/BattleAI/BattleEvaluator.h index 0f00ffc7c..7385a0809 100644 --- a/AI/BattleAI/BattleEvaluator.h +++ b/AI/BattleAI/BattleEvaluator.h @@ -53,6 +53,8 @@ public: std::optional findBestCreatureSpell(const CStack * stack); BattleAction goTowardsNearest(const CStack * stack, std::vector hexes, const PotentialTargets & targets); std::vector getBrokenWallMoatHexes() const; + static std::vector getCastleHexes(); + bool hasWorkingTowers() const; void evaluateCreatureSpellcast(const CStack * stack, PossibleSpellcast & ps); //for offensive damaging spells only void print(const std::string & text) const; BattleAction moveOrAttack(const CStack * stack, BattleHex hex, const PotentialTargets & targets); diff --git a/AI/BattleAI/BattleExchangeVariant.cpp b/AI/BattleAI/BattleExchangeVariant.cpp index 07576eb1f..357df8f70 100644 --- a/AI/BattleAI/BattleExchangeVariant.cpp +++ b/AI/BattleAI/BattleExchangeVariant.cpp @@ -9,6 +9,7 @@ */ #include "StdInc.h" #include "BattleExchangeVariant.h" +#include "BattleEvaluator.h" #include "../../lib/CStack.h" AttackerValue::AttackerValue() @@ -213,9 +214,11 @@ EvaluationResult BattleExchangeEvaluator::findBestTarget( const battle::Unit * activeStack, PotentialTargets & targets, DamageCache & damageCache, - std::shared_ptr hb) + std::shared_ptr hb, + bool siegeDefense) { EvaluationResult result(targets.bestAction()); + std::vector castleHexes = BattleEvaluator::getCastleHexes(); if(!activeStack->waited() && !activeStack->acquireState()->hadMorale) { @@ -231,6 +234,9 @@ EvaluationResult BattleExchangeEvaluator::findBestTarget( for(auto & ap : targets.possibleAttacks) { + if (siegeDefense && std::find(castleHexes.begin(), castleHexes.end(), ap.from) == castleHexes.end()) + continue; + float score = evaluateExchange(ap, 0, targets, damageCache, hbWaited); if(score > result.score) @@ -263,6 +269,9 @@ EvaluationResult BattleExchangeEvaluator::findBestTarget( for(auto & ap : targets.possibleAttacks) { + if (siegeDefense && std::find(castleHexes.begin(), castleHexes.end(), ap.from) == castleHexes.end()) + continue; + float score = evaluateExchange(ap, 0, targets, damageCache, hb); bool sameScoreButWaited = vstd::isAlmostEqual(score, result.score) && result.wait; @@ -350,11 +359,32 @@ MoveTarget BattleExchangeEvaluator::findMoveTowardsUnreachable( if(distance <= speed) continue; + float penaltyMultiplier = 1.0f; // Default multiplier, no penalty + float closestAllyDistance = std::numeric_limits::max(); + + for (const battle::Unit* ally : hb->battleAliveUnits()) { + if (ally == activeStack) + continue; + if (ally->unitSide() != activeStack->unitSide()) + continue; + + float allyDistance = dists.distToNearestNeighbour(ally, enemy); + if (allyDistance < closestAllyDistance) + { + closestAllyDistance = allyDistance; + } + } + + // If an ally is closer to the enemy, compute the penaltyMultiplier + if (closestAllyDistance < distance) { + penaltyMultiplier = closestAllyDistance / distance; // Ratio of distances + } + auto turnsToRich = (distance - 1) / speed + 1; auto hexes = enemy->getSurroundingHexes(); auto enemySpeed = enemy->getMovementRange(); auto speedRatio = speed / static_cast(enemySpeed); - auto multiplier = speedRatio > 1 ? 1 : speedRatio; + auto multiplier = (speedRatio > 1 ? 1 : speedRatio) * penaltyMultiplier; for(auto & hex : hexes) { diff --git a/AI/BattleAI/BattleExchangeVariant.h b/AI/BattleAI/BattleExchangeVariant.h index 7ba3886df..dbbbaab3d 100644 --- a/AI/BattleAI/BattleExchangeVariant.h +++ b/AI/BattleAI/BattleExchangeVariant.h @@ -159,7 +159,8 @@ public: const battle::Unit * activeStack, PotentialTargets & targets, DamageCache & damageCache, - std::shared_ptr hb); + std::shared_ptr hb, + bool siegeDefense = false); float evaluateExchange( const AttackPossibility & ap, From 4ff7516a5877a24dc4564d0fcf9359bdf6b30cef Mon Sep 17 00:00:00 2001 From: Xilmi Date: Sun, 8 Dec 2024 12:29:30 +0100 Subject: [PATCH 08/21] Fix crash Reinstated 2 lines who's removal caused a crash at the end of a turn in which a hero is killed. --- lib/networkPacks/NetPacksLib.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/networkPacks/NetPacksLib.cpp b/lib/networkPacks/NetPacksLib.cpp index e4f099010..13a851bac 100644 --- a/lib/networkPacks/NetPacksLib.cpp +++ b/lib/networkPacks/NetPacksLib.cpp @@ -1197,7 +1197,9 @@ void RemoveObject::applyGs(CGameState *gs) { auto * beatenHero = dynamic_cast(obj); assert(beatenHero); + PlayerState* p = gs->getPlayerState(beatenHero->tempOwner); gs->map->heroesOnMap -= beatenHero; + p->removeOwnedObject(beatenHero); auto * siegeNode = beatenHero->whereShouldBeAttachedOnSiege(gs); From 7a5c311d6479b633f40fcf0fb05841fdeca1a55d Mon Sep 17 00:00:00 2001 From: Xilmi Date: Sun, 8 Dec 2024 12:34:14 +0100 Subject: [PATCH 09/21] Revert "Fix crash" This reverts commit 4ff7516a5877a24dc4564d0fcf9359bdf6b30cef. --- lib/networkPacks/NetPacksLib.cpp | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/networkPacks/NetPacksLib.cpp b/lib/networkPacks/NetPacksLib.cpp index 0677d79ea..a187f48e6 100644 --- a/lib/networkPacks/NetPacksLib.cpp +++ b/lib/networkPacks/NetPacksLib.cpp @@ -1208,9 +1208,7 @@ void RemoveObject::applyGs(CGameState *gs) { auto * beatenHero = dynamic_cast(obj); assert(beatenHero); - PlayerState* p = gs->getPlayerState(beatenHero->tempOwner); gs->map->heroesOnMap -= beatenHero; - p->removeOwnedObject(beatenHero); auto * siegeNode = beatenHero->whereShouldBeAttachedOnSiege(gs); From f56681d52100c61ce31fb3f4318b4a305bf84b59 Mon Sep 17 00:00:00 2001 From: Xilmi Date: Sun, 8 Dec 2024 21:43:17 +0100 Subject: [PATCH 10/21] Removed weird logic that prevented AI from casting spells. What was the rationale? AI loses fights with full Mana that it could easily have won otherwise. I just removed that weird logic and now it uses it's mana and wins. --- AI/BattleAI/BattleAI.cpp | 6 +----- AI/BattleAI/BattleAI.h | 1 - 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/AI/BattleAI/BattleAI.cpp b/AI/BattleAI/BattleAI.cpp index b1e5172e0..9b15eafb6 100644 --- a/AI/BattleAI/BattleAI.cpp +++ b/AI/BattleAI/BattleAI.cpp @@ -167,14 +167,12 @@ void CBattleAI::activeStack(const BattleID & battleID, const CStack * stack ) result = evaluator.selectStackAction(stack); - if(autobattlePreferences.enableSpellsUsage && !skipCastUntilNextBattle && evaluator.canCastSpell()) + if(autobattlePreferences.enableSpellsUsage && evaluator.canCastSpell()) { auto spelCasted = evaluator.attemptCastingSpell(stack); if(spelCasted) return; - - skipCastUntilNextBattle = true; } logAi->trace("Spellcast attempt completed in %lld", timeElapsed(start)); @@ -256,8 +254,6 @@ void CBattleAI::battleStart(const BattleID & battleID, const CCreatureSet *army1 { LOG_TRACE(logAi); side = Side; - - skipCastUntilNextBattle = false; } void CBattleAI::print(const std::string &text) const diff --git a/AI/BattleAI/BattleAI.h b/AI/BattleAI/BattleAI.h index f36c2cb26..18df749f0 100644 --- a/AI/BattleAI/BattleAI.h +++ b/AI/BattleAI/BattleAI.h @@ -62,7 +62,6 @@ class CBattleAI : public CBattleGameInterface bool wasWaitingForRealize; bool wasUnlockingGs; int movesSkippedByDefense; - bool skipCastUntilNextBattle; public: CBattleAI(); From eab6de4686082ceb780c96f0235c95967db224eb Mon Sep 17 00:00:00 2001 From: Xilmi Date: Tue, 10 Dec 2024 19:21:23 +0100 Subject: [PATCH 11/21] Fixed an issue that could cause the AI to skip almost their entire turn If the best Task is to recruit a hero this now triggers pathfinding again as the newly bought hero may impair other heroe's paths. --- AI/Nullkiller/Engine/Nullkiller.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/AI/Nullkiller/Engine/Nullkiller.cpp b/AI/Nullkiller/Engine/Nullkiller.cpp index c21f1c57d..b2c3d0eb1 100644 --- a/AI/Nullkiller/Engine/Nullkiller.cpp +++ b/AI/Nullkiller/Engine/Nullkiller.cpp @@ -397,7 +397,12 @@ void Nullkiller::makeTurn() if(!executeTask(bestTask)) return; - updateAiState(i, true); + bool fastUpdate = true; + + if (bestTask->getHero() != nullptr) + fastUpdate = false; + + updateAiState(i, fastUpdate); } else { From 650db733001bd335b2ed385873a8796d0c1b98e1 Mon Sep 17 00:00:00 2001 From: Xilmi Date: Wed, 11 Dec 2024 13:05:51 +0100 Subject: [PATCH 12/21] Ignore all ExceuteHeroChain-tasks with 0 movement-cost These can happen when an enemy spawns ontop of an AI-hero. If the action would win, it wouldn't be executed anyways. So now AI does the next best thing instead, which likely what it wanted to do anyways. --- AI/Nullkiller/Engine/PriorityEvaluator.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/AI/Nullkiller/Engine/PriorityEvaluator.cpp b/AI/Nullkiller/Engine/PriorityEvaluator.cpp index d17848062..b35fa995b 100644 --- a/AI/Nullkiller/Engine/PriorityEvaluator.cpp +++ b/AI/Nullkiller/Engine/PriorityEvaluator.cpp @@ -1006,6 +1006,9 @@ public: Goals::ExecuteHeroChain & chain = dynamic_cast(*task); const AIPath & path = chain.getPath(); + if (path.movementCost() == 0) + return; + vstd::amax(evaluationContext.danger, path.getTotalDanger()); evaluationContext.movementCost += path.movementCost(); evaluationContext.closestWayRatio = chain.closestWayRatio; From 347efa98a0164a3c1ba7e396213be04bd6dd2d77 Mon Sep 17 00:00:00 2001 From: Xilmi Date: Wed, 11 Dec 2024 14:47:08 +0100 Subject: [PATCH 13/21] Introduced new priority-tiers for handling attacking and gathering near enemies that are really far away So that the AI won't become too passive on giant maps. --- AI/Nullkiller/Engine/PriorityEvaluator.cpp | 6 +++++- AI/Nullkiller/Engine/PriorityEvaluator.h | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/AI/Nullkiller/Engine/PriorityEvaluator.cpp b/AI/Nullkiller/Engine/PriorityEvaluator.cpp index b35fa995b..6fea201d7 100644 --- a/AI/Nullkiller/Engine/PriorityEvaluator.cpp +++ b/AI/Nullkiller/Engine/PriorityEvaluator.cpp @@ -1364,7 +1364,7 @@ float PriorityEvaluator::evaluate(Goals::TSubgoal task, int priorityTier) const float maxWillingToLose = amIInDanger ? 1 : ai->settings->getMaxArmyLossTarget(); bool arriveNextWeek = false; - if (ai->cb->getDate(Date::DAY_OF_WEEK) + evaluationContext.turn > 7) + if (ai->cb->getDate(Date::DAY_OF_WEEK) + evaluationContext.turn > 7 && priorityTier < PriorityTier::FAR_KILL) arriveNextWeek = true; #if NKAI_TRACE_LEVEL >= 2 @@ -1417,6 +1417,8 @@ float PriorityEvaluator::evaluate(Goals::TSubgoal task, int priorityTier) break; } case PriorityTier::KILL: //Take towns / kill heroes that are further away + //FALL_THROUGH + case PriorityTier::FAR_KILL: { if (evaluationContext.turn > 0 && evaluationContext.isHero) return 0; @@ -1464,6 +1466,8 @@ float PriorityEvaluator::evaluate(Goals::TSubgoal task, int priorityTier) break; } case PriorityTier::HUNTER_GATHER: //Collect guarded stuff + //FALL_THROUGH + case PriorityTier::FAR_HUNTER_GATHER: { if (evaluationContext.enemyHeroDangerRatio > 1 && !evaluationContext.isDefend) return 0; diff --git a/AI/Nullkiller/Engine/PriorityEvaluator.h b/AI/Nullkiller/Engine/PriorityEvaluator.h index ee983e43b..a403ee6bd 100644 --- a/AI/Nullkiller/Engine/PriorityEvaluator.h +++ b/AI/Nullkiller/Engine/PriorityEvaluator.h @@ -118,6 +118,8 @@ public: HIGH_PRIO_EXPLORE, HUNTER_GATHER, LOW_PRIO_EXPLORE, + FAR_KILL, + FAR_HUNTER_GATHER, DEFEND }; From 32d85ce6ffbbb7f523d37c89c6332517fd4d7469 Mon Sep 17 00:00:00 2001 From: Xilmi Date: Thu, 12 Dec 2024 11:07:56 +0100 Subject: [PATCH 14/21] Fix for retreating-behavior generally not working Fixed that armyInvolvement was only filled in when the action involved a target to interact with rather than just a tile. Since armyInvolvement was used for scoring actions such as retreating towards the closest town, this caused the AI to never retreat to their towns when they were supposed to. --- AI/Nullkiller/Engine/PriorityEvaluator.cpp | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/AI/Nullkiller/Engine/PriorityEvaluator.cpp b/AI/Nullkiller/Engine/PriorityEvaluator.cpp index 6fea201d7..d30d42449 100644 --- a/AI/Nullkiller/Engine/PriorityEvaluator.cpp +++ b/AI/Nullkiller/Engine/PriorityEvaluator.cpp @@ -1060,10 +1060,10 @@ public: if (target->getOwner() != PlayerColor::NEUTRAL && ai->cb->getPlayerRelations(ai->playerID, target->getOwner()) == PlayerRelations::ENEMIES) evaluationContext.isEnemy = true; evaluationContext.goldCost += evaluationContext.evaluator.getGoldCost(target, hero, army); - evaluationContext.armyInvolvement += army->getArmyCost(); if(evaluationContext.danger > 0) evaluationContext.skillReward += (float)evaluationContext.danger / (float)hero->getArmyStrength(); } + evaluationContext.armyInvolvement += army->getArmyCost(); vstd::amax(evaluationContext.armyLossPersentage, (float)path.getTotalArmyLoss() / (float)army->getArmyStrength()); addTileDanger(evaluationContext, path.targetTile(), path.turn(), path.getHeroStrength()); @@ -1368,13 +1368,14 @@ float PriorityEvaluator::evaluate(Goals::TSubgoal task, int priorityTier) arriveNextWeek = true; #if NKAI_TRACE_LEVEL >= 2 - logAi->trace("BEFORE: priorityTier %d, Evaluated %s, loss: %f, turn: %d, turns main: %f, scout: %f, gold: %f, cost: %d, army gain: %f, army growth: %f skill: %f danger: %d, threatTurns: %d, threat: %d, role: %s, strategical value: %f, conquest value: %f cwr: %f, fear: %f, explorePriority: %d isDefend: %d", + logAi->trace("BEFORE: priorityTier %d, Evaluated %s, loss: %f, turn: %d, turns main: %f, scout: %f, army-involvement: %f, gold: %f, cost: %d, army gain: %f, army growth: %f skill: %f danger: %d, threatTurns: %d, threat: %d, role: %s, strategical value: %f, conquest value: %f cwr: %f, fear: %f, explorePriority: %d isDefend: %d", priorityTier, task->toString(), evaluationContext.armyLossPersentage, (int)evaluationContext.turn, evaluationContext.movementCostByRole[HeroRole::MAIN], evaluationContext.movementCostByRole[HeroRole::SCOUT], + evaluationContext.armyInvolvement, goldRewardPerTurn, evaluationContext.goldCost, evaluationContext.armyReward, @@ -1579,13 +1580,14 @@ float PriorityEvaluator::evaluate(Goals::TSubgoal task, int priorityTier) } #if NKAI_TRACE_LEVEL >= 2 - logAi->trace("priorityTier %d, Evaluated %s, loss: %f, turn: %d, turns main: %f, scout: %f, gold: %f, cost: %d, army gain: %f, army growth: %f skill: %f danger: %d, threatTurns: %d, threat: %d, role: %s, strategical value: %f, conquest value: %f cwr: %f, fear: %f, result %f", + logAi->trace("priorityTier %d, Evaluated %s, loss: %f, turn: %d, turns main: %f, scout: %f, army-involvement: %f, gold: %f, cost: %d, army gain: %f, army growth: %f skill: %f danger: %d, threatTurns: %d, threat: %d, role: %s, strategical value: %f, conquest value: %f cwr: %f, fear: %f, result %f", priorityTier, task->toString(), evaluationContext.armyLossPersentage, (int)evaluationContext.turn, evaluationContext.movementCostByRole[HeroRole::MAIN], evaluationContext.movementCostByRole[HeroRole::SCOUT], + evaluationContext.armyInvolvement, goldRewardPerTurn, evaluationContext.goldCost, evaluationContext.armyReward, From 59497db4289a8110f3b86fe11136a714f265f008 Mon Sep 17 00:00:00 2001 From: Xilmi Date: Thu, 12 Dec 2024 13:40:50 +0100 Subject: [PATCH 15/21] Fixed isEnemy becoming true for things it shouldn't Not sure whether it actually impacts behavior but better fix it either way. --- AI/Nullkiller/Engine/PriorityEvaluator.cpp | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/AI/Nullkiller/Engine/PriorityEvaluator.cpp b/AI/Nullkiller/Engine/PriorityEvaluator.cpp index d30d42449..0a2d614ab 100644 --- a/AI/Nullkiller/Engine/PriorityEvaluator.cpp +++ b/AI/Nullkiller/Engine/PriorityEvaluator.cpp @@ -1057,7 +1057,7 @@ public: evaluationContext.conquestValue += evaluationContext.evaluator.getConquestValue(target); if (target->ID == Obj::HERO) evaluationContext.isHero = true; - if (target->getOwner() != PlayerColor::NEUTRAL && ai->cb->getPlayerRelations(ai->playerID, target->getOwner()) == PlayerRelations::ENEMIES) + if (target->getOwner().isValidPlayer() && ai->cb->getPlayerRelations(ai->playerID, target->getOwner()) == PlayerRelations::ENEMIES) evaluationContext.isEnemy = true; evaluationContext.goldCost += evaluationContext.evaluator.getGoldCost(target, hero, army); if(evaluationContext.danger > 0) @@ -1368,7 +1368,7 @@ float PriorityEvaluator::evaluate(Goals::TSubgoal task, int priorityTier) arriveNextWeek = true; #if NKAI_TRACE_LEVEL >= 2 - logAi->trace("BEFORE: priorityTier %d, Evaluated %s, loss: %f, turn: %d, turns main: %f, scout: %f, army-involvement: %f, gold: %f, cost: %d, army gain: %f, army growth: %f skill: %f danger: %d, threatTurns: %d, threat: %d, role: %s, strategical value: %f, conquest value: %f cwr: %f, fear: %f, explorePriority: %d isDefend: %d", + logAi->trace("BEFORE: priorityTier %d, Evaluated %s, loss: %f, turn: %d, turns main: %f, scout: %f, army-involvement: %f, gold: %f, cost: %d, army gain: %f, army growth: %f skill: %f danger: %d, threatTurns: %d, threat: %d, role: %s, strategical value: %f, conquest value: %f cwr: %f, fear: %f, explorePriority: %d isDefend: %d isEnemy: %d arriveNextWeek: %d", priorityTier, task->toString(), evaluationContext.armyLossPersentage, @@ -1390,7 +1390,9 @@ float PriorityEvaluator::evaluate(Goals::TSubgoal task, int priorityTier) evaluationContext.closestWayRatio, evaluationContext.enemyHeroDangerRatio, evaluationContext.explorePriority, - evaluationContext.isDefend); + evaluationContext.isDefend, + evaluationContext.isEnemy, + arriveNextWeek); #endif switch (priorityTier) From 6536c9a18e82cb37d6bb161dfc06b11f1fe35215 Mon Sep 17 00:00:00 2001 From: Xilmi Date: Thu, 12 Dec 2024 14:58:29 +0100 Subject: [PATCH 16/21] Satisfy SonarCube --- AI/Nullkiller/Engine/PriorityEvaluator.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/AI/Nullkiller/Engine/PriorityEvaluator.cpp b/AI/Nullkiller/Engine/PriorityEvaluator.cpp index 0a2d614ab..a0afaf7d2 100644 --- a/AI/Nullkiller/Engine/PriorityEvaluator.cpp +++ b/AI/Nullkiller/Engine/PriorityEvaluator.cpp @@ -1006,7 +1006,7 @@ public: Goals::ExecuteHeroChain & chain = dynamic_cast(*task); const AIPath & path = chain.getPath(); - if (path.movementCost() == 0) + if (vstd::isAlmostZero(path.movementCost())) return; vstd::amax(evaluationContext.danger, path.getTotalDanger()); @@ -1446,7 +1446,7 @@ float PriorityEvaluator::evaluate(Goals::TSubgoal task, int priorityTier) return 0; if (maxWillingToLose - evaluationContext.armyLossPersentage < 0) return 0; - if (evaluationContext.armyLossPersentage == 0 && evaluationContext.closestWayRatio < 1.0) + if (vstd::isAlmostZero(evaluationContext.armyLossPersentage) && evaluationContext.closestWayRatio < 1.0) return 0; score = 1000; if (evaluationContext.movementCost > 0) @@ -1461,7 +1461,7 @@ float PriorityEvaluator::evaluate(Goals::TSubgoal task, int priorityTier) return 0; if (maxWillingToLose - evaluationContext.armyLossPersentage < 0) return 0; - if (evaluationContext.armyLossPersentage == 0 && evaluationContext.closestWayRatio < 1.0) + if (vstd::isAlmostZero(evaluationContext.armyLossPersentage) && evaluationContext.closestWayRatio < 1.0) return 0; score = 1000; if (evaluationContext.movementCost > 0) @@ -1486,7 +1486,7 @@ float PriorityEvaluator::evaluate(Goals::TSubgoal task, int priorityTier) return 0; if (maxWillingToLose - evaluationContext.armyLossPersentage < 0) return 0; - if (evaluationContext.armyLossPersentage == 0 && evaluationContext.closestWayRatio < 1.0) + if (vstd::isAlmostZero(evaluationContext.armyLossPersentage) && evaluationContext.closestWayRatio < 1.0) return 0; score += evaluationContext.strategicalValue * 1000; score += evaluationContext.goldReward; From 2ad903870909ef01e9dba69218a81e039e02bc52 Mon Sep 17 00:00:00 2001 From: Xilmi Date: Thu, 12 Dec 2024 20:06:33 +0100 Subject: [PATCH 17/21] AI can now disband units that block slots for buying better units When the AI cannot buy units in a city because all slots are blocked and the units in the slot are cheaper than the units it wants to buy, the AI will now get rid of the units that block that slot in order to be able to buy the better units. --- AI/Nullkiller/Analyzers/ArmyManager.cpp | 35 +++++++++++++++++++++++-- AI/Nullkiller/Goals/BuyArmy.cpp | 23 ++++++++++++++++ AI/Nullkiller/Pathfinding/Actors.cpp | 10 ++++--- 3 files changed, 62 insertions(+), 6 deletions(-) diff --git a/AI/Nullkiller/Analyzers/ArmyManager.cpp b/AI/Nullkiller/Analyzers/ArmyManager.cpp index b366fa176..1f6997db3 100644 --- a/AI/Nullkiller/Analyzers/ArmyManager.cpp +++ b/AI/Nullkiller/Analyzers/ArmyManager.cpp @@ -309,6 +309,10 @@ std::vector ArmyManager::getArmyAvailableToBuy( ? dynamic_cast(dwelling) : nullptr; + // Keep track of the least valuable slot in the hero's army + SlotID leastValuableSlot; + int leastValuableStackMarketValue = std::numeric_limits::max(); + for(int i = dwelling->creatures.size() - 1; i >= 0; i--) { auto ci = infoFromDC(dwelling->creatures[i]); @@ -322,13 +326,40 @@ std::vector ArmyManager::getArmyAvailableToBuy( if(!ci.count) continue; + // Calculate the market value of the new stack + int newStackMarketValue = ci.creID.toCreature()->getFullRecruitCost().marketValue() * ci.count; + SlotID dst = hero->getSlotFor(ci.creID); if(!hero->hasStackAtSlot(dst)) //need another new slot for this stack { - if(!freeHeroSlots) //no more place for stacks - continue; + if(!freeHeroSlots) // No free slots; consider replacing + { + // Check for the least valuable existing stack + for (auto& slot : hero->Slots()) + { + if(slot.second->getCreatureID() != CreatureID::NONE) + { + int currentStackMarketValue = + slot.second->getCreatureID().toCreature()->getFullRecruitCost().marketValue() * slot.second->getCount(); + + if(currentStackMarketValue < leastValuableStackMarketValue) + { + leastValuableStackMarketValue = currentStackMarketValue; + leastValuableSlot = slot.first; + } + } + } + + // Decide whether to replace the least valuable stack + if(newStackMarketValue <= leastValuableStackMarketValue) + { + continue; // Skip if the new stack isn't worth replacing + } + } else + { freeHeroSlots--; //new slot will be occupied + } } vstd::amin(ci.count, availableRes / ci.creID.toCreature()->getFullRecruitCost()); //max count we can afford diff --git a/AI/Nullkiller/Goals/BuyArmy.cpp b/AI/Nullkiller/Goals/BuyArmy.cpp index 861097eb8..83f131110 100644 --- a/AI/Nullkiller/Goals/BuyArmy.cpp +++ b/AI/Nullkiller/Goals/BuyArmy.cpp @@ -58,6 +58,29 @@ void BuyArmy::accept(AIGateway * ai) if(ci.count) { + if (!town->getUpperArmy()->hasStackAtSlot(town->getUpperArmy()->getSlotFor(ci.creID))) + { + SlotID lowestValueSlot; + int lowestValue = std::numeric_limits::max(); + for (auto slot : town->getUpperArmy()->Slots()) + { + if (slot.second->getCreatureID() != CreatureID::NONE) + { + int currentStackMarketValue = + slot.second->getCreatureID().toCreature()->getFullRecruitCost().marketValue() * slot.second->getCount(); + + if (currentStackMarketValue < lowestValue) + { + lowestValue = currentStackMarketValue; + lowestValueSlot = slot.first; + } + } + } + if (lowestValueSlot.validSlot()) + { + cb->dismissCreature(town->getUpperArmy(), lowestValueSlot); + } + } cb->recruitCreatures(town, town->getUpperArmy(), ci.creID, ci.count, ci.level); valueBought += ci.count * ci.creID.toCreature()->getAIValue(); } diff --git a/AI/Nullkiller/Pathfinding/Actors.cpp b/AI/Nullkiller/Pathfinding/Actors.cpp index 4d3a86a28..8db9230cc 100644 --- a/AI/Nullkiller/Pathfinding/Actors.cpp +++ b/AI/Nullkiller/Pathfinding/Actors.cpp @@ -374,10 +374,12 @@ HeroExchangeArmy * HeroExchangeMap::tryUpgrade( for(auto & creatureToBuy : buyArmy) { auto targetSlot = target->getSlotFor(creatureToBuy.creID.toCreature()); - - target->addToSlot(targetSlot, creatureToBuy.creID, creatureToBuy.count); - target->armyCost += creatureToBuy.creID.toCreature()->getFullRecruitCost() * creatureToBuy.count; - target->requireBuyArmy = true; + if (targetSlot.validSlot()) + { + target->addToSlot(targetSlot, creatureToBuy.creID, creatureToBuy.count); + target->armyCost += creatureToBuy.creID.toCreature()->getFullRecruitCost() * creatureToBuy.count; + target->requireBuyArmy = true; + } } } From fac18d953ee9d09a948ab2d6ce73845ca3ba20fa Mon Sep 17 00:00:00 2001 From: Xilmi Date: Fri, 13 Dec 2024 00:09:10 +0100 Subject: [PATCH 18/21] Fixed and improved disband-logic The score for buying army now scales with the cost of that army. The cost of the units that need to be disbanded in order to hire the new units is subtracted from the army-hiring-score so the AI will prefer hiring armies where the amount of troops to disband is low or non-existent. Fixed a bug that also disbanded troops when there still were free slots available. --- AI/Nullkiller/Analyzers/ArmyManager.cpp | 44 ++++++++++++++++----- AI/Nullkiller/Behaviors/BuyArmyBehavior.cpp | 2 +- AI/Nullkiller/Goals/BuyArmy.cpp | 5 ++- 3 files changed, 39 insertions(+), 12 deletions(-) diff --git a/AI/Nullkiller/Analyzers/ArmyManager.cpp b/AI/Nullkiller/Analyzers/ArmyManager.cpp index 1f6997db3..a8eb72c34 100644 --- a/AI/Nullkiller/Analyzers/ArmyManager.cpp +++ b/AI/Nullkiller/Analyzers/ArmyManager.cpp @@ -309,9 +309,7 @@ std::vector ArmyManager::getArmyAvailableToBuy( ? dynamic_cast(dwelling) : nullptr; - // Keep track of the least valuable slot in the hero's army - SlotID leastValuableSlot; - int leastValuableStackMarketValue = std::numeric_limits::max(); + std::set alreadyDisbanded; for(int i = dwelling->creatures.size() - 1; i >= 0; i--) { @@ -327,9 +325,15 @@ std::vector ArmyManager::getArmyAvailableToBuy( if(!ci.count) continue; // Calculate the market value of the new stack - int newStackMarketValue = ci.creID.toCreature()->getFullRecruitCost().marketValue() * ci.count; + TResources newStackValue = ci.creID.toCreature()->getFullRecruitCost() * ci.count; SlotID dst = hero->getSlotFor(ci.creID); + + // Keep track of the least valuable slot in the hero's army + SlotID leastValuableSlot; + TResources leastValuableStackValue; + leastValuableStackValue[6] = std::numeric_limits::max(); + bool shouldDisband = false; if(!hero->hasStackAtSlot(dst)) //need another new slot for this stack { if(!freeHeroSlots) // No free slots; consider replacing @@ -337,24 +341,33 @@ std::vector ArmyManager::getArmyAvailableToBuy( // Check for the least valuable existing stack for (auto& slot : hero->Slots()) { + if (alreadyDisbanded.find(slot.first) != alreadyDisbanded.end()) + continue; + if(slot.second->getCreatureID() != CreatureID::NONE) { - int currentStackMarketValue = - slot.second->getCreatureID().toCreature()->getFullRecruitCost().marketValue() * slot.second->getCount(); + TResources currentStackValue = slot.second->getCreatureID().toCreature()->getFullRecruitCost() * slot.second->getCount(); - if(currentStackMarketValue < leastValuableStackMarketValue) + if (town && slot.second->getCreatureID().toCreature()->getFactionID() == town->getFactionID()) + continue; + + if(currentStackValue.marketValue() < leastValuableStackValue.marketValue()) { - leastValuableStackMarketValue = currentStackMarketValue; + leastValuableStackValue = currentStackValue; leastValuableSlot = slot.first; } } } // Decide whether to replace the least valuable stack - if(newStackMarketValue <= leastValuableStackMarketValue) + if(newStackValue.marketValue() <= leastValuableStackValue.marketValue()) { continue; // Skip if the new stack isn't worth replacing } + else + { + shouldDisband = true; + } } else { @@ -364,7 +377,18 @@ std::vector ArmyManager::getArmyAvailableToBuy( vstd::amin(ci.count, availableRes / ci.creID.toCreature()->getFullRecruitCost()); //max count we can afford - if(!ci.count) continue; + int disbandMalus = 0; + + if (shouldDisband) + { + disbandMalus = leastValuableStackValue / ci.creID.toCreature()->getFullRecruitCost(); + alreadyDisbanded.insert(leastValuableSlot); + } + + ci.count -= disbandMalus; + + if(ci.count <= 0) + continue; ci.level = i; //this is important for Dungeon Summoning Portal creaturesInDwellings.push_back(ci); diff --git a/AI/Nullkiller/Behaviors/BuyArmyBehavior.cpp b/AI/Nullkiller/Behaviors/BuyArmyBehavior.cpp index 738196e2d..b8bb5e470 100644 --- a/AI/Nullkiller/Behaviors/BuyArmyBehavior.cpp +++ b/AI/Nullkiller/Behaviors/BuyArmyBehavior.cpp @@ -64,7 +64,7 @@ Goals::TGoalVec BuyArmyBehavior::decompose(const Nullkiller * ai) const if(reinforcement) { - tasks.push_back(Goals::sptr(Goals::BuyArmy(town, reinforcement).setpriority(5))); + tasks.push_back(Goals::sptr(Goals::BuyArmy(town, reinforcement).setpriority(reinforcement))); } } } diff --git a/AI/Nullkiller/Goals/BuyArmy.cpp b/AI/Nullkiller/Goals/BuyArmy.cpp index 83f131110..1e1b96b14 100644 --- a/AI/Nullkiller/Goals/BuyArmy.cpp +++ b/AI/Nullkiller/Goals/BuyArmy.cpp @@ -58,7 +58,7 @@ void BuyArmy::accept(AIGateway * ai) if(ci.count) { - if (!town->getUpperArmy()->hasStackAtSlot(town->getUpperArmy()->getSlotFor(ci.creID))) + if (town->stacksCount() == GameConstants::ARMY_SIZE) { SlotID lowestValueSlot; int lowestValue = std::numeric_limits::max(); @@ -69,6 +69,9 @@ void BuyArmy::accept(AIGateway * ai) int currentStackMarketValue = slot.second->getCreatureID().toCreature()->getFullRecruitCost().marketValue() * slot.second->getCount(); + if (slot.second->getCreatureID().toCreature()->getFactionID() == town->getFactionID()) + continue; + if (currentStackMarketValue < lowestValue) { lowestValue = currentStackMarketValue; From 79fb5faa2ea246542bdcde99b107e21d224bf9fe Mon Sep 17 00:00:00 2001 From: Xilmi Date: Fri, 13 Dec 2024 12:23:13 +0100 Subject: [PATCH 19/21] Fixed a freeze It was happening when all slots were full but no unit even needed disbanding because the unit to be bought is part of the units that are inside of the existing slots. (This commit contains excessive debugging but I don't want to remove it just yet incase another issue pops up.) --- AI/Nullkiller/Analyzers/ArmyManager.cpp | 1 + AI/Nullkiller/Goals/BuyArmy.cpp | 13 +++++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/AI/Nullkiller/Analyzers/ArmyManager.cpp b/AI/Nullkiller/Analyzers/ArmyManager.cpp index a8eb72c34..6179b562c 100644 --- a/AI/Nullkiller/Analyzers/ArmyManager.cpp +++ b/AI/Nullkiller/Analyzers/ArmyManager.cpp @@ -382,6 +382,7 @@ std::vector ArmyManager::getArmyAvailableToBuy( if (shouldDisband) { disbandMalus = leastValuableStackValue / ci.creID.toCreature()->getFullRecruitCost(); + logAi->info("Should disband %d %s at %s worth: %d", hero->getStack(leastValuableSlot).count, hero->getStack(leastValuableSlot).getCreatureID().toCreature()->getNamePluralTranslated(), town->getNameTranslated(), leastValuableStackValue.marketValue()); alreadyDisbanded.insert(leastValuableSlot); } diff --git a/AI/Nullkiller/Goals/BuyArmy.cpp b/AI/Nullkiller/Goals/BuyArmy.cpp index 1e1b96b14..b66b42ec7 100644 --- a/AI/Nullkiller/Goals/BuyArmy.cpp +++ b/AI/Nullkiller/Goals/BuyArmy.cpp @@ -58,7 +58,7 @@ void BuyArmy::accept(AIGateway * ai) if(ci.count) { - if (town->stacksCount() == GameConstants::ARMY_SIZE) + if (town->getUpperArmy()->stacksCount() == GameConstants::ARMY_SIZE) { SlotID lowestValueSlot; int lowestValue = std::numeric_limits::max(); @@ -70,10 +70,14 @@ void BuyArmy::accept(AIGateway * ai) slot.second->getCreatureID().toCreature()->getFullRecruitCost().marketValue() * slot.second->getCount(); if (slot.second->getCreatureID().toCreature()->getFactionID() == town->getFactionID()) + { + logAi->info("Skipped Dismissing %s due to same faction", slot.second->getCreatureID().toCreature()->getNamePluralTranslated()); continue; + } if (currentStackMarketValue < lowestValue) { + logAi->info("Marked %s for dismissal.", slot.second->getCreatureID().toCreature()->getNamePluralTranslated()); lowestValue = currentStackMarketValue; lowestValueSlot = slot.first; } @@ -81,10 +85,15 @@ void BuyArmy::accept(AIGateway * ai) } if (lowestValueSlot.validSlot()) { + logAi->info("Dismiss %d %s at %s slot: %d", town->getUpperArmy()->getStackCount(lowestValueSlot), town->getUpperArmy()->getStack(lowestValueSlot).getCreatureID().toCreature()->getNamePluralTranslated(), town->getNameTranslated(), lowestValueSlot.getNum()); cb->dismissCreature(town->getUpperArmy(), lowestValueSlot); } } - cb->recruitCreatures(town, town->getUpperArmy(), ci.creID, ci.count, ci.level); + if (town->getUpperArmy()->stacksCount() < GameConstants::ARMY_SIZE || town->getUpperArmy()->getSlotFor(ci.creID).validSlot()) //It is possible we don't scrap despite we wanted to due to not scrapping stacks that fit our faction + { + logAi->info("Buy %d %s at %s", ci.count, ci.creID.toCreature()->getNamePluralTranslated(), town->getNameTranslated()); + cb->recruitCreatures(town, town->getUpperArmy(), ci.creID, ci.count, ci.level); + } valueBought += ci.count * ci.creID.toCreature()->getAIValue(); } } From 33f5b473b36ce78840a98893fcc8f6ced817694b Mon Sep 17 00:00:00 2001 From: Xilmi Date: Fri, 13 Dec 2024 21:50:45 +0100 Subject: [PATCH 20/21] Defender should not run off for troop-delivery Fixed a rare case in which a town-defender could become an indirect part of a troop-delivery-task by doing a sub-task like capturing a shipyard that takes less than 1 turn but leaves him out in the open. --- AI/Nullkiller/Engine/PriorityEvaluator.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AI/Nullkiller/Engine/PriorityEvaluator.cpp b/AI/Nullkiller/Engine/PriorityEvaluator.cpp index a0afaf7d2..fe08da693 100644 --- a/AI/Nullkiller/Engine/PriorityEvaluator.cpp +++ b/AI/Nullkiller/Engine/PriorityEvaluator.cpp @@ -1401,6 +1401,8 @@ float PriorityEvaluator::evaluate(Goals::TSubgoal task, int priorityTier) { if (evaluationContext.turn > 0) return 0; + if (evaluationContext.movementCost >= 1) + return 0; if(evaluationContext.conquestValue > 0) score = evaluationContext.armyInvolvement; if (vstd::isAlmostZero(score) || (evaluationContext.enemyHeroDangerRatio > 1 && (evaluationContext.turn > 0 || evaluationContext.isExchange) && !ai->cb->getTownsInfo().empty())) From 5ff834aac29269f4ec68537d9a948beb5b4ec4a9 Mon Sep 17 00:00:00 2001 From: Xilmi Date: Fri, 13 Dec 2024 22:13:27 +0100 Subject: [PATCH 21/21] Remove debug-outputs Debug-outputs removed --- AI/Nullkiller/Analyzers/ArmyManager.cpp | 1 - AI/Nullkiller/Goals/BuyArmy.cpp | 6 ------ 2 files changed, 7 deletions(-) diff --git a/AI/Nullkiller/Analyzers/ArmyManager.cpp b/AI/Nullkiller/Analyzers/ArmyManager.cpp index 6179b562c..a8eb72c34 100644 --- a/AI/Nullkiller/Analyzers/ArmyManager.cpp +++ b/AI/Nullkiller/Analyzers/ArmyManager.cpp @@ -382,7 +382,6 @@ std::vector ArmyManager::getArmyAvailableToBuy( if (shouldDisband) { disbandMalus = leastValuableStackValue / ci.creID.toCreature()->getFullRecruitCost(); - logAi->info("Should disband %d %s at %s worth: %d", hero->getStack(leastValuableSlot).count, hero->getStack(leastValuableSlot).getCreatureID().toCreature()->getNamePluralTranslated(), town->getNameTranslated(), leastValuableStackValue.marketValue()); alreadyDisbanded.insert(leastValuableSlot); } diff --git a/AI/Nullkiller/Goals/BuyArmy.cpp b/AI/Nullkiller/Goals/BuyArmy.cpp index b66b42ec7..cdd7d9d84 100644 --- a/AI/Nullkiller/Goals/BuyArmy.cpp +++ b/AI/Nullkiller/Goals/BuyArmy.cpp @@ -70,14 +70,10 @@ void BuyArmy::accept(AIGateway * ai) slot.second->getCreatureID().toCreature()->getFullRecruitCost().marketValue() * slot.second->getCount(); if (slot.second->getCreatureID().toCreature()->getFactionID() == town->getFactionID()) - { - logAi->info("Skipped Dismissing %s due to same faction", slot.second->getCreatureID().toCreature()->getNamePluralTranslated()); continue; - } if (currentStackMarketValue < lowestValue) { - logAi->info("Marked %s for dismissal.", slot.second->getCreatureID().toCreature()->getNamePluralTranslated()); lowestValue = currentStackMarketValue; lowestValueSlot = slot.first; } @@ -85,13 +81,11 @@ void BuyArmy::accept(AIGateway * ai) } if (lowestValueSlot.validSlot()) { - logAi->info("Dismiss %d %s at %s slot: %d", town->getUpperArmy()->getStackCount(lowestValueSlot), town->getUpperArmy()->getStack(lowestValueSlot).getCreatureID().toCreature()->getNamePluralTranslated(), town->getNameTranslated(), lowestValueSlot.getNum()); cb->dismissCreature(town->getUpperArmy(), lowestValueSlot); } } if (town->getUpperArmy()->stacksCount() < GameConstants::ARMY_SIZE || town->getUpperArmy()->getSlotFor(ci.creID).validSlot()) //It is possible we don't scrap despite we wanted to due to not scrapping stacks that fit our faction { - logAi->info("Buy %d %s at %s", ci.count, ci.creID.toCreature()->getNamePluralTranslated(), town->getNameTranslated()); cb->recruitCreatures(town, town->getUpperArmy(), ci.creID, ci.count, ci.level); } valueBought += ci.count * ci.creID.toCreature()->getAIValue();