From 9cd531c8cf3482ab9579cbaa0cda784db521d0da Mon Sep 17 00:00:00 2001 From: Oskar Niesen Date: Sat, 8 Jun 2024 13:56:51 -0500 Subject: [PATCH] Reworked AI war evaluation and plans (#11688) * hasAtLeastMotivationToAttack now takes uses City.neighboringCities * Changed attack pathing to use Astar * Made the base force higher * Declare war checks for total population instead of number of cities * MotivationToAttackAutomation takes into account denunciation * Set up DeclareWarTargetAutomation.kt * Added logic for Civs to try and gang up on other Civs * Added logic for civs to try and join an ally civ in their war and to declare war directly * Added declineJoinWarOffer flag * Reduced the likelyhood of declaring war a little * Civs don't try to join wars against city-states * Removed calculating targets with 0 motivation * Relative production is not calculated against city-states * Improved getAttackPathsModifier to only calculate the best path per city * Refactored filter statements * AI now tries to execute planned wars by default * Culture Civs can now declare war, AI can declare multiple wars * AI won't gift gold to city-states that it wants to attack * Changed motivation from combat * Changed AI aggression towards city-states * Civs don't want to sign a DOF with the only nearby major civ as much * City.neighboringCities filters out cities that are not visible * Fixed some conditionals in tryJoinWar * Fixed some war plan types breaking instead of continuing * Civs are more likely to sign open borders if they haven't seen their cities * Changed far away cities to have less of a value * Fixed neighboringCities and getNeighboringCivilizations * Other fixes * Reduced motivation to attack from relative strength * Added more to motivation to attack * Added extra friendship modifiers * Moved war evaluation to WarPlanEvaluator * Added comments and re-named preparingWarPlan * AI Team wars require neutral relations to send * Added a team war notification * Added evaluation of join war trades * Tweaked MotivationToAttackAutomation * Improved peace deal offers * AI peace deals wait until 10 turns after declaring war * Made the AI declare war a little less * AI builds more military units * AI keeps at least 2 great generals to not build citadels with * AI TeamWar is more specialized for fighting stronger Civs * Removed extra line * Added more comments * Improved unit tryPrepare logic * Minor respelling and style improvements * Changed MotivationToAttackAutomation HashMap to be a list * Added a heuristic for the Astar search * TeamWarPlan focuses more on relative force and fighting stronger civs * MotivationToAttackAutomation takes into account planned wars and can target stronger civs * Added logic for AI's to request other civs to join their war * Fixed some WarDeclaration TradeEvaluation logic and reduced costs * Added some extra safety against extreme force values in DeclareWarPlanEvaluator --- .../jsons/translations/template.properties | 5 + .../automation/city/ConstructionAutomation.kt | 4 +- .../civilization/DeclareWarPlanEvaluator.kt | 173 ++++++++++++ .../DeclareWarTargetAutomation.kt | 128 +++++++++ .../civilization/DiplomacyAutomation.kt | 140 ++++++---- .../MotivationToAttackAutomation.kt | 257 ++++++++++++------ .../civilization/NextTurnAutomation.kt | 1 + .../civilization/TradeAutomation.kt | 8 +- .../civilization/UseGoldAutomation.kt | 2 +- .../unit/RoadBetweenCitiesAutomation.kt | 2 +- .../automation/unit/SpecificUnitAutomation.kt | 4 + .../logic/automation/unit/UnitAutomation.kt | 33 +++ core/src/com/unciv/logic/city/City.kt | 4 +- .../unciv/logic/civilization/Civilization.kt | 2 + .../civilization/diplomacy/DeclareWar.kt | 42 ++- .../diplomacy/DiplomacyManager.kt | 1 + .../diplomacy/DiplomacyTurnManager.kt | 9 +- .../civilization/managers/ThreatManager.kt | 11 + core/src/com/unciv/logic/map/MapPathing.kt | 34 ++- core/src/com/unciv/logic/trade/Trade.kt | 6 +- .../com/unciv/logic/trade/TradeEvaluation.kt | 81 +++--- core/src/com/unciv/logic/trade/TradeLogic.kt | 7 +- 22 files changed, 761 insertions(+), 193 deletions(-) create mode 100644 core/src/com/unciv/logic/automation/civilization/DeclareWarPlanEvaluator.kt create mode 100644 core/src/com/unciv/logic/automation/civilization/DeclareWarTargetAutomation.kt diff --git a/android/assets/jsons/translations/template.properties b/android/assets/jsons/translations/template.properties index 77711b3758..549e08e684 100644 --- a/android/assets/jsons/translations/template.properties +++ b/android/assets/jsons/translations/template.properties @@ -155,10 +155,15 @@ Denounce ([numberOfTurns] turns) = We will remember this. = [civName] has declared war on [targetCivName]! = +# Join War [civName] has joined [allyCivName] in the war against us! = We have joined [allyCivName] in the war against [enemyCivName]! = [civName] has joined [allyCivName] in the war against [enemyCivName]! = [civName] has joined us in the war against [enemyCivName]! = +# Team War +You and [allyCivName] have declared war against [enemyCivName]! = +[civName] and [allyCivName] have declared war against us! = +[civName] and [allyCivName] have declared war against [enemyCivName]! = [civName] cancelled their Defensive Pact with [otherCivName]! = [civName] cancelled their Defensive Pact with us! = diff --git a/core/src/com/unciv/logic/automation/city/ConstructionAutomation.kt b/core/src/com/unciv/logic/automation/city/ConstructionAutomation.kt index a4ba4c0f09..c0f05dd2b5 100644 --- a/core/src/com/unciv/logic/automation/city/ConstructionAutomation.kt +++ b/core/src/com/unciv/logic/automation/city/ConstructionAutomation.kt @@ -165,13 +165,13 @@ class ConstructionAutomation(val cityConstructions: CityConstructions) { private fun addMilitaryUnitChoice() { if (!isAtWar && !cityIsOverAverageProduction) return // don't make any military units here. Infrastructure first! - if (!isAtWar && (civInfo.stats.statsForNextTurn.gold < 0 || militaryUnits > max(5, cities * 2))) return + if (!isAtWar && (civInfo.stats.statsForNextTurn.gold < 0 || militaryUnits > max(7, cities * 5))) return if (civInfo.gold < -50) return val militaryUnit = Automation.chooseMilitaryUnit(city, units) ?: return val unitsToCitiesRatio = cities.toFloat() / (militaryUnits + 1) // most buildings and civ units contribute the the civ's growth, military units are anti-growth - var modifier = sqrt(unitsToCitiesRatio) / 2 + var modifier = 1 + sqrt(unitsToCitiesRatio) / 2 if (civInfo.wantsToFocusOn(Victory.Focus.Military) || isAtWar) modifier *= 2 if (Automation.afraidOfBarbarians(civInfo)) modifier = 2f // military units are pro-growth if pressured by barbs diff --git a/core/src/com/unciv/logic/automation/civilization/DeclareWarPlanEvaluator.kt b/core/src/com/unciv/logic/automation/civilization/DeclareWarPlanEvaluator.kt new file mode 100644 index 0000000000..c98b81b13d --- /dev/null +++ b/core/src/com/unciv/logic/automation/civilization/DeclareWarPlanEvaluator.kt @@ -0,0 +1,173 @@ +package com.unciv.logic.automation.civilization + +import com.unciv.logic.civilization.Civilization +import com.unciv.logic.civilization.diplomacy.DiplomacyFlags +import com.unciv.logic.civilization.diplomacy.DiplomaticStatus +import com.unciv.logic.civilization.diplomacy.RelationshipLevel +import com.unciv.ui.screens.victoryscreen.RankingType + +/** + * Contains the logic for evaluating how we want to declare war on another civ. + */ +object DeclareWarPlanEvaluator { + + /** + * How much motivation [civInfo] has to do a team war with [teamCiv] against [target]. + * + * This style of declaring war favors fighting stronger civilizations. + * @return The movtivation of the plan. If it is > 0 then we can declare the war. + */ + fun evaluateTeamWarPlan(civInfo: Civilization, target: Civilization, teamCiv: Civilization, givenMotivation: Int?): Int { + if (civInfo.getDiplomacyManager(teamCiv).isRelationshipLevelLT(RelationshipLevel.Neutral)) return -1000 + + var motivation = givenMotivation + ?: MotivationToAttackAutomation.hasAtLeastMotivationToAttack(civInfo, target, 0) + + if (civInfo.getDiplomacyManager(teamCiv).isRelationshipLevelEQ(RelationshipLevel.Neutral)) motivation -= 5 + // Make sure that they can actually help us with the target + if (!teamCiv.threatManager.getNeighboringCivilizations().contains(target)) { + motivation -= 40 + } + + val civForce = civInfo.getStatForRanking(RankingType.Force) + val targetForce = target.getStatForRanking(RankingType.Force) + val teamCivForce = (teamCiv.getStatForRanking(RankingType.Force) - 0.8f * teamCiv.threatManager.getCombinedForceOfWarringCivs()).coerceAtLeast(100f) + + // A higher motivation means that we can be riskier + val multiplier = when { + motivation < 5 -> 1.2f + motivation < 10 -> 1.1f + motivation < 20 -> 1f + else -> 0.8f + } + if (civForce + teamCivForce < targetForce * multiplier) { + // We are weaker then them even with our combined forces + // If they have twice our combined force we will have -30 motivation + motivation -= (30 * ((targetForce * multiplier) / (teamCivForce + civForce) - 1)).toInt() + } else if (civForce + teamCivForce > targetForce * 2) { + // Why gang up on such a weaker enemy when we can declare war ourselves? + // If our combined force is twice their force we will have -20 motivation + motivation -= (20 * ((civForce + teamCivForce) / targetForce * 2) - 1).toInt() + } + + val civScore = civInfo.getStatForRanking(RankingType.Score) + val teamCivScore = teamCiv.getStatForRanking(RankingType.Score) + val targetCivScore = target.getStatForRanking(RankingType.Score) + + if (teamCivScore > civScore * 1.4f && teamCivScore >= targetCivScore) { + // If teamCiv has more score than us and the target they are likely in a good position already + motivation -= (20 * ((teamCivScore / (civScore * 1.4f)) - 1)).toInt() + } + return motivation - 20 + } + + /** + * How much motivation [civInfo] has to join [civToJoin] in their war against [target]. + * + * Favors protecting allies. + * @return The movtivation of the plan. If it is > 0 then we can declare the war. + */ + fun evaluateJoinWarPlan(civInfo: Civilization, target: Civilization, civToJoin: Civilization, givenMotivation: Int?): Int { + if (civInfo.getDiplomacyManager(civToJoin).isRelationshipLevelLE(RelationshipLevel.Favorable)) return -1000 + + var motivation = givenMotivation + ?: MotivationToAttackAutomation.hasAtLeastMotivationToAttack(civInfo, target, 0) + // We need to be able to trust the thirdCiv at least somewhat + val thirdCivDiplo = civInfo.getDiplomacyManager(civToJoin) + if (thirdCivDiplo.diplomaticStatus != DiplomaticStatus.DefensivePact && + thirdCivDiplo.opinionOfOtherCiv() + motivation * 2 < 80) { + motivation -= 80 - (thirdCivDiplo.opinionOfOtherCiv() + motivation * 2).toInt() + } + if (!civToJoin.threatManager.getNeighboringCivilizations().contains(target)) { + motivation -= 20 + } + + val targetForce = target.getStatForRanking(RankingType.Force) - 0.8f * target.getCivsAtWarWith().sumOf { it.getStatForRanking(RankingType.Force) }.coerceAtLeast(100) + val civForce = civInfo.getStatForRanking(RankingType.Force) + + // They need to be at least half the targets size, and we need to be stronger than the target together + val civToJoinForce = (civToJoin.getStatForRanking(RankingType.Force) - 0.8f * civToJoin.getCivsAtWarWith().sumOf { it.getStatForRanking(RankingType.Force) }).coerceAtLeast(100f) + if (civToJoinForce < targetForce / 2) { + // Make sure that there is no wrap around + motivation -= (10 * (targetForce / civToJoinForce)).toInt().coerceIn(-1000, 1000) + } + + // A higher motivation means that we can be riskier + val multiplier = when { + motivation < 10 -> 1.4f + motivation < 15 -> 1.3f + motivation < 20 -> 1.2f + motivation < 25 -> 1f + else -> 0.8f + } + if (civToJoinForce + civForce < targetForce * multiplier) { + motivation -= (20 * (targetForce * multiplier) / (civToJoinForce + civForce)).toInt().coerceIn(-1000, 1000) + } + + return motivation - 15 + } + + /** + * How much motivation [civInfo] has for [civToJoin] to join them in their war against [target]. + * + * @return The movtivation of the plan. If it is > 0 then we can declare the war. + */ + fun evaluateJoinOurWarPlan(civInfo: Civilization, target: Civilization, civToJoin: Civilization, givenMotivation: Int?): Int { + if (civInfo.getDiplomacyManager(civToJoin).isRelationshipLevelLT(RelationshipLevel.Favorable)) return -1000 + var motivation = givenMotivation ?: 0 + if (!civToJoin.threatManager.getNeighboringCivilizations().contains(target)) { + motivation -= 50 + } + + val targetForce = target.getStatForRanking(RankingType.Force) + val civForce = civInfo.getStatForRanking(RankingType.Force) + + // They need to be at least half the targets size + val thirdCivForce = (civToJoin.getStatForRanking(RankingType.Force) - 0.8f * civToJoin.getCivsAtWarWith().sumOf { it.getStatForRanking(RankingType.Force) }).coerceAtLeast(100f) + motivation += (20 * thirdCivForce / targetForce.toFloat()).toInt().coerceAtMost(40) + + // If we have less relative force then the target then we have more motivation to accept + motivation += (30 * (1 - (civForce / targetForce.toFloat()))).toInt().coerceIn(-30, 30) + + return motivation - 20 + } + + /** + * How much motivation [civInfo] has to declare war against [target] this turn. + * This can be through a prepared war or a suprise war. + * + * @return The movtivation of the plan. If it is > 0 then we can declare the war. + */ + fun evaluateDeclareWarPlan(civInfo: Civilization, target: Civilization, givenMotivation: Int?): Int { + val motivation = givenMotivation + ?: MotivationToAttackAutomation.hasAtLeastMotivationToAttack(civInfo, target, 0) + + val diploManager = civInfo.getDiplomacyManager(target) + + if (diploManager.hasFlag(DiplomacyFlags.WaryOf) && diploManager.getFlag(DiplomacyFlags.WaryOf) < 0) { + val turnsToPlan = (10 - (motivation / 10)).coerceAtLeast(3) + val turnsToWait = turnsToPlan + diploManager.getFlag(DiplomacyFlags.WaryOf) + return motivation - turnsToWait * 3 + } + + return motivation - 40 + } + + /** + * How much motivation [civInfo] has to start preparing for a war agaist [target]. + * + * @return The motivation of the plan. If it is > 0 then we can start planning the war. + */ + fun evaluateStartPreparingWarPlan(civInfo: Civilization, target: Civilization, givenMotivation: Int?): Int { + val motivation = givenMotivation + ?: MotivationToAttackAutomation.hasAtLeastMotivationToAttack(civInfo, target, 0) + + // TODO: We use negative values in WaryOf for now so that we aren't adding any extra fields to the save file + // This will very likely change in the future and we will want to build upon it + val diploManager = civInfo.getDiplomacyManager(target) + if (diploManager.hasFlag(DiplomacyFlags.WaryOf)) return 0 + + return motivation - 15 + } +} + diff --git a/core/src/com/unciv/logic/automation/civilization/DeclareWarTargetAutomation.kt b/core/src/com/unciv/logic/automation/civilization/DeclareWarTargetAutomation.kt new file mode 100644 index 0000000000..fa0ce5f29f --- /dev/null +++ b/core/src/com/unciv/logic/automation/civilization/DeclareWarTargetAutomation.kt @@ -0,0 +1,128 @@ +package com.unciv.logic.automation.civilization + +import com.unciv.logic.civilization.Civilization +import com.unciv.logic.civilization.diplomacy.DiplomacyFlags +import com.unciv.logic.civilization.diplomacy.RelationshipLevel +import com.unciv.logic.trade.TradeLogic +import com.unciv.logic.trade.TradeOffer +import com.unciv.logic.trade.TradeRequest +import com.unciv.logic.trade.TradeType +import com.unciv.ui.screens.victoryscreen.RankingType + +object DeclareWarTargetAutomation { + + /** + * Chooses a target civilization along with a plan of attack. + * Note that this doesn't guarantee that we will declare war on them immediatly, or that we will end up declaring war at all. + */ + fun chooseDeclareWarTarget(civInfo: Civilization, civAttackMotivations: List>) { + val highestValueTargets = civAttackMotivations.sortedByDescending { it.first.getStatForRanking(RankingType.Score) } + + for (target in highestValueTargets) { + if (tryDeclareWarWithPlan(civInfo, target.first, target.second)) + return // We have successfully found a plan and started executing it! + } + } + + /** + * Determines a war plan against this [target] and executes it if able. + */ + private fun tryDeclareWarWithPlan(civInfo: Civilization, target: Civilization, motivation: Int): Boolean { + + if (!target.isCityState()) { + if (motivation > 5 && tryTeamWar(civInfo, target, motivation)) return true + + if (motivation >= 15 && tryJoinWar(civInfo, target, motivation)) return true + } + + if (motivation >= 20 && declareWar(civInfo, target, motivation)) return true + + if (motivation >= 15 && prepareWar(civInfo, target, motivation)) return true + + return false + } + + /** + * The safest option for war is to invite a new ally to join the war with us. + * Together we are stronger and are more likely to take down bigger threats. + */ + private fun tryTeamWar(civInfo: Civilization, target: Civilization, motivation: Int): Boolean { + val potentialAllies = civInfo.getDiplomacyManager(target).getCommonKnownCivs() + .filter { + it.isMajorCiv() + && !civInfo.getDiplomacyManager(it).hasFlag(DiplomacyFlags.DeclinedJoinWarOffer) + && civInfo.getDiplomacyManager(it).isRelationshipLevelGE(RelationshipLevel.Neutral) + && !it.isAtWarWith(target) + }.sortedByDescending { it.getStatForRanking(RankingType.Force) } + + for (thirdCiv in potentialAllies) { + if (DeclareWarPlanEvaluator.evaluateTeamWarPlan(civInfo, target, thirdCiv, motivation) <= 0) continue + + // Send them an offer + val tradeLogic = TradeLogic(civInfo, thirdCiv) + tradeLogic.currentTrade.ourOffers.add(TradeOffer(target.civName, TradeType.WarDeclaration)) + tradeLogic.currentTrade.theirOffers.add(TradeOffer(target.civName, TradeType.WarDeclaration)) + + thirdCiv.tradeRequests.add(TradeRequest(civInfo.civName, tradeLogic.currentTrade.reverse())) + + return true + } + + return false + } + + /** + * The next safest aproach is to join an existing war on the side of an ally that is already at war with [target]. + */ + private fun tryJoinWar(civInfo: Civilization, target: Civilization, motivation: Int): Boolean { + val potentialAllies = civInfo.getDiplomacyManager(target).getCommonKnownCivs() + .filter { + it.isMajorCiv() + && !civInfo.getDiplomacyManager(it).hasFlag(DiplomacyFlags.DeclinedJoinWarOffer) + && civInfo.getDiplomacyManager(it).isRelationshipLevelGE(RelationshipLevel.Favorable) + && it.isAtWarWith(target) + } // Must be a civ not already at war with them + .sortedByDescending { it.getStatForRanking(RankingType.Force) } + + for (thirdCiv in potentialAllies) { + if (DeclareWarPlanEvaluator.evaluateJoinWarPlan(civInfo, target, thirdCiv, motivation) <= 0) continue + + // Send them an offer + val tradeLogic = TradeLogic(civInfo, thirdCiv) + tradeLogic.currentTrade.ourOffers.add(TradeOffer(target.civName, TradeType.WarDeclaration)) + // TODO: Maybe add in payment requests in some situations + thirdCiv.tradeRequests.add(TradeRequest(civInfo.civName, tradeLogic.currentTrade.reverse())) + + return true + } + + return false + } + + /** + * Lastly, if our motivation is high enough and we don't have any better plans then lets just declare war. + */ + private fun declareWar(civInfo: Civilization, target: Civilization, motivation: Int): Boolean { + if (DeclareWarPlanEvaluator.evaluateDeclareWarPlan(civInfo, target, motivation) > 0) { + civInfo.getDiplomacyManager(target).declareWar() + return true + } + return false + } + + /** + * Slightly safter is to silently plan an invasion and declare war later. + */ + private fun prepareWar(civInfo: Civilization, target: Civilization, motivation: Int): Boolean { + // TODO: We use negative values in WaryOf for now so that we aren't adding any extra fields to the save file + // This will very likely change in the future and we will want to build upon it + val diploManager = civInfo.getDiplomacyManager(target) + if (DeclareWarPlanEvaluator.evaluateStartPreparingWarPlan(civInfo, target, motivation) > 0) { + diploManager.setFlag(DiplomacyFlags.WaryOf, -1) + return true + } + return false + } + +} + diff --git a/core/src/com/unciv/logic/automation/civilization/DiplomacyAutomation.kt b/core/src/com/unciv/logic/automation/civilization/DiplomacyAutomation.kt index eb6bc8bd3c..2f2fe085bc 100644 --- a/core/src/com/unciv/logic/automation/civilization/DiplomacyAutomation.kt +++ b/core/src/com/unciv/logic/automation/civilization/DiplomacyAutomation.kt @@ -15,17 +15,20 @@ import com.unciv.logic.trade.TradeLogic import com.unciv.logic.trade.TradeOffer import com.unciv.logic.trade.TradeRequest import com.unciv.logic.trade.TradeType -import com.unciv.models.ruleset.Victory import com.unciv.models.ruleset.unique.UniqueType import com.unciv.models.translations.tr import com.unciv.ui.screens.victoryscreen.RankingType +import kotlin.math.abs +import kotlin.random.Random object DiplomacyAutomation { internal fun offerDeclarationOfFriendship(civInfo: Civilization) { val civsThatWeCanDeclareFriendshipWith = civInfo.getKnownCivs() - .filter { civInfo.diplomacyFunctions.canSignDeclarationOfFriendshipWith(it) - && !civInfo.getDiplomacyManager(it).hasFlag(DiplomacyFlags.DeclinedDeclarationOfFriendship)} + .filter { + civInfo.diplomacyFunctions.canSignDeclarationOfFriendshipWith(it) + && !civInfo.getDiplomacyManager(it).hasFlag(DiplomacyFlags.DeclinedDeclarationOfFriendship) + } .sortedByDescending { it.getDiplomacyManager(civInfo).relationshipLevel() }.toList() for (otherCiv in civsThatWeCanDeclareFriendshipWith) { // Default setting is 2, this will be changed according to different civ. @@ -53,7 +56,7 @@ object DiplomacyAutomation { // If the other civ is stronger than we are compelled to be nice to them // If they are too weak, then thier friendship doesn't mean much to us - motivation += when (Automation.threatAssessment(civInfo,otherCiv)) { + motivation += when (Automation.threatAssessment(civInfo, otherCiv)) { ThreatLevel.VeryHigh -> 10 ThreatLevel.High -> 5 ThreatLevel.VeryLow -> -5 @@ -86,6 +89,13 @@ object DiplomacyAutomation { val civsToKnow = 0.75f * allAliveCivs motivation -= ((civsToKnow - knownCivs) / civsToKnow * 30f).toInt().coerceAtLeast(0) + // If they are the only non-friendly civ near us then they are the only civ to attack and expand into + if (civInfo.threatManager.getNeighboringCivilizations().none { + it.isMajorCiv() && it != otherCiv + && civInfo.getDiplomacyManager(it).isRelationshipLevelLT(RelationshipLevel.Favorable) + }) + motivation -= 20 + motivation -= hasAtLeastMotivationToAttack(civInfo, otherCiv, motivation / 2) * 2 return motivation > 0 @@ -94,14 +104,14 @@ object DiplomacyAutomation { internal fun offerOpenBorders(civInfo: Civilization) { if (!civInfo.hasUnique(UniqueType.EnablesOpenBorders)) return val civsThatWeCanOpenBordersWith = civInfo.getKnownCivs() - .filter { it.isMajorCiv() && !civInfo.isAtWarWith(it) - && it.hasUnique(UniqueType.EnablesOpenBorders) - && !civInfo.getDiplomacyManager(it).hasOpenBorders - && !civInfo.getDiplomacyManager(it).hasFlag(DiplomacyFlags.DeclinedOpenBorders) - && !isTradeBeingOffered(civInfo, it, Constants.openBorders) - } + .filter { + it.isMajorCiv() && !civInfo.isAtWarWith(it) + && it.hasUnique(UniqueType.EnablesOpenBorders) + && !civInfo.getDiplomacyManager(it).hasOpenBorders + && !civInfo.getDiplomacyManager(it).hasFlag(DiplomacyFlags.DeclinedOpenBorders) + && !isTradeBeingOffered(civInfo, it, Constants.openBorders) + }.sortedByDescending { it.getDiplomacyManager(civInfo).relationshipLevel() }.toList() - .sortedByDescending { it.getDiplomacyManager(civInfo).relationshipLevel() }.toList() for (otherCiv in civsThatWeCanOpenBordersWith) { // Default setting is 3, this will be changed according to different civ. if ((1..10).random() < 7) continue @@ -123,9 +133,12 @@ object DiplomacyAutomation { if (diploManager.hasFlag(DiplomacyFlags.DeclinedOpenBorders)) return false if (diploManager.isRelationshipLevelLT(RelationshipLevel.Favorable)) return false // Don't accept if they are at war with our friends, they might use our land to attack them - if (civInfo.diplomacy.values.any { it.isRelationshipLevelGE(RelationshipLevel.Friend) && it.otherCiv().isAtWarWith(otherCiv)}) + if (civInfo.diplomacy.values.any { it.isRelationshipLevelGE(RelationshipLevel.Friend) && it.otherCiv().isAtWarWith(otherCiv) }) return false - if (hasAtLeastMotivationToAttack(civInfo, otherCiv, (diploManager.opinionOfOtherCiv()/ 2 - 10).toInt()) >= 0) + // Being able to see their cities can give us an advantage later on, especially with espionage enabled + if (otherCiv.cities.count { !it.getCenterTile().isVisible(civInfo) } < otherCiv.cities.count() * .8f) + return true + if (hasAtLeastMotivationToAttack(civInfo, otherCiv, (diploManager.opinionOfOtherCiv() / 2).toInt()) > 0) return false return true } @@ -200,8 +213,10 @@ object DiplomacyAutomation { } val defensivePacts = civInfo.diplomacy.count { it.value.hasFlag(DiplomacyFlags.DefensivePact) } - val otherCivNonOverlappingDefensivePacts = otherCiv.diplomacy.values.count { it.hasFlag(DiplomacyFlags.DefensivePact) - && (!it.otherCiv().knows(civInfo) || !it.otherCiv().getDiplomacyManager(civInfo).hasFlag(DiplomacyFlags.DefensivePact)) } + val otherCivNonOverlappingDefensivePacts = otherCiv.diplomacy.values.count { + it.hasFlag(DiplomacyFlags.DefensivePact) + && (!it.otherCiv().knows(civInfo) || !it.otherCiv().getDiplomacyManager(civInfo).hasFlag(DiplomacyFlags.DefensivePact)) + } val allCivs = civInfo.gameInfo.civilizations.count { it.isMajorCiv() } - 1 // Don't include us val deadCivs = civInfo.gameInfo.civilizations.count { it.isMajorCiv() && !it.isAlive() } val allAliveCivs = allCivs - deadCivs @@ -211,7 +226,7 @@ object DiplomacyAutomation { // If they are stronger than us, then we value it a lot more // If they are weaker than us, then we don't value it - motivation += when (Automation.threatAssessment(civInfo,otherCiv)) { + motivation += when (Automation.threatAssessment(civInfo, otherCiv)) { ThreatLevel.VeryHigh -> 10 ThreatLevel.High -> 5 ThreatLevel.Low -> -5 @@ -234,84 +249,111 @@ object DiplomacyAutomation { } internal fun declareWar(civInfo: Civilization) { - if (civInfo.wantsToFocusOn(Victory.Focus.Culture) && - civInfo.getPersonality().isNeutralPersonality) - return if (civInfo.cities.isEmpty() || civInfo.diplomacy.isEmpty()) return - if (civInfo.isAtWar() || civInfo.getHappiness() <= 0) return + if (civInfo.getHappiness() <= 0) return val ourMilitaryUnits = civInfo.units.getCivUnits().filter { !it.isCivilian() }.count() if (ourMilitaryUnits < civInfo.cities.size) return - if (ourMilitaryUnits < 4) return // to stop AI declaring war at the beginning of games when everyone isn't set up well enough\ - if (civInfo.cities.size < 3) return // FAR too early for that what are you thinking! + if (ourMilitaryUnits < 4) return // to stop AI declaring war at the beginning of games when everyone isn't set up well enough + // For mods we can't check the number of cities, so we will check the population instead. + if (civInfo.cities.sumOf { it.population.population } < 12) return // FAR too early for that what are you thinking! //evaluate war - val enemyCivs = civInfo.getKnownCivs() + val targetCivs = civInfo.getKnownCivs() .filterNot { it == civInfo || it.cities.isEmpty() || !civInfo.getDiplomacyManager(it).canDeclareWar() || it.cities.none { city -> civInfo.hasExplored(city.getCenterTile()) } } - // If the AI declares war on a civ without knowing the location of any cities, it'll just keep amassing an army and not sending it anywhere, - // and end up at a massive disadvantage + // If the AI declares war on a civ without knowing the location of any cities, + // it'll just keep amassing an army and not sending it anywhere, and end up at a massive disadvantage. - if (enemyCivs.none()) return + if (targetCivs.none()) return - val minMotivationToAttack = 20 - // Attack the highest score enemy that we are willing to fight. - // This is to help prevent civs from ganging up on smaller civs - // and directs them to fight their competitors instead. - val civWithBestMotivationToAttack = enemyCivs - .filter { hasAtLeastMotivationToAttack(civInfo, it, minMotivationToAttack) >= 20 } - .maxByOrNull { it.getStatForRanking(RankingType.Score) } + val targetCivsWithMotivation: List> = targetCivs + .map { Pair(it, hasAtLeastMotivationToAttack(civInfo, it, 0)) } + .filter { it.second > 0 }.toList() - if (civWithBestMotivationToAttack != null) - civInfo.getDiplomacyManager(civWithBestMotivationToAttack).declareWar() + DeclareWarTargetAutomation.chooseDeclareWarTarget(civInfo, targetCivsWithMotivation) } - internal fun offerPeaceTreaty(civInfo: Civilization) { if (!civInfo.isAtWar() || civInfo.cities.isEmpty() || civInfo.diplomacy.isEmpty()) return val enemiesCiv = civInfo.diplomacy.filter { it.value.diplomaticStatus == DiplomaticStatus.War } .map { it.value.otherCiv() } - .filterNot { it == civInfo || it.isBarbarian() || it.cities.isEmpty() } - .filter { !civInfo.getDiplomacyManager(it).hasFlag(DiplomacyFlags.DeclinedPeace) } + .filterNot { + it == civInfo || it.isBarbarian() || it.cities.isEmpty() + || it.getDiplomacyManager(civInfo).hasFlag(DiplomacyFlags.DeclaredWar) + || civInfo.getDiplomacyManager(it).hasFlag(DiplomacyFlags.DeclaredWar) + }.filter { !civInfo.getDiplomacyManager(it).hasFlag(DiplomacyFlags.DeclinedPeace) } // Don't allow AIs to offer peace to city states allied with their enemies .filterNot { it.isCityState() && it.getAllyCiv() != null && civInfo.isAtWarWith(civInfo.gameInfo.getCivilization(it.getAllyCiv()!!)) } // ignore civs that we have already offered peace this turn as a counteroffer to another civ's peace offer .filter { it.tradeRequests.none { tradeRequest -> tradeRequest.requestingCiv == civInfo.civName && tradeRequest.trade.isPeaceTreaty() } } for (enemy in enemiesCiv) { - if(hasAtLeastMotivationToAttack(civInfo, enemy, 10) >= 10) { + if (hasAtLeastMotivationToAttack(civInfo, enemy, 10) >= 10) { // We can still fight. Refuse peace. continue } + if (civInfo.getStatForRanking(RankingType.Force) - 0.8f * civInfo.threatManager.getCombinedForceOfWarringCivs() > 0) { + val randomSeed = civInfo.gameInfo.civilizations.indexOf(enemy) + civInfo.getCivsAtWarWith().count() + 123 * civInfo.gameInfo.turns + if (Random(randomSeed).nextInt(100) > 80) continue + } + // pay for peace val tradeLogic = TradeLogic(civInfo, enemy) tradeLogic.currentTrade.ourOffers.add(TradeOffer(Constants.peaceTreaty, TradeType.Treaty)) tradeLogic.currentTrade.theirOffers.add(TradeOffer(Constants.peaceTreaty, TradeType.Treaty)) - var moneyWeNeedToPay = -TradeEvaluation().evaluatePeaceCostForThem(civInfo, enemy) + if (enemy.isMajorCiv()) { + var moneyWeNeedToPay = -TradeEvaluation().evaluatePeaceCostForThem(civInfo, enemy) - if (civInfo.gold > 0 && moneyWeNeedToPay > 0) { - if (moneyWeNeedToPay > civInfo.gold) { - moneyWeNeedToPay = civInfo.gold // As much as possible + if (civInfo.gold > 0 && moneyWeNeedToPay > 0) { + if (moneyWeNeedToPay > civInfo.gold) { + moneyWeNeedToPay = civInfo.gold // As much as possible + } + tradeLogic.currentTrade.ourOffers.add( + TradeOffer("Gold".tr(), TradeType.Gold, moneyWeNeedToPay) + ) + } else if (moneyWeNeedToPay < -100) { + val moneyTheyNeedToPay = abs(moneyWeNeedToPay).coerceAtMost(enemy.gold) + if (moneyTheyNeedToPay > 0) { + tradeLogic.currentTrade.theirOffers.add( + TradeOffer("Gold".tr(), TradeType.Gold, moneyTheyNeedToPay) + ) + } } - tradeLogic.currentTrade.ourOffers.add( - TradeOffer("Gold".tr(), TradeType.Gold, moneyWeNeedToPay) - ) } enemy.tradeRequests.add(TradeRequest(civInfo.civName, tradeLogic.currentTrade.reverse())) } } - private fun isTradeBeingOffered(civInfo: Civilization, otherCiv: Civilization, offerName:String): Boolean { + internal fun askForHelp(civInfo: Civilization) { + if (!civInfo.isAtWar() || civInfo.cities.isEmpty() || civInfo.diplomacy.isEmpty()) return + + for (enemyCiv in civInfo.getCivsAtWarWith().sortedByDescending { it.getStatForRanking(RankingType.Force) }) { + val potentialAllies = enemyCiv.threatManager.getNeighboringCivilizations() + .filter { !it.isAtWarWith(enemyCiv) && civInfo.getDiplomacyManager(it).isRelationshipLevelGE(RelationshipLevel.Friend) + && !it.getDiplomacyManager(civInfo).hasFlag(DiplomacyFlags.DeclinedJoinWarOffer) } + .sortedByDescending { it.getStatForRanking(RankingType.Force) } + val civToAsk = potentialAllies.firstOrNull { + DeclareWarPlanEvaluator.evaluateJoinOurWarPlan(civInfo, enemyCiv, it, null) > 0 } ?: continue + + val tradeLogic = TradeLogic(civInfo, civToAsk) + // TODO: add gold offer here + tradeLogic.currentTrade.theirOffers.add(TradeOffer(enemyCiv.civName, TradeType.WarDeclaration)) + civToAsk.tradeRequests.add(TradeRequest(civInfo.civName, tradeLogic.currentTrade.reverse())) + } + } + + private fun isTradeBeingOffered(civInfo: Civilization, otherCiv: Civilization, offerName: String): Boolean { return civInfo.tradeRequests.filter { request -> request.requestingCiv == otherCiv.civName } - .any { trade -> trade.trade.ourOffers.any { offer -> offer.name == offerName }} + .any { trade -> trade.trade.ourOffers.any { offer -> offer.name == offerName } } || civInfo.tradeRequests.filter { request -> request.requestingCiv == otherCiv.civName } - .any { trade -> trade.trade.theirOffers.any { offer -> offer.name == offerName }} + .any { trade -> trade.trade.theirOffers.any { offer -> offer.name == offerName } } } } diff --git a/core/src/com/unciv/logic/automation/civilization/MotivationToAttackAutomation.kt b/core/src/com/unciv/logic/automation/civilization/MotivationToAttackAutomation.kt index 86aa03ebea..44c2ef1bdc 100644 --- a/core/src/com/unciv/logic/automation/civilization/MotivationToAttackAutomation.kt +++ b/core/src/com/unciv/logic/automation/civilization/MotivationToAttackAutomation.kt @@ -9,6 +9,7 @@ import com.unciv.logic.civilization.diplomacy.DiplomacyFlags import com.unciv.logic.civilization.diplomacy.DiplomacyManager import com.unciv.logic.civilization.diplomacy.RelationshipLevel import com.unciv.logic.map.BFS +import com.unciv.logic.map.MapPathing import com.unciv.logic.map.tile.Tile import com.unciv.models.ruleset.Building import com.unciv.models.ruleset.unique.UniqueType @@ -19,95 +20,110 @@ object MotivationToAttackAutomation { /** Will return the motivation to attack, but might short circuit if the value is guaranteed to * be lower than `atLeast`. So any values below `atLeast` should not be used for comparison. */ - fun hasAtLeastMotivationToAttack(civInfo: Civilization, otherCiv: Civilization, atLeast: Int): Int { - val closestCities = NextTurnAutomation.getClosestCities(civInfo, otherCiv) ?: return 0 - val baseForce = 30f + fun hasAtLeastMotivationToAttack(civInfo: Civilization, targetCiv: Civilization, atLeast: Int): Int { + val targetCitiesWithOurCity = civInfo.threatManager.getNeighboringCitiesOfOtherCivs().filter { it.second.civ == targetCiv }.toList() + val targetCities = targetCitiesWithOurCity.map { it.second } - val ourCombatStrength = calculateSelfCombatStrength(civInfo, baseForce) - val theirCombatStrength = calculateCombatStrengthWithProtectors(otherCiv, baseForce, civInfo) + if (targetCitiesWithOurCity.isEmpty()) return 0 - if (theirCombatStrength > ourCombatStrength) return 0 - - val ourCity = closestCities.city1 - val theirCity = closestCities.city2 - - if (hasNoUnitsThatCanAttackCityWithoutDying(civInfo, theirCity)) + if (targetCities.all { hasNoUnitsThatCanAttackCityWithoutDying(civInfo, it) }) return 0 - fun isTileCanMoveThrough(tile: Tile): Boolean { - val owner = tile.getOwner() - return !tile.isImpassible() - && (owner == otherCiv || owner == null || civInfo.diplomacyFunctions.canPassThroughTiles(owner)) + val baseForce = 100f + + val ourCombatStrength = calculateSelfCombatStrength(civInfo, baseForce) + val theirCombatStrength = calculateCombatStrengthWithProtectors(targetCiv, baseForce, civInfo) + + val modifiers:MutableList> = mutableListOf() + modifiers.add(Pair("Base motivation", -15)) + + modifiers.add(Pair("Relative combat strength", getCombatStrengthModifier(ourCombatStrength, theirCombatStrength + 0.8f * civInfo.threatManager.getCombinedForceOfWarringCivs()))) + // TODO: For now this will be a very high value because the AI can't handle multiple fronts, this should be changed later though + modifiers.add(Pair("Concurrent wars", -civInfo.getCivsAtWarWith().count { it.isMajorCiv() && it != targetCiv } * 20)) + modifiers.add(Pair("Their concurrent wars", targetCiv.getCivsAtWarWith().count { it.isMajorCiv() } * 3)) + + modifiers.add(Pair("Their allies", getDefensivePactAlliesScore(targetCiv, civInfo, baseForce, ourCombatStrength))) + + if (civInfo.threatManager.getNeighboringCivilizations().none { it != targetCiv && it.isMajorCiv() + && civInfo.getDiplomacyManager(it).isRelationshipLevelLT(RelationshipLevel.Friend) }) + modifiers.add(Pair("No other threats", 10)) + + if (targetCiv.isMajorCiv()) { + val scoreRatioModifier = getScoreRatioModifier(targetCiv, civInfo) + modifiers.add(Pair("Relative score", scoreRatioModifier)) + + modifiers.add(Pair("Relative technologies", getRelativeTechModifier(civInfo, targetCiv))) + + if (civInfo.stats.getUnitSupplyDeficit() != 0) { + modifiers.add(Pair("Over unit supply", (civInfo.stats.getUnitSupplyDeficit() * 2).coerceAtMost(20))) + } else if (targetCiv.stats.getUnitSupplyDeficit() == 0 && !targetCiv.isCityState()) { + modifiers.add(Pair("Relative production", getProductionRatioModifier(civInfo, targetCiv))) + } } - val modifierMap = HashMap() - modifierMap["Relative combat strength"] = getCombatStrengthModifier(ourCombatStrength, theirCombatStrength) + val minTargetCityDistance = targetCitiesWithOurCity.minOf { it.second.getCenterTile().aerialDistanceTo(it.first.getCenterTile()) } + modifiers.add(Pair("Far away cities", when { + minTargetCityDistance > 20 -> -10 + minTargetCityDistance > 14 -> -8 + minTargetCityDistance > 10 -> -3 + else -> 0 + })) + if (minTargetCityDistance < 6) modifiers.add(Pair("Close cities", 5)) - modifierMap["Their allies"] = getDefensivePactAlliesScore(otherCiv, civInfo, baseForce, ourCombatStrength) + val diplomacyManager = civInfo.getDiplomacyManager(targetCiv) - val scoreRatioModifier = getScoreRatioModifier(otherCiv, civInfo) - modifierMap["Relative score"] = scoreRatioModifier - - if (civInfo.stats.getUnitSupplyDeficit() != 0) { - modifierMap["Over unit supply"] = (civInfo.stats.getUnitSupplyDeficit() * 2).coerceAtMost(20) - } else if (otherCiv.stats.getUnitSupplyDeficit() == 0) { - modifierMap["Relative production"] = getProductionRatioModifier(civInfo, otherCiv) - } - - modifierMap["Relative technologies"] = getRelativeTechModifier(civInfo, otherCiv) - - if (closestCities.aerialDistance > 7) - modifierMap["Far away cities"] = -10 - - val diplomacyManager = civInfo.getDiplomacyManager(otherCiv) if (diplomacyManager.hasFlag(DiplomacyFlags.ResearchAgreement)) - modifierMap["Research Agreement"] = -5 + modifiers.add(Pair("Research Agreement", -5)) if (diplomacyManager.hasFlag(DiplomacyFlags.DeclarationOfFriendship)) - modifierMap["Declaration of Friendship"] = -10 + modifiers.add(Pair("Declaration of Friendship", -10)) if (diplomacyManager.hasFlag(DiplomacyFlags.DefensivePact)) - modifierMap["Defensive Pact"] = -10 + modifiers.add(Pair("Defensive Pact", -15)) - modifierMap["Relationship"] = getRelationshipModifier(diplomacyManager) + modifiers.add(Pair("Relationship", getRelationshipModifier(diplomacyManager))) + + if (diplomacyManager.hasFlag(DiplomacyFlags.Denunciation)) { + modifiers.add(Pair("Denunciation", 5)) + } + + if (diplomacyManager.hasFlag(DiplomacyFlags.WaryOf) && diplomacyManager.getFlag(DiplomacyFlags.WaryOf) < 0) { + modifiers.add(Pair("PlanningAttack", -diplomacyManager.getFlag(DiplomacyFlags.WaryOf))) + } else { + val attacksPlanned = civInfo.diplomacy.values.count { it.hasFlag(DiplomacyFlags.WaryOf) && it.getFlag(DiplomacyFlags.WaryOf) < 0 } + modifiers.add(Pair("PlanningAttackAgainstOtherCivs", -attacksPlanned * 5)) + } if (diplomacyManager.resourcesFromTrade().any { it.amount > 0 }) - modifierMap["Receiving trade resources"] = -5 + modifiers.add(Pair("Receiving trade resources", -5)) - if (theirCity.getTiles().none { tile -> tile.neighbors.any { it.getOwner() == theirCity.civ && it.getCity() != theirCity } }) - modifierMap["Isolated city"] = 15 - - if (otherCiv.isCityState()) { - modifierMap["City-state"] = -20 - if (otherCiv.getAllyCiv() == civInfo.civName) - modifierMap["Allied City-state"] = -20 // There had better be a DAMN good reason + // If their cities don't have any nearby cities that are also targets to us and it doesn't include their capital + // Then there cities are likely isolated and a good target. + if (targetCiv.getCapital(true) !in targetCities + && targetCities.all { theirCity -> !theirCity.neighboringCities.any { it !in targetCities } }) { + modifiers.add(Pair("Isolated city", 15)) } - addWonderBasedMotivations(otherCiv, modifierMap) - - modifierMap["War with allies"] = getAlliedWarMotivation(civInfo, otherCiv) - - - var motivationSoFar = modifierMap.values.sum() - - // Short-circuit to avoid expensive BFS - if (motivationSoFar < atLeast) return motivationSoFar - - val landPathBFS = BFS(ourCity.getCenterTile()) { - it.isLand && isTileCanMoveThrough(it) + if (targetCiv.isCityState()) { + modifiers.add(Pair("Protectors", -targetCiv.cityStateFunctions.getProtectorCivs().size * 3)) + if (targetCiv.cityStateFunctions.getProtectorCivs().contains(civInfo)) + modifiers.add(Pair("Under our protection", -15)) + if (targetCiv.getAllyCiv() == civInfo.civName) + modifiers.add(Pair("Allied City-state", -20)) // There had better be a DAMN good reason } - landPathBFS.stepUntilDestination(theirCity.getCenterTile()) - if (!landPathBFS.hasReachedTile(theirCity.getCenterTile())) - motivationSoFar -= -10 + addWonderBasedMotivations(targetCiv, modifiers) - // Short-circuit to avoid expensive BFS + modifiers.add(Pair("War with allies", getAlliedWarMotivation(civInfo, targetCiv))) + + // Purely for debugging, remove modifiers that don't have an effect + modifiers.removeAll { it.second == 0 } + var motivationSoFar = modifiers.sumOf { it.second } + + // Short-circuit to avoid A-star if (motivationSoFar < atLeast) return motivationSoFar - val reachableEnemyCitiesBfs = BFS(civInfo.getCapital()!!.getCenterTile()) { isTileCanMoveThrough(it) } - reachableEnemyCitiesBfs.stepToEnd() - val reachableEnemyCities = otherCiv.cities.filter { reachableEnemyCitiesBfs.hasReachedTile(it.getCenterTile()) } - if (reachableEnemyCities.isEmpty()) return 0 // Can't even reach the enemy city, no point in war. + motivationSoFar += getAttackPathsModifier(civInfo, targetCiv, targetCitiesWithOurCity) return motivationSoFar } @@ -129,21 +145,21 @@ object MotivationToAttackAutomation { return ourCombatStrength } - private fun addWonderBasedMotivations(otherCiv: Civilization, modifierMap: HashMap) { + private fun addWonderBasedMotivations(otherCiv: Civilization, modifiers: MutableList>) { var wonderCount = 0 for (city in otherCiv.cities) { val construction = city.cityConstructions.getCurrentConstruction() if (construction is Building && construction.hasUnique(UniqueType.TriggersCulturalVictory)) - modifierMap["About to win"] = 15 + modifiers.add(Pair("About to win", 15)) if (construction is BaseUnit && construction.hasUnique(UniqueType.AddInCapital)) - modifierMap["About to win"] = 15 + modifiers.add(Pair("About to win", 15)) wonderCount += city.cityConstructions.getBuiltBuildings().count { it.isWonder } } // The more wonders they have, the more beneficial it is to conquer them // Civs need an army to protect thier wonders which give the most score if (wonderCount > 0) - modifierMap["Owned Wonders"] = wonderCount + modifiers.add(Pair("Owned Wonders", wonderCount)) } /** If they are at war with our allies, then we should join in */ @@ -151,10 +167,14 @@ object MotivationToAttackAutomation { var alliedWarMotivation = 0 for (thirdCiv in civInfo.getDiplomacyManager(otherCiv).getCommonKnownCivs()) { val thirdCivDiploManager = civInfo.getDiplomacyManager(thirdCiv) - if (thirdCivDiploManager.hasFlag(DiplomacyFlags.DeclinedDeclarationOfFriendship) + if (thirdCivDiploManager.isRelationshipLevelGE(RelationshipLevel.Friend) && thirdCiv.isAtWarWith(otherCiv) ) { - alliedWarMotivation += if (thirdCivDiploManager.hasFlag(DiplomacyFlags.DefensivePact)) 15 else 5 + if (thirdCiv.getDiplomacyManager(otherCiv).hasFlag(DiplomacyFlags.Denunciation)) + alliedWarMotivation += 2 + alliedWarMotivation += if (thirdCivDiploManager.hasFlag(DiplomacyFlags.DefensivePact)) 15 + else if (thirdCivDiploManager.isRelationshipLevelGT(RelationshipLevel.Friend)) 5 + else 2 } } return alliedWarMotivation @@ -162,9 +182,12 @@ object MotivationToAttackAutomation { private fun getRelationshipModifier(diplomacyManager: DiplomacyManager): Int { val relationshipModifier = when (diplomacyManager.relationshipIgnoreAfraid()) { - RelationshipLevel.Unforgivable -> 10 - RelationshipLevel.Enemy -> 5 - RelationshipLevel.Ally -> -5 // this is so that ally + DoF is not too unbalanced - + RelationshipLevel.Unforgivable -> 15 + RelationshipLevel.Enemy -> 10 + RelationshipLevel.Competitor -> 5 + RelationshipLevel.Favorable -> -2 + RelationshipLevel.Friend -> -5 + RelationshipLevel.Ally -> -10 // this is so that ally + DoF is not too unbalanced - // still possible for AI to declare war for isolated city else -> 0 } @@ -210,10 +233,11 @@ object MotivationToAttackAutomation { // Designed to mitigate AIs declaring war on weaker civs instead of their rivals val scoreRatio = otherCiv.getStatForRanking(RankingType.Score).toFloat() / civInfo.getStatForRanking(RankingType.Score).toFloat() val scoreRatioModifier = when { - scoreRatio > 2f -> 15 - scoreRatio > 1.5f -> 10 - scoreRatio > 1.25f -> 5 - scoreRatio > 1f -> 0 + scoreRatio > 2f -> 20 + scoreRatio > 1.5f -> 15 + scoreRatio > 1.25f -> 10 + scoreRatio > 1f -> 2 + scoreRatio > .8f -> 0 scoreRatio > .5f -> -2 scoreRatio > .25f -> -5 else -> -10 @@ -240,11 +264,16 @@ object MotivationToAttackAutomation { private fun getCombatStrengthModifier(ourCombatStrength: Float, theirCombatStrength: Float): Int { val combatStrengthRatio = ourCombatStrength / theirCombatStrength val combatStrengthModifier = when { - combatStrengthRatio > 3f -> 30 - combatStrengthRatio > 2.5f -> 25 - combatStrengthRatio > 2f -> 20 - combatStrengthRatio > 1.5f -> 10 - else -> 0 + combatStrengthRatio > 5f -> 30 + combatStrengthRatio > 4f -> 20 + combatStrengthRatio > 3f -> 15 + combatStrengthRatio > 2f -> 10 + combatStrengthRatio > 1.5f -> 5 + combatStrengthRatio > .8f -> 0 + combatStrengthRatio > .6f -> -5 + combatStrengthRatio > .4f -> -15 + combatStrengthRatio > .2f -> -20 + else -> -20 } return combatStrengthModifier } @@ -258,4 +287,66 @@ object MotivationToAttackAutomation { damageReceivedWhenAttacking < 100 } + /** + * Checks the routes of attack against [otherCiv] using [targetCitiesWithOurCity]. + * + * The more routes of attack and shorter the path the higher a motivation will be returned. + * Sea attack routes are less valuable + * + * @return The motivation ranging from -30 to around +10 + */ + private fun getAttackPathsModifier(civInfo: Civilization, otherCiv: Civilization, targetCitiesWithOurCity: List>): Int { + + fun isTileCanMoveThrough(civInfo: Civilization, tile: Tile): Boolean { + val owner = tile.getOwner() + return !tile.isImpassible() + && (owner == otherCiv || owner == null || civInfo.diplomacyFunctions.canPassThroughTiles(owner)) + } + + fun isLandTileCanMoveThrough(civInfo: Civilization, tile: Tile): Boolean { + return tile.isLand && isTileCanMoveThrough(civInfo, tile) + } + + val attackPaths: MutableList> = mutableListOf() + var attackPathModifiers: Int = -3 + + // For each city, we want to calculate if there is an attack path to the enemy + for (attacksGroupedByCity in targetCitiesWithOurCity.groupBy { it.first }) { + val cityToAttackFrom = attacksGroupedByCity.key + var cityAttackValue = 0 + + // We only want to calculate the best attack path and use it's value + // Land routes are clearly better than sea routes + for ((_, cityToAttack) in attacksGroupedByCity.value) { + val landAttackPath = MapPathing.getConnection(civInfo, cityToAttackFrom.getCenterTile(), cityToAttack.getCenterTile(), ::isLandTileCanMoveThrough) + if (landAttackPath != null && landAttackPath.size < 16) { + attackPaths.add(landAttackPath) + cityAttackValue = 3 + break + } + + if (cityAttackValue > 0) continue + + val landAndSeaAttackPath = MapPathing.getConnection(civInfo, cityToAttackFrom.getCenterTile(), cityToAttack.getCenterTile(), ::isTileCanMoveThrough) + if (landAndSeaAttackPath != null && landAndSeaAttackPath.size < 16) { + attackPaths.add(landAndSeaAttackPath) + cityAttackValue += 1 + } + } + attackPathModifiers += cityAttackValue + } + + if (attackPaths.isEmpty()) { + // Do an expensive BFS to find any possible attack path + val reachableEnemyCitiesBfs = BFS(civInfo.getCapital(true)!!.getCenterTile()) { isTileCanMoveThrough(civInfo, it) } + reachableEnemyCitiesBfs.stepToEnd() + val reachableEnemyCities = otherCiv.cities.filter { reachableEnemyCitiesBfs.hasReachedTile(it.getCenterTile()) } + if (reachableEnemyCities.isEmpty()) return -50 // Can't even reach the enemy city, no point in war. + val minAttackDistance = reachableEnemyCities.minOf { reachableEnemyCitiesBfs.getPathTo(it.getCenterTile()).count() } + + // Longer attack paths are worse, but if the attack path is too far away we shouldn't completely discard the possibility + attackPathModifiers -= (minAttackDistance - 10).coerceIn(0, 30) + } + return attackPathModifiers + } } diff --git a/core/src/com/unciv/logic/automation/civilization/NextTurnAutomation.kt b/core/src/com/unciv/logic/automation/civilization/NextTurnAutomation.kt index fa5a2b6f84..13a3e89ea1 100644 --- a/core/src/com/unciv/logic/automation/civilization/NextTurnAutomation.kt +++ b/core/src/com/unciv/logic/automation/civilization/NextTurnAutomation.kt @@ -43,6 +43,7 @@ object NextTurnAutomation { if (!civInfo.gameInfo.ruleset.modOptions.hasUnique(UniqueType.DiplomaticRelationshipsCannotChange)) { DiplomacyAutomation.declareWar(civInfo) DiplomacyAutomation.offerPeaceTreaty(civInfo) + DiplomacyAutomation.askForHelp(civInfo) DiplomacyAutomation.offerDeclarationOfFriendship(civInfo) } if (civInfo.gameInfo.isReligionEnabled()) { diff --git a/core/src/com/unciv/logic/automation/civilization/TradeAutomation.kt b/core/src/com/unciv/logic/automation/civilization/TradeAutomation.kt index 17a186bce8..49b901b0cd 100644 --- a/core/src/com/unciv/logic/automation/civilization/TradeAutomation.kt +++ b/core/src/com/unciv/logic/automation/civilization/TradeAutomation.kt @@ -80,7 +80,7 @@ object TradeAutomation { if (offer.type == TradeType.Treaty) continue // Don't try to counter with a defensive pact or research pact - val value = evaluation.evaluateBuyCostWithInflation(offer, civInfo, otherCiv) + val value = evaluation.evaluateBuyCostWithInflation(offer, civInfo, otherCiv, tradeRequest.trade) if (value > 0) potentialAsks[offer] = value } @@ -111,7 +111,7 @@ object TradeAutomation { while (ask.amount > 1 && originalValue == evaluation.evaluateBuyCostWithInflation( TradeOffer(ask.name, ask.type, ask.amount - 1, ask.duration), - civInfo, otherCiv) ) { + civInfo, otherCiv, tradeRequest.trade) ) { ask.amount-- } } @@ -121,7 +121,7 @@ object TradeAutomation { for (goldAsk in counterofferAsks.keys .filter { it.type == TradeType.Gold_Per_Turn || it.type == TradeType.Gold } .sortedByDescending { it.type.ordinal }) { // Do GPT first - val valueOfOne = evaluation.evaluateBuyCostWithInflation(TradeOffer(goldAsk.name, goldAsk.type, 1, goldAsk.duration), civInfo, otherCiv) + val valueOfOne = evaluation.evaluateBuyCostWithInflation(TradeOffer(goldAsk.name, goldAsk.type, 1, goldAsk.duration), civInfo, otherCiv, tradeRequest.trade) val amountCanBeRemoved = deltaInOurFavor / valueOfOne if (amountCanBeRemoved >= goldAsk.amount) { deltaInOurFavor -= counterofferAsks[goldAsk]!! @@ -141,7 +141,7 @@ object TradeAutomation { .sortedByDescending { it.type.ordinal }) { if (tradeLogic.currentTrade.theirOffers.none { it.type == ourGold.type } && counterofferAsks.keys.none { it.type == ourGold.type } ) { - val valueOfOne = evaluation.evaluateSellCostWithInflation(TradeOffer(ourGold.name, ourGold.type, 1, ourGold.duration), civInfo, otherCiv) + val valueOfOne = evaluation.evaluateSellCostWithInflation(TradeOffer(ourGold.name, ourGold.type, 1, ourGold.duration), civInfo, otherCiv, tradeRequest.trade) val amountToGive = min(deltaInOurFavor / valueOfOne, ourGold.amount) deltaInOurFavor -= amountToGive * valueOfOne if (amountToGive > 0) { diff --git a/core/src/com/unciv/logic/automation/civilization/UseGoldAutomation.kt b/core/src/com/unciv/logic/automation/civilization/UseGoldAutomation.kt index 77e4e94ba6..889a6cde31 100644 --- a/core/src/com/unciv/logic/automation/civilization/UseGoldAutomation.kt +++ b/core/src/com/unciv/logic/automation/civilization/UseGoldAutomation.kt @@ -37,7 +37,7 @@ object UseGoldAutomation { private fun useGoldForCityStates(civ: Civilization) { // RARE EDGE CASE: If you ally with a city-state, you may reveal more map that includes ANOTHER civ! // So if we don't lock this list, we may later discover that there are more known civs, concurrent modification exception! - val knownCityStates = civ.getKnownCivs().filter { it.isCityState() }.toList() + val knownCityStates = civ.getKnownCivs().filter { it.isCityState() && MotivationToAttackAutomation.hasAtLeastMotivationToAttack(civ, it, 0) <= 0 }.toList() // canBeMarriedBy checks actual cost, but it can't be below 500*speedmodifier, and the later check is expensive if (civ.gold >= 330 && civ.getHappiness() > 0 && civ.hasUnique(UniqueType.CityStateCanBeBoughtForGold)) { diff --git a/core/src/com/unciv/logic/automation/unit/RoadBetweenCitiesAutomation.kt b/core/src/com/unciv/logic/automation/unit/RoadBetweenCitiesAutomation.kt index bcc3db3ad1..dd02344106 100644 --- a/core/src/com/unciv/logic/automation/unit/RoadBetweenCitiesAutomation.kt +++ b/core/src/com/unciv/logic/automation/unit/RoadBetweenCitiesAutomation.kt @@ -96,7 +96,7 @@ class RoadBetweenCitiesAutomation(val civInfo: Civilization, cachedForTurn: Int, val basePriority = rankRoadCapitalPriority(roadToCapitalStatus) val roadsToBuild: MutableList = mutableListOf() - for (closeCity in city.neighboringCities.filter { it.civ == civInfo }) { + for (closeCity in city.neighboringCities.filter { it.civ == civInfo && it.getCenterTile().aerialDistanceTo(city.getCenterTile()) <= 8 }) { // Try to find if the other city has planned to build a road to this city if (roadsToBuildByCitiesCache.containsKey(closeCity)) { diff --git a/core/src/com/unciv/logic/automation/unit/SpecificUnitAutomation.kt b/core/src/com/unciv/logic/automation/unit/SpecificUnitAutomation.kt index 478cd018a8..fc11a8adf3 100644 --- a/core/src/com/unciv/logic/automation/unit/SpecificUnitAutomation.kt +++ b/core/src/com/unciv/logic/automation/unit/SpecificUnitAutomation.kt @@ -27,6 +27,10 @@ object SpecificUnitAutomation { } fun automateCitadelPlacer(unit: MapUnit): Boolean { + // Keep at least 2 generals alive + if (unit.hasUnique(UniqueType.StrengthBonusInRadius) + && unit.civ.units.getCivUnits().count { it.hasUnique(UniqueType.StrengthBonusInRadius) } < 3) + return false // try to revenge and capture their tiles val enemyCities = unit.civ.getKnownCivs() .filter { unit.civ.getDiplomacyManager(it).hasModifier(DiplomaticModifiers.StealingTerritory) } diff --git a/core/src/com/unciv/logic/automation/unit/UnitAutomation.kt b/core/src/com/unciv/logic/automation/unit/UnitAutomation.kt index bd7c6b5cba..6fbf473a3e 100644 --- a/core/src/com/unciv/logic/automation/unit/UnitAutomation.kt +++ b/core/src/com/unciv/logic/automation/unit/UnitAutomation.kt @@ -3,6 +3,7 @@ package com.unciv.logic.automation.unit import com.unciv.Constants import com.unciv.UncivGame import com.unciv.logic.automation.Automation +import com.unciv.logic.automation.civilization.NextTurnAutomation import com.unciv.logic.battle.Battle import com.unciv.logic.battle.BattleDamage import com.unciv.logic.battle.CityCombatant @@ -10,7 +11,9 @@ import com.unciv.logic.battle.ICombatant import com.unciv.logic.battle.MapUnitCombatant import com.unciv.logic.battle.TargetHelper import com.unciv.logic.city.City +import com.unciv.logic.civilization.Civilization import com.unciv.logic.civilization.NotificationCategory +import com.unciv.logic.civilization.diplomacy.DiplomacyFlags import com.unciv.logic.civilization.diplomacy.DiplomaticStatus import com.unciv.logic.map.mapunit.MapUnit import com.unciv.logic.map.tile.Tile @@ -247,6 +250,8 @@ object UnitAutomation { if (unit.health < 100 && tryHealUnit(unit)) return + if (tryPrepare(unit)) return + // else, try to go to unreached tiles if (tryExplore(unit)) return @@ -493,6 +498,34 @@ object UnitAutomation { return false } + /** + * Tries to move the unit to the closest city that is close to a target civilization + */ + private fun tryPrepare(unit: MapUnit): Boolean { + val civInfo = unit.civ + + fun hasPreparationFlag(targetCiv: Civilization): Boolean { + val diploManager = civInfo.getDiplomacyManager(targetCiv) + if (diploManager.hasFlag(DiplomacyFlags.Denunciation) + || diploManager.otherCivDiplomacy().hasFlag(DiplomacyFlags.Denunciation)) return true + if (diploManager.hasFlag(DiplomacyFlags.WaryOf) && diploManager.getFlag(DiplomacyFlags.WaryOf) < 0) return true + return false + } + + val hostileCivs = civInfo.getKnownCivs().filter { it.isAtWarWith(civInfo) || hasPreparationFlag(it) } + val citiesToDefend = hostileCivs.mapNotNull { NextTurnAutomation.getClosestCities(civInfo, it) } + .sortedBy { unit.getTile().aerialDistanceTo(it.city1.getCenterTile()) } + + // Move to the closest city with a tile we can enter nearby + for ((city, enemyCity) in citiesToDefend) { + if (unit.getTile().aerialDistanceTo(city.getCenterTile()) <= 2) return true + val tileToMoveTo = city.getCenterTile().getTilesInDistance(2).firstOrNull { unit.movement.canMoveTo(it) && unit.movement.canReach(it) } ?: continue + unit.movement.headTowards(tileToMoveTo) + return true + } + return false + } + private fun tryAccompanySettlerOrGreatPerson(unit: MapUnit): Boolean { val distanceToTiles = unit.movement.getDistanceToTiles() val settlerOrGreatPersonToAccompany = unit.civ.units.getCivUnits() diff --git a/core/src/com/unciv/logic/city/City.kt b/core/src/com/unciv/logic/city/City.kt index 5ca2b5b6ac..7a2e541ae3 100644 --- a/core/src/com/unciv/logic/city/City.kt +++ b/core/src/com/unciv/logic/city/City.kt @@ -88,8 +88,8 @@ class City : IsPartOfGameInfoSerialization, INamed { var updateCitizens = false // flag so that on startTurn() the Governor reassigns Citizens @delegate:Transient - val neighboringCities: List by lazy { - civ.gameInfo.getCities().filter { it != this && it.getCenterTile().aerialDistanceTo(getCenterTile()) <= 8 }.toList() + val neighboringCities: List by lazy { + civ.gameInfo.getCities().filter { it != this && it.getCenterTile().isExplored(civ) && it.getCenterTile().aerialDistanceTo(getCenterTile()) <= 12 }.toList() } var cityAIFocus: String = CityFocus.NoFocus.name diff --git a/core/src/com/unciv/logic/civilization/Civilization.kt b/core/src/com/unciv/logic/civilization/Civilization.kt index d918e10f61..99620268bf 100644 --- a/core/src/com/unciv/logic/civilization/Civilization.kt +++ b/core/src/com/unciv/logic/civilization/Civilization.kt @@ -626,6 +626,8 @@ class Civilization : IsPartOfGameInfoSerialization { fun isAtWar() = diplomacy.values.any { it.diplomaticStatus == DiplomaticStatus.War && !it.otherCiv().isDefeated() } + fun getCivsAtWarWith() = diplomacy.values.filter { it.diplomaticStatus == DiplomaticStatus.War && !it.otherCiv().isDefeated() }.map { it.otherCiv() } + /** * Returns a civilization caption suitable for greetings including player type info: diff --git a/core/src/com/unciv/logic/civilization/diplomacy/DeclareWar.kt b/core/src/com/unciv/logic/civilization/diplomacy/DeclareWar.kt index 5976c4082c..f0977543d9 100644 --- a/core/src/com/unciv/logic/civilization/diplomacy/DeclareWar.kt +++ b/core/src/com/unciv/logic/civilization/diplomacy/DeclareWar.kt @@ -72,11 +72,11 @@ object DeclareWar { otherCiv.popupAlerts.add(PopupAlert(AlertType.WarDeclaration, civInfo.civName)) otherCiv.addNotification("[${civInfo.civName}] has declared war on us!", - NotificationCategory.Diplomacy, NotificationIcon.War, civInfo.civName) + NotificationCategory.Diplomacy, otherCiv.civName, NotificationIcon.War, civInfo.civName) diplomacyManager.getCommonKnownCivsWithSpectators().forEach { - it.addNotification("[${civInfo.civName}] has declared war on [${diplomacyManager.otherCivName}]!", - NotificationCategory.Diplomacy, civInfo.civName, NotificationIcon.War, otherCiv.civName) + it.addNotification("[${civInfo.civName}] has declared war on [${otherCiv.civName}]!", + NotificationCategory.Diplomacy, otherCiv.civName, NotificationIcon.War, civInfo.civName) } } WarType.DefensivePactWar, WarType.CityStateAllianceWar, WarType.JoinWar -> { @@ -86,18 +86,40 @@ object DeclareWar { val defender = if (declareWarReason.warType == WarType.DefensivePactWar) civInfo else otherCiv defender.addNotification("[${agressor.civName}] has joined [${allyCiv.civName}] in the war against us!", - NotificationCategory.Diplomacy, NotificationIcon.War, agressor.civName) + NotificationCategory.Diplomacy, defender.civName, NotificationIcon.War, allyCiv.civName, agressor.civName) agressor.addNotification("We have joined [${allyCiv.civName}] in the war against [${defender.civName}]!", - NotificationCategory.Diplomacy, NotificationIcon.War, defender.civName) + NotificationCategory.Diplomacy, defender.civName, NotificationIcon.War, allyCiv.civName, agressor.civName) diplomacyManager.getCommonKnownCivsWithSpectators().filterNot { it == allyCiv }.forEach { it.addNotification("[${agressor.civName}] has joined [${allyCiv.civName}] in the war against [${defender.civName}]!", - NotificationCategory.Diplomacy, agressor.civName, NotificationIcon.War, defender.civName) + NotificationCategory.Diplomacy, defender.civName, NotificationIcon.War, allyCiv.civName, agressor.civName) } allyCiv.addNotification("[${agressor.civName}] has joined us in the war against [${defender.civName}]!", - NotificationCategory.Diplomacy, agressor.civName, NotificationIcon.War, defender.civName) + NotificationCategory.Diplomacy, defender.civName, NotificationIcon.War, allyCiv.civName, agressor.civName) + } + WarType.TeamWar -> { + val allyCiv = declareWarReason.allyCiv!! + // We only want to send these notifications once, it doesn't matter who sends it though + if (civInfo.gameInfo.civilizations.indexOf(civInfo) > civInfo.gameInfo.civilizations.indexOf(allyCiv)) return + + otherCiv.popupAlerts.add(PopupAlert(AlertType.WarDeclaration, civInfo.civName)) + otherCiv.popupAlerts.add(PopupAlert(AlertType.WarDeclaration, allyCiv.civName)) + + civInfo.addNotification("You and [${allyCiv.civName}] have declared war against [${otherCiv.civName}]!", + NotificationCategory.Diplomacy, otherCiv.civName, NotificationIcon.War, allyCiv.civName, civInfo.civName) + + allyCiv.addNotification("You and [${civInfo.civName}] have declared war against [${otherCiv.civName}]!", + NotificationCategory.Diplomacy, otherCiv.civName, NotificationIcon.War, civInfo.civName, allyCiv.civName) + + civInfo.addNotification("[${civInfo.civName}] and [${allyCiv.civName}] have declared war against us!", + NotificationCategory.Diplomacy, otherCiv.civName, NotificationIcon.War, allyCiv.civName, civInfo.civName) + + diplomacyManager.getCommonKnownCivsWithSpectators().filterNot { it == allyCiv }.forEach { + it.addNotification("[${civInfo.civName}] and [${allyCiv.civName}] have declared war against [${otherCiv.civName}]!", + NotificationCategory.Diplomacy, otherCiv.civName, NotificationIcon.War, allyCiv.civName, civInfo.civName) + } } } } @@ -135,7 +157,7 @@ object DeclareWar { diplomacyManager.updateHasOpenBorders() diplomacyManager.removeModifier(DiplomaticModifiers.YearsOfPeace) - diplomacyManager.setFlag(DiplomacyFlags.DeclinedPeace, 10)/// AI won't propose peace for 10 turns + diplomacyManager.setFlag(DiplomacyFlags.DeclinedPeace, 3)/// AI won't propose peace for 3 turns diplomacyManager.setFlag(DiplomacyFlags.DeclaredWar, 10) // AI won't agree to trade for 10 turns diplomacyManager.removeFlag(DiplomacyFlags.BorderConflict) } @@ -299,8 +321,10 @@ enum class WarType { CityStateAllianceWar, /** A civilization has joined a war through it's defensive pact. */ DefensivePactWar, - /** A civilization has joined a war through a trade. Has the same diplomatic repercussions as direct war.*/ + /** A civilization has joined a war through a trade.*/ JoinWar, + /** Two civilizations are starting a war through a trade. */ + TeamWar, } /** diff --git a/core/src/com/unciv/logic/civilization/diplomacy/DiplomacyManager.kt b/core/src/com/unciv/logic/civilization/diplomacy/DiplomacyManager.kt index 68e9f192e4..7103de80c0 100644 --- a/core/src/com/unciv/logic/civilization/diplomacy/DiplomacyManager.kt +++ b/core/src/com/unciv/logic/civilization/diplomacy/DiplomacyManager.kt @@ -46,6 +46,7 @@ enum class DiplomacyFlags { DeclinedDeclarationOfFriendship, DefensivePact, DeclinedDefensivePact, + DeclinedJoinWarOffer, ResearchAgreement, BorderConflict, SettledCitiesNearUs, diff --git a/core/src/com/unciv/logic/civilization/diplomacy/DiplomacyTurnManager.kt b/core/src/com/unciv/logic/civilization/diplomacy/DiplomacyTurnManager.kt index c6d970560a..7378de3080 100644 --- a/core/src/com/unciv/logic/civilization/diplomacy/DiplomacyTurnManager.kt +++ b/core/src/com/unciv/logic/civilization/diplomacy/DiplomacyTurnManager.kt @@ -135,9 +135,8 @@ object DiplomacyTurnManager { private fun DiplomacyManager.nextTurnFlags() { loop@ for (flag in flagsCountdown.keys.toList()) { - // No need to decrement negative countdown flags: they do not expire - if (flagsCountdown[flag]!! > 0) - flagsCountdown[flag] = flagsCountdown[flag]!! - 1 + // We want negative flags to keep on going negative to keep track of time + flagsCountdown[flag] = flagsCountdown[flag]!! - 1 // If we have uniques that make city states grant military units faster when at war with a common enemy, add higher numbers to this flag if (flag == DiplomacyFlags.ProvideMilitaryUnit.name && civInfo.isMajorCiv() && otherCiv().isCityState() && @@ -207,6 +206,10 @@ object DiplomacyTurnManager { } } + flagsCountdown.remove(flag) + } else if (flag == DiplomacyFlags.WaryOf.name && flagsCountdown[flag]!! < -10) { + // Used in DeclareWarTargetAutomation.declarePlannedWar to count the number of turns preparing + // If we have been preparing for over 10 turns then cancel our attack plan flagsCountdown.remove(flag) } } diff --git a/core/src/com/unciv/logic/civilization/managers/ThreatManager.kt b/core/src/com/unciv/logic/civilization/managers/ThreatManager.kt index 4c3813e2ed..68c89d8812 100644 --- a/core/src/com/unciv/logic/civilization/managers/ThreatManager.kt +++ b/core/src/com/unciv/logic/civilization/managers/ThreatManager.kt @@ -1,8 +1,10 @@ package com.unciv.logic.civilization.managers +import com.unciv.logic.city.City import com.unciv.logic.civilization.Civilization import com.unciv.logic.map.mapunit.MapUnit import com.unciv.logic.map.tile.Tile +import com.unciv.ui.screens.victoryscreen.RankingType /** * Handles optimised operations related to finding threats or allies in an area. @@ -169,6 +171,15 @@ class ThreatManager(val civInfo: Civilization) { return false } + /** @return a sequence of pairs of cities, the first city is our city and the second city is a nearby city that is not from our civ. */ + fun getNeighboringCitiesOfOtherCivs(): Sequence> = civInfo.cities.flatMap { + ourCity -> ourCity.neighboringCities.filter { it.civ != civInfo }.map { Pair(ourCity, it) } + }.asSequence() + + fun getNeighboringCivilizations(): Set = civInfo.cities.flatMap { it.neighboringCities }.filter { it.civ != civInfo && civInfo.knows(it.civ) }.map { it.civ }.toSet() + + fun getCombinedForceOfWarringCivs(): Int = civInfo.getCivsAtWarWith().sumOf { it.getStatForRanking(RankingType.Force) } + fun clear() { distanceToClosestEnemyTiles.clear() } diff --git a/core/src/com/unciv/logic/map/MapPathing.kt b/core/src/com/unciv/logic/map/MapPathing.kt index bc9ba4bd55..37a90a4301 100644 --- a/core/src/com/unciv/logic/map/MapPathing.kt +++ b/core/src/com/unciv/logic/map/MapPathing.kt @@ -1,5 +1,6 @@ package com.unciv.logic.map +import com.unciv.logic.civilization.Civilization import com.unciv.logic.map.mapunit.MapUnit import com.unciv.logic.map.tile.RoadStatus import com.unciv.logic.map.tile.Tile @@ -83,7 +84,7 @@ object MapPathing { while (true) { if (astar.hasEnded()) { // We failed to find a path - Log.debug("getRoadPath failed at AStar search size ${astar.size()}") + Log.debug("getPath failed at AStar search size ${astar.size()}") return null } if (!astar.hasReachedTile(endTile)) { @@ -97,4 +98,35 @@ object MapPathing { } } + /** + * Gets the connection to the end tile. This does not take into account tile movement costs. + * Takes in a civilization instead of a specific unit. + */ + fun getConnection(civ: Civilization, + startTile: Tile, + endTile: Tile, + predicate: (Civilization, Tile) -> Boolean + ): List? { + val astar = AStar( + startTile, + { tile -> predicate(civ, tile) }, + { from, to -> 1f }, + { from, to -> from.aerialDistanceTo(to).toFloat() } + ) + while (true) { + if (astar.hasEnded()) { + // We failed to find a path + Log.debug("getConnection failed at AStar search size ${astar.size()}") + return null + } + if (!astar.hasReachedTile(endTile)) { + astar.nextStep() + continue + } + // Found a path. + return astar.getPathTo(endTile) + .toList() + .reversed() + } + } } diff --git a/core/src/com/unciv/logic/trade/Trade.kt b/core/src/com/unciv/logic/trade/Trade.kt index fb55d4ff59..d162e7e42f 100644 --- a/core/src/com/unciv/logic/trade/Trade.kt +++ b/core/src/com/unciv/logic/trade/Trade.kt @@ -56,7 +56,7 @@ class TradeRequest : IsPartOfGameInfoSerialization { val requestingCivDiploManager = requestingCivInfo.getDiplomacyManager(decliningCiv) // the numbers of the flags (20,5) are the amount of turns to wait until offering again if (trade.ourOffers.all { it.type == TradeType.Luxury_Resource } - && trade.theirOffers.all { it.type==TradeType.Luxury_Resource }) + && trade.theirOffers.all { it.type == TradeType.Luxury_Resource }) requestingCivDiploManager.setFlag(DiplomacyFlags.DeclinedLuxExchange,20) if (trade.ourOffers.any { it.name == Constants.researchAgreement }) requestingCivDiploManager.setFlag(DiplomacyFlags.DeclinedResearchAgreement,20) @@ -64,6 +64,10 @@ class TradeRequest : IsPartOfGameInfoSerialization { requestingCivDiploManager.setFlag(DiplomacyFlags.DeclinedDefensivePact,20) if (trade.ourOffers.any { it.name == Constants.openBorders }) requestingCivDiploManager.setFlag(DiplomacyFlags.DeclinedOpenBorders, if (decliningCiv.isAI()) 10 else 20) + if (trade.theirOffers.any { it.type == TradeType.WarDeclaration }) + requestingCivDiploManager.setFlag(DiplomacyFlags.DeclinedJoinWarOffer, if (decliningCiv.isAI()) 10 else 20) + if (trade.ourOffers.any { it.type == TradeType.WarDeclaration }) + requestingCivDiploManager.otherCivDiplomacy().setFlag(DiplomacyFlags.DeclinedJoinWarOffer, if (decliningCiv.isAI()) 10 else 20) if (trade.isPeaceTreaty()) requestingCivDiploManager.setFlag(DiplomacyFlags.DeclinedPeace, 5) diff --git a/core/src/com/unciv/logic/trade/TradeEvaluation.kt b/core/src/com/unciv/logic/trade/TradeEvaluation.kt index 7ab3b439c3..d2cf2efdd0 100644 --- a/core/src/com/unciv/logic/trade/TradeEvaluation.kt +++ b/core/src/com/unciv/logic/trade/TradeEvaluation.kt @@ -1,9 +1,9 @@ package com.unciv.logic.trade import com.unciv.Constants -import com.unciv.logic.automation.Automation -import com.unciv.logic.automation.ThreatLevel import com.unciv.logic.automation.civilization.DiplomacyAutomation +import com.unciv.logic.automation.civilization.MotivationToAttackAutomation +import com.unciv.logic.automation.civilization.DeclareWarPlanEvaluator import com.unciv.logic.city.City import com.unciv.logic.civilization.Civilization import com.unciv.logic.civilization.diplomacy.DiplomacyFlags @@ -54,7 +54,7 @@ class TradeEvaluation { TradeType.Strategic_Resource -> hasResource(tradeOffer) TradeType.Technology -> true TradeType.Introduction -> !tradePartner.knows(tradeOffer.name) // You can't introduce them to someone they already know! - TradeType.WarDeclaration -> true + TradeType.WarDeclaration -> !offerer.isAtWarWith(offerer.gameInfo.getCivilization(tradeOffer.name)) TradeType.City -> offerer.cities.any { it.id == tradeOffer.name } } } @@ -72,9 +72,9 @@ class TradeEvaluation { val sumOfTheirOffers = trade.theirOffers.asSequence() .filter { it.type != TradeType.Treaty } // since treaties should only be evaluated once for 2 sides - .map { evaluateBuyCostWithInflation(it, evaluator, tradePartner) }.sum() + .map { evaluateBuyCostWithInflation(it, evaluator, tradePartner, trade) }.sum() - var sumOfOurOffers = trade.ourOffers.sumOf { evaluateSellCostWithInflation(it, evaluator, tradePartner) } + var sumOfOurOffers = trade.ourOffers.sumOf { evaluateSellCostWithInflation(it, evaluator, tradePartner, trade) } val relationshipLevel = evaluator.getDiplomacyManager(tradePartner).relationshipIgnoreAfraid() // If we're making a peace treaty, don't try to up the bargain for people you don't like. @@ -94,13 +94,16 @@ class TradeEvaluation { return sumOfTheirOffers - sumOfOurOffers } - fun evaluateBuyCostWithInflation(offer: TradeOffer, civInfo: Civilization, tradePartner: Civilization): Int { + fun evaluateBuyCostWithInflation(offer: TradeOffer, civInfo: Civilization, tradePartner: Civilization, trade: Trade): Int { if (offer.type != TradeType.Gold && offer.type != TradeType.Gold_Per_Turn) - return (evaluateBuyCost(offer, civInfo, tradePartner) / getGoldInflation(civInfo)).toInt() - return evaluateBuyCost(offer, civInfo, tradePartner) + return (evaluateBuyCost(offer, civInfo, tradePartner, trade) / getGoldInflation(civInfo)).toInt() + return evaluateBuyCost(offer, civInfo, tradePartner, trade) } - private fun evaluateBuyCost(offer: TradeOffer, civInfo: Civilization, tradePartner: Civilization): Int { + /** + * How much their offer is worth to us in gold + */ + private fun evaluateBuyCost(offer: TradeOffer, civInfo: Civilization, tradePartner: Civilization, trade: Trade): Int { when (offer.type) { TradeType.Gold -> return offer.amount // GPT loses 1% of value for each 'future' turn, meaning: gold now is more valuable than gold in the future @@ -154,15 +157,16 @@ class TradeEvaluation { TradeType.Introduction -> return introductionValue(civInfo.gameInfo.ruleset) TradeType.WarDeclaration -> { val civToDeclareWarOn = civInfo.gameInfo.getCivilization(offer.name) - val threatToThem = Automation.threatAssessment(civInfo, civToDeclareWarOn) - - return if (!civInfo.isAtWarWith(civToDeclareWarOn)) 0 // why should we pay you to go fight someone...? - else when (threatToThem) { - ThreatLevel.VeryLow -> 0 - ThreatLevel.Low -> 0 - ThreatLevel.Medium -> 100 - ThreatLevel.High -> 500 - ThreatLevel.VeryHigh -> 1000 + if (trade.theirOffers.any { it.type == TradeType.WarDeclaration && it.name == offer.name } + && trade.ourOffers.any {it.type == TradeType.WarDeclaration && it.name == offer.name}) { + // Team war is handled in the selling method + return 0 + } else if (civInfo.isAtWarWith(civToDeclareWarOn)) { + // We shouldn't require them to pay us to join our war (no negative values) + return (20 * DeclareWarPlanEvaluator.evaluateJoinOurWarPlan(civInfo, civToDeclareWarOn, tradePartner, null)).coerceAtLeast(0) + } else { + // Why should we pay you to go fight someone else? + return 0 } } TradeType.City -> { @@ -208,13 +212,13 @@ class TradeEvaluation { } - fun evaluateSellCostWithInflation(offer: TradeOffer, civInfo: Civilization, tradePartner: Civilization): Int { + fun evaluateSellCostWithInflation(offer: TradeOffer, civInfo: Civilization, tradePartner: Civilization, trade: Trade): Int { if (offer.type != TradeType.Gold && offer.type != TradeType.Gold_Per_Turn) - return (evaluateSellCost(offer, civInfo, tradePartner) / getGoldInflation(civInfo)).toInt() - return evaluateSellCost(offer, civInfo, tradePartner) + return (evaluateSellCost(offer, civInfo, tradePartner, trade) / getGoldInflation(civInfo)).toInt() + return evaluateSellCost(offer, civInfo, tradePartner, trade) } - private fun evaluateSellCost(offer: TradeOffer, civInfo: Civilization, tradePartner: Civilization): Int { + private fun evaluateSellCost(offer: TradeOffer, civInfo: Civilization, tradePartner: Civilization, trade: Trade): Int { when (offer.type) { TradeType.Gold -> return offer.amount TradeType.Gold_Per_Turn -> return offer.amount * offer.duration @@ -273,13 +277,17 @@ class TradeEvaluation { TradeType.Introduction -> return introductionValue(civInfo.gameInfo.ruleset) TradeType.WarDeclaration -> { val civToDeclareWarOn = civInfo.gameInfo.getCivilization(offer.name) - - return when (Automation.threatAssessment(civInfo, civToDeclareWarOn)) { - ThreatLevel.VeryLow -> 100 - ThreatLevel.Low -> 250 - ThreatLevel.Medium -> 500 - ThreatLevel.High -> 1000 - ThreatLevel.VeryHigh -> 10000 // no way boyo + if (trade.theirOffers.any { it.type == TradeType.WarDeclaration && it.name == offer.name } + && trade.ourOffers.any {it.type == TradeType.WarDeclaration && it.name == offer.name}) { + // Only accept if the war will benefit us, or if they pay us enough + // We shouldn't want to pay them for us to declare war (no negative values) + return (-20 * DeclareWarPlanEvaluator.evaluateTeamWarPlan(civInfo, civToDeclareWarOn, tradePartner, null)).coerceAtLeast(0) + } else if (tradePartner.isAtWarWith(civToDeclareWarOn)) { + // We might want them to pay us to join them in war (no negative values) + return (-20 * DeclareWarPlanEvaluator.evaluateJoinWarPlan(civInfo, civToDeclareWarOn, tradePartner, null)).coerceAtLeast(0) + } else { + // We might want them to pay us to declare war (no negative values) + return (-25 * DeclareWarPlanEvaluator.evaluateDeclareWarPlan(civInfo, civToDeclareWarOn, null)).coerceAtLeast(0) } } @@ -319,7 +327,7 @@ class TradeEvaluation { // Goes from 1 at GPT = 0 to .834 at GPT = 100, .296 at GPT = 1000 and 0.116 at GPT = 10000 // The current value of gold will never go below 10% or the .1f that it is set to // So this does not scale off to infinity - return modifier / (goldPerTurn.pow(1.2).coerceAtLeast(0.0) + (1.11f * modifier)) + .1f + return modifier / (goldPerTurn.coerceAtLeast(1.0).pow(1.2) + (1.11 * modifier)) + .1 } /** This code returns a positive value if the city is significantly far away from the capital @@ -333,20 +341,21 @@ class TradeEvaluation { return (distanceToCapital - 500) * civInfo.getEraNumber() } - fun evaluatePeaceCostForThem(ourCivilization: Civilization, otherCivilization: Civilization): Int { - val ourCombatStrength = ourCivilization.getStatForRanking(RankingType.Force) - val theirCombatStrength = otherCivilization.getStatForRanking(RankingType.Force) + fun evaluatePeaceCostForThem(ourCiv: Civilization, otherCiv: Civilization): Int { + val ourCombatStrength = ourCiv.getStatForRanking(RankingType.Force) + val theirCombatStrength = otherCiv.getStatForRanking(RankingType.Force) if (ourCombatStrength * 1.5f >= theirCombatStrength && theirCombatStrength * 1.5f >= ourCombatStrength) return 0 // we're roughly equal, there's no huge power imbalance if (ourCombatStrength > theirCombatStrength) { + if (MotivationToAttackAutomation.hasAtLeastMotivationToAttack(ourCiv, otherCiv, 0) <= 0) return 0 val absoluteAdvantage = ourCombatStrength - theirCombatStrength val percentageAdvantage = absoluteAdvantage / theirCombatStrength.toFloat() // We don't add the same constraint here. We should not make peace easily if we're // heavily advantaged. - val totalAdvantage = (absoluteAdvantage * percentageAdvantage).toInt() * 10 + val totalAdvantage = (absoluteAdvantage * percentageAdvantage).toInt() if(totalAdvantage < 0) //May be a negative number if strength disparity is such that it leads to integer overflow return 10000 //in that rare case, the AI would accept peace against a defeated foe. - return totalAdvantage + return (totalAdvantage / (getGoldInflation(otherCiv) * 2)).toInt() } else { // This results in huge values for large power imbalances. However, we should not give // up everything just because there is a big power imbalance. There's a better chance to @@ -371,7 +380,7 @@ class TradeEvaluation { // (stats ~30 each) val absoluteAdvantage = theirCombatStrength - ourCombatStrength val percentageAdvantage = absoluteAdvantage / ourCombatStrength.toFloat() - return -min((absoluteAdvantage * percentageAdvantage).toInt() * 10, 100000) + return -(absoluteAdvantage * percentageAdvantage / (getGoldInflation(ourCiv) * 2)).coerceAtMost(10000.0).toInt() } } diff --git a/core/src/com/unciv/logic/trade/TradeLogic.kt b/core/src/com/unciv/logic/trade/TradeLogic.kt index d00d7d9d94..575331e1be 100644 --- a/core/src/com/unciv/logic/trade/TradeLogic.kt +++ b/core/src/com/unciv/logic/trade/TradeLogic.kt @@ -139,7 +139,12 @@ class TradeLogic(val ourCivilization: Civilization, val otherCivilization: Civil TradeType.Introduction -> to.diplomacyFunctions.makeCivilizationsMeet(to.gameInfo.getCivilization(offer.name)) TradeType.WarDeclaration -> { val nameOfCivToDeclareWarOn = offer.name - from.getDiplomacyManager(nameOfCivToDeclareWarOn).declareWar(DeclareWarReason(WarType.JoinWar, to)) + val warType = if (currentTrade.theirOffers.any { it.type == TradeType.WarDeclaration && it.name == nameOfCivToDeclareWarOn } + && currentTrade.ourOffers.any {it.type == TradeType.WarDeclaration && it.name == nameOfCivToDeclareWarOn}) + WarType.TeamWar + else WarType.JoinWar + + from.getDiplomacyManager(nameOfCivToDeclareWarOn).declareWar(DeclareWarReason(warType, to)) } else -> {} }