Add AI optimization capabilities
authorTheSaminator <TheSaminator@users.noreply.github.com>
Thu, 2 Jun 2022 15:26:30 +0000 (11:26 -0400)
committerTheSaminator <TheSaminator@users.noreply.github.com>
Thu, 2 Jun 2022 15:26:30 +0000 (11:26 -0400)
14 files changed:
.gitignore
build.gradle.kts
src/commonMain/kotlin/starshipfights/game/ai/ai_behaviors.kt
src/commonMain/kotlin/starshipfights/game/ai/ai_brainitude.kt
src/commonMain/kotlin/starshipfights/game/ai/ai_coroutine.kt
src/commonMain/kotlin/starshipfights/game/ai/ai_optimization.kt [new file with mode: 0644]
src/commonMain/kotlin/starshipfights/game/ai/ai_optimization_util.kt [new file with mode: 0644]
src/commonMain/kotlin/starshipfights/game/ai/ai_util_combat.kt
src/commonMain/kotlin/starshipfights/game/ai/ai_util_deploy.kt
src/commonMain/kotlin/starshipfights/game/ai/ai_util_nav.kt
src/commonMain/kotlin/starshipfights/game/ai/util.kt
src/commonMain/kotlin/starshipfights/game/game_initiative.kt
src/jvmTest/kotlin/starshipfights/game/ai/AITesting.kt [new file with mode: 0644]
src/jvmTest/resources/logback.xml [new file with mode: 0644]

index 3d8be00f3057d837c58a5e651d0cfe0f52a82c43..d8215682cd30f55af08774418d5388bf981ed7fc 100644 (file)
@@ -154,6 +154,7 @@ gradle-app.setting
 logs/
 mongodb/
 config.json
+test_results.html
 
 ### Hosting server-side
 .bash_history
index 1075b6aefd816bdb259e68ca20453feb1f991261..3fad64f4ee8d22acde511163384f238582013105 100644 (file)
@@ -181,3 +181,9 @@ tasks.named<JavaExec>("run") {
        dependsOn(tasks.named<Jar>("jvmJar"))
        classpath(tasks.named<Jar>("jvmJar"))
 }
+
+tasks.create("runAiTest", JavaExec::class.java) {
+       group = "test"
+       classpath = sourceSets.getByName("test").runtimeClasspath
+       mainClass.set("starshipfights.game.ai.AITesting")
+}
index a89f43ef3b1231abf933bd19c862fabc62443428..685dd5b1683baa9fb48e158c718100d048352734 100644 (file)
@@ -1,13 +1,11 @@
 package starshipfights.game.ai
 
+import kotlinx.coroutines.*
 import kotlinx.coroutines.channels.Channel
 import kotlinx.coroutines.channels.ReceiveChannel
 import kotlinx.coroutines.channels.SendChannel
-import kotlinx.coroutines.coroutineScope
-import kotlinx.coroutines.delay
 import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withTimeoutOrNull
+import kotlinx.coroutines.flow.produceIn
 import starshipfights.data.Id
 import starshipfights.game.*
 import kotlin.math.pow
@@ -17,8 +15,10 @@ data class AIPlayer(
        val gameState: StateFlow<GameState>,
        val doActions: SendChannel<PlayerAction>,
        val getErrors: ReceiveChannel<String>,
+       val onGameEnd: CompletableJob
 )
 
