Refactor BattleDamage object and test it (#9992)

* 💡 add some comments providing examples

* ♻ refactor getGeneralModifiers to increase readability

* ♻ refactor getAttackModifiers to increase readability

* 🏗 move constants expressed as magic number to separated class to increase maintainability and expressivity

* ♻ invert condition to remove continue statement and simplify code

* 💚 add some tests for battle damage class
This commit is contained in:
Framonti
2023-08-30 22:37:10 +02:00
committed by GitHub
parent 96e96cf449
commit 7952366afb
3 changed files with 272 additions and 65 deletions

View File

@ -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
}

View File

@ -30,7 +30,7 @@ object BattleDamage {
return "$source - $conditionalsText"
}
private fun getGeneralModifiers(combatant: ICombatant, enemy: ICombatant, combatAction: CombatAction, tileToAttackFrom:Tile): Counter<String> {
private fun getGeneralModifiers(combatant: ICombatant, enemy: ICombatant, combatAction: CombatAction, tileToAttackFrom: Tile): Counter<String> {
val modifiers = Counter<String>()
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<String>) {
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<String>) {
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<String>) {
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<String> {
@ -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()

View File

@ -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 <when attacking> <for [Military] units>")
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 <for [Military] units>")
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 <for [All] units> <during a Golden Age>") // 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())
}
}