Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Issue 155 combat defense #157

Open
wants to merge 18 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ import de.gleex.pltcmd.game.engine.extensions.GameEntity
import de.gleex.pltcmd.game.engine.extensions.getAttribute
import de.gleex.pltcmd.game.engine.extensions.logIdentifier
import de.gleex.pltcmd.game.options.GameConstants.Time.timeSimulatedPerTick
import de.gleex.pltcmd.model.combat.defense.AwarenessState
import de.gleex.pltcmd.model.combat.defense.MovementState
import de.gleex.pltcmd.model.combat.defense.TotalDefense
import de.gleex.pltcmd.model.combat.defense.cover
import de.gleex.pltcmd.model.world.WorldMap
import de.gleex.pltcmd.util.measure.area.squareMeters
import de.gleex.pltcmd.util.measure.distance.Distance
import mu.KotlinLogging
Expand Down Expand Up @@ -37,17 +42,32 @@ val CombatantEntity.combatReadyCount: Int
val CombatantEntity.woundedCount: Int
get() = shooters.woundedCount

/**
* The ratio that the defense reduces the chance of an attacker to hit
* @param worldMap the world in which this entity is positioned
**/
fun CombatantEntity.getDefense(worldMap: WorldMap): TotalDefense {
Loomie marked this conversation as resolved.
Show resolved Hide resolved
val moving = when {
asMovableEntity { it.isMoving }.orElse(false) -> MovementState.MOVING
else -> MovementState.STATIONARY
}
val cover = worldMap[currentPosition].cover
val awareness = AwarenessState.OBSERVING
return TotalDefense(moving, cover, awareness)
}

infix fun CombatantEntity.onDefeat(callback: () -> Unit) {
shooters.onDefeat(callback)
}

