From: TheSaminator Date: Thu, 2 Jun 2022 15:26:30 +0000 (-0400) Subject: Add AI optimization capabilities X-Git-Url: https://gitweb.starshipfights.net/?a=commitdiff_plain;h=dac0838291d4f2112e62a5c9378fecbd1a6bb486;p=starship-fights Add AI optimization capabilities --- diff --git a/.gitignore b/.gitignore index 3d8be00..d821568 100644 --- a/.gitignore +++ b/.gitignore @@ -154,6 +154,7 @@ gradle-app.setting logs/ mongodb/ config.json +test_results.html ### Hosting server-side .bash_history diff --git a/build.gradle.kts b/build.gradle.kts index 1075b6a..3fad64f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -181,3 +181,9 @@ tasks.named("run") { dependsOn(tasks.named("jvmJar")) classpath(tasks.named("jvmJar")) } + +tasks.create("runAiTest", JavaExec::class.java) { + group = "test" + classpath = sourceSets.getByName("test").runtimeClasspath + mainClass.set("starshipfights.game.ai.AITesting") +} diff --git a/src/commonMain/kotlin/starshipfights/game/ai/ai_behaviors.kt b/src/commonMain/kotlin/starshipfights/game/ai/ai_behaviors.kt index a89f43e..685dd5b 100644 --- a/src/commonMain/kotlin/starshipfights/game/ai/ai_behaviors.kt +++ b/src/commonMain/kotlin/starshipfights/game/ai/ai_behaviors.kt @@ -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, val doActions: SendChannel, val getErrors: ReceiveChannel, + 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>(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))) + } } } } diff --git a/src/commonMain/kotlin/starshipfights/game/ai/ai_brainitude.kt b/src/commonMain/kotlin/starshipfights/game/ai/ai_brainitude.kt index 22c91e4..e5cd88b 100644 --- a/src/commonMain/kotlin/starshipfights/game/ai/ai_brainitude.kt +++ b/src/commonMain/kotlin/starshipfights/game/ai/ai_brainitude.kt @@ -14,13 +14,17 @@ import kotlin.properties.ReadOnlyProperty value class Instincts private constructor(private val numbers: MutableMap) { 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) = Instincts(values.toMutableMap()) + } } -data class Instinct(val key: String, val default: () -> Double) +data class Instinct(val key: String, val randRange: ClosedFloatingPointRange) -fun instinct(default: () -> Double) = ReadOnlyProperty { _, property -> - Instinct(property.name, default) +fun instinct(randRange: ClosedFloatingPointRange) = ReadOnlyProperty { _, property -> + Instinct(property.name, randRange) } @JvmInline diff --git a/src/commonMain/kotlin/starshipfights/game/ai/ai_coroutine.kt b/src/commonMain/kotlin/starshipfights/game/ai/ai_coroutine.kt index 8ef8eb1..0c3cfa5 100644 --- a/src/commonMain/kotlin/starshipfights/game/ai/ai_coroutine.kt +++ b/src/commonMain/kotlin/starshipfights/game/ai/ai_coroutine.kt @@ -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 index 0000000..fd998e5 --- /dev/null +++ b/src/commonMain/kotlin/starshipfights/game/ai/ai_optimization.kt @@ -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 { + 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(Channel.UNLIMITED) + private val guestErrorMessages = Channel(Channel.UNLIMITED) + + private fun errorMessageChannel(player: GlobalSide) = when (player) { + GlobalSide.HOST -> hostErrorMessages + GlobalSide.GUEST -> guestErrorMessages + } + + fun errorMessages(player: GlobalSide): ReceiveChannel = when (player) { + GlobalSide.HOST -> hostErrorMessages + GlobalSide.GUEST -> guestErrorMessages + } + + private val gameEndMutable = CompletableDeferred() + val gameEnd: Deferred + 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() + val hostEvents = Channel() + val hostSession = AISession(GlobalSide.HOST, hostActions, hostEvents, hostInstincts) + + val guestActions = Channel() + val guestEvents = Channel() + 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, 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, validBattleSizes: Set = BattleSize.values().toSet(), validFactions: Set = Faction.values().toSet()): Map { + 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 index 0000000..3adcaa1 --- /dev/null +++ b/src/commonMain/kotlin/starshipfights/game/ai/ai_optimization_util.kt @@ -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 { + 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> { + 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>() + 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.isNullVector: Boolean + get() { + return all { abs(it) < EPSILON } + } + +fun Iterable.normalize(): List { + val magnitude = sqrt(sumOf { it * it }) + if (magnitude < EPSILON) + throw IllegalArgumentException("Cannot normalize the zero vector!") + + return this div magnitude +} + +infix fun Iterable.dot(other: Iterable): 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.project(onto: Iterable): List { + 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.plus(other: Iterable): List { + 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.minus(other: Iterable) = this plus (other times -1.0) + +infix fun Iterable.times(scale: Double): List = map { it * scale } +infix fun Iterable.div(scale: Double): List = map { it / scale } + +fun Instinct.denormalize(normalValue: Double): Double { + val zeroToOne = (normalValue + 1) / 2 + return (zeroToOne * (randRange.endInclusive - randRange.start)) + randRange.start +} diff --git a/src/commonMain/kotlin/starshipfights/game/ai/ai_util_combat.kt b/src/commonMain/kotlin/starshipfights/game/ai/ai_util_combat.kt index 362e8c2..e8050c0 100644 --- a/src/commonMain/kotlin/starshipfights/game/ai/ai_util_combat.kt +++ b/src/commonMain/kotlin/starshipfights/game/ai/ai_util_combat.kt @@ -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) diff --git a/src/commonMain/kotlin/starshipfights/game/ai/ai_util_deploy.kt b/src/commonMain/kotlin/starshipfights/game/ai/ai_util_deploy.kt index ce3ab8b..8bb6e23 100644 --- a/src/commonMain/kotlin/starshipfights/game/ai/ai_util_deploy.kt +++ b/src/commonMain/kotlin/starshipfights/game/ai/ai_util_deploy.kt @@ -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) { diff --git a/src/commonMain/kotlin/starshipfights/game/ai/ai_util_nav.kt b/src/commonMain/kotlin/starshipfights/game/ai/ai_util_nav.kt index 0cdbf18..fe7b662 100644 --- a/src/commonMain/kotlin/starshipfights/game/ai/ai_util_nav.kt +++ b/src/commonMain/kotlin/starshipfights/game/ai/ai_util_nav.kt @@ -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) diff --git a/src/commonMain/kotlin/starshipfights/game/ai/util.kt b/src/commonMain/kotlin/starshipfights/game/ai/util.kt index d5a8a54..9e121bb 100644 --- a/src/commonMain/kotlin/starshipfights/game/ai/util.kt +++ b/src/commonMain/kotlin/starshipfights/game/ai/util.kt @@ -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.random(random: Random = Random) = random.nextDouble(start, endInclusive.nextUp()) + fun Map.weightedRandom(random: Random = Random): T { return weightedRandomOrNull(random) ?: error("Cannot take weighted random of effectively-empty collection!") } diff --git a/src/commonMain/kotlin/starshipfights/game/game_initiative.kt b/src/commonMain/kotlin/starshipfights/game/game_initiative.kt index 028e689..0a31e5b 100644 --- a/src/commonMain/kotlin/starshipfights/game/game_initiative.kt +++ b/src/commonMain/kotlin/starshipfights/game/game_initiative.kt @@ -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 index 0000000..67433e4 --- /dev/null +++ b/src/jvmTest/kotlin/starshipfights/game/ai/AITesting.kt @@ -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) { + 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 index 0000000..cd42545 --- /dev/null +++ b/src/jvmTest/resources/logback.xml @@ -0,0 +1,12 @@ + + + + + %d{yyyy-MM-dd/HH:mm:ss.SSS} [%thread] %.-1level \(%logger\) - %msg%n + + + + + + +