+@OptIn(FlowPreview::class)
 suspend fun AIPlayer.behave(instincts: Instincts, mySide: GlobalSide) {
        try {
                coroutineScope {
@@ -26,12 +26,10 @@ suspend fun AIPlayer.behave(instincts: Instincts, mySide: GlobalSide) {
                        
                        val phasePipe = Channel<Pair<GamePhase, Boolean>>(Channel.CONFLATED)
                        
-                       launch {
+                       launch(onGameEnd) {
                                var prevSentAt = Moment.now
                                
-                               gameState.collect { state ->
-                                       logInfo(jsonSerializer.encodeToString(Brain.serializer(), brain))
-                                       
+                               for (state in gameState.produceIn(this)) {
                                        phasePipe.send(state.phase to (state.doneWithPhase != mySide && (!state.phase.usesInitiative || state.currentInitiative != mySide.other)))
                                        
                                        for (msg in state.chatBox.takeLastWhile { msg -> msg.sentAt > prevSentAt }) {
@@ -72,7 +70,7 @@ suspend fun AIPlayer.behave(instincts: Instincts, mySide: GlobalSide) {
                                }
                        }
                        
-                       launch {
+                       launch(onGameEnd) {
                                for ((phase, canAct) in phasePipe) {
                                        if (!canAct) continue
                                        
@@ -125,7 +123,7 @@ suspend fun AIPlayer.behave(instincts: Instincts, mySide: GlobalSide) {
                                                        }
                                                }
                                                is GamePhase.Attack -> {
-                                                       val attackWith = state.ships.values.flatMap { ship ->
+                                                       val potentialAttacks = state.ships.values.flatMap { ship ->
                                                                if (ship.owner == mySide)
                                                                        ship.armaments.weaponInstances.keys.filter {
                                                                                ship.canUseWeapon(it)
@@ -137,14 +135,26 @@ suspend fun AIPlayer.behave(instincts: Instincts, mySide: GlobalSide) {
                                                                else emptyList()
                                                        }.associateWith { (ship, weaponId, target) ->
                                                                weaponId.expectedAdvantageFromWeaponUsage(state, ship, target) * smoothNegative(brain[shipAttackPriority forShip target.id].signedPow(instincts[combatPrioritization])) * (1 + target.calculateSuffering()).signedPow(instincts[combatPreyOnTheWeak])
-                                                       }.weightedRandomOrNull()
+                                                       }
+                                                       
+                                                       val attackWith = potentialAttacks.weightedRandomOrNull()
                                                        
                                                        if (attackWith == null)
                                                                doActions.send(PlayerAction.UseAbility(PlayerAbilityType.DonePhase(phase), PlayerAbilityData.DonePhase))
                                                        else {
                                                                val (ship, weaponId, target) = attackWith
                                                                val targetPickResponse = when (val weaponSpec = ship.armaments.weaponInstances[weaponId]?.weapon) {
-                                                                       is AreaWeapon -> PickResponse.Location(ship.getWeaponPickRequest(weaponSpec).boundary.closestPointTo(target.position.location))
+                                                                       is AreaWeapon -> {
+                                                                               val pickRequest = ship.getWeaponPickRequest(weaponSpec)
+                                                                               val targetLocation = target.position.location
+                                                                               val closestValidLocation = pickRequest.boundary.closestPointTo(targetLocation)
+                                                                               
+                                                                               val chosenLocation = if ((targetLocation - closestValidLocation).length >= EPSILON)
+                                                                                       closestValidLocation + ((closestValidLocation - targetLocation) * 0.2)
+                                                                               else closestValidLocation
+                                                                               
+                                                                               PickResponse.Location(chosenLocation)
+                                                                       }
                                                                        else -> PickResponse.Ship(target.id)
                                                                }
                                                                
@@ -153,8 +163,18 @@ suspend fun AIPlayer.behave(instincts: Instincts, mySide: GlobalSide) {
                                                                withTimeoutOrNull(50L) { getErrors.receive() }?.let { error ->
                                                                        logWarning("Error when attacking target ship ID ${target.id} with weapon $weaponId of ship ID ${ship.id} - $error")
                                                                        
-                                                                       val nextState = gameState.value
-                                                                       phasePipe.send(nextState.phase to (nextState.doneWithPhase != mySide && (!nextState.phase.usesInitiative || nextState.currentInitiative != mySide.other)))
+                                                                       val remainingAllAreaWeapons = potentialAttacks.keys.map { (attacker, weaponId, _) ->
+                                                                               attacker to weaponId
+                                                                       }.toSet().all { (attacker, weaponId) ->
+                                                                               attacker.armaments.weaponInstances[weaponId]?.weapon is AreaWeapon
+                                                                       }
+                                                                       
+                                                                       if (remainingAllAreaWeapons)
+                                                                               doActions.send(PlayerAction.UseAbility(PlayerAbilityType.DonePhase(phase), PlayerAbilityData.DonePhase))
+                                                                       else {
+                                                                               val nextState = gameState.value
+                                                                               phasePipe.send(nextState.phase to (nextState.doneWithPhase != mySide && (!nextState.phase.usesInitiative || nextState.currentInitiative != mySide.other)))
+                                                                       }
                                                                }
                                                        }
                                                }
index 22c91e4421d7c4ec707505fabd5341c37b81fff3..e5cd88bab2456ffad9e562ffdb3ead33978a1e0b 100644 (file)
@@ -14,13 +14,17 @@ import kotlin.properties.ReadOnlyProperty
 value class Instincts private constructor(private val numbers: MutableMap<String, Double>) {
        constructor() : this(mutableMapOf())
        
-       operator fun get(instinct: Instinct) = numbers.getOrPut(instinct.key, instinct.default)
+       operator fun get(instinct: Instinct) = numbers.getOrPut(instinct.key) { instinct.randRange.random() }
+       
+       companion object {
+               fun fromValues(values: Map<String, Double>) = Instincts(values.toMutableMap())
+       }
 }
 
-data class Instinct(val key: String, val default: () -> Double)
+data class Instinct(val key: String, val randRange: ClosedFloatingPointRange<Double>)
 
-fun instinct(default: () -> Double) = ReadOnlyProperty<Any?, Instinct> { _, property ->
-       Instinct(property.name, default)
+fun instinct(randRange: ClosedFloatingPointRange<Double>) = ReadOnlyProperty<Any?, Instinct> { _, property ->
+       Instinct(property.name, randRange)
 }
 
 @JvmInline
index 8ef8eb17bb3464d5e58fc47878f02a98b1693a06..0c3cfa56e297272a50a231819610a84981effe6d 100644 (file)
@@ -1,7 +1,7 @@
 package starshipfights.game.ai
 
+import kotlinx.coroutines.CancellationException
 import kotlinx.coroutines.Job
-import kotlinx.coroutines.cancelAndJoin
 import kotlinx.coroutines.channels.Channel
 import kotlinx.coroutines.channels.ReceiveChannel
 import kotlinx.coroutines.channels.SendChannel
@@ -28,7 +28,8 @@ suspend fun aiPlayer(session: AISession, initialState: GameState) = coroutineSco
        val aiPlayer = AIPlayer(
                gameStateFlow,
                session.actions,
-               errors
+               errors,
+               gameDone
        )
        
        val behavingJob = launch {
@@ -47,8 +48,17 @@ suspend fun aiPlayer(session: AISession, initialState: GameState) = coroutineSco
        
        gameDone.join()
        
-       behavingJob.cancelAndJoin()
-       handlingJob.cancelAndJoin()
+       try {
+               behavingJob.join()
+       } catch (_: CancellationException) {
+               // ignore it
+       }
+       
+       try {
+               handlingJob.join()
+       } catch (_: CancellationException) {
+               // ignore it again
+       }
        
        session.actions.close()
 }
diff --git a/src/commonMain/kotlin/starshipfights/game/ai/ai_optimization.kt b/src/commonMain/kotlin/starshipfights/game/ai/ai_optimization.kt
new file mode 100644 (file)
index 0000000..fd998e5
--- /dev/null
@@ -0,0 +1,279 @@
+package starshipfights.game.ai
+
+import kotlinx.coroutines.*
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.channels.ReceiveChannel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import starshipfights.data.Id
+import starshipfights.game.*
+import kotlin.math.PI
+import kotlin.random.Random
+
+val allInstincts = listOf(
+       combatTargetShipWeight,
+       combatAvengeShipwrecks,
+       combatAvengeShipWeight,
+       combatPrioritization,
+       combatAvengeAttacks,
+       combatForgiveTarget,
+       combatPreyOnTheWeak,
+       combatFrustratedByFailedAttacks,
+       
+       deployEscortFocus,
+       deployCruiserFocus,
+       deployBattleshipFocus,
+       
+       navAggression,
+       navPassivity,
+       navLustForBlood,
+       navSqueamishness,
+       navTunnelVision,
+       navOptimality,
+)
+
+fun genInstinctCandidates(count: Int): Set<Instincts> {
+       return Random.nextOrthonormalBasis(allInstincts.size).take(count).map { vector ->
+               Instincts.fromValues((allInstincts zip vector).associate { (key, value) ->
+                       key.key to key.denormalize(value)
+               })
+       }.toSet()
+}
+
+class TestSession(gameState: GameState) {
+       private val stateMutable = MutableStateFlow(gameState)
+       private val stateMutex = Mutex()
+       
+       val state = stateMutable.asStateFlow()
+       
+       private val hostErrorMessages = Channel<String>(Channel.UNLIMITED)
+       private val guestErrorMessages = Channel<String>(Channel.UNLIMITED)
+       
+       private fun errorMessageChannel(player: GlobalSide) = when (player) {
+               GlobalSide.HOST -> hostErrorMessages
+               GlobalSide.GUEST -> guestErrorMessages
+       }
+       
+       fun errorMessages(player: GlobalSide): ReceiveChannel<String> = when (player) {
+               GlobalSide.HOST -> hostErrorMessages
+               GlobalSide.GUEST -> guestErrorMessages
+       }
+       
+       private val gameEndMutable = CompletableDeferred<GameEvent.GameEnd>()
+       val gameEnd: Deferred<GameEvent.GameEnd>
+               get() = gameEndMutable
+       
+       suspend fun onPacket(player: GlobalSide, packet: PlayerAction) {
+               stateMutex.withLock {
+                       when (val result = state.value.after(player, packet)) {
+                               is GameEvent.StateChange -> {
+                                       stateMutable.value = result.newState
+                                       result.newState.checkVictory()?.let { gameEndMutable.complete(it) }
+                               }
+                               is GameEvent.InvalidAction -> {
+                                       errorMessageChannel(player).send(result.message)
+                               }
+                               is GameEvent.GameEnd -> {
+                                       gameEndMutable.complete(result)
+                               }
+                       }
+               }
+       }
+}
+
+suspend fun performTestSession(gameState: GameState, hostInstincts: Instincts, guestInstincts: Instincts): GlobalSide? {
+       val testSession = TestSession(gameState)
+       
+       val hostActions = Channel<PlayerAction>()
+       val hostEvents = Channel<GameEvent>()
+       val hostSession = AISession(GlobalSide.HOST, hostActions, hostEvents, hostInstincts)
+       
+       val guestActions = Channel<PlayerAction>()
+       val guestEvents = Channel<GameEvent>()
+       val guestSession = AISession(GlobalSide.GUEST, guestActions, guestEvents, guestInstincts)
+       
+       return coroutineScope {
+               val hostHandlingJob = launch {
+                       launch {
+                               listOf(
+                                       // Game state changes
+                                       launch {
+                                               testSession.state.collect { state ->
+                                                       hostEvents.send(GameEvent.StateChange(state))
+                                               }
+                                       },
+                                       // Invalid action messages
+                                       launch {
+                                               for (errorMessage in testSession.errorMessages(GlobalSide.HOST)) {
+                                                       hostEvents.send(GameEvent.InvalidAction(errorMessage))
+                                               }
+                                       }
+                               ).joinAll()
+                       }
+                       
+                       launch {
+                               for (action in hostActions)
+                                       testSession.onPacket(GlobalSide.HOST, action)
+                       }
+                       
+                       aiPlayer(hostSession, testSession.state.value)
+               }
+               
+               val guestHandlingJob = launch {
+                       launch {
+                               listOf(
+                                       // Game state changes
+                                       launch {
+                                               testSession.state.collect { state ->
+                                                       guestEvents.send(GameEvent.StateChange(state))
+                                               }
+                                       },
+                                       // Invalid action messages
+                                       launch {
+                                               for (errorMessage in testSession.errorMessages(GlobalSide.GUEST)) {
+                                                       guestEvents.send(GameEvent.InvalidAction(errorMessage))
+                                               }
+                                       }
+                               ).joinAll()
+                       }
+                       
+                       launch {
+                               for (action in guestActions)
+                                       testSession.onPacket(GlobalSide.GUEST, action)
+                       }
+                       
+                       aiPlayer(guestSession, testSession.state.value)
+               }
+               
+               val gameEnd = testSession.gameEnd.await()
+               
+               hostHandlingJob.cancel()
+               guestHandlingJob.cancel()
+               
+               gameEnd.winner
+       }
+}
+
+val BattleSize.minRank: AdmiralRank
+       get() = AdmiralRank.values().first {
+               it.maxShipWeightClass.tier >= maxWeightClass.tier
+       }
+
+fun generateFleet(faction: Faction, rank: AdmiralRank, side: GlobalSide): Map<Id<Ship>, Ship> = ShipWeightClass.values()
+       .flatMap { swc ->
+               val shipTypes = ShipType.values().filter { st ->
+                       st.weightClass == swc && st.faction == faction
+               }.shuffled()
+               
+               if (shipTypes.isEmpty())
+                       emptyList()
+               else
+                       (0 until ((rank.maxShipWeightClass.tier - swc.tier + 1) * 2).coerceAtLeast(0)).map { i ->
+                               shipTypes[i % shipTypes.size]
+                       }
+       }
+       .let { shipTypes ->
+               var shipCount = 0
+               shipTypes.map { st ->
+                       val name = "${side}_${++shipCount}"
+                       Ship(
+                               id = Id(name),
+                               name = name,
+                               shipType = st,
+                       )
+               }.associateBy { it.id }
+       }
+
+fun generateOptimizationInitialState(hostFaction: Faction, guestFaction: Faction, battleInfo: BattleInfo): GameState {
+       val battleWidth = (25..35).random() * 500.0
+       val battleLength = (15..45).random() * 500.0
+       
+       val deployWidth2 = battleWidth / 2
+       val deployLength2 = 875.0
+       
+       val hostDeployCenter = Position(Vec2(0.0, (-battleLength / 2) + deployLength2))
+       val guestDeployCenter = Position(Vec2(0.0, (battleLength / 2) - deployLength2))
+       
+       val rank = battleInfo.size.minRank
+       
+       return GameState(
+               GameStart(
+                       battleWidth, battleLength,
+                       
+                       PlayerStart(
+                               hostDeployCenter,
+                               PI / 2,
+                               PickBoundary.Rectangle(hostDeployCenter, deployWidth2, deployLength2),
+                               PI / 2,
+                               generateFleet(hostFaction, rank, GlobalSide.HOST)
+                       ),
+                       
+                       PlayerStart(
+                               guestDeployCenter,
+                               -PI / 2,
+                               PickBoundary.Rectangle(guestDeployCenter, deployWidth2, deployLength2),
+                               -PI / 2,
+                               generateFleet(guestFaction, rank, GlobalSide.GUEST)
+                       )
+               ),
+               InGameAdmiral(
+                       id = Id(GlobalSide.HOST.name),
+                       user = InGameUser(
+                               id = Id(GlobalSide.HOST.name),
+                               username = GlobalSide.HOST.name
+                       ),
+                       name = GlobalSide.HOST.name,
+                       isFemale = false,
+                       faction = hostFaction,
+                       rank = rank
+               ),
+               InGameAdmiral(
+                       id = Id(GlobalSide.GUEST.name),
+                       user = InGameUser(
+                               id = Id(GlobalSide.GUEST.name),
+                               username = GlobalSide.GUEST.name
+                       ),
+                       name = GlobalSide.GUEST.name,
+                       isFemale = false,
+                       faction = guestFaction,
+                       rank = rank
+               ),
+               battleInfo
+       )
+}
+
+suspend fun performTrials(numTrialsPerPairing: Int, instincts: Set<Instincts>, validBattleSizes: Set<BattleSize> = BattleSize.values().toSet(), validFactions: Set<Faction> = Faction.values().toSet()): Map<Instincts, Int> {
+       return coroutineScope {
+               instincts.associateWith { host ->
+                       async {
+                               instincts.map { guest ->
+                                       async {
+                                               (1..numTrialsPerPairing).map {
+                                                       async {
+                                                               val battleSize = validBattleSizes.random()
+                                                               
+                                                               val hostFaction = validFactions.random()
+                                                               val guestFaction = validFactions.random()
+                                                               
+                                                               val gameState = generateOptimizationInitialState(hostFaction, guestFaction, BattleInfo(battleSize, BattleBackground.BLUE_BROWN))
+                                                               val winner = withTimeoutOrNull(150_000L) {
+                                                                       performTestSession(gameState, host, guest)
+                                                               }
+                                                               
+                                                               logInfo("A trial has ended! Winner: ${winner ?: "NEITHER"}")
+                                                               
+                                                               when (winner) {
+                                                                       GlobalSide.HOST -> 1
+                                                                       GlobalSide.GUEST -> -1
+                                                                       else -> 0
+                                                               }
+                                                       }
+                                               }.sumOf { it.await() }
+                                       }
+                               }.sumOf { it.await() }
+                       }
+               }.mapValues { (_, it) -> it.await() }
+       }
+}
diff --git a/src/commonMain/kotlin/starshipfights/game/ai/ai_optimization_util.kt b/src/commonMain/kotlin/starshipfights/game/ai/ai_optimization_util.kt
new file mode 100644 (file)
index 0000000..3adcaa1
--- /dev/null
@@ -0,0 +1,85 @@
+package starshipfights.game.ai
+
+import starshipfights.game.EPSILON
+import kotlin.math.abs
+import kotlin.math.sqrt
+import kotlin.random.Random
+
+// close enough
+fun Random.nextGaussian() = (1..12).sumOf { nextDouble() } - 6
+
+fun Random.nextUnitVector(size: Int): List<Double> {
+       if (size <= 0)
+               throw IllegalArgumentException("Cannot have vector of zero or negative dimension!")
+       
+       if (size == 1)
+               return listOf(if (nextBoolean()) 1.0 else -1.0)
+       
+       return (1..size).map { nextGaussian() }.normalize()
+}
+
+fun Random.nextOrthonormalBasis(size: Int): List<List<Double>> {
+       if (size <= 0)
+               throw IllegalArgumentException("Cannot have orthonormal basis of zero or negative dimension!")
+       
+       if (size == 1)
+               return listOf(listOf(if (nextBoolean()) 1.0 else -1.0))
+       
+       val orthogonalBasis = mutableListOf<List<Double>>()
+       while (orthogonalBasis.size < size) {
+               val vector = nextUnitVector(size)
+               var orthogonal = vector
+               for (prevVector in orthogonalBasis)
+                       orthogonal = orthogonal minus (vector project prevVector)
+               
+               if (!orthogonal.isNullVector)
+                       orthogonalBasis.add(orthogonal)
+       }
+       
+       orthogonalBasis.shuffle(this)
+       return orthogonalBasis.map { it.normalize() }
+}
+
+val Iterable<Double>.isNullVector: Boolean
+       get() {
+               return all { abs(it) < EPSILON }
+       }
+
+fun Iterable<Double>.normalize(): List<Double> {
+       val magnitude = sqrt(sumOf { it * it })
+       if (magnitude < EPSILON)
+               throw IllegalArgumentException("Cannot normalize the zero vector!")
+       
+       return this div magnitude
+}
+
+infix fun Iterable<Double>.dot(other: Iterable<Double>): Double {
+       if (count() != other.count())
+               throw IllegalArgumentException("Cannot take inner product of vectors of unequal dimensions!")
+       
+       return (this zip other).sumOf { (a, b) -> a * b }
+}
+
+infix fun Iterable<Double>.project(onto: Iterable<Double>): List<Double> {
+       if (count() != onto.count())
+               throw IllegalArgumentException("Cannot take inner product of vectors of unequal dimensions!")
+       
+       return this times ((this dot onto) / (this dot this))
+}
+
+infix fun Iterable<Double>.plus(other: Iterable<Double>): List<Double> {
+       if (count() != other.count())
+               throw IllegalArgumentException("Cannot take sum of vectors of unequal dimensions!")
+       
+       return (this zip other).map { (a, b) -> a + b }
+}
+
+infix fun Iterable<Double>.minus(other: Iterable<Double>) = this plus (other times -1.0)
+
+infix fun Iterable<Double>.times(scale: Double): List<Double> = map { it * scale }
+infix fun Iterable<Double>.div(scale: Double): List<Double> = map { it / scale }
+
+fun Instinct.denormalize(normalValue: Double): Double {
+       val zeroToOne = (normalValue + 1) / 2
+       return (zeroToOne * (randRange.endInclusive - randRange.start)) + randRange.start
+}
index 362e8c244b85b763b8ccf7f808a0e6fd4292245e..e8050c028b4b99d6cf7f0dcd9a3e06ecd590ea9d 100644 (file)
@@ -4,20 +4,19 @@ import starshipfights.game.FelinaeShipReactor
 import starshipfights.game.ShipInstance
 import starshipfights.game.ShipModuleStatus
 import starshipfights.game.durability
-import kotlin.random.Random
 
-val combatTargetShipWeight by instinct { Random.nextDouble(0.5, 2.5) }
+val combatTargetShipWeight by instinct(0.5..2.5)
 
-val combatAvengeShipwrecks by instinct { Random.nextDouble(0.5, 4.5) }
-val combatAvengeShipWeight by instinct { Random.nextDouble(-0.5, 1.5) }
+val combatAvengeShipwrecks by instinct(0.5..4.5)
+val combatAvengeShipWeight by instinct(-0.5..1.5)
 
-val combatPrioritization by instinct { Random.nextDouble(-1.5, 2.5) }
+val combatPrioritization by instinct(-1.5..2.5)
 
-val combatAvengeAttacks by instinct { Random.nextDouble(0.5, 4.5) }
-val combatForgiveTarget by instinct { Random.nextDouble(-1.5, 2.5) }
-val combatPreyOnTheWeak by instinct { Random.nextDouble(-1.5, 2.5) }
+val combatAvengeAttacks by instinct(0.5..4.5)
+val combatForgiveTarget by instinct(-1.5..2.5)
+val combatPreyOnTheWeak by instinct(-1.5..2.5)
 
-val combatFrustratedByFailedAttacks by instinct { Random.nextDouble(-2.5, 5.5) }
+val combatFrustratedByFailedAttacks by instinct(-2.5..5.5)
 
 fun ShipInstance.calculateSuffering(): Double {
        return (durability.maxHullPoints - hullAmount) + (if (ship.reactor is FelinaeShipReactor)
index ce3ab8b773a20a94d84e067810708075e06e21e2..8bb6e23e976df6f01d26b5a267b8c06a7f6c9bb6 100644 (file)
@@ -3,11 +3,10 @@ package starshipfights.game.ai
 import starshipfights.data.Id
 import starshipfights.game.*
 import kotlin.math.sign
-import kotlin.random.Random
 
-val deployEscortFocus by instinct { Random.nextDouble(1.0, 5.0) }
-val deployCruiserFocus by instinct { Random.nextDouble(1.0, 5.0) }
-val deployBattleshipFocus by instinct { Random.nextDouble(1.0, 5.0) }
+val deployEscortFocus by instinct(1.0..5.0)
+val deployCruiserFocus by instinct(1.0..5.0)
+val deployBattleshipFocus by instinct(1.0..5.0)
 
 val ShipWeightClass.focus: Instinct
        get() = when (this) {
index 0cdbf18f9afa35a592925a1742df3aebbfd33833..fe7b662e97cf683b519218df523f66a65272494c 100644 (file)
@@ -4,14 +4,13 @@ import starshipfights.data.Id
 import starshipfights.game.*
 import kotlin.math.expm1
 import kotlin.math.pow
-import kotlin.random.Random
 
-val navAggression by instinct { Random.nextDouble(0.5, 1.5) }
-val navPassivity by instinct { Random.nextDouble(0.5, 1.5) }
-val navLustForBlood by instinct { Random.nextDouble(-0.5, 0.5) }
-val navSqueamishness by instinct { Random.nextDouble(0.25, 1.25) }
-val navTunnelVision by instinct { Random.nextDouble(-0.25, 1.25) }
-val navOptimality by instinct { Random.nextDouble(1.25, 2.75) }
+val navAggression by instinct(0.5..1.5)
+val navPassivity by instinct(0.5..1.5)
+val navLustForBlood by instinct(-0.5..0.5)
+val navSqueamishness by instinct(0.25..1.25)
+val navTunnelVision by instinct(-0.25..1.25)
+val navOptimality by instinct(1.25..2.75)
 
 fun ShipPosition.score(gameState: GameState, shipInstance: ShipInstance, instincts: Instincts, brain: Brain): Double {
        val ship = shipInstance.copy(position = this)
index d5a8a542468a09b89c96821d3c80f407858e1355..9e121bb0c7796fddb9ce5532a41c33a9b90bf0ef 100644 (file)
@@ -4,6 +4,7 @@ import starshipfights.game.EPSILON
 import starshipfights.game.Vec2
 import starshipfights.game.div
 import kotlin.math.absoluteValue
+import kotlin.math.nextUp
 import kotlin.math.pow
 import kotlin.math.sign
 import kotlin.random.Random
@@ -12,6 +13,8 @@ expect fun logInfo(message: Any?)
 expect fun logWarning(message: Any?)
 expect fun logError(message: Any?)
 
+fun ClosedFloatingPointRange<Double>.random(random: Random = Random) = random.nextDouble(start, endInclusive.nextUp())
+
 fun <T : Any> Map<T, Double>.weightedRandom(random: Random = Random): T {
        return weightedRandomOrNull(random) ?: error("Cannot take weighted random of effectively-empty collection!")
 }
index 028e68960d92a9d2f2d7f30c20492891082a851d..0a31e5b6e7f6f5c23b3957758cc5e2c133c85a68 100644 (file)
@@ -50,7 +50,7 @@ fun GameState.isValidTarget(ship: ShipInstance, weapon: ShipWeaponInstance, pick
        
        return when (val weaponSpec = weapon.weapon) {
                is AreaWeapon ->
-                       target.owner != ship.owner && (targetPos - pickRequest.boundary.closestPointTo(targetPos)).length <= weaponSpec.areaRadius
+                       target.owner != ship.owner && (targetPos - pickRequest.boundary.closestPointTo(targetPos)).length < weaponSpec.areaRadius
                else ->
                        target.owner in (pickRequest.type as PickType.Ship).allowSides && isValidPick(pickRequest, PickResponse.Ship(target.id))
        }
diff --git a/src/jvmTest/kotlin/starshipfights/game/ai/AITesting.kt b/src/jvmTest/kotlin/starshipfights/game/ai/AITesting.kt
new file mode 100644 (file)
index 0000000..67433e4
--- /dev/null
@@ -0,0 +1,144 @@
+package starshipfights.game.ai
+
+import kotlinx.coroutines.runBlocking
+import kotlinx.html.*
+import kotlinx.html.stream.createHTML
+import starshipfights.game.BattleSize
+import starshipfights.game.Faction
+import java.io.File
+import javax.swing.JOptionPane
+import javax.swing.UIManager
+
+object AITesting {
+       @JvmStatic
+       fun main(args: Array<String>) {
+               UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName())
+               
+               val instinctVectorCounts = listOf(5, 11, 17)
+               val instinctVectorOptions = instinctVectorCounts.map { it.toString() }.toTypedArray()
+               
+               val instinctVectorIndex = JOptionPane.showOptionDialog(
+                       null, "Please select the number of Instinct vectors to generate",
+                       "Generate Instinct Vectors", JOptionPane.DEFAULT_OPTION,
+                       JOptionPane.QUESTION_MESSAGE, null,
+                       instinctVectorOptions, instinctVectorOptions[0]
+               )
+               
+               if (instinctVectorIndex == JOptionPane.CLOSED_OPTION) return
+               
+               val instinctVectors = genInstinctCandidates(instinctVectorCounts[instinctVectorIndex])
+               
+               val numTrialCounts = listOf(3, 5, 7, 10)
+               val numTrialOptions = numTrialCounts.map { it.toString() }.toTypedArray()
+               
+               val numTrialIndex = JOptionPane.showOptionDialog(
+                       null, "Please select the number of trials to execute per instinct pairing",
+                       "Number of Trials", JOptionPane.DEFAULT_OPTION,
+                       JOptionPane.QUESTION_MESSAGE, null,
+                       numTrialOptions, numTrialOptions[0]
+               )
+               
+               if (numTrialIndex == JOptionPane.CLOSED_OPTION) return
+               
+               val numTrials = numTrialCounts[numTrialIndex]
+               
+               val allowedBattleSizeChoices = BattleSize.values().map { setOf(it) } + listOf(BattleSize.values().toSet())
+               val allowedBattleSizeOptions = allowedBattleSizeChoices.map { it.singleOrNull()?.displayName ?: "Allow Any" }.toTypedArray()
+               
+               val allowedBattleSizeIndex = JOptionPane.showOptionDialog(
+                       null, "Please select the allowed sizes of battle",
+                       "Allowed Battle Sizes", JOptionPane.DEFAULT_OPTION,
+                       JOptionPane.QUESTION_MESSAGE, null,
+                       allowedBattleSizeOptions, allowedBattleSizeOptions[0]
+               )
+               
+               if (allowedBattleSizeIndex == JOptionPane.CLOSED_OPTION) return
+               
+               val allowedBattleSizes = allowedBattleSizeChoices[allowedBattleSizeIndex]
+               
+               val allowedFactionChoices = Faction.values().map { setOf(it) } + listOf(Faction.values().toSet())
+               val allowedFactionOptions = allowedFactionChoices.map { it.singleOrNull()?.shortName ?: "Allow Any" }.toTypedArray()
+               
+               val allowedFactionIndex = JOptionPane.showOptionDialog(
+                       null, "Please select the allowed factions in battle",
+                       "Allowed Factions", JOptionPane.DEFAULT_OPTION,
+                       JOptionPane.QUESTION_MESSAGE, null,
+                       allowedFactionOptions, allowedFactionOptions[0]
+               )
+               
+               if (allowedFactionIndex == JOptionPane.CLOSED_OPTION) return
+               
+               val allowedFactions = allowedFactionChoices[allowedFactionIndex]
+               
+               val instinctSuccessRate = runBlocking {
+                       performTrials(numTrials, instinctVectors, allowedBattleSizes, allowedFactions)
+               }
+               
+               val indexedInstincts = instinctSuccessRate
+                       .toList()
+                       .sortedBy { (_, v) -> v }
+                       .mapIndexed { i, p -> (i + 1) to p }
+               
+               val results = createHTML(prettyPrint = false, xhtmlCompatible = true).html {
+                       head {
+                               title { +"Test Results" }
+                       }
+                       body {
+                               style = "font-family:sans-serif"
+                               
+                               h1 { +"Test Results" }
+                               p { +"These are the results of testing AI instinct parameters in Starship Fights" }
+                               h2 { +"Test Parameters" }
+                               p { +"Number of Instincts Generated: ${instinctVectors.size}" }
+                               p { +"Number of Trials Per Instinct Pairing: $numTrials" }
+                               p { +"Battle Sizes Allowed: ${allowedBattleSizes.singleOrNull()?.displayName ?: "All"}" }
+                               p { +"Factions Allowed: ${allowedFactions.singleOrNull()?.polityName ?: "All"}" }
+                               h2 { +"Instincts Vectors and Battle Results" }
+                               table {
+                                       val cellStyle = "border: 1px solid rgba(0, 0, 0, 0.6)"
+                                       thead {
+                                               tr {
+                                                       th(scope = ThScope.row) {
+                                                               style = cellStyle
+                                                               +"Vector Values"
+                                                       }
+                                                       th(scope = ThScope.col) {
+                                                               style = cellStyle
+                                                               +"Battles Won as Host"
+                                                       }
+                                                       allInstincts.forEach {
+                                                               th(scope = ThScope.col) {
+                                                                       style = cellStyle
+                                                                       +it.key
+                                                               }
+                                                       }
+                                               }
+                                       }
+                                       tbody {
+                                               indexedInstincts.forEach { (i, pair) ->
+                                                       val (instincts, successRate) = pair
+                                                       tr {
+                                                               th(scope = ThScope.row) {
+                                                                       style = cellStyle
+                                                                       +"Instincts $i"
+                                                               }
+                                                               td {
+                                                                       style = cellStyle
+                                                                       +"$successRate"
+                                                               }
+                                                               allInstincts.forEach { key ->
+                                                                       td {
+                                                                               style = cellStyle
+                                                                               +"${instincts[key]}"
+                                                                       }
+                                                               }
+                                                       }
+                                               }
+                                       }
+                               }
+                       }
+               }
+               
+               File("test_results.html").writeText(results)
+       }
+}
diff --git a/src/jvmTest/resources/logback.xml b/src/jvmTest/resources/logback.xml
new file mode 100644 (file)
index 0000000..cd42545
--- /dev/null
@@ -0,0 +1,12 @@
+<?xml version="1.1" encoding="utf-8"?>
+<configuration>
+       <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
+               <encoder>
+                       <pattern>%d{yyyy-MM-dd/HH:mm:ss.SSS} [%thread] %.-1level \(%logger\) - %msg%n</pattern>
+               </encoder>
+       </appender>
+
+       <root level="any">
+               <appender-ref ref="STDOUT"/>
+       </root>
+</configuration>