logs/
mongodb/
config.json
+test_results.html
### Hosting server-side
.bash_history
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")
+}
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
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 {
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 }) {
}
}
- launch {
+ launch(onGameEnd) {
for ((phase, canAct) in phasePipe) {
if (!canAct) continue
}
}
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)
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)
}
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)))
+ }
}
}
}
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
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
val aiPlayer = AIPlayer(
gameStateFlow,
session.actions,
- errors
+ errors,
+ gameDone
)
val behavingJob = launch {
gameDone.join()
- behavingJob.cancelAndJoin()
- handlingJob.cancelAndJoin()
+ try {
+ behavingJob.join()
+ } catch (_: CancellationException) {
+ // ignore it
+ }
+
+ try {
+ handlingJob.join()
+ } catch (_: CancellationException) {
+ // ignore it again
+ }
session.actions.close()
}
--- /dev/null
+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() }
+ }
+}
--- /dev/null
+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
+}
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)
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) {
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)
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
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!")
}
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))
}
--- /dev/null
+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)
+ }
+}
--- /dev/null
+<?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>