Skip to content

Commit

Permalink
Great General typed uniques and improved moddability (#6818)
Browse files Browse the repository at this point in the history
* Great General UniqueTyped and improved moddability - draft

* Great General UniqueTyped and improved moddability - reviews

* Great General UniqueTyped and improved moddability - no reason not to cache another

* Integration with JackRainy's solution

* Integration with JackRainy's solution - part 2

* Revert of maxGreatGeneralBonusRadius logic

* Minor refactoring

* Code review: minor refactoring

* Keep the warning for the modders about the obsolete unique

* Code review: Better wording for the unique

Co-authored-by: SomeTroglodyte <63000004+SomeTroglodyte@users.noreply.github.com>
  • Loading branch information
JackRainy and SomeTroglodyte committed May 17, 2022
1 parent 7079619 commit 4986505
Show file tree
Hide file tree
Showing 16 changed files with 189 additions and 80 deletions.
4 changes: 2 additions & 2 deletions android/assets/jsons/Civ V - Gods & Kings/Units.json
Expand Up @@ -1634,7 +1634,7 @@
{
"name": "Great General",
"unitType": "Civilian",
"uniques": ["Can start an [8]-turn golden age", "Bonus for units in 2 tile radius 15%", "Can construct [Citadel]",
"uniques": ["Can start an [8]-turn golden age", "[+15]% Strength bonus for [Military] units within [2] tiles", "Can construct [Citadel]",
"Great Person - [War]", "Unbuildable", "Uncapturable"],
"movement": 2
},
Expand All @@ -1643,7 +1643,7 @@
"unitType": "Civilian",
"uniqueTo": "Mongolia",
"replaces": "Great General",
"uniques": ["Can start an [8]-turn golden age","Bonus for units in 2 tile radius 15%",
"uniques": ["Can start an [8]-turn golden age","[+15]% Strength bonus for [Military] units within [2] tiles",
"All adjacent units heal [+15] HP when healing", "[+15] HP when healing", "Can construct [Citadel]", "Great Person - [War]", "Unbuildable", "Uncapturable"],
"movement": 5
},
Expand Down
4 changes: 2 additions & 2 deletions android/assets/jsons/Civ V - Vanilla/Units.json
Expand Up @@ -1310,7 +1310,7 @@
{
"name": "Great General",
"unitType": "Civilian",
"uniques": ["Can start an [8]-turn golden age", "Bonus for units in 2 tile radius 15%", "Can construct [Citadel]",
"uniques": ["Can start an [8]-turn golden age", "[+15]% Strength bonus for [Military] units within [2] tiles", "Can construct [Citadel]",
"Great Person - [War]", "Unbuildable", "Uncapturable"],
"movement": 2
},
Expand All @@ -1319,7 +1319,7 @@
"unitType": "Civilian",
"uniqueTo": "Mongolia",
"replaces": "Great General",
"uniques": ["Can start an [8]-turn golden age","Bonus for units in 2 tile radius 15%",
"uniques": ["Can start an [8]-turn golden age", "[+15]% Strength bonus for [Military] units within [2] tiles",
"All adjacent units heal [+15] HP when healing", "[+15] HP when healing", "Can construct [Citadel]", "Great Person - [War]", "Unbuildable", "Uncapturable"],
"movement": 5
},
Expand Down
8 changes: 8 additions & 0 deletions core/src/com/unciv/logic/BackwardCompatibility.kt
Expand Up @@ -144,6 +144,14 @@ object BackwardCompatibility {
unit.promotions.addPromotion(startingPromo, true)
}

/** Upgrade the uniques from deprecated format to the new more general one **/
fun GameInfo.updateGreatGeneralUniques() {
ruleSet.units.values.filter { it.uniques.contains("Bonus for units in 2 tile radius 15%") }.forEach {
it.uniques.remove("Bonus for units in 2 tile radius 15%")
it.uniques.add("[+15]% Strength bonus for [Military] units within [2] tiles")
}
}

/** Move max XP from barbarians to new home */
@Suppress("DEPRECATION")
fun ModOptions.updateDeprecations() {
Expand Down
2 changes: 2 additions & 0 deletions core/src/com/unciv/logic/GameInfo.kt
Expand Up @@ -6,6 +6,7 @@ import com.unciv.logic.BackwardCompatibility.guaranteeUnitPromotions
import com.unciv.logic.BackwardCompatibility.migrateBarbarianCamps
import com.unciv.logic.BackwardCompatibility.migrateSeenImprovements
import com.unciv.logic.BackwardCompatibility.removeMissingModReferences
import com.unciv.logic.BackwardCompatibility.updateGreatGeneralUniques
import com.unciv.logic.automation.NextTurnAutomation
import com.unciv.logic.civilization.*
import com.unciv.logic.city.CityInfo
Expand Down Expand Up @@ -417,6 +418,7 @@ class GameInfo {

removeMissingModReferences()

updateGreatGeneralUniques()

for (baseUnit in ruleSet.units.values)
baseUnit.ruleset = ruleSet
Expand Down
5 changes: 2 additions & 3 deletions core/src/com/unciv/logic/automation/NextTurnAutomation.kt
Expand Up @@ -163,8 +163,7 @@ object NextTurnAutomation {
while (delta > 0) {
// Now remove the best offer valued below delta until the deal is barely acceptable
val offerToRemove = counterofferAsks.filter { it.value <= delta }.maxByOrNull { it.value }
if (offerToRemove == null)
break // Nothing more can be removed, at least en bloc
?: break // Nothing more can be removed, at least en bloc
delta -= offerToRemove.value
counterofferAsks.remove(offerToRemove.key)
}
Expand Down Expand Up @@ -831,7 +830,7 @@ object NextTurnAutomation {
when {
unit.baseUnit.isRanged() -> rangedUnits.add(unit)
unit.baseUnit.isMelee() -> meleeUnits.add(unit)
unit.hasUnique("Bonus for units in 2 tile radius 15%")
unit.isGreatPersonOfType("War")
-> generals.add(unit) // Generals move after military units
else -> civilianUnits.add(unit)
}
Expand Down
62 changes: 29 additions & 33 deletions core/src/com/unciv/logic/automation/SpecificUnitAutomation.kt
@@ -1,6 +1,7 @@
package com.unciv.logic.automation

import com.unciv.logic.battle.Battle
import com.unciv.logic.battle.GreatGeneralImplementation
import com.unciv.logic.battle.MapUnitCombatant
import com.unciv.logic.city.CityInfo
import com.unciv.logic.civilization.CivilizationInfo
Expand Down Expand Up @@ -45,28 +46,16 @@ object SpecificUnitAutomation {
}
}

fun automateGreatGeneral(unit: MapUnit) {
fun automateGreatGeneral(unit: MapUnit): Boolean {
//try to follow nearby units. Do not garrison in city if possible
val militaryUnitTilesInDistance = unit.movement.getDistanceToTiles().asSequence()
.filter {
val militant = it.key.militaryUnit
militant != null && militant.civInfo == unit.civInfo
&& (it.key.civilianUnit == null || it.key.civilianUnit == unit)
&& militant.getMaxMovement() <= 2 && !it.key.isCityCenter()
}
val maxAffectedTroopsTile = GreatGeneralImplementation.getBestAffectedTroopsTile(unit)
?: return false

val maxAffectedTroopsTile = militaryUnitTilesInDistance
.maxByOrNull {
it.key.getTilesInDistance(2).count { tile ->
val militaryUnit = tile.militaryUnit
militaryUnit != null && militaryUnit.civInfo == unit.civInfo
}
}?.key
if (maxAffectedTroopsTile != null) {
unit.movement.headTowards(maxAffectedTroopsTile)
return
}
unit.movement.headTowards(maxAffectedTroopsTile)
return true
}

fun automateCitadelPlacer(unit: MapUnit): Boolean {
// try to revenge and capture their tiles
val enemyCities = unit.civInfo.getKnownCivs()
.filter { unit.civInfo.getDiplomacyManager(it).hasModifier(DiplomaticModifiers.StealingTerritory) }
Expand Down Expand Up @@ -94,16 +83,19 @@ object SpecificUnitAutomation {
unit.movement.headTowards(tileToSteal)
if (unit.currentMovement > 0 && unit.currentTile == tileToSteal)
UnitActions.getImprovementConstructionActions(unit, unit.currentTile).firstOrNull()?.action?.invoke()
return
return true
}

// try to build a citadel for defensive purposes
if (WorkerAutomation.evaluateFortPlacement(unit.currentTile, unit.civInfo, true)) {
UnitActions.getImprovementConstructionActions(unit, unit.currentTile).firstOrNull()?.action?.invoke()
return
return true
}
return false
}

//if no unit to follow, take refuge in city or build citadel there.
fun automateGreatGeneralFallback(unit: MapUnit) {
// if no unit to follow, take refuge in city or build citadel there.
val reachableTest: (TileInfo) -> Boolean = {
it.civilianUnit == null &&
unit.movement.canMoveTo(it)
Expand All @@ -112,22 +104,26 @@ object SpecificUnitAutomation {
val cityToGarrison = unit.civInfo.cities.asSequence().map { it.getCenterTile() }
.sortedBy { it.aerialDistanceTo(unit.currentTile) }
.firstOrNull { reachableTest(it) }
?: return
if (!unit.hasCitadelPlacementUnique) {
unit.movement.headTowards(cityToGarrison)
return
}

if (cityToGarrison != null) {
// try to find a good place for citadel nearby
val potentialTilesNearCity = cityToGarrison.getTilesInDistanceRange(3..4)
val tileForCitadel = potentialTilesNearCity.firstOrNull {
// try to find a good place for citadel nearby
val tileForCitadel = cityToGarrison.getTilesInDistanceRange(3..4)
.firstOrNull {
reachableTest(it) &&
WorkerAutomation.evaluateFortPlacement(it, unit.civInfo, true)
WorkerAutomation.evaluateFortPlacement(it, unit.civInfo, true)
}
if (tileForCitadel != null) {
unit.movement.headTowards(tileForCitadel)
if (unit.currentMovement > 0 && unit.currentTile == tileForCitadel)
UnitActions.getImprovementConstructionActions(unit, unit.currentTile).firstOrNull()?.action?.invoke()
} else
unit.movement.headTowards(cityToGarrison)
if (tileForCitadel == null) {
unit.movement.headTowards(cityToGarrison)
return
}
unit.movement.headTowards(tileForCitadel)
if (unit.currentMovement > 0 && unit.currentTile == tileForCitadel)
UnitActions.getImprovementConstructionActions(unit, unit.currentTile)
.firstOrNull()?.action?.invoke()
}

private fun rankTileAsCityCenter(tileInfo: TileInfo, nearbyTileRankings: Map<TileInfo, Float>,
Expand Down
12 changes: 9 additions & 3 deletions core/src/com/unciv/logic/automation/UnitAutomation.kt
Expand Up @@ -157,9 +157,15 @@ object UnitAutomation {
// For now its a simple option to allow AI to win a science victory again
if (unit.hasUnique(UniqueType.AddInCapital))
return SpecificUnitAutomation.automateAddInCapital(unit)

if (unit.hasUnique("Bonus for units in 2 tile radius 15%"))
return SpecificUnitAutomation.automateGreatGeneral(unit)

//todo this now supports "Great General"-like mod units not combining 'aura' and citadel
// abilities, but not additional capabilities if automation finds no use for those two
if (unit.hasStrengthBonusInRadiusUnique && SpecificUnitAutomation.automateGreatGeneral(unit))
return
if (unit.hasCitadelPlacementUnique && SpecificUnitAutomation.automateCitadelPlacer(unit))
return
if (unit.hasCitadelPlacementUnique || unit.hasStrengthBonusInRadiusUnique)
return SpecificUnitAutomation.automateGreatGeneralFallback(unit)

if (unit.hasUnique(UniqueType.ConstructImprovementConsumingUnit))
return SpecificUnitAutomation.automateImprovementPlacer(unit) // includes great people plus moddable units
Expand Down
23 changes: 7 additions & 16 deletions core/src/com/unciv/logic/battle/BattleDamage.kt
Expand Up @@ -40,10 +40,9 @@ object BattleDamage {

val conditionalState = StateForConditionals(civInfo, cityInfo = (combatant as? CityCombatant)?.city, ourCombatant = combatant, theirCombatant = enemy,
attackedTile = attackedTile, combatAction = combatAction)



if (combatant is MapUnitCombatant) {

for (unique in combatant.getMatchingUniques(UniqueType.Strength, conditionalState, true)) {
modifiers.add(getModifierStringFromUnique(unique), unique.params[0].toInt())
}
Expand All @@ -61,9 +60,7 @@ object BattleDamage {

//https://www.carlsguides.com/strategy/civilization5/war/combatbonuses.php
val adjacentUnits = combatant.getTile().neighbors.flatMap { it.getUnits() }


for (unique in adjacentUnits.filter { it.civInfo.isAtWarWith(combatant.getCivInfo()) }
for (unique in adjacentUnits.filter { it.civInfo.isAtWarWith(civInfo) }
.flatMap { it.getMatchingUniques(UniqueType.StrengthForAdjacentEnemies) })
if (combatant.matchesCategory(unique.params[1]) && combatant.getTile()
.matchesFilter(unique.params[2])
Expand All @@ -73,17 +70,11 @@ object BattleDamage {
val civResources = civInfo.getCivResourcesByName()
for (resource in combatant.unit.baseUnit.getResourceRequirements().keys)
if (civResources[resource]!! < 0 && !civInfo.isBarbarian())
modifiers["Missing resource"] = -25
modifiers["Missing resource"] = -25 //todo ModConstants


val nearbyCivUnits = combatant.unit.getTile().getTilesInDistance(2)
.flatMap { it.getUnits() }.filter { it.civInfo == combatant.unit.civInfo }
if (nearbyCivUnits.any { it.hasUnique("Bonus for units in 2 tile radius 15%") }) {
val greatGeneralModifier =
if (combatant.unit.civInfo.hasUnique(UniqueType.GreatGeneralProvidesDoubleCombatBonus)) 30 else 15

modifiers["Great General"] = greatGeneralModifier
}
val (greatGeneralName, greatGeneralBonus) = GreatGeneralImplementation.getGreatGeneralBonus(combatant.unit)
if (greatGeneralBonus != 0)
modifiers[greatGeneralName] = greatGeneralBonus

for (unique in combatant.unit.getMatchingUniques(UniqueType.StrengthWhenStacked)) {
var stackedUnitsBonus = 0
Expand Down
98 changes: 98 additions & 0 deletions core/src/com/unciv/logic/battle/GreatGeneralImplementation.kt
@@ -0,0 +1,98 @@
package com.unciv.logic.battle

import com.unciv.logic.map.MapUnit
import com.unciv.logic.map.TileInfo
import com.unciv.models.ruleset.unique.Unique
import com.unciv.models.ruleset.unique.UniqueType
import com.unciv.logic.automation.SpecificUnitAutomation // for Kdoc


object GreatGeneralImplementation {

private data class GeneralBonusData(val general: MapUnit, val radius: Int, val filter: String, val bonus: Int) {
constructor(general: MapUnit, unique: Unique) : this(
general,
radius = unique.params[2].toIntOrNull() ?: 0,
filter = unique.params[1],
bonus = unique.params[0].toIntOrNull() ?: 0
)
}

/**
* Determine the "Great General" bonus for [unit] by searching for units carrying the [UniqueType.StrengthBonusInRadius] in the vicinity.
*
* Used by [BattleDamage.getGeneralModifiers].
*
* @return A pair of unit's name and bonus (percentage) as Int (typically 15), or 0 if no applicable Great General equivalents found
*/
fun getGreatGeneralBonus(unit: MapUnit): Pair<String, Int> {
val civInfo = unit.civInfo
val allGenerals = civInfo.getCivUnits()
.filter { it.hasStrengthBonusInRadiusUnique }
if (allGenerals.none()) return Pair("", 0)

val greatGeneral = allGenerals
.flatMap { general ->
general.getMatchingUniques(UniqueType.StrengthBonusInRadius)
.map { GeneralBonusData(general, it) }
}.filter {
// Support the border case when a mod unit has several
// GreatGeneralAura uniques (e.g. +50% as radius 1, +25% at radius 2, +5% at radius 3)
// The "Military" test is also supported deep down in unit.matchesFilter, a small
// optimization for the most common case, as this function is only called for `MapUnitCombatant`s
it.general.currentTile.aerialDistanceTo(unit.getTile()) <= it.radius
&& (it.filter == "Military" || unit.matchesFilter(it.filter))
}
val greatGeneralModifier = greatGeneral.maxByOrNull { it.bonus } ?: return Pair("",0)

if (unit.hasUnique(UniqueType.GreatGeneralProvidesDoubleCombatBonus, checkCivInfoUniques = true)
&& greatGeneralModifier.general.isGreatPersonOfType("War")) // apply only on "true" generals
return Pair(greatGeneralModifier.general.name, greatGeneralModifier.bonus * 2)
return Pair(greatGeneralModifier.general.name, greatGeneralModifier.bonus)
}

/**
* Find a tile for accompanying a military unit where the total bonus for all affected units is maximized.
*
* Used by [SpecificUnitAutomation.automateGreatGeneral].
*/
fun getBestAffectedTroopsTile(general: MapUnit): TileInfo? {
// Normally we have only one Unique here. But a mix is not forbidden, so let's try to support mad modders.
// (imagine several GreatGeneralAura uniques - +50% at radius 1, +25% at radius 2, +5% at radius 3 - possibly learnable from promotions via buildings or natural wonders?)

// Map out the uniques sorted by bonus, as later only the best bonus will apply.
val generalBonusData = (
general.getMatchingUniques(UniqueType.StrengthBonusInRadius).map { GeneralBonusData(general, it) }
).sortedWith(compareByDescending<GeneralBonusData> { it.bonus }.thenBy { it.radius })
.toList()

// Get candidate units to 'follow', coarsely.
// The mapUnitFilter of the unique won't apply here but in the ranking of the "Aura" effectiveness.
val unitMaxMovement = general.getMaxMovement()
val militaryUnitTilesInDistance = general.movement.getDistanceToTiles().asSequence()
.map { it.key }
.filter { tile ->
val militaryUnit = tile.militaryUnit
militaryUnit != null && militaryUnit.civInfo == general.civInfo
&& (tile.civilianUnit == null || tile.civilianUnit == general)
&& militaryUnit.getMaxMovement() <= unitMaxMovement
&& !tile.isCityCenter()
}

// rank tiles and find best
val unitBonusRadius = generalBonusData.maxOfOrNull { it.radius }
?: return null
return militaryUnitTilesInDistance
.maxByOrNull { unitTile ->
unitTile.getTilesInDistance(unitBonusRadius).sumOf { auraTile ->
val militaryUnit = auraTile.militaryUnit
if (militaryUnit == null || militaryUnit.civInfo != general.civInfo) 0
else generalBonusData.firstOrNull {
// "Military" as commented above only a small optimization
auraTile.aerialDistanceTo(unitTile) <= it.radius
&& (it.filter == "Military" || militaryUnit.matchesFilter(it.filter))
}?.bonus ?: 0
}
}
}
}

0 comments on commit 4986505

Please sign in to comment.