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 all 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
@@ -0,0 +1,11 @@
package de.gleex.pltcmd.game.engine.attributes.combat

import de.gleex.pltcmd.model.combat.defense.TotalDefense
import org.hexworks.amethyst.api.base.BaseAttribute

/**
* Holds all values relevant for the combat defense.
*/
internal class DefenseAttribute : BaseAttribute() {
var total: TotalDefense = TotalDefense()
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package de.gleex.pltcmd.game.engine.entities

import de.gleex.pltcmd.game.engine.GameContext
import de.gleex.pltcmd.game.engine.attributes.*
import de.gleex.pltcmd.game.engine.attributes.combat.DefenseAttribute
import de.gleex.pltcmd.game.engine.attributes.combat.ShootersAttribute
import de.gleex.pltcmd.game.engine.attributes.memory.Memory
import de.gleex.pltcmd.game.engine.attributes.movement.MovementBaseSpeed
Expand Down Expand Up @@ -100,6 +101,7 @@ object EntityFactory {
// TODO if call sign of the element gets mutable, use a function or ObservableValue as parameter (#98)
RadioAttribute(RadioCommunicator(element.callSign, radioSender)),
ShootersAttribute(element),
DefenseAttribute(),
Memory(world)
.apply { knownWorld reveal visionAttribute.vision.area },

Expand All @@ -119,6 +121,7 @@ object EntityFactory {
SharingKnowledge,
MovingForOneMinute,
Communicating,
Defending,
Fighting
)
if (element.kind == ElementKind.Infantry) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
package de.gleex.pltcmd.game.engine.entities.types

import de.gleex.pltcmd.game.engine.attributes.combat.DefenseAttribute
import de.gleex.pltcmd.game.engine.attributes.combat.Shooter
import de.gleex.pltcmd.game.engine.attributes.combat.ShootersAttribute
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.engine.extensions.*
import de.gleex.pltcmd.game.options.GameConstants.Time.timeSimulatedPerTick
import de.gleex.pltcmd.model.combat.defense.TotalDefense
import de.gleex.pltcmd.util.measure.area.squareMeters
import de.gleex.pltcmd.util.measure.distance.Distance
import mu.KotlinLogging
import org.hexworks.cobalt.datatypes.Maybe
import kotlin.random.Random
import kotlin.time.Duration
import kotlin.time.DurationUnit
import kotlin.time.ExperimentalTime

/**
* This file contains code for entities that have the [ShootersAttribute].
* This file contains code for entities that have the [ShootersAttribute] and [DefenseAttribute].
*/
private val log = KotlinLogging.logger {}

Expand All @@ -26,6 +27,10 @@ typealias CombatantEntity = GameEntity<Combatant>
private val CombatantEntity.shooters: ShootersAttribute
get() = getAttribute(ShootersAttribute::class)

/** Access to [DefenseAttribute] */
private val CombatantEntity.defense: DefenseAttribute
get() = getAttribute(DefenseAttribute::class)

val CombatantEntity.isAbleToFight: Boolean
get() = shooters.isAbleToFight

Expand All @@ -37,6 +42,15 @@ val CombatantEntity.combatReadyCount: Int
val CombatantEntity.woundedCount: Int
get() = shooters.woundedCount

/**
* The ratio that the defense reduces the chance of an attacker to hit
**/
var CombatantEntity.currentDefense: TotalDefense
get() = defense.total
internal set(value) {
defense.total = value
}

infix fun CombatantEntity.onDefeat(callback: () -> Unit) {
shooters.onDefeat(callback)
}
Expand All @@ -45,9 +59,10 @@ infix fun CombatantEntity.onDefeat(callback: () -> Unit) {
internal fun CombatantEntity.attack(target: CombatantEntity, random: Random) {
if (target.isAbleToFight) {
val range: Distance = currentPosition distanceTo target.currentPosition
val defense = target.currentDefense
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 +75,30 @@ 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
}

/**
* Invokes [whenCombatant] if this entity is an [CombatantEntity]. When the type is not [Combatant],
* [Maybe.empty] is returned.
*
* @param R the type that is returned by [whenCombatant]
*/
fun <R> AnyGameEntity.asCombatantEntity(whenCombatant: (CombatantEntity) -> R): Maybe<R> =
tryCastTo<CombatantEntity, Combatant, R>(whenCombatant)
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] and we have a [destination] */
val MovableEntity.isMoving: Boolean
get() = currentSpeed != Speed.ZERO && destination.isPresent

/** 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
@@ -0,0 +1,50 @@
package de.gleex.pltcmd.game.engine.systems.behaviours

import de.gleex.pltcmd.game.engine.GameContext
import de.gleex.pltcmd.game.engine.attributes.PositionAttribute
import de.gleex.pltcmd.game.engine.attributes.combat.DefenseAttribute
import de.gleex.pltcmd.game.engine.entities.types.*
import de.gleex.pltcmd.game.engine.extensions.logIdentifier
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 mu.KotlinLogging
import org.hexworks.amethyst.api.base.BaseBehavior
import org.hexworks.amethyst.api.entity.Entity
import org.hexworks.amethyst.api.entity.EntityType

private val log = KotlinLogging.logger {}

/**
* Updates the defensive state of a [CombatantEntity].
*/
internal object Defending : BaseBehavior<GameContext>(DefenseAttribute::class, PositionAttribute::class) {

override suspend fun update(entity: Entity<EntityType, GameContext>, context: GameContext): Boolean {
return entity.asCombatantEntity {
it.updateDefense(context.world)
true
}.orElse(false)
}

/**
* Updates the defense attribute for the current state of this combatant.
* @param worldMap the world in which this entity is positioned
**/
fun CombatantEntity.updateDefense(worldMap: WorldMap) {
currentDefense = determineDefense(worldMap)
log.trace { "Defense of $logIdentifier set to $currentDefense" }
}

fun CombatantEntity.determineDefense(worldMap: WorldMap): TotalDefense {
val moving = when {
asMovableEntity { it.isMoving }.orElse(false) -> MovementState.MOVING
else -> MovementState.STATIONARY
}
val cover = worldMap[currentPosition].terrain.cover
val awareness = AwarenessState.OBSERVING
return TotalDefense(moving, cover, awareness)
}
}
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
@@ -0,0 +1,67 @@
package de.gleex.pltcmd.game.engine.systems.behaviours

import de.gleex.pltcmd.game.engine.GameContext
import de.gleex.pltcmd.game.engine.entities.types.ElementEntity
import de.gleex.pltcmd.game.engine.entities.types.currentPosition
import de.gleex.pltcmd.game.engine.entities.types.movementPath
import de.gleex.pltcmd.game.engine.systems.behaviours.Defending.determineDefense
import de.gleex.pltcmd.model.faction.Faction
import de.gleex.pltcmd.model.world.coordinate.Coordinate
import de.gleex.pltcmd.model.world.terrain.Terrain
import de.gleex.pltcmd.model.world.terrain.TerrainHeight
import de.gleex.pltcmd.model.world.terrain.TerrainType
import io.kotest.core.spec.IsolationMode
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.doubles.plusOrMinus
import io.kotest.matchers.shouldBe
import io.mockk.every

class DefendingTest : StringSpec({
// create new combatant for each test
isolationMode = IsolationMode.InstancePerTest

val playerFaction = Faction("player faction")
val context = createContext()
val combatantPosition = Coordinate(123, 456)
val combatant = createCombatant(combatantPosition, playerFaction, context)

"determineDefense standing in forest" {
testedDefense(combatant, context) shouldBe (0.3 plusOrMinus 0.0000000000000001)
}

"determineDefense moving in forest" {
movingNorth(combatant)
testedDefense(combatant, context) shouldBe 0.5
}

"determineDefense standing in grassland" {
beInGrassland(combatantPosition, context)
testedDefense(combatant, context) shouldBe 0.1
}

"determineDefense moving in grassland" {
movingNorth(combatant)
beInGrassland(combatantPosition, context)
testedDefense(combatant, context) shouldBe (0.3 plusOrMinus 0.0000000000000001)
}
})

private fun testedDefense(
combatant: ElementEntity,
context: GameContext
) = combatant.determineDefense(context.world).attackReduction

fun movingNorth(
combatant: ElementEntity
) {
combatant.movementPath.apply {
add(combatant.currentPosition.withRelativeNorthing(1))
}
}

fun beInGrassland(
combatantPosition: Coordinate,
context: GameContext
) {
every { context.world[combatantPosition].terrain } returns Terrain.Companion.of(TerrainType.GRASSLAND, TerrainHeight.MIN)
}
Loading