/** This combatant attacks the given [target] for a full tick */
internal fun CombatantEntity.attack(target: CombatantEntity, random: Random) {
internal fun CombatantEntity.attack(target: CombatantEntity, worldMap: WorldMap, random: Random) {
if (target.isAbleToFight) {
val range: Distance = currentPosition distanceTo target.currentPosition
val defense = target.getDefense(worldMap)
val hitsPerTick = shooters
.combatReady
.sumOf { it.fireShots(range, timeSimulatedPerTick, random) }
.sumOf { it.fireShots(range, timeSimulatedPerTick, random, defense) }
val wounded = hitsPerTick * 1 // wounded / shot TODO depend on weapon https://github.com/Baret/pltcmd/issues/115
target.shooters.wound(wounded)
log.info { "$logIdentifier attack with $hitsPerTick hits resulted in ${target.shooters.combatReadyCount} combat-ready units of ${target.logIdentifier}" }
Expand All @@ -60,19 +80,21 @@ internal fun CombatantEntity.attack(target: CombatantEntity, random: Random) {
* @return number of all hits in the given time.
**/
@OptIn(ExperimentalTime::class)
internal fun Shooter.fireShots(range: Distance, attackDuration: Duration, random: Random): Int {
internal fun Shooter.fireShots(range: Distance, attackDuration: Duration, random: Random, defense: TotalDefense): Int {
val shotsPerDuration = weapon.roundsPerMinute * attackDuration.toDouble(DurationUnit.MINUTES) + partialShot
// rounding down loses a partial shot that is remember for the next call.
val chanceToHitMan = weapon.shotAccuracy.chanceToHitAreaAt(1.0.squareMeters, range) // 1.0m² = 2 m tall * 0.5 m wide
val chanceToHitDefender = chanceToHitMan * (1 - defense.attackReduction)

// rounding down loses a partial shot that is remembered for the next call.
val fullShots: Int = shotsPerDuration.toInt()
partialShot = shotsPerDuration % 1

var hits = 0
repeat(fullShots) {
val chanceToHitMan = weapon.shotAccuracy.chanceToHitAreaAt(1.0.squareMeters, range) // 1.0m² = 2 m tall * 0.5 m wide
if (random.nextDouble() <= chanceToHitMan) {
if (random.nextDouble() <= chanceToHitDefender) {
hits++
}
}
log.trace { "$this firing $shotsPerDuration shots in $attackDuration with accuracy ${weapon.shotAccuracy} results in $hits hits" }
log.trace { "$this firing $shotsPerDuration shots in $attackDuration with accuracy ${weapon.shotAccuracy} at defense $defense results in $hits hits" }
return hits
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package de.gleex.pltcmd.game.engine.entities.types

import de.gleex.pltcmd.game.engine.extensions.AnyGameEntity
import de.gleex.pltcmd.game.engine.extensions.GameEntity
import de.gleex.pltcmd.game.engine.extensions.tryCastTo
import org.hexworks.amethyst.api.base.BaseEntityType
import org.hexworks.cobalt.datatypes.Maybe

/**
* The entity type for Forward Operating Bases (FOBs).
Expand All @@ -13,3 +16,12 @@ object FOBType : BaseEntityType("FOB", "A stationary forward operating base (FOB
* An entity of type [FOBType].
*/
typealias FOBEntity = GameEntity<FOBType>

/**
* Invokes [whenFOB] if this entity is an [FOBEntity]. When the type is not [FOBType],
* [Maybe.empty] is returned.
*
* @param R the type that is returned by [whenFOB]
*/
fun <R> AnyGameEntity.asFOBEntity(whenFOB: (FOBEntity) -> R): Maybe<R> =
tryCastTo<FOBEntity, FOBType, R>(whenFOB)
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,7 @@ import de.gleex.pltcmd.game.engine.attributes.movement.MovementBaseSpeed
import de.gleex.pltcmd.game.engine.attributes.movement.MovementModifier
import de.gleex.pltcmd.game.engine.attributes.movement.MovementPath
import de.gleex.pltcmd.game.engine.attributes.movement.MovementProgress
import de.gleex.pltcmd.game.engine.extensions.AnyGameEntity
import de.gleex.pltcmd.game.engine.extensions.GameEntity
import de.gleex.pltcmd.game.engine.extensions.castToSuspending
import de.gleex.pltcmd.game.engine.extensions.getAttribute
import de.gleex.pltcmd.game.engine.extensions.*
import de.gleex.pltcmd.model.world.coordinate.Coordinate
import de.gleex.pltcmd.util.measure.distance.kilometers
import de.gleex.pltcmd.util.measure.speed.Speed
Expand Down Expand Up @@ -58,6 +55,10 @@ val MovableEntity.baseSpeed: Speed
val MovableEntity.currentSpeed: Speed
get() = movementModifiers.fold(baseSpeed) { speed: Speed, modifier: MovementModifier -> modifier(speed) }

/** true if [currentSpeed] is not [Speed.ZERO] */
val MovableEntity.isMoving: Boolean
get() = currentSpeed != Speed.ZERO

/** Check if a destination is set. */
val MovableEntity.hasNoDestination: Boolean
get() = destination.isEmpty()
Expand Down Expand Up @@ -94,3 +95,12 @@ val MovableEntity.canNotMove: Boolean
suspend fun AnyGameEntity.invokeWhenMovable(whenMovable: suspend (MovableEntity) -> Boolean): Boolean =
castToSuspending<MovableEntity, Movable, Boolean>(whenMovable)
.orElse(false)

/**
* Invokes [whenElement] if this entity is an [MovableEntity]. When the type is not [Movable],
* [Maybe.empty] is returned.
*
* @param R the type that is returned by [whenElement]
*/
fun <R> AnyGameEntity.asMovableEntity(whenElement: (MovableEntity) -> R): Maybe<R> =
tryCastTo<MovableEntity, Movable, R>(whenElement)
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ internal object Fighting :
.firstOrNull()
?.let { enemyToAttack ->
log.debug { "${attacker.callsign} attacks ${enemyToAttack.callsign}" }
attacker.attack(enemyToAttack, context.random)
attacker.attack(enemyToAttack, context.world, context.random)
true
}
?: false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,11 @@ package de.gleex.pltcmd.game.engine.systems.facets
import de.gleex.pltcmd.game.engine.GameContext
import de.gleex.pltcmd.game.engine.attributes.CommandersIntent
import de.gleex.pltcmd.game.engine.attributes.goals.SecurityHalt
import de.gleex.pltcmd.game.engine.entities.types.MovableEntity
import de.gleex.pltcmd.game.engine.entities.types.baseSpeed
import de.gleex.pltcmd.game.engine.entities.types.currentSpeed
import de.gleex.pltcmd.game.engine.entities.types.movementPath
import de.gleex.pltcmd.game.engine.entities.types.*
import de.gleex.pltcmd.game.engine.messages.UpdatePosition
import de.gleex.pltcmd.game.options.GameConstants
import de.gleex.pltcmd.game.ticks.Ticker
import de.gleex.pltcmd.model.world.sectorOrigin
import de.gleex.pltcmd.util.measure.speed.Speed
import mu.KotlinLogging
import org.hexworks.amethyst.api.Pass
import org.hexworks.amethyst.api.Response
Expand All @@ -32,7 +28,7 @@ object MakesSecurityHalts : BaseFacet<GameContext, UpdatePosition>(UpdatePositio
// Make a security halt when approximately 300m into the new sector
val afterTiles = 3.0
val ticksPerTile =
GameConstants.Movement.speedForOneTileInOneTick / if (entity.currentSpeed > Speed.ZERO) entity.currentSpeed else entity.baseSpeed
GameConstants.Movement.speedForOneTileInOneTick / if (entity.isMoving) entity.currentSpeed else entity.baseSpeed
val inTurns = (afterTiles * ticksPerTile).toInt()

if (entity.movementPath.size > afterTiles) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import de.gleex.pltcmd.game.engine.attributes.FactionAttribute
import de.gleex.pltcmd.game.engine.attributes.PositionAttribute
import de.gleex.pltcmd.game.engine.attributes.SightedAttribute
import de.gleex.pltcmd.game.engine.attributes.combat.ShootersAttribute
import de.gleex.pltcmd.game.engine.attributes.movement.MovementBaseSpeed
import de.gleex.pltcmd.game.engine.entities.EntitySet
import de.gleex.pltcmd.game.engine.entities.types.*
import de.gleex.pltcmd.model.elements.*
Expand All @@ -17,6 +18,7 @@ import de.gleex.pltcmd.model.faction.Faction
import de.gleex.pltcmd.model.faction.FactionRelations
import de.gleex.pltcmd.model.signals.vision.Visibility
import de.gleex.pltcmd.model.world.coordinate.Coordinate
import de.gleex.pltcmd.model.world.terrain.TerrainType
import io.kotest.assertions.assertSoftly
import io.kotest.core.spec.style.StringSpec
import io.kotest.data.forAll
Expand Down Expand Up @@ -85,13 +87,19 @@ class FightingTest : StringSpec({
val context = createContext()
val target = createTarget(attacker, opfor, createInfantryElement((Units.Rifleman * 100).new()))

Fighting.attackNearbyEnemies(attacker, context) // 38 dmg
assertCombatResult(attacker, target, 62, true)
Fighting.attackNearbyEnemies(attacker, context) // 20 dmg
assertCombatResult(attacker, target, 80, true)

Fighting.attackNearbyEnemies(attacker, context) // 40 dmg
assertCombatResult(attacker, target, 22, true)
Fighting.attackNearbyEnemies(attacker, context) // 23 dmg
assertCombatResult(attacker, target, 57, true)

Fighting.attackNearbyEnemies(attacker, context) // 45 dmg
Fighting.attackNearbyEnemies(attacker, context) // 21 dmg
assertCombatResult(attacker, target, 36, true)

Fighting.attackNearbyEnemies(attacker, context) // 21 dmg
assertCombatResult(attacker, target, 15, true)

Fighting.attackNearbyEnemies(attacker, context) // 22 dmg
assertCombatResult(attacker, target, 0, false)
}

Expand All @@ -101,35 +109,37 @@ class FightingTest : StringSpec({
val context = createContext()
val target = createTarget(attacker, opfor, createInfantryElement((Units.Rifleman * 100).new()))
val singleRifleman = createCombatant(attackerPosition.movedBy(2,2), opfor)
singleRifleman.attack(attacker, context.random)
val attacksAbleToFight = 4
attacker.combatReadyCount shouldBe attacksAbleToFight
attacker.woundedCount shouldBe 6
singleRifleman.attack(attacker, context.world, context.random)
val attackersAbleToFight = 7
attacker.combatReadyCount shouldBe attackersAbleToFight
attacker.woundedCount shouldBe 3

var expectedTargetCombatReady = target.combatReadyCount
forAll( // shots random hits
row(19),
row(21),
row(21),
row(18),
row(16),
row(24),
row(13),
row(19)
) { expectedDamage ->
Fighting.attackNearbyEnemies(attacker, context)
expectedTargetCombatReady -= expectedDamage
assertCombatResult(attacker, target, expectedTargetCombatReady, true, attacksAbleToFight)
assertCombatResult(attacker, target, expectedTargetCombatReady, true, attackersAbleToFight)
}
expectedTargetCombatReady shouldBe 2
expectedTargetCombatReady shouldBe 7

Fighting.attackNearbyEnemies(attacker, context) // 24 dmg
assertCombatResult(attacker, target, 0, false, attacksAbleToFight)
Fighting.attackNearbyEnemies(attacker, context) // 13 dmg
assertCombatResult(attacker, target, 0, false, attackersAbleToFight)
}
})

private fun createContext(): GameContext {

val context = mockk<GameContext>()
every { context.random } returns Random(123L)
val random = Random(123L)
every { context.random } returns random
every { context.elementsAt(any()) } returns EntitySet()
every { context.world[any<Coordinate>()].type } returns TerrainType.FOREST

return context
}
Expand Down Expand Up @@ -158,7 +168,8 @@ fun createCombatant(position: Coordinate, faction: Faction, element: CommandingE
FactionAttribute(faction),
PositionAttribute(position.toProperty()),
ShootersAttribute(element),
SightedAttribute()
SightedAttribute(),
MovementBaseSpeed(element)
)
behaviors(Fighting)
facets()
Expand Down
4 changes: 4 additions & 0 deletions model/combat/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,9 @@
<groupId>de.gleex.pltcmd.util</groupId>
<artifactId>measure</artifactId>
</dependency>
<dependency>
<groupId>de.gleex.pltcmd.model</groupId>
<artifactId>world</artifactId>
</dependency>
</dependencies>
</project>
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import kotlin.math.tan
* This is an angle for such a cone. It is measured in [milliradian](https://en.wikipedia.org/wiki/Milliradian)
* (mrad, a thousandth of a radian). A mrad is ≈ 0.057296 degrees.
*/
class Precision(private val mrad: Double) {
data class Precision(private val mrad: Double) {

companion object {
const val mradsPerCircle = 2000.0
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package de.gleex.pltcmd.model.combat.defense

/**
* Describes how much an element exepcts enemy troops.
*/
enum class AwarenessState : DefenseFactor {
CARELESS {
override val attackReduction = -0.15
},
OBSERVING {
override val attackReduction = 0.1
},
ALERTED {
override val attackReduction = 0.4
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package de.gleex.pltcmd.model.combat.defense

import de.gleex.pltcmd.model.world.terrain.Terrain
import de.gleex.pltcmd.model.world.terrain.TerrainType.*

/**
* Defensive state that describes the cover and concealment to hide the defender from the attacker.
*/
enum class CoverState : DefenseFactor {
Loomie marked this conversation as resolved.
Show resolved Hide resolved
/** no cover at all on an open field */
OPEN {
override val attackReduction = 0.0
},

/** Some bushes or other things that partially conceal troops */
LIGHT {
override val attackReduction = 0.2
},

/** Some hard cover that is either left for short amounts of time or does not fully cover the element */
MODERATE {
override val attackReduction = 0.5
},

/** hidden completely in a solid bunker */
FULL {
override val attackReduction = 0.8
}
}


val Terrain.cover: CoverState
get() = when (this.type) {
GRASSLAND -> CoverState.OPEN
FOREST -> CoverState.LIGHT
HILL -> CoverState.LIGHT
MOUNTAIN -> CoverState.MODERATE
WATER_DEEP -> CoverState.MODERATE
WATER_SHALLOW -> CoverState.LIGHT
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package de.gleex.pltcmd.model.combat.defense

/**
* Describes a fact that is considered for defense. So it is able to reduce the chance of being hit by an attack.
**/
interface DefenseFactor {
/**
* A ratio which describes the chance for an attacker to miss a careful shot. Negative values may be used to
* indicate that this factor reduces the defense (which is provided by other factors).
**/
val attackReduction: Double
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package de.gleex.pltcmd.model.combat.defense

/**
* Describes the type of movement an actor currently executes.
*/
enum class MovementState : DefenseFactor {
MOVING {
override val attackReduction = 0.2
},
STATIONARY {
override val attackReduction = 0.0
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package de.gleex.pltcmd.model.combat.defense

/**
* Contains all factors that affect the defense (positive and negative).
*/
class TotalDefense(private vararg val factors: DefenseFactor) {
val attackReduction: Double
get() = factors.sumOf { it.attackReduction }.coerceIn(0.0, 1.0)

override fun toString(): String {
return "TotalDefense[attackReduction=$attackReduction from ${factors.contentToString()}]"
}
}