diff --git a/core/src/com/unciv/logic/battle/BattleConstants.kt b/core/src/com/unciv/logic/battle/BattleConstants.kt new file mode 100644 index 0000000000..321c6a80ed --- /dev/null +++ b/core/src/com/unciv/logic/battle/BattleConstants.kt @@ -0,0 +1,15 @@ +package com.unciv.logic.battle + +object BattleConstants { + //https://www.carlsguides.com/strategy/civilization5/war/combatbonuses.php + const val LANDING_MALUS = -50 + const val BOARDING_MALUS = -50 + const val ATTACKING_ACROSS_RIVER_MALUS = -20 + const val BASE_FLANKING_BONUS = 10f + const val MISSING_RESOURCES_MALUS = -25 + const val EMBARKED_DEFENCE_BONUS = 100 + const val FORTIFICATION_BONUS = 20 + const val DAMAGE_REDUCTION_WOUNDED_UNIT_RATIO_PERCENTAGE = 300f + const val DAMAGE_TO_CIVILIAN_UNIT = 40 + +} diff --git a/core/src/com/unciv/logic/battle/BattleDamage.kt b/core/src/com/unciv/logic/battle/BattleDamage.kt index f76f25f727..e66aa8761f 100644 --- a/core/src/com/unciv/logic/battle/BattleDamage.kt +++ b/core/src/com/unciv/logic/battle/BattleDamage.kt @@ -30,7 +30,7 @@ object BattleDamage { return "$source - $conditionalsText" } - private fun getGeneralModifiers(combatant: ICombatant, enemy: ICombatant, combatAction: CombatAction, tileToAttackFrom:Tile): Counter { + private fun getGeneralModifiers(combatant: ICombatant, enemy: ICombatant, combatAction: CombatAction, tileToAttackFrom: Tile): Counter { val modifiers = Counter() val civInfo = combatant.getCivInfo() @@ -43,38 +43,9 @@ object BattleDamage { if (combatant is MapUnitCombatant) { - for (unique in combatant.getMatchingUniques(UniqueType.Strength, conditionalState, true)) { - modifiers.add(getModifierStringFromUnique(unique), unique.params[0].toInt()) - } - for (unique in combatant.getMatchingUniques( - UniqueType.StrengthNearCapital, conditionalState, true - )) { - if (civInfo.cities.isEmpty() || civInfo.getCapital() == null) break - val distance = - combatant.getTile().aerialDistanceTo(civInfo.getCapital()!!.getCenterTile()) - // https://steamcommunity.com/sharedfiles/filedetails/?id=326411722#464287 - val effect = unique.params[0].toInt() - 3 * distance - if (effect <= 0) continue - modifiers.add("${unique.sourceObjectName} (${unique.sourceObjectType})", effect) - } + addUnitUniqueModifiers(combatant, enemy, conditionalState, tileToAttackFrom, modifiers) - //https://www.carlsguides.com/strategy/civilization5/war/combatbonuses.php - var adjacentUnits = combatant.getTile().neighbors.flatMap { it.getUnits() } - if (enemy.getTile() !in combatant.getTile().neighbors && tileToAttackFrom in combatant.getTile().neighbors - && enemy is MapUnitCombatant) - adjacentUnits += sequenceOf(enemy.unit) - val strengthMalus = adjacentUnits.filter { it.civ.isAtWarWith(civInfo) } - .flatMap { it.getMatchingUniques(UniqueType.StrengthForAdjacentEnemies) } - .filter { combatant.matchesCategory(it.params[1]) && combatant.getTile().matchesFilter(it.params[2]) } - .maxByOrNull { it.params[0] } - if (strengthMalus != null) { - modifiers.add("Adjacent enemy units", strengthMalus.params[0].toInt()) - } - - val civResources = civInfo.getCivResourcesByName() - for (resource in combatant.unit.baseUnit.getResourceRequirementsPerTurn().keys) - if (civResources[resource]!! < 0 && !civInfo.isBarbarian()) - modifiers["Missing resource"] = -25 //todo ModConstants + addResourceLackingMalus(combatant, modifiers) val (greatGeneralName, greatGeneralBonus) = GreatGeneralImplementation.getGreatGeneralBonus(combatant, enemy, combatAction) if (greatGeneralBonus != 0) @@ -88,11 +59,6 @@ object BattleDamage { if (stackedUnitsBonus > 0) modifiers["Stacked with [${unique.params[1]}]"] = stackedUnitsBonus } - - if (enemy.getCivInfo().isCityState() - && civInfo.hasUnique(UniqueType.StrengthBonusVsCityStates) - ) - modifiers["vs [City-States]"] = 30 } else if (combatant is CityCombatant) { for (unique in combatant.city.getMatchingUniques(UniqueType.StrengthForCities, conditionalState)) { modifiers.add(getModifierStringFromUnique(unique), unique.params[0].toInt()) @@ -107,6 +73,58 @@ object BattleDamage { return modifiers } + private fun addUnitUniqueModifiers(combatant: MapUnitCombatant, enemy: ICombatant, conditionalState: StateForConditionals, + tileToAttackFrom: Tile, modifiers: Counter) { + val civInfo = combatant.getCivInfo() + + for (unique in combatant.getMatchingUniques(UniqueType.Strength, conditionalState, true)) { + modifiers.add(getModifierStringFromUnique(unique), unique.params[0].toInt()) + } + + // e.g., Mehal Sefari https://civilization.fandom.com/wiki/Mehal_Sefari_(Civ5) + for (unique in combatant.getMatchingUniques( + UniqueType.StrengthNearCapital, conditionalState, true + )) { + if (civInfo.cities.isEmpty() || civInfo.getCapital() == null) break + val distance = + combatant.getTile().aerialDistanceTo(civInfo.getCapital()!!.getCenterTile()) + // https://steamcommunity.com/sharedfiles/filedetails/?id=326411722#464287 + val effect = unique.params[0].toInt() - 3 * distance + if (effect > 0) + modifiers.add("${unique.sourceObjectName} (${unique.sourceObjectType})", effect) + } + + //https://www.carlsguides.com/strategy/civilization5/war/combatbonuses.php + var adjacentUnits = combatant.getTile().neighbors.flatMap { it.getUnits() } + if (enemy.getTile() !in combatant.getTile().neighbors && tileToAttackFrom in combatant.getTile().neighbors + && enemy is MapUnitCombatant + ) + adjacentUnits += sequenceOf(enemy.unit) + + // e.g., Maori Warrior - https://civilization.fandom.com/wiki/Maori_Warrior_(Civ5) + val strengthMalus = adjacentUnits.filter { it.civ.isAtWarWith(combatant.getCivInfo()) } + .flatMap { it.getMatchingUniques(UniqueType.StrengthForAdjacentEnemies) } + .filter { combatant.matchesCategory(it.params[1]) && combatant.getTile().matchesFilter(it.params[2]) } + .maxByOrNull { it.params[0] } + if (strengthMalus != null) { + modifiers.add("Adjacent enemy units", strengthMalus.params[0].toInt()) + } + + // e.g., Mongolia - https://civilization.fandom.com/wiki/Mongolian_(Civ5) + if (enemy.getCivInfo().isCityState() + && civInfo.hasUnique(UniqueType.StrengthBonusVsCityStates) + ) + modifiers["vs [City-States]"] = 30 + } + + private fun addResourceLackingMalus(combatant: MapUnitCombatant, modifiers: Counter) { + val civInfo = combatant.getCivInfo() + val civResources = civInfo.getCivResourcesByName() + for (resource in combatant.unit.baseUnit.getResourceRequirementsPerTurn().keys) + if (civResources[resource]!! < 0 && !civInfo.isBarbarian()) + modifiers["Missing resource"] = BattleConstants.MISSING_RESOURCES_MALUS + } + fun getAttackModifiers( attacker: ICombatant, defender: ICombatant, tileToAttackFrom: Tile @@ -114,18 +132,9 @@ object BattleDamage { val modifiers = getGeneralModifiers(attacker, defender, CombatAction.Attack, tileToAttackFrom) if (attacker is MapUnitCombatant) { - if (attacker.unit.isEmbarked() && defender.getTile().isLand - && !attacker.unit.hasUnique(UniqueType.AttackAcrossCoast)) - modifiers["Landing"] = -50 - // Land Melee Unit attacking to Water - if (attacker.unit.type.isLandUnit() && !attacker.getTile().isWater && attacker.isMelee() && defender.getTile().isWater - && !attacker.unit.hasUnique(UniqueType.AttackAcrossCoast)) - modifiers["Boarding"] = -50 - // Melee Unit on water attacking to Land (not City) unit - if (!attacker.unit.type.isAirUnit() && attacker.isMelee() && attacker.getTile().isWater && !defender.getTile().isWater - && !attacker.unit.hasUnique(UniqueType.AttackAcrossCoast) && !defender.isCity()) - modifiers["Landing"] = -50 + addTerrainAttackModifiers(attacker, defender, tileToAttackFrom, modifiers) + // Air unit attacking with Air Sweep if (attacker.unit.isPreparingAirSweep()) modifiers.add(getAirSweepAttackModifiers(attacker)) @@ -137,24 +146,14 @@ object BattleDamage { && MapUnitCombatant(it.militaryUnit!!).isMelee() } if (numberOfOtherAttackersSurroundingDefender > 0) { - var flankingBonus = 10f //https://www.carlsguides.com/strategy/civilization5/war/combatbonuses.php + var flankingBonus = BattleConstants.BASE_FLANKING_BONUS + + // e.g., Discipline policy - https://civilization.fandom.com/wiki/Discipline_(Civ5) for (unique in attacker.unit.getMatchingUniques(UniqueType.FlankAttackBonus, checkCivInfoUniques = true)) flankingBonus *= unique.params[0].toPercent() modifiers["Flanking"] = (flankingBonus * numberOfOtherAttackersSurroundingDefender).toInt() } - if (tileToAttackFrom.aerialDistanceTo(defender.getTile()) == 1 && - tileToAttackFrom.isConnectedByRiver(defender.getTile()) && - !attacker.unit.hasUnique(UniqueType.AttackAcrossRiver) - ) { - if (!tileToAttackFrom - .hasConnection(attacker.getCivInfo()) // meaning, the tiles are not road-connected for this civ - || !defender.getTile().hasConnection(attacker.getCivInfo()) - || !attacker.getCivInfo().tech.roadsConnectAcrossRivers - ) { - modifiers["Across river"] = -20 - } - } } } @@ -162,6 +161,41 @@ object BattleDamage { return modifiers } + private fun addTerrainAttackModifiers(attacker: MapUnitCombatant, defender: ICombatant, + tileToAttackFrom: Tile, modifiers: Counter) { + if (attacker.unit.isEmbarked() && defender.getTile().isLand + && !attacker.unit.hasUnique(UniqueType.AttackAcrossCoast) + ) + modifiers["Landing"] = BattleConstants.LANDING_MALUS + + // Land Melee Unit attacking to Water + if (attacker.unit.type.isLandUnit() && !attacker.getTile().isWater && attacker.isMelee() && defender.getTile().isWater + && !attacker.unit.hasUnique(UniqueType.AttackAcrossCoast) + ) + modifiers["Boarding"] = BattleConstants.BOARDING_MALUS + + // Melee Unit on water attacking to Land (not City) unit + if (!attacker.unit.type.isAirUnit() && attacker.isMelee() && attacker.getTile().isWater && !defender.getTile().isWater + && !attacker.unit.hasUnique(UniqueType.AttackAcrossCoast) && !defender.isCity() + ) + modifiers["Landing"] = BattleConstants.LANDING_MALUS + + if (isMeleeAttackingAcrossRiverWithNoBridge(attacker, tileToAttackFrom, defender)) + modifiers["Across river"] = BattleConstants.ATTACKING_ACROSS_RIVER_MALUS + } + + private fun isMeleeAttackingAcrossRiverWithNoBridge(attacker: MapUnitCombatant, tileToAttackFrom: Tile, defender: ICombatant) = ( + attacker.isMelee() + && + (tileToAttackFrom.aerialDistanceTo(defender.getTile()) == 1 + && tileToAttackFrom.isConnectedByRiver(defender.getTile()) + && !attacker.unit.hasUnique(UniqueType.AttackAcrossRiver)) + && + (!tileToAttackFrom.hasConnection(attacker.getCivInfo()) // meaning, the tiles are not road-connected for this civ + || !defender.getTile().hasConnection(attacker.getCivInfo()) + || !attacker.getCivInfo().tech.roadsConnectAcrossRivers) + ) + fun getAirSweepAttackModifiers( attacker: ICombatant ): Counter { @@ -186,7 +220,7 @@ object BattleDamage { // embarked units get no defensive modifiers apart from this unique if (defender.unit.hasUnique(UniqueType.DefenceBonusWhenEmbarked, checkCivInfoUniques = true) ) - modifiers["Embarked"] = 100 + modifiers["Embarked"] = BattleConstants.EMBARKED_DEFENCE_BONUS return modifiers } @@ -199,7 +233,7 @@ object BattleDamage { if (defender.unit.isFortified()) - modifiers["Fortification"] = 20 * defender.unit.getFortificationTurns() + modifiers["Fortification"] = BattleConstants.FORTIFICATION_BONUS * defender.unit.getFortificationTurns() } return modifiers @@ -217,7 +251,7 @@ object BattleDamage { || combatant.unit.hasUnique(UniqueType.NoDamagePenalty, checkCivInfoUniques = true) ) 1f // Each 3 points of health reduces damage dealt by 1% - else 1 - (100 - combatant.getHealth()) / 300f + else 1 - (100 - combatant.getHealth()) / BattleConstants.DAMAGE_REDUCTION_WOUNDED_UNIT_RATIO_PERCENTAGE } @@ -264,7 +298,7 @@ object BattleDamage { randomnessFactor: Float = Random(defender.getCivInfo().gameInfo.turns * defender.getTile().position.hashCode().toLong()).nextFloat() , ): Int { - if (defender.isCivilian()) return 40 + if (defender.isCivilian()) return BattleConstants.DAMAGE_TO_CIVILIAN_UNIT val ratio = getAttackingStrength(attacker, defender, tileToAttackFrom) / getDefendingStrength(attacker, defender, tileToAttackFrom) return (damageModifier(ratio, false, randomnessFactor) * getHealthDependantDamageRatio(attacker)).roundToInt() diff --git a/tests/src/com/unciv/logic/battle/BattleDamageTest.kt b/tests/src/com/unciv/logic/battle/BattleDamageTest.kt new file mode 100644 index 0000000000..f3ba3c5a4c --- /dev/null +++ b/tests/src/com/unciv/logic/battle/BattleDamageTest.kt @@ -0,0 +1,158 @@ +package com.unciv.logic.battle + +import com.badlogic.gdx.math.Vector2 +import com.unciv.logic.civilization.Civilization +import com.unciv.logic.civilization.managers.TurnManager +import com.unciv.logic.map.mapunit.MapUnit +import com.unciv.logic.map.tile.Tile +import com.unciv.testing.GdxTestRunner +import com.unciv.uniques.TestGame +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(GdxTestRunner::class) +class BattleDamageTest { + private lateinit var attackerCiv: Civilization + private lateinit var defenderCiv: Civilization + + private lateinit var defaultAttackerTile: Tile + private lateinit var defaultDefenderTile: Tile + + private lateinit var defaultAttackerUnit: MapUnit + private lateinit var defaultDefenderUnit: MapUnit + + private val testGame = TestGame() + + @Before + fun setUp() { + testGame.makeHexagonalMap(4) + attackerCiv = testGame.addCiv() + defenderCiv = testGame.addCiv() + + defaultAttackerTile = testGame.getTile(Vector2(1f, 1f)) + defaultAttackerUnit = testGame.addUnit("Warrior", attackerCiv, defaultAttackerTile) + defaultDefenderTile = testGame.getTile(Vector2(0f, 1f)) + defaultDefenderUnit = testGame.addUnit("Warrior", defenderCiv, defaultDefenderTile) + } + + @Test + fun `should retrieve modifiers from policies`() { + // given + val policy = testGame.createPolicy("[+25]% Strength ") + attackerCiv.policies.adopt(policy, true) + + // when + val attackModifiers = BattleDamage.getAttackModifiers(MapUnitCombatant(defaultAttackerUnit), MapUnitCombatant(defaultDefenderUnit), defaultAttackerTile) + + // then + assertEquals(1, attackModifiers.size) + assertEquals(25, attackModifiers.sumValues()) + } + + @Test + fun `should retrieve modifiers from buldings`() { + // given + val building = testGame.createBuilding("[+15]% Strength ") + val attackerCity = testGame.addCity(attackerCiv, testGame.getTile(Vector2.Zero)) + attackerCity.cityConstructions.addBuilding(building.name) + + // when + val attackModifiers = BattleDamage.getAttackModifiers(MapUnitCombatant(defaultAttackerUnit), MapUnitCombatant(defaultDefenderUnit), defaultAttackerTile) + + // then + assertEquals(1, attackModifiers.size) + assertEquals(15, attackModifiers.sumValues()) + } + + @Test + fun `should retrieve modifiers from national abilities`() { + // given + val civ = testGame.addCiv("[+10]% Strength ") // i.e., Persia national ability + civ.goldenAges.enterGoldenAge(2) + val attackerTile = testGame.getTile(Vector2.Zero) + val attackerUnit = testGame.addUnit("Warrior", civ, attackerTile) + + // when + val attackModifiers = BattleDamage.getAttackModifiers(MapUnitCombatant(attackerUnit), MapUnitCombatant(defaultDefenderUnit), attackerTile) + + // then + assertEquals(1, attackModifiers.size) + assertEquals(10, attackModifiers.sumValues()) + } + + @Test + fun `should retrieve modifiers from lack of strategic resource`() { + // given + defaultAttackerTile.militaryUnit = null // otherwise we'll also get a flanking bonus + val attackerTile = testGame.getTile(Vector2.Zero) + val attackerUnit = testGame.addUnit("Horseman", attackerCiv, attackerTile) + + // when + val attackModifiers = BattleDamage.getAttackModifiers(MapUnitCombatant(attackerUnit), MapUnitCombatant(defaultDefenderUnit), attackerTile) + + // then + assertEquals(1, attackModifiers.size) + assertEquals(BattleConstants.MISSING_RESOURCES_MALUS, attackModifiers.sumValues()) + } + + @Test + fun `should retrieve attacking flank bonus modifiers`() { + // given + val flankingAttackerTile = testGame.getTile(Vector2.Zero) + testGame.addUnit("Warrior", attackerCiv, flankingAttackerTile) + + // when + val attackModifiers = BattleDamage.getAttackModifiers(MapUnitCombatant(defaultAttackerUnit), MapUnitCombatant(defaultDefenderUnit), defaultAttackerTile) + + // then + assertEquals(1, attackModifiers.size) + assertEquals(BattleConstants.BASE_FLANKING_BONUS.toInt(), attackModifiers.sumValues()) + } + + @Test + fun `should retrieve defence fortification modifiers`() { + // given + defaultDefenderUnit.currentMovement = 2f // base warrior max movement points + defaultDefenderUnit.fortify() + TurnManager(defenderCiv).endTurn() + + // when + val defenceModifiers = BattleDamage.getDefenceModifiers(MapUnitCombatant(defaultAttackerUnit), MapUnitCombatant(defaultDefenderUnit), defaultAttackerTile) + + // then + assertEquals(1, defenceModifiers.size) + assertEquals(BattleConstants.FORTIFICATION_BONUS, defenceModifiers.sumValues()) + } + + @Test + fun `should retrieve defence terrain modifiers`() { + // given + testGame.setTileFeatures(defaultDefenderTile.position, "Hill") + + // when + val defenceModifiers = BattleDamage.getDefenceModifiers(MapUnitCombatant(defaultAttackerUnit), MapUnitCombatant(defaultDefenderUnit), defaultAttackerTile) + + // then + assertEquals(1, defenceModifiers.size) + assertEquals(25, defenceModifiers.sumValues()) + } + + @Test + fun `should not retrieve defence terrain modifiers when unit doesn't get them`() { + // given + val defenderTile = testGame.getTile(Vector2.Zero) + testGame.setTileFeatures(defenderTile.position, "Hill") + defenderCiv.resourceStockpiles.add("Horses", 1) // no resource penalty + val defenderUnit = testGame.addUnit("Horseman", defenderCiv, defenderTile) + + // when + val defenceModifiers = BattleDamage.getDefenceModifiers(MapUnitCombatant(defaultAttackerUnit), MapUnitCombatant(defenderUnit), defaultAttackerTile) + + // then + assertTrue(defenceModifiers.isEmpty()) + assertEquals(0, defenceModifiers.sumValues()) + } +}