Add singleplayer against AI
authorTheSaminator <TheSaminator@users.noreply.github.com>
Mon, 30 May 2022 16:17:00 +0000 (12:17 -0400)
committerTheSaminator <TheSaminator@users.noreply.github.com>
Mon, 30 May 2022 16:17:00 +0000 (12:17 -0400)
41 files changed:
src/commonMain/kotlin/starshipfights/game/admiralty.kt
src/commonMain/kotlin/starshipfights/game/ai/ai_behaviors.kt [new file with mode: 0644]
src/commonMain/kotlin/starshipfights/game/ai/ai_brainitude.kt [new file with mode: 0644]
src/commonMain/kotlin/starshipfights/game/ai/ai_coroutine.kt [new file with mode: 0644]
src/commonMain/kotlin/starshipfights/game/ai/ai_util.kt [new file with mode: 0644]
src/commonMain/kotlin/starshipfights/game/ai/ai_util_combat.kt [new file with mode: 0644]
src/commonMain/kotlin/starshipfights/game/ai/ai_util_deploy.kt [new file with mode: 0644]
src/commonMain/kotlin/starshipfights/game/ai/ai_util_nav.kt [new file with mode: 0644]
src/commonMain/kotlin/starshipfights/game/ai/util.kt [new file with mode: 0644]
src/commonMain/kotlin/starshipfights/game/client_mode.kt
src/commonMain/kotlin/starshipfights/game/game_ability.kt
src/commonMain/kotlin/starshipfights/game/game_initiative.kt
src/commonMain/kotlin/starshipfights/game/game_phase.kt
src/commonMain/kotlin/starshipfights/game/game_time.kt
src/commonMain/kotlin/starshipfights/game/matchmaking.kt
src/commonMain/kotlin/starshipfights/game/math.kt
src/commonMain/kotlin/starshipfights/game/pick_bounds.kt
src/commonMain/kotlin/starshipfights/game/ship_modules.kt
src/commonMain/kotlin/starshipfights/game/ship_types.kt
src/commonMain/kotlin/starshipfights/game/ship_weapons.kt
src/jsMain/kotlin/starshipfights/game/ai/util_js.kt [new file with mode: 0644]
src/jsMain/kotlin/starshipfights/game/client.kt
src/jsMain/kotlin/starshipfights/game/client_game.kt
src/jsMain/kotlin/starshipfights/game/client_matchmaking.kt
src/jsMain/kotlin/starshipfights/game/client_training.kt [new file with mode: 0644]
src/jsMain/kotlin/starshipfights/game/game_resources.kt
src/jsMain/kotlin/starshipfights/game/game_time_js.kt
src/jsMain/kotlin/starshipfights/game/game_ui.kt
src/jsMain/kotlin/starshipfights/game/popup.kt
src/jsMain/kotlin/starshipfights/game/popup_util.kt
src/jvmMain/kotlin/starshipfights/data/admiralty/admirals.kt
src/jvmMain/kotlin/starshipfights/data/admiralty/ship_prices.kt
src/jvmMain/kotlin/starshipfights/game/ai/util_jvm.kt [new file with mode: 0644]
src/jvmMain/kotlin/starshipfights/game/endpoints_game.kt
src/jvmMain/kotlin/starshipfights/game/game_start_jvm.kt
src/jvmMain/kotlin/starshipfights/game/game_time_jvm.kt
src/jvmMain/kotlin/starshipfights/game/server_matchmaking.kt
src/jvmMain/kotlin/starshipfights/game/views_game.kt
src/jvmMain/kotlin/starshipfights/game/views_training.kt [new file with mode: 0644]
src/jvmMain/kotlin/starshipfights/info/views_ships.kt
src/jvmMain/kotlin/starshipfights/info/views_user.kt

index 256009a11103058ede1b0a16e00f22332a0ff377..bef107603cdde5a7f04cab59dc6fd92c147626d1 100644 (file)
@@ -20,7 +20,7 @@ enum class AdmiralRank {
                }
        
        val maxBattleSize: BattleSize
-               get() = BattleSize.values().last { it.maxWeightClass.rank <= maxShipWeightClass.rank }
+               get() = BattleSize.values().last { it.maxWeightClass.tier <= maxShipWeightClass.tier }
        
        val minAcumen: Int
                get() = when (this) {
diff --git a/src/commonMain/kotlin/starshipfights/game/ai/ai_behaviors.kt b/src/commonMain/kotlin/starshipfights/game/ai/ai_behaviors.kt
new file mode 100644 (file)
index 0000000..ce77d49
--- /dev/null
@@ -0,0 +1,326 @@
+package starshipfights.game.ai
+
+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 starshipfights.data.Id
+import starshipfights.game.*
+import kotlin.math.pow
+import kotlin.random.Random
+
+data class AIPlayer(
+       val gameState: StateFlow<GameState>,
+       val doActions: SendChannel<PlayerAction>,
+       val getErrors: ReceiveChannel<String>,
+)
+
+suspend fun AIPlayer.behave(instincts: Instincts, mySide: GlobalSide) {
+       try {
+               coroutineScope {
+                       val brain = Brain()
+                       
+                       val phasePipe = Channel<Pair<GamePhase, Boolean>>(Channel.CONFLATED)
+                       
+                       launch {
+                               var prevSentAt = Moment.now
+                               
+                               gameState.collect { state ->
+                                       logInfo(jsonSerializer.encodeToString(Brain.serializer(), brain))
+                                       
+                                       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 }) {
+                                               if (msg.sentAt > prevSentAt)
+                                                       prevSentAt = msg.sentAt
+                                               
+                                               when (msg) {
+                                                       is ChatEntry.PlayerMessage -> {
+                                                               // ignore
+                                                       }
+                                                       is ChatEntry.ShipIdentified -> {
+                                                               val identifiedShip = state.ships[msg.ship] ?: continue
+                                                               if (identifiedShip.owner != mySide)
+                                                                       brain[shipAttackPriority forShip identifiedShip.id] += identifiedShip.ship.shipType.weightClass.tier.toDouble().pow(instincts[combatTargetShipWeight])
+                                                       }
+                                                       is ChatEntry.ShipEscaped -> {
+                                                               // handle escaping ship
+                                                       }
+                                                       is ChatEntry.ShipAttacked -> {
+                                                               val targetedShip = state.ships[msg.ship] ?: continue
+                                                               if (targetedShip.owner != mySide)
+                                                                       brain[shipAttackPriority forShip targetedShip.id] -= Random.nextDouble(msg.damageInflicted - 0.5, msg.damageInflicted + 0.5) * instincts[combatForgiveTarget]
+                                                               else if (msg.attacker is ShipAttacker.EnemyShip)
+                                                                       brain[shipAttackPriority forShip msg.attacker.id] += Random.nextDouble(msg.damageInflicted - 0.5, msg.damageInflicted + 0.5) * instincts[combatAvengeAttacks]
+                                                       }
+                                                       is ChatEntry.ShipAttackFailed -> {
+                                                               val targetedShip = state.ships[msg.ship] ?: continue
+                                                               if (targetedShip.owner != mySide)
+                                                                       brain[shipAttackPriority forShip targetedShip.id] += instincts[combatFrustratedByFailedAttacks]
+                                                       }
+                                                       is ChatEntry.ShipDestroyed -> {
+                                                               val targetedShip = state.ships[msg.ship] ?: continue
+                                                               if (targetedShip.owner == mySide && msg.destroyedBy is ShipAttacker.EnemyShip)
+                                                                       brain[shipAttackPriority forShip msg.destroyedBy.id] += instincts[combatAvengeShipwrecks] * targetedShip.ship.shipType.weightClass.tier.toDouble().pow(instincts[combatAvengeShipWeight])
+                                                       }
+                                               }
+                                       }
+                               }
+                       }
+                       
+                       launch {
+                               for ((phase, canAct) in phasePipe) {
+                                       if (!canAct) continue
+                                       
+                                       val state = gameState.value
+                                       
+                                       when (phase) {
+                                               GamePhase.Deploy -> {
+                                                       for ((shipId, position) in deploy(state, mySide, instincts)) {
+                                                               val abilityType = PlayerAbilityType.DeployShip(shipId.reinterpret())
+                                                               val abilityData = PlayerAbilityData.DeployShip(position)
+                                                               
+                                                               doActions.send(PlayerAction.UseAbility(abilityType, abilityData))
+                                                               
+                                                               withTimeoutOrNull(50L) { getErrors.receive() }?.let { errorMsg ->
+                                                                       logWarning("Error when deploying ship ID $shipId - $errorMsg")
+                                                               }
+                                                       }
+                                                       
+                                                       doActions.send(PlayerAction.UseAbility(PlayerAbilityType.DonePhase(phase), PlayerAbilityData.DonePhase))
+                                               }
+                                               is GamePhase.Power -> {
+                                                       doActions.send(PlayerAction.UseAbility(PlayerAbilityType.DonePhase(phase), PlayerAbilityData.DonePhase))
+                                               }
+                                               is GamePhase.Move -> {
+                                                       val movableShips = state.ships.values.filter { ship ->
+                                                               ship.owner == mySide && !ship.isDoneCurrentPhase
+                                                       }
+                                                       
+                                                       val smallestShipTier = movableShips.minOfOrNull { ship -> ship.ship.shipType.weightClass.tier }
+                                                       
+                                                       if (smallestShipTier == null)
+                                                               doActions.send(PlayerAction.UseAbility(PlayerAbilityType.DonePhase(phase), PlayerAbilityData.DonePhase))
+                                                       else {
+                                                               val movableSmallestShips = movableShips.filter { ship ->
+                                                                       ship.ship.shipType.weightClass.tier == smallestShipTier
+                                                               }
+                                                               
+                                                               val moveThisShip = movableSmallestShips.associateWith { it.calculateSuffering() + 1.0 }.weightedRandom()
+                                                               doActions.send(navigate(state, moveThisShip, instincts, brain))
+                                                               
+                                                               withTimeoutOrNull(50L) { getErrors.receive() }?.let { error ->
+                                                                       logWarning("Error when moving ship ID ${moveThisShip.id} - $error")
+                                                                       doActions.send(
+                                                                               PlayerAction.UseAbility(
+                                                                                       PlayerAbilityType.MoveShip(moveThisShip.id),
+                                                                                       PlayerAbilityData.MoveShip(moveThisShip.position)
+                                                                               )
+                                                                       )
+                                                               }
+                                                       }
+                                               }
+                                               is GamePhase.Attack -> {
+                                                       val attackWith = state.ships.values.flatMap { ship ->
+                                                               if (ship.owner == mySide)
+                                                                       ship.armaments.weaponInstances.keys.filter {
+                                                                               ship.canUseWeapon(it)
+                                                                       }.flatMap { weaponId ->
+                                                                               weaponId.validTargets(state, ship).map { target ->
+                                                                                       Triple(ship, weaponId, target)
+                                                                               }
+                                                                       }
+                                                               else emptyList()
+                                                       }.associateWith { (ship, weaponId, target) ->
+                                                               weaponId.expectedAdvantageFromWeaponUsage(state, ship, target) * brain[shipAttackPriority forShip target.id].signedPow(instincts[combatPrioritization])
+                                                       }.weightedRandomOrNull()
+                                                       
+                                                       if (attackWith == null)
+                                                               doActions.send(PlayerAction.UseAbility(PlayerAbilityType.DonePhase(phase), PlayerAbilityData.DonePhase))
+                                                       else {
+                                                               val (ship, weaponId, target) = attackWith
+                                                               val targetPickResponse = if (ship.armaments.weaponInstances[weaponId]?.weapon is AreaWeapon)
+                                                                       PickResponse.Location(target.position.location)
+                                                               else
+                                                                       PickResponse.Ship(target.id)
+                                                               
+                                                               doActions.send(PlayerAction.UseAbility(PlayerAbilityType.UseWeapon(ship.id, weaponId), PlayerAbilityData.UseWeapon(targetPickResponse)))
+                                                       }
+                                               }
+                                               is GamePhase.Repair -> {
+                                                       val repairAbility = state.getPossibleAbilities(mySide).filter {
+                                                               it !is PlayerAbilityType.DonePhase
+                                                       }.randomOrNull()
+                                                       
+                                                       if (repairAbility == null)
+                                                               doActions.send(PlayerAction.UseAbility(PlayerAbilityType.DonePhase(phase), PlayerAbilityData.DonePhase))
+                                                       else {
+                                                               when (repairAbility) {
+                                                                       is PlayerAbilityType.RepairShipModule -> PlayerAbilityData.RepairShipModule
+                                                                       is PlayerAbilityType.ExtinguishFire -> PlayerAbilityData.ExtinguishFire
+                                                                       is PlayerAbilityType.Recoalesce -> PlayerAbilityData.Recoalesce
+                                                                       else -> null
+                                                               }?.let { repairData ->
+                                                                       doActions.send(PlayerAction.UseAbility(repairAbility, repairData))
+                                                               }
+                                                       }
+                                               }
+                                       }
+                               }
+                       }
+               }
+       } catch (ex: Exception) {
+               logError(ex)
+               doActions.send(PlayerAction.SendChatMessage(ex.stackTraceToString()))
+               delay(2000L)
+               doActions.send(PlayerAction.Disconnect)
+       }
+}
+
+fun deploy(gameState: GameState, mySide: GlobalSide, instincts: Instincts): Map<Id<ShipInstance>, Position> {
+       val size = gameState.battleInfo.size
+       val totalPoints = size.numPoints
+       val maxWC = size.maxWeightClass
+       
+       val myStart = gameState.start.playerStart(mySide)
+       
+       val deployable = myStart.deployableFleet.values.filter { it.shipType.weightClass.tier <= maxWC.tier }.toMutableSet()
+       val deployed = mutableSetOf<Ship>()
+       
+       while (true) {
+               val deployShip = deployable.filter { ship ->
+                       deployed.sumOf { it.pointCost } + ship.pointCost <= totalPoints
+               }.associateWith { ship ->
+                       instincts[ship.shipType.weightClass.focus]
+               }.weightedRandomOrNull() ?: break
+               
+               deployable -= deployShip
+               deployed += deployShip
+       }
+       
+       return placeShips(deployed, myStart.deployZone)
+}
+
+fun navigate(gameState: GameState, ship: ShipInstance, instincts: Instincts, brain: Brain): PlayerAction.UseAbility {
+       val noEnemyShipsSeen = gameState.ships.values.none { it.owner != ship.owner && it.isIdentified }
+       
+       if (noEnemyShipsSeen || !ship.isIdentified) return engage(gameState, ship)
+       
+       val currPos = ship.position.location
+       val currAngle = ship.position.facing
+       
+       val movement = ship.movement
+       
+       if (movement is FelinaeShipMovement && Random.nextDouble() < 1.0 / (ship.usedInertialessDriveShots * 3 + 5)) {
+               val maxJump = movement.inertialessDriveRange * 0.99
+               
+               val positions = listOf(
+                       normalVector(currAngle),
+                       normalVector(currAngle).let { (x, y) -> Vec2(-y, x) },
+                       -normalVector(currAngle),
+                       normalVector(currAngle).let { (x, y) -> Vec2(y, -x) },
+               ).flatMap {
+                       listOf(
+                               ShipPosition(currPos + (Distance(it) * maxJump), it.angle),
+                               ShipPosition(currPos + (Distance(it) * (maxJump * 2 / 3)), it.angle),
+                               ShipPosition(currPos + (Distance(it) * (maxJump / 3)), it.angle),
+                       )
+               }.filter { shipPos ->
+                       (gameState.ships - ship.id).none { (_, otherShip) ->
+                               (otherShip.position.location - shipPos.location).length <= SHIP_BASE_SIZE
+                       }
+               }
+               
+               val position = positions.associateWith {
+                       it.score(gameState, ship, instincts, brain)
+               }.weightedRandomOrNull() ?: return pursue(gameState, ship)
+               
+               return PlayerAction.UseAbility(
+                       PlayerAbilityType.UseInertialessDrive(ship.id),
+                       PlayerAbilityData.UseInertialessDrive(position.location)
+               )
+       }
+       
+       val maxTurn = movement.turnAngle * 0.99
+       val maxMove = movement.moveSpeed * 0.99
+       
+       val positions = (listOf(
+               normalDistance(currAngle) rotatedBy -maxTurn,
+               normalDistance(currAngle) rotatedBy (-maxTurn / 2),
+               normalDistance(currAngle),
+               normalDistance(currAngle) rotatedBy (maxTurn / 2),
+               normalDistance(currAngle) rotatedBy maxTurn,
+       ).flatMap {
+               listOf(
+                       ShipPosition(currPos + (it * maxMove), it.angle),
+                       ShipPosition(currPos + (it * (maxMove / 2)), it.angle),
+               )
+       } + listOf(ship.position)).filter { shipPos ->
+               (gameState.ships - ship.id).none { (_, otherShip) ->
+                       (otherShip.position.location - shipPos.location).length <= SHIP_BASE_SIZE
+               }
+       }
+       
+       val position = positions.associateWith {
+               it.score(gameState, ship, instincts, brain)
+       }.weightedRandomOrNull() ?: return pursue(gameState, ship)
+       
+       return PlayerAction.UseAbility(
+               PlayerAbilityType.MoveShip(ship.id),
+               PlayerAbilityData.MoveShip(position)
+       )
+}
+
+fun engage(gameState: GameState, ship: ShipInstance): PlayerAction.UseAbility {
+       val mySideMeanPosition = gameState.ships.values
+               .filter { it.owner == ship.owner }
+               .map { it.position.location.vector }
+               .mean()
+       
+       val enemySideMeanPosition = gameState.ships.values
+               .filter { it.owner != ship.owner }
+               .map { it.position.location.vector }
+               .mean()
+       
+       val angleTo = normalVector(ship.position.facing) angleTo (enemySideMeanPosition - mySideMeanPosition)
+       val maxTurn = ship.movement.turnAngle * 0.99
+       val turnNormal = normalDistance(ship.position.facing) rotatedBy angleTo.coerceIn(-maxTurn..maxTurn)
+       
+       val move = (ship.movement.moveSpeed * 0.99) * turnNormal
+       val newLoc = ship.position.location + move
+       
+       val position = ShipPosition(newLoc, move.angle)
+       
+       return PlayerAction.UseAbility(
+               PlayerAbilityType.MoveShip(ship.id),
+               PlayerAbilityData.MoveShip(position)
+       )
+}
+
+fun pursue(gameState: GameState, ship: ShipInstance): PlayerAction.UseAbility {
+       val myLocation = ship.position.location
+       val targetLocation = gameState.ships.values.filter { it.owner != ship.owner }.map { it.position.location }.minByOrNull { loc ->
+               (loc - myLocation).length
+       } ?: return PlayerAction.UseAbility(
+               PlayerAbilityType.MoveShip(ship.id),
+               PlayerAbilityData.MoveShip(ship.position)
+       )
+       
+       val angleTo = normalDistance(ship.position.facing) angleTo (targetLocation - myLocation)
+       val maxTurn = ship.movement.turnAngle * 0.99
+       val turnNormal = normalDistance(ship.position.facing) rotatedBy angleTo.coerceIn(-maxTurn..maxTurn)
+       
+       val move = (ship.movement.moveSpeed * if (turnNormal angleBetween (targetLocation - myLocation) < EPSILON) 0.999 else 0.501) * turnNormal
+       val newLoc = ship.position.location + move
+       
+       val position = ShipPosition(newLoc, move.angle)
+       
+       return PlayerAction.UseAbility(
+               PlayerAbilityType.MoveShip(ship.id),
+               PlayerAbilityData.MoveShip(position)
+       )
+}
diff --git a/src/commonMain/kotlin/starshipfights/game/ai/ai_brainitude.kt b/src/commonMain/kotlin/starshipfights/game/ai/ai_brainitude.kt
new file mode 100644 (file)
index 0000000..22c91e4
--- /dev/null
@@ -0,0 +1,56 @@
+package starshipfights.game.ai
+
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.JsonElement
+import starshipfights.data.Id
+import starshipfights.game.ShipInstance
+import starshipfights.game.jsonSerializer
+import kotlin.jvm.JvmInline
+import kotlin.properties.ReadOnlyProperty
+
+@JvmInline
+@Serializable
+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)
+}
+
+data class Instinct(val key: String, val default: () -> Double)
+
+fun instinct(default: () -> Double) = ReadOnlyProperty<Any?, Instinct> { _, property ->
+       Instinct(property.name, default)
+}
+
+@JvmInline
+@Serializable
+value class Brain private constructor(private val data: MutableMap<String, JsonElement>) {
+       constructor() : this(mutableMapOf())
+       
+       operator fun <T> get(neuron: Neuron<T>) = jsonSerializer.decodeFromJsonElement(
+               neuron.codec,
+               data.getOrPut(neuron.key) {
+                       jsonSerializer.encodeToJsonElement(
+                               neuron.codec,
+                               neuron.default()
+                       )
+               }
+       )
+       
+       operator fun <T> set(neuron: Neuron<T>, value: T) = data.set(
+               neuron.key,
+               jsonSerializer.encodeToJsonElement(
+                       neuron.codec,
+                       value
+               )
+       )
+}
+
+data class Neuron<T>(val key: String, val codec: KSerializer<T>, val default: () -> T)
+
+fun <T> neuron(codec: KSerializer<T>, default: () -> T) = ReadOnlyProperty<Any?, Neuron<T>> { _, property ->
+       Neuron(property.name, codec, default)
+}
+
+infix fun <T> Neuron<T>.forShip(ship: Id<ShipInstance>) = copy(key = "$key[$ship]")
diff --git a/src/commonMain/kotlin/starshipfights/game/ai/ai_coroutine.kt b/src/commonMain/kotlin/starshipfights/game/ai/ai_coroutine.kt
new file mode 100644 (file)
index 0000000..8ef8eb1
--- /dev/null
@@ -0,0 +1,54 @@
+package starshipfights.game.ai
+
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancelAndJoin
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.channels.ReceiveChannel
+import kotlinx.coroutines.channels.SendChannel
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.launch
+import starshipfights.game.GameEvent
+import starshipfights.game.GameState
+import starshipfights.game.GlobalSide
+import starshipfights.game.PlayerAction
+
+data class AISession(
+       val mySide: GlobalSide,
+       val actions: SendChannel<PlayerAction>,
+       val events: ReceiveChannel<GameEvent>,
+       val instincts: Instincts = Instincts(),
+)
+
+suspend fun aiPlayer(session: AISession, initialState: GameState) = coroutineScope {
+       val gameDone = Job()
+       
+       val errors = Channel<String>()
+       val gameStateFlow = MutableStateFlow(initialState)
+       val aiPlayer = AIPlayer(
+               gameStateFlow,
+               session.actions,
+               errors
+       )
+       
+       val behavingJob = launch {
+               aiPlayer.behave(session.instincts, session.mySide)
+       }
+       
+       val handlingJob = launch {
+               for (event in session.events) {
+                       when (event) {
+                               is GameEvent.GameEnd -> gameDone.complete()
+                               is GameEvent.InvalidAction -> launch { errors.send(event.message) }
+                               is GameEvent.StateChange -> gameStateFlow.value = event.newState
+                       }
+               }
+       }
+       
+       gameDone.join()
+       
+       behavingJob.cancelAndJoin()
+       handlingJob.cancelAndJoin()
+       
+       session.actions.close()
+}
diff --git a/src/commonMain/kotlin/starshipfights/game/ai/ai_util.kt b/src/commonMain/kotlin/starshipfights/game/ai/ai_util.kt
new file mode 100644 (file)
index 0000000..7f9c525
--- /dev/null
@@ -0,0 +1,5 @@
+package starshipfights.game.ai
+
+import kotlinx.serialization.builtins.serializer
+
+val shipAttackPriority by neuron(Double.serializer()) { 1.0 }
diff --git a/src/commonMain/kotlin/starshipfights/game/ai/ai_util_combat.kt b/src/commonMain/kotlin/starshipfights/game/ai/ai_util_combat.kt
new file mode 100644 (file)
index 0000000..8cbfc61
--- /dev/null
@@ -0,0 +1,32 @@
+package starshipfights.game.ai
+
+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 combatAvengeShipwrecks by instinct { Random.nextDouble(0.5, 4.5) }
+val combatAvengeShipWeight by instinct { Random.nextDouble(-0.5, 1.5) }
+
+val combatPrioritization by instinct { Random.nextDouble(-1.0, 1.0) }
+
+val combatAvengeAttacks by instinct { Random.nextDouble(0.5, 4.5) }
+val combatForgiveTarget by instinct { Random.nextDouble(-1.5, 2.5) }
+
+val combatFrustratedByFailedAttacks by instinct { Random.nextDouble(-2.5, 5.5) }
+
+fun ShipInstance.calculateSuffering(): Double {
+       return (durability.maxHullPoints - hullAmount) + (if (ship.reactor is FelinaeShipReactor)
+               0
+       else powerMode.shields - shieldAmount) + (numFires * 0.5) + modulesStatus.statuses.values.sumOf { status ->
+               when (status) {
+                       ShipModuleStatus.INTACT -> 0.0
+                       ShipModuleStatus.DAMAGED -> 0.75
+                       ShipModuleStatus.DESTROYED -> 1.5
+                       ShipModuleStatus.ABSENT -> 0.0
+               }
+       }
+}
diff --git a/src/commonMain/kotlin/starshipfights/game/ai/ai_util_deploy.kt b/src/commonMain/kotlin/starshipfights/game/ai/ai_util_deploy.kt
new file mode 100644 (file)
index 0000000..ce3ab8b
--- /dev/null
@@ -0,0 +1,88 @@
+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 ShipWeightClass.focus: Instinct
+       get() = when (this) {
+               ShipWeightClass.ESCORT -> deployEscortFocus
+               ShipWeightClass.DESTROYER -> deployCruiserFocus
+               ShipWeightClass.CRUISER -> deployCruiserFocus
+               ShipWeightClass.BATTLECRUISER -> deployCruiserFocus
+               ShipWeightClass.BATTLESHIP -> deployBattleshipFocus
+               
+               ShipWeightClass.BATTLE_BARGE -> deployBattleshipFocus
+               
+               ShipWeightClass.GRAND_CRUISER -> deployBattleshipFocus
+               ShipWeightClass.COLOSSUS -> deployBattleshipFocus
+               
+               ShipWeightClass.FF_ESCORT -> deployEscortFocus
+               ShipWeightClass.FF_DESTROYER -> deployCruiserFocus
+               ShipWeightClass.FF_CRUISER -> deployCruiserFocus
+               ShipWeightClass.FF_BATTLECRUISER -> deployCruiserFocus
+               ShipWeightClass.FF_BATTLESHIP -> deployBattleshipFocus
+               
+               ShipWeightClass.AUXILIARY_SHIP -> deployEscortFocus
+               ShipWeightClass.LIGHT_CRUISER -> deployEscortFocus
+               ShipWeightClass.MEDIUM_CRUISER -> deployCruiserFocus
+               ShipWeightClass.HEAVY_CRUISER -> deployBattleshipFocus
+               
+               ShipWeightClass.FRIGATE -> deployEscortFocus
+               ShipWeightClass.LINE_SHIP -> deployCruiserFocus
+               ShipWeightClass.DREADNOUGHT -> deployBattleshipFocus
+       }
+
+private val ShipWeightClass.rowIndex: Int
+       get() = when (this) {
+               ShipWeightClass.ESCORT -> 3
+               ShipWeightClass.DESTROYER -> 2
+               ShipWeightClass.CRUISER -> 2
+               ShipWeightClass.BATTLECRUISER -> 1
+               ShipWeightClass.BATTLESHIP -> 0
+               
+               ShipWeightClass.BATTLE_BARGE -> 0
+               
+               ShipWeightClass.GRAND_CRUISER -> 1
+               ShipWeightClass.COLOSSUS -> 0
+               
+               ShipWeightClass.FF_ESCORT -> 3
+               ShipWeightClass.FF_DESTROYER -> 2
+               ShipWeightClass.FF_CRUISER -> 1
+               ShipWeightClass.FF_BATTLECRUISER -> 1
+               ShipWeightClass.FF_BATTLESHIP -> 0
+               
+               ShipWeightClass.AUXILIARY_SHIP -> 3
+               ShipWeightClass.LIGHT_CRUISER -> 2
+               ShipWeightClass.MEDIUM_CRUISER -> 1
+               ShipWeightClass.HEAVY_CRUISER -> 0
+               
+               ShipWeightClass.FRIGATE -> 2
+               ShipWeightClass.LINE_SHIP -> 1
+               ShipWeightClass.DREADNOUGHT -> 0
+       }
+
+fun placeShips(ships: Set<Ship>, deployRectangle: PickBoundary.Rectangle): Map<Id<ShipInstance>, Position> {
+       val fieldBoundSign = deployRectangle.center.vector.y.sign
+       val fieldBoundary = deployRectangle.center.vector.y + (deployRectangle.length2 * fieldBoundSign)
+       val rows = listOf(125.0, 625.0, 1125.0, 1625.0).map {
+               fieldBoundary - (it * fieldBoundSign)
+       }
+       
+       val shipsByRow = ships.groupBy { it.shipType.weightClass.rowIndex }
+       return buildMap {
+               for ((rowIndex, rowShips) in shipsByRow) {
+                       val row = rows[rowIndex]
+                       val rowMax = rowShips.size - 1
+                       for ((shipIndex, ship) in rowShips.withIndex()) {
+                               val col = (shipIndex * 500.0) - (rowMax * 250.0)
+                               put(ship.id.reinterpret(), Position(Vec2(col, row)))
+                       }
+               }
+       }
+}
diff --git a/src/commonMain/kotlin/starshipfights/game/ai/ai_util_nav.kt b/src/commonMain/kotlin/starshipfights/game/ai/ai_util_nav.kt
new file mode 100644 (file)
index 0000000..d931040
--- /dev/null
@@ -0,0 +1,103 @@
+package starshipfights.game.ai
+
+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) }
+
+fun ShipPosition.score(gameState: GameState, shipInstance: ShipInstance, instincts: Instincts, brain: Brain): Double {
+       val ship = shipInstance.copy(position = this)
+       
+       val canAttack = ship.canAttackWithDamage(gameState)
+       val canBeAttackedBy = ship.attackableWithDamageBy(gameState)
+       
+       val opportunityScore = canAttack.map { (targetId, potentialDamage) ->
+               brain[shipAttackPriority forShip targetId].signedPow(instincts[navTunnelVision]) * potentialDamage
+       }.sum() + (ship.calculateSuffering() * instincts[navLustForBlood])
+       
+       val vulnerabilityScore = canBeAttackedBy.map { (targetId, potentialDamage) ->
+               brain[shipAttackPriority forShip targetId].signedPow(instincts[navTunnelVision]) * potentialDamage
+       }.sum() * -expm1(-ship.calculateSuffering() * instincts[navSqueamishness])
+       
+       return instincts[navOptimality].pow(opportunityScore.signedPow(instincts[navAggression]) - vulnerabilityScore.signedPow(instincts[navPassivity]))
+}
+
+fun ShipInstance.canAttackWithDamage(gameState: GameState): Map<Id<ShipInstance>, Double> {
+       return attackableTargets(gameState).mapValues { (targetId, weapons) ->
+               val target = gameState.ships[targetId] ?: return@mapValues 0.0
+               
+               weapons.sumOf { weaponId ->
+                       weaponId.expectedAdvantageFromWeaponUsage(gameState, this, target)
+               }
+       }
+}
+
+fun ShipInstance.attackableTargets(gameState: GameState): Map<Id<ShipInstance>, Set<Id<ShipWeapon>>> {
+       return armaments.weaponInstances.keys.associateWith { weaponId ->
+               weaponId.validTargets(gameState, this).map { it.id }.toSet()
+       }.transpose()
+}
+
+fun ShipInstance.attackableWithDamageBy(gameState: GameState): Map<Id<ShipInstance>, Double> {
+       return gameState.getValidAttackersWith(this).mapValues { (attackerId, weapons) ->
+               val attacker = gameState.ships[attackerId] ?: return@mapValues 0.0
+               
+               weapons.sumOf { weaponId ->
+                       weaponId.expectedAdvantageFromWeaponUsage(gameState, attacker, this)
+               }
+       }
+}
+
+fun Id<ShipWeapon>.validTargets(gameState: GameState, ship: ShipInstance): List<ShipInstance> {
+       if (!ship.canUseWeapon(this)) return emptyList()
+       val weaponInstance = ship.armaments.weaponInstances[this] ?: return emptyList()
+       
+       return gameState.getValidTargets(ship, weaponInstance)
+}
+
+fun Id<ShipWeapon>.expectedAdvantageFromWeaponUsage(gameState: GameState, ship: ShipInstance, target: ShipInstance): Double {
+       if (!ship.canUseWeapon(this)) return 0.0
+       val weaponInstance = ship.armaments.weaponInstances[this] ?: return 0.0
+       val mustBeSameSide = weaponInstance is ShipWeaponInstance.Hangar && weaponInstance.weapon.wing == StrikeCraftWing.FIGHTERS
+       if ((ship.owner == target.owner) != mustBeSameSide) return 0.0
+       
+       return when (weaponInstance) {
+               is ShipWeaponInstance.Cannon -> cannonChanceToHit(ship, target) * weaponInstance.weapon.numShots
+               is ShipWeaponInstance.Lance -> weaponInstance.charge * weaponInstance.weapon.numShots
+               is ShipWeaponInstance.Torpedo -> if (target.shieldAmount > 0) 0.5 else 2.0
+               is ShipWeaponInstance.Hangar -> when (weaponInstance.weapon.wing) {
+                       StrikeCraftWing.BOMBERS -> {
+                               val calculatedPrevBombing = target.calculateBombing(gameState.ships) ?: 0.0
+                               val calculatedNextBombing = target.calculateBombing(gameState.ships, extraBombers = weaponInstance.wingHealth) ?: 0.0
+                               
+                               calculateShipDamageChanceFromBombing(calculatedNextBombing) - calculateShipDamageChanceFromBombing(calculatedPrevBombing)
+                       }
+                       StrikeCraftWing.FIGHTERS -> {
+                               val calculatedPrevBombing = target.calculateBombing(gameState.ships) ?: 0.0
+                               val calculatedNextBombing = target.calculateBombing(gameState.ships, extraFighters = weaponInstance.wingHealth) ?: 0.0
+                               
+                               calculateShipDamageChanceFromBombing(calculatedPrevBombing) - calculateShipDamageChanceFromBombing(calculatedNextBombing)
+                       }
+               }
+               is ShipWeaponInstance.ParticleClawLauncher -> (cannonChanceToHit(ship, target) + 1) * weaponInstance.weapon.numShots
+               is ShipWeaponInstance.LightningYarn -> weaponInstance.weapon.numShots.toDouble()
+               is ShipWeaponInstance.MegaCannon -> 5.0
+               is ShipWeaponInstance.RevelationGun -> (target.shieldAmount + target.hullAmount).toDouble()
+               is ShipWeaponInstance.EmpAntenna -> target.shieldAmount * 0.5
+       }
+}
+
+private fun calculateShipDamageChanceFromBombing(calculatedBombing: Double): Double {
+       val maxBomberWingOutput = smoothNegative(calculatedBombing)
+       val maxFighterWingOutput = smoothNegative(-calculatedBombing)
+       
+       return smoothNegative(maxBomberWingOutput - maxFighterWingOutput)
+}
diff --git a/src/commonMain/kotlin/starshipfights/game/ai/util.kt b/src/commonMain/kotlin/starshipfights/game/ai/util.kt
new file mode 100644 (file)
index 0000000..d5a8a54
--- /dev/null
@@ -0,0 +1,53 @@
+package starshipfights.game.ai
+
+import starshipfights.game.EPSILON
+import starshipfights.game.Vec2
+import starshipfights.game.div
+import kotlin.math.absoluteValue
+import kotlin.math.pow
+import kotlin.math.sign
+import kotlin.random.Random
+
+expect fun logInfo(message: Any?)
+expect fun logWarning(message: Any?)
+expect fun logError(message: Any?)
+
+fun <T : Any> Map<T, Double>.weightedRandom(random: Random = Random): T {
+       return weightedRandomOrNull(random) ?: error("Cannot take weighted random of effectively-empty collection!")
+}
+
+fun <T : Any> Map<T, Double>.weightedRandomOrNull(random: Random = Random): T? {
+       if (isEmpty()) return null
+       
+       val total = values.sum()
+       if (total < EPSILON) return null
+       
+       var hasChoice = false
+       var choice = random.nextDouble(total)
+       for ((result, chance) in this) {
+               if (chance < EPSILON) continue
+               if (chance >= choice)
+                       return result
+               choice -= chance
+               hasChoice = true
+       }
+       
+       return if (hasChoice)
+               keys.last()
+       else null
+}
+
+fun <T, U> Map<T, Set<U>>.transpose(): Map<U, Set<T>> =
+       flatMap { (k, v) -> v.map { it to k } }
+               .groupBy(Pair<U, T>::first, Pair<U, T>::second)
+               .mapValues { (_, it) -> it.toSet() }
+
+fun Iterable<Vec2>.mean(): Vec2 {
+       if (none()) return Vec2(0.0, 0.0)
+       
+       val mx = sumOf { it.x }
+       val my = sumOf { it.y }
+       return Vec2(mx, my) / count().toDouble()
+}
+
+fun Double.signedPow(x: Double) = if (absoluteValue < EPSILON) 0.0 else sign * absoluteValue.pow(x)
index 903a4216c571ff9a944057581413aa2cd28cb0e2..b4e9198eaf35503097eafcb1b0a2cf741fd2356e 100644 (file)
@@ -7,6 +7,9 @@ sealed class ClientMode {
        @Serializable
        data class MatchmakingMenu(val admirals: List<InGameAdmiral>) : ClientMode()
        
+       @Serializable
+       data class InTrainingGame(val initialState: GameState) : ClientMode()
+       
        @Serializable
        data class InGame(val playerSide: GlobalSide, val connectToken: String, val initialState: GameState) : ClientMode()
        
index 4e7f5601bd738a21301b8fedc6a749f78d41d315..04f7745caa2e206f97bc670d9a6f0baea618875c 100644 (file)
@@ -238,6 +238,9 @@ sealed class PlayerAbilityType {
                        val shipInstance = gameState.ships[ship] ?: return GameEvent.InvalidAction("That ship does not exist")
                        if (shipInstance.isDoneCurrentPhase) return GameEvent.InvalidAction("Ships cannot be moved twice")
                        
+                       if ((gameState.ships - ship).any { (_, otherShip) -> (otherShip.position.location - data.newPosition.location).length <= SHIP_BASE_SIZE })
+                               return GameEvent.InvalidAction("You cannot move that ship there")
+                       
                        val moveOrigin = shipInstance.position.location
                        val newFacingNormal = normalDistance(data.newPosition.facing)
                        val oldFacingNormal = normalDistance(shipInstance.position.facing)
@@ -333,6 +336,9 @@ sealed class PlayerAbilityType {
                        if (data !is PlayerAbilityData.UseInertialessDrive) return GameEvent.InvalidAction("Internal error from using player ability")
                        if (!gameState.canShipMove(ship)) return GameEvent.InvalidAction("You do not have the initiative")
                        
+                       if ((gameState.ships - ship).any { (_, otherShip) -> (otherShip.position.location - data.newPosition).length <= SHIP_BASE_SIZE })
+                               return GameEvent.InvalidAction("You cannot move that ship there")
+                       
                        val shipInstance = gameState.ships[ship] ?: return GameEvent.InvalidAction("That ship does not exist")
                        if (shipInstance.isDoneCurrentPhase) return GameEvent.InvalidAction("Ships cannot be moved twice")
                        
@@ -432,7 +438,7 @@ sealed class PlayerAbilityType {
                                        ships = gameState.ships + mapOf(
                                                ship to shipInstance.copy(
                                                        weaponAmount = shipInstance.weaponAmount - 1,
-                                                       armaments = shipInstance.armaments.copy(
+                                                       armaments = ShipInstanceArmaments(
                                                                weaponInstances = shipInstance.armaments.weaponInstances + mapOf(
                                                                        weapon to shipWeapon.copy(numCharges = shipWeapon.numCharges + shipInstance.firepower.lanceCharging)
                                                                )
index bbf75391ab0e5ba7f25dd9cc5c9e32a4d73c7cee..028e68960d92a9d2f2d7f30c20492891082a851d 100644 (file)
@@ -35,6 +35,16 @@ fun GameState.calculateMovePhaseInitiative(): InitiativePair = InitiativePair(
                }
 )
 
+fun GameState.getValidAttackersWith(target: ShipInstance): Map<Id<ShipInstance>, Set<Id<ShipWeapon>>> {
+       return ships.mapValues { (_, ship) -> isValidAttackerWith(ship, target) }
+}
+
+fun GameState.isValidAttackerWith(attacker: ShipInstance, target: ShipInstance): Set<Id<ShipWeapon>> {
+       return attacker.armaments.weaponInstances.filterValues {
+               isValidTarget(attacker, it, attacker.getWeaponPickRequest(it.weapon), target)
+       }.keys
+}
+
 fun GameState.isValidTarget(ship: ShipInstance, weapon: ShipWeaponInstance, pickRequest: PickRequest, target: ShipInstance): Boolean {
        val targetPos = target.position.location
        
index 8c47bdd36c9c7e79206f783e10b9b29277545740..51b4b93215ff26244ba9a9baedbab04074aba2ed 100644 (file)
@@ -35,3 +35,6 @@ sealed class GamePhase {
                override fun next() = Power(turn + 1)
        }
 }
+
+val GamePhase.usesInitiative: Boolean
+       get() = this is GamePhase.Move || this is GamePhase.Attack
index 9f29bcffff320b018a79b6e20d20a62282cd5953..583745d95c9222b6d3ddcb15a798d8eef3f7d8b5 100644 (file)
@@ -8,9 +8,11 @@ import kotlinx.serialization.encoding.Decoder
 import kotlinx.serialization.encoding.Encoder
 
 @Serializable(with = MomentSerializer::class)
-expect class Moment(millis: Double) {
+expect class Moment(millis: Double) : Comparable<Moment> {
        fun toMillis(): Double
        
+       override fun compareTo(other: Moment): Int
+       
        companion object {
                val now: Moment
        }
index e074674710ec6794521c3e499f165835afda1aca..095ac222d0a4be275e3a07c4f2c7dfb288129233 100644 (file)
@@ -39,7 +39,13 @@ data class PlayerLogin(
 
 @Serializable
 sealed class LoginMode {
-       abstract val globalSide: GlobalSide
+       abstract val globalSide: GlobalSide?
+       
+       @Serializable
+       data class Train(val battleInfo: BattleInfo, val enemyFaction: Faction?) : LoginMode() {
+               override val globalSide: GlobalSide?
+                       get() = null
+       }
        
        @Serializable
        data class Host(val battleInfo: BattleInfo) : LoginMode() {
index 2fe4fb71a5a5a802d334b71a895d0cac0296bd7c..f7878fcd2ed0845521a2ee561815231a6728f72d 100644 (file)
@@ -17,7 +17,6 @@ inline operator fun Vec2.times(scale: Double) = Vec2(x * scale, y * scale)
 inline operator fun Vec2.div(scale: Double) = Vec2(x / scale, y / scale)
 
 inline operator fun Double.times(vec: Vec2) = vec * this
-inline operator fun Double.div(vec: Vec2) = vec / this
 
 inline operator fun Vec2.unaryPlus() = this
 inline operator fun Vec2.unaryMinus() = this * -1.0
@@ -25,11 +24,10 @@ inline operator fun Vec2.unaryMinus() = this * -1.0
 inline infix fun Vec2.dot(other: Vec2) = x * other.x + y * other.y
 inline infix fun Vec2.cross(other: Vec2) = x * other.y - y * other.x
 
-inline infix fun Vec2.angleBetween(other: Vec2) = acos((this dot other) / (this.magnitude * other.magnitude))
 inline infix fun Vec2.angleTo(other: Vec2) = atan2(this cross other, this dot other)
+inline infix fun Vec2.angleBetween(other: Vec2) = abs(this angleTo other)
 
 inline infix fun Vec2.rotatedBy(angle: Double) = normalVector(angle).let { (c, s) -> Vec2(c * x - s * y, c * y + s * x) }
-inline infix fun Vec2.scaleUneven(scalarVector: Vec2) = Vec2(x * scalarVector.x, y * scalarVector.y)
 
 inline fun normalVector(angle: Double) = Vec2(cos(angle), sin(angle))
 inline fun polarVector(radius: Double, angle: Double) = Vec2(radius * cos(angle), radius * sin(angle))
@@ -41,7 +39,12 @@ inline val Vec2.angle: Double
        get() = atan2(y, x)
 
 inline val Vec2.normal: Vec2
-       get() = this / magnitude
+       get() {
+               val thisMagnitude = this.magnitude
+               return if (thisMagnitude == 0.0)
+                       Vec2(0.0, 0.0)
+               else this / thisMagnitude
+       }
 
 // AFFINE vs DISPLACEMENT QUANTITIES
 
@@ -61,8 +64,6 @@ inline operator fun Position.minus(relativeTo: Position) = Distance(vector - rel
 inline operator fun Position.minus(distance: Distance) = Position(vector - distance.vector)
 inline operator fun Distance.minus(other: Distance) = Distance(vector - other.vector)
 
-inline fun Position.relativeTo(origin: Position, operation: (Distance) -> Distance) = operation(this - origin) + origin
-
 inline operator fun Distance.times(scale: Double) = Distance(vector * scale)
 inline operator fun Distance.div(scale: Double) = Distance(vector / scale)
 
index b5fdc7f467c4ea29628af97eb40c51b6b87fa658..4afa103f9d89ba664f36aff540a0926255732bbe 100644 (file)
@@ -175,9 +175,8 @@ fun PickBoundary.closestPointTo(position: Position): Position = when (this) {
                
                if ((distance angleBetween midNormal) <= maxAngle)
                        position
-               else {
-                       ((midNormal rotatedBy (midNormal angleTo distance).coerceIn(-maxAngle, maxAngle)) * distance.length) + center
-               }
+               else
+                       ((midNormal rotatedBy (midNormal angleTo distance).coerceIn(-maxAngle..maxAngle)) * distance.length) + center
        }
        is PickBoundary.Circle -> {
                val distance = position - center
@@ -188,7 +187,7 @@ fun PickBoundary.closestPointTo(position: Position): Position = when (this) {
        }
        is PickBoundary.Rectangle -> {
                Distance((position - center).vector.let { (x, y) ->
-                       Vec2(x.coerceIn(-width2, width2), y.coerceIn(-length2, length2))
+                       Vec2(x.coerceIn(-width2..width2), y.coerceIn(-length2..length2))
                }) + center
        }
        is PickBoundary.WeaponsFire -> {
index 5ad79f38de46f505a59fa0dc89e348edd54262d7..b3f78b9388a10326d9f4f42c460d7dc7a80dbd41 100644 (file)
@@ -8,6 +8,7 @@ import kotlinx.serialization.descriptors.SerialDescriptor
 import kotlinx.serialization.encoding.Decoder
 import kotlinx.serialization.encoding.Encoder
 import starshipfights.data.Id
+import kotlin.jvm.JvmInline
 import kotlin.random.Random
 import kotlin.random.nextInt
 
@@ -52,8 +53,9 @@ enum class ShipModuleStatus(val canBeUsed: Boolean, val canBeRepaired: Boolean)
        ABSENT(false, false)
 }
 
+@JvmInline
 @Serializable(with = ShipModulesStatusSerializer::class)
-data class ShipModulesStatus(val statuses: Map<ShipModule, ShipModuleStatus>) {
+value class ShipModulesStatus(val statuses: Map<ShipModule, ShipModuleStatus>) {
        operator fun get(module: ShipModule) = statuses[module] ?: ShipModuleStatus.ABSENT
        
        fun repair(module: ShipModule, repairUnrepairable: Boolean = false) = ShipModulesStatus(
index db352465d66dfc1d22d651ff6e33cf1cae9bab95..be4b7667fb904de8e0c070543996ef03448bbd37 100644 (file)
@@ -2,7 +2,7 @@ package starshipfights.game
 
 enum class ShipWeightClass(
        val meshIndex: Int,
-       val rank: Int
+       val tier: Int
 ) {
        // General
        ESCORT(1, 0),
index 2330109550bcb3f203163864b2037a25f0490edf..a1b5f7918741fe47d472d80b888278fb824ef4d2 100644 (file)
@@ -2,6 +2,7 @@ package starshipfights.game
 
 import kotlinx.serialization.Serializable
 import starshipfights.data.Id
+import kotlin.jvm.JvmInline
 import kotlin.math.*
 import kotlin.random.Random
 
@@ -281,15 +282,17 @@ sealed class ShipWeaponInstance {
        }
 }
 
+@JvmInline
 @Serializable
-data class ShipArmaments(
+value class ShipArmaments(
        val weapons: Map<Id<ShipWeapon>, ShipWeapon>
 ) {
        fun instantiate() = ShipInstanceArmaments(weapons.mapValues { (_, weapon) -> weapon.instantiate() })
 }
 
+@JvmInline
 @Serializable
-data class ShipInstanceArmaments(
+value class ShipInstanceArmaments(
        val weaponInstances: Map<Id<ShipWeapon>, ShipWeaponInstance>
 )
 
diff --git a/src/jsMain/kotlin/starshipfights/game/ai/util_js.kt b/src/jsMain/kotlin/starshipfights/game/ai/util_js.kt
new file mode 100644 (file)
index 0000000..b755861
--- /dev/null
@@ -0,0 +1,13 @@
+package starshipfights.game.ai
+
+actual fun logInfo(message: Any?) {
+       console.log(message)
+}
+
+actual fun logWarning(message: Any?) {
+       console.warn(message)
+}
+
+actual fun logError(message: Any?) {
+       console.error(message)
+}
index e0560e432a153fcaedd356feb3783448a65a7d65..5534cc8e181c0984594b20eab47b533efc61f5eb 100644 (file)
@@ -46,11 +46,12 @@ fun main() {
        
        AppScope.launch {
                Popup.LoadingScreen("Loading resources...") {
-                       RenderResources.load(clientMode !is ClientMode.InGame)
+                       RenderResources.load(clientMode.isSmallLoad)
                }.display()
                
                when (clientMode) {
                        is ClientMode.MatchmakingMenu -> matchmakingMain(clientMode.admirals)
+                       is ClientMode.InTrainingGame -> trainingMain(clientMode.initialState)
                        is ClientMode.InGame -> gameMain(clientMode.playerSide, clientMode.connectToken, clientMode.initialState)
                        is ClientMode.Error -> errorMain(clientMode.message)
                }
index a92e81f0c8ef39e26070ddcdbde2e35e47d3753c..23b378c6b81389ffc783818141cef08d610a58a5 100644 (file)
@@ -25,11 +25,10 @@ class GameRenderInteraction(
 )
 
 lateinit var mySide: GlobalSide
-       private set
 
 private val pickContextDeferred = CompletableDeferred<PickContext>()
 
-private suspend fun GameRenderInteraction.execute(scope: CoroutineScope) {
+suspend fun GameRenderInteraction.execute(scope: CoroutineScope) {
        GameUI.initGameUI(scope.uiResponder(playerActions))
        
        GameUI.drawGameUI(gameState.value)
index cc06a63e9a61b625cf584231841d3761a8bfb316..c110bfaae8b5ef07a97749434f913ea15061cb0b 100644 (file)
@@ -15,6 +15,7 @@ import kotlinx.html.dom.append
 import kotlinx.html.hiddenInput
 import kotlinx.html.js.form
 import kotlinx.html.style
+import starshipfights.data.Id
 
 suspend fun setupBackground() {
        val camera = PerspectiveCamera(69, window.aspectRatio, 0.01, 1_000)
@@ -44,6 +45,31 @@ suspend fun setupBackground() {
        }
 }
 
+private suspend fun enterTraining(admiral: Id<InGameAdmiral>, battleInfo: BattleInfo, faction: Faction?): Nothing {
+       interruptExit = false
+       
+       document.body!!.append.form(action = "/train", method = FormMethod.post, encType = FormEncType.applicationXWwwFormUrlEncoded) {
+               style = "display:none"
+               hiddenInput {
+                       name = "admiral"
+                       value = admiral.toString()
+               }
+               hiddenInput {
+                       name = "battle-size"
+                       value = battleInfo.size.toUrlSlug()
+               }
+               hiddenInput {
+                       name = "battle-bg"
+                       value = battleInfo.bg.toUrlSlug()
+               }
+               hiddenInput {
+                       name = "enemy-faction"
+                       value = faction?.toUrlSlug() ?: "-random"
+               }
+       }.submit()
+       awaitCancellation()
+}
+
 private suspend fun enterGame(connectToken: String): Nothing {
        interruptExit = false
        
@@ -59,13 +85,20 @@ private suspend fun enterGame(connectToken: String): Nothing {
 
 private suspend fun usePlayerLogin(admirals: List<InGameAdmiral>) {
        val playerLogin = Popup.getPlayerLogin(admirals)
+       val playerLoginSide = playerLogin.login.globalSide
+       
+       if (playerLoginSide == null) {
+               val (battleInfo, enemyFaction) = playerLogin.login as LoginMode.Train
+               enterTraining(playerLogin.admiral, battleInfo, enemyFaction)
+       }
+       
        val admiral = admirals.single { it.id == playerLogin.admiral }
        
        try {
                httpClient.webSocket("$rootPathWs/matchmaking") {
                        sendObject(PlayerLogin.serializer(), playerLogin)
                        
-                       when (playerLogin.login.globalSide) {
+                       when (playerLoginSide) {
                                GlobalSide.HOST -> {
                                        var loadingText = "Awaiting join request..."
                                        
diff --git a/src/jsMain/kotlin/starshipfights/game/client_training.kt b/src/jsMain/kotlin/starshipfights/game/client_training.kt
new file mode 100644 (file)
index 0000000..a4bacf8
--- /dev/null
@@ -0,0 +1,144 @@
+package starshipfights.game
+
+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.game.ai.AISession
+import starshipfights.game.ai.aiPlayer
+
+class GameSession(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)
+                               }
+                       }
+               }
+       }
+}
+
+private suspend fun GameNetworkInteraction.execute(): Pair<LocalSide?, String> {
+       val gameSession = GameSession(gameState.value)
+       
+       val aiSide = mySide.other
+       val aiActions = Channel<PlayerAction>()
+       val aiEvents = Channel<GameEvent>()
+       val aiSession = AISession(aiSide, aiActions, aiEvents)
+       
+       return coroutineScope {
+               val aiHandlingJob = launch {
+                       launch {
+                               listOf(
+                                       // Game state changes
+                                       launch {
+                                               gameSession.state.collect { state ->
+                                                       aiEvents.send(GameEvent.StateChange(state))
+                                               }
+                                       },
+                                       // Invalid action messages
+                                       launch {
+                                               for (errorMessage in gameSession.errorMessages(aiSide)) {
+                                                       aiEvents.send(GameEvent.InvalidAction(errorMessage))
+                                               }
+                                       }
+                               ).joinAll()
+                       }
+                       
+                       launch {
+                               for (action in aiActions)
+                                       gameSession.onPacket(aiSide, action)
+                       }
+                       
+                       aiPlayer(aiSession, gameState.value)
+               }
+               
+               val playerHandlingJob = launch {
+                       launch {
+                               listOf(
+                                       // Game state changes
+                                       launch {
+                                               gameSession.state.collect { state ->
+                                                       gameState.value = state
+                                               }
+                                       },
+                                       // Invalid action messages
+                                       launch {
+                                               for (errorMessage in gameSession.errorMessages(mySide)) {
+                                                       errorMessages.send(errorMessage)
+                                               }
+                                       }
+                               ).joinAll()
+                       }
+                       
+                       for (action in playerActions)
+                               gameSession.onPacket(mySide, action)
+               }
+               
+               val gameEnd = gameSession.gameEnd.await()
+               
+               aiHandlingJob.cancel()
+               playerHandlingJob.cancel()
+               
+               gameEnd.winner?.relativeTo(mySide) to gameEnd.message
+       }
+}
+
+suspend fun trainingMain(state: GameState) {
+       interruptExit = true
+       
+       initializePicking()
+       
+       mySide = GlobalSide.HOST
+       
+       val gameState = MutableStateFlow(state)
+       val playerActions = Channel<PlayerAction>(Channel.UNLIMITED)
+       val errorMessages = Channel<String>(Channel.UNLIMITED)
+       
+       val gameConnection = GameNetworkInteraction(gameState, playerActions, errorMessages)
+       val gameRendering = GameRenderInteraction(gameState, playerActions, errorMessages)
+       
+       coroutineScope {
+               val connectionJob = async { gameConnection.execute() }
+               val renderingJob = launch { gameRendering.execute(this@coroutineScope) }
+               
+               val (finalWinner, finalMessage) = connectionJob.await()
+               renderingJob.cancel()
+               
+               interruptExit = false
+               Popup.GameOver(finalWinner, finalMessage, gameState.value).display()
+       }
+}
index a05807c9d57524542c9715371b33b5a4adf894a6..3ab364103d1e57dbcb9cfb7416b98d1c68781cfe 100644 (file)
@@ -18,6 +18,9 @@ fun interface CustomRenderFactory<T> {
        fun generate(parameter: T): Object3D
 }
 
+val ClientMode.isSmallLoad: Boolean
+       get() = this !is ClientMode.InGame && this !is ClientMode.InTrainingGame
+
 object RenderResources {
        const val LOGO_URL = "/static/images/logo.svg"
        
index bac2c4b83b5252e398e86abf15c5fb1e2a9f7be5..4ea6ba02dfd0b009ed429aa194ba02517f91d2b2 100644 (file)
@@ -4,13 +4,15 @@ import kotlinx.serialization.Serializable
 import kotlin.js.Date
 
 @Serializable(with = MomentSerializer::class)
-actual class Moment(val date: Date) {
+actual class Moment(val date: Date) : Comparable<Moment> {
        actual constructor(millis: Double) : this(Date(millis))
        
        actual fun toMillis(): Double {
                return date.getTime()
        }
        
+       actual override fun compareTo(other: Moment) = toMillis().compareTo(other.toMillis())
+       
        actual companion object {
                actual val now: Moment
                        get() = Moment(Date())
index 99e1fbb0e2874e9efa3bb5831d8f47cfdfe0cce9..0c6d3193923ff2a124171f58581c771f242e0c47 100644 (file)
@@ -160,7 +160,7 @@ object GameUI {
        fun drawGameUI(state: GameState) {
                chatHistory.clear()
                chatHistory.append {
-                       for (entry in state.chatBox.sortedBy { it.sentAt.toMillis() }) {
+                       for (entry in state.chatBox.sortedBy { it.sentAt }) {
                                p {
                                        title = "At ${entry.sentAt.date}"
                                        
@@ -390,12 +390,6 @@ object GameUI {
                                                }
                                                br
                                                +"Phase II - Ship Movement"
-                                               br
-                                               +if (state.doneWithPhase == mySide)
-                                                       "You have ended your phase"
-                                               else if (state.currentInitiative != mySide.other)
-                                                       "You have the initiative!"
-                                               else "Your opponent has the initiative"
                                        }
                                        is GamePhase.Attack -> {
                                                strong(classes = "heading") {
@@ -403,12 +397,6 @@ object GameUI {
                                                }
                                                br
                                                +"Phase III - Weapons Fire"
-                                               br
-                                               +if (state.doneWithPhase == mySide)
-                                                       "You have ended your phase"
-                                               else if (state.currentInitiative != mySide.other)
-                                                       "You have the initiative!"
-                                               else "Your opponent has the initiative"
                                        }
                                        is GamePhase.Repair -> {
                                                strong(classes = "heading") {
@@ -418,6 +406,18 @@ object GameUI {
                                                +"Phase IV - Onboard Repairs"
                                        }
                                }
+                               
+                               if (state.phase.usesInitiative) {
+                                       br
+                                       +if (state.doneWithPhase == mySide)
+                                               "You have ended your phase"
+                                       else if (state.currentInitiative != mySide.other)
+                                               "You have the initiative!"
+                                       else "Your opponent has the initiative"
+                               } else if (state.doneWithPhase == mySide) {
+                                       br
+                                       +"You have ended your phase"
+                               }
                        }
                }
                
@@ -588,8 +588,8 @@ object GameUI {
                                if (ship.bomberWings.isNotEmpty()) {
                                        span {
                                                val (borderColor, fillColor) = when (bomberSide) {
-                                                       LocalSide.GREEN -> "#39F" to "#135"
-                                                       LocalSide.RED -> "#F66" to "#522"
+                                                       LocalSide.GREEN -> "#5F5" to "#262"
+                                                       LocalSide.RED -> "#F55" to "#622"
                                                }
                                                
                                                style = "display:inline-block;border:5px solid $borderColor;border-radius:15px;background-color:$fillColor;color:#fff"
index d342307a719bf87736c0a48cf2357ffa9469772d..70b58ea6f2bbc80f6eb8e0af2a3daf36493429b2 100644 (file)
@@ -106,8 +106,8 @@ sealed class Popup<out T> {
                }
        }
        
-       class MainMenuScreen(private val admiralInfo: InGameAdmiral) : Popup<GlobalSide?>() {
-               override fun TagConsumer<*>.render(context: CoroutineContext, callback: (GlobalSide?) -> Unit) {
+       class MainMenuScreen(private val admiralInfo: InGameAdmiral) : Popup<MainMenuOption?>() {
+               override fun TagConsumer<*>.render(context: CoroutineContext, callback: (MainMenuOption?) -> Unit) {
                        p {
                                style = "text-align:center"
                                
@@ -135,19 +135,27 @@ sealed class Popup<out T> {
                        
                        div(classes = "button-set col") {
                                button {
-                                       +"Host Battle"
+                                       +"Play Singleplayer Battle"
                                        onClickFunction = { e ->
                                                e.preventDefault()
                                                
-                                               callback(GlobalSide.HOST)
+                                               callback(MainMenuOption.Singleplayer)
                                        }
                                }
                                button {
-                                       +"Join Battle"
+                                       +"Host Multiplayer Battle"
                                        onClickFunction = { e ->
                                                e.preventDefault()
                                                
-                                               callback(GlobalSide.GUEST)
+                                               callback(MainMenuOption.Multiplayer(GlobalSide.HOST))
+                                       }
+                               }
+                               button {
+                                       +"Join Multiplayer Battle"
+                                       onClickFunction = { e ->
+                                               e.preventDefault()
+                                               
+                                               callback(MainMenuOption.Multiplayer(GlobalSide.GUEST))
                                        }
                                }
                        }
@@ -216,6 +224,47 @@ sealed class Popup<out T> {
                }
        }
        
+       object ChooseEnemyFactionScreen : Popup<AIFactionChoice?>() {
+               override fun TagConsumer<*>.render(context: CoroutineContext, callback: (AIFactionChoice?) -> Unit) {
+                       p {
+                               style = "text-align:center"
+                               
+                               +"Select an enemy faction"
+                       }
+                       
+                       div(classes = "button-set col") {
+                               button {
+                                       +"Random"
+                                       onClickFunction = { e ->
+                                               e.preventDefault()
+                                               callback(AIFactionChoice.Random)
+                                       }
+                               }
+                               for (faction in Faction.values()) {
+                                       button {
+                                               +faction.navyName
+                                               +Entities.nbsp
+                                               img(alt = faction.shortName, src = faction.flagUrl) {
+                                                       style = "width:1.2em;height:0.75em"
+                                               }
+                                               
+                                               onClickFunction = { e ->
+                                                       e.preventDefault()
+                                                       callback(AIFactionChoice.Chosen(faction))
+                                               }
+                                       }
+                               }
+                               button {
+                                       +"Cancel"
+                                       onClickFunction = { e ->
+                                               e.preventDefault()
+                                               callback(null)
+                                       }
+                               }
+                       }
+               }
+       }
+       
        class GuestRequestScreen(private val hostInfo: InGameAdmiral, private val guestInfo: InGameAdmiral) : Popup<Boolean?>() {
                override fun TagConsumer<*>.render(context: CoroutineContext, callback: (Boolean?) -> Unit) {
                        p {
@@ -360,6 +409,7 @@ sealed class Popup<out T> {
                                        td { +Entities.nbsp }
                                        td { +Entities.nbsp }
                                        td { +Entities.nbsp }
+                                       td { +Entities.nbsp }
                                        td {
                                                style = "text-align:center"
                                                
index 4ea1be15aafcf7e14b0c2d7ef3dbe2e4660bf0e2..ea8bbce2d6a59e0fdbf727c08da68fc6c345997b 100644 (file)
@@ -1,5 +1,17 @@
 package starshipfights.game
 
+sealed class MainMenuOption {
+       object Singleplayer : MainMenuOption()
+       
+       data class Multiplayer(val side: GlobalSide) : MainMenuOption()
+}
+
+sealed class AIFactionChoice {
+       object Random : AIFactionChoice()
+       
+       data class Chosen(val faction: Faction) : AIFactionChoice()
+}
+
 private suspend fun Popup.Companion.getPlayerInfo(admirals: List<InGameAdmiral>): InGameAdmiral {
        return Popup.ChooseAdmiralScreen(admirals).display()
 }
@@ -10,11 +22,20 @@ private suspend fun Popup.Companion.getBattleInfo(admiral: InGameAdmiral): Battl
        return BattleInfo(battleSize, battleBackground)
 }
 
+private suspend fun Popup.Companion.getTrainingInfo(admiral: InGameAdmiral): LoginMode? {
+       val battleInfo = getBattleInfo(admiral) ?: return getLoginMode(admiral)
+       val faction = Popup.ChooseEnemyFactionScreen.display() ?: return getLoginMode(admiral)
+       return LoginMode.Train(battleInfo, (faction as? AIFactionChoice.Chosen)?.faction)
+}
+
 private suspend fun Popup.Companion.getLoginMode(admiral: InGameAdmiral): LoginMode? {
-       val globalSide = Popup.MainMenuScreen(admiral).display() ?: return null
-       return when (globalSide) {
-               GlobalSide.HOST -> LoginMode.Host(getBattleInfo(admiral) ?: return getLoginMode(admiral))
-               GlobalSide.GUEST -> LoginMode.Join
+       val mainMenuOption = Popup.MainMenuScreen(admiral).display() ?: return null
+       return when (mainMenuOption) {
+               MainMenuOption.Singleplayer -> getTrainingInfo(admiral)
+               is MainMenuOption.Multiplayer -> when (mainMenuOption.side) {
+                       GlobalSide.HOST -> LoginMode.Host(getBattleInfo(admiral) ?: return getLoginMode(admiral))
+                       GlobalSide.GUEST -> LoginMode.Join
+               }
        }
 }
 
index dc80e9e5f018d3cc9a614eef0d51e444f027d9c5..2577a2bad595c08f7d87848d44c2fb8045b9a077 100644 (file)
@@ -40,6 +40,18 @@ data class Admiral(
        })
 }
 
+fun genAI(faction: Faction, forBattleSize: BattleSize) = Admiral(
+       id = Id("advanced_robotical_admiral"),
+       owningUser = Id("fake_player_actually_an_AI"),
+       name = "M-5 Computational Unit",
+       isFemale = true,
+       faction = faction,
+       acumen = AdmiralRank.values().first {
+               it.maxShipWeightClass.tier >= forBattleSize.maxWeightClass.tier
+       }.minAcumen + 500,
+       money = 0
+)
+
 infix fun AdmiralRank.Companion.eq(rank: AdmiralRank): Bson = when (rank.ordinal) {
        0 -> Admiral::acumen lt AdmiralRank.values()[1].minAcumen
        AdmiralRank.values().size - 1 -> Admiral::acumen gte rank.minAcumen
@@ -111,7 +123,7 @@ fun generateFleet(admiral: Admiral): List<ShipInDrydock> = ShipWeightClass.value
                if (shipTypes.isEmpty())
                        emptyList()
                else
-                       (0 until ((admiral.rank.maxShipWeightClass.rank - swc.rank + 1) * 2).coerceAtLeast(0)).map { i ->
+                       (0 until ((admiral.rank.maxShipWeightClass.tier - swc.tier + 1) * 2).coerceAtLeast(0)).map { i ->
                                shipTypes[i % shipTypes.size]
                        }
        }
index 758aede55dc721cb72148f44c57e489854fd72ab..4a6534b029075a421b92d77e01e51a847797b5ad 100644 (file)
@@ -18,7 +18,7 @@ fun ShipType.buyPriceChecked(admiral: Admiral, ownedShips: List<ShipInDrydock>):
 }
 
 fun ShipType.buyPrice(admiral: Admiral, ownedShips: List<ShipInDrydock>): Int? {
-       if (weightClass.rank > admiral.rank.maxShipWeightClass.rank) return null
+       if (weightClass.tier > admiral.rank.maxShipWeightClass.tier) return null
        if (weightClass.isUnique && ownedShips.any { it.shipType.weightClass == weightClass }) return null
        return when {
                admiral.faction == faction -> buyPrice
diff --git a/src/jvmMain/kotlin/starshipfights/game/ai/util_jvm.kt b/src/jvmMain/kotlin/starshipfights/game/ai/util_jvm.kt
new file mode 100644 (file)
index 0000000..7a3df6c
--- /dev/null
@@ -0,0 +1,18 @@
+package starshipfights.game.ai
+
+import org.slf4j.Logger
+import org.slf4j.LoggerFactory
+
+val aiLogger: Logger = LoggerFactory.getLogger("SF_AI")
+
+actual fun logInfo(message: Any?) {
+       aiLogger.info(message.toString())
+}
+
+actual fun logWarning(message: Any?) {
+       aiLogger.warn(message.toString())
+}
+
+actual fun logError(message: Any?) {
+       aiLogger.error(message.toString())
+}
index 9276add77461b2841a9fcb9af30b32ec4ac932e0..b6b00e1bd589454f98cda1a1190ca27d7529a051 100644 (file)
@@ -42,6 +42,12 @@ fun Routing.installGame() {
                call.respondHtml(HttpStatusCode.OK, clientMode.view())
        }
        
+       post("/train") {
+               val clientMode = call.getTrainingClientMode()
+               
+               call.respondHtml(HttpStatusCode.OK, clientMode.view())
+       }
+       
        webSocket("/matchmaking") {
                val oldUser = call.getUser() ?: closeAndReturn("You must be logged in to play") { return@webSocket }
                if (oldUser.status != UserStatus.AVAILABLE)
index 21668624d16e2a0d924c39b88718d92a4f0b7ae3..921f752201e093b4c9f9d27744932931ce53915d 100644 (file)
@@ -1,5 +1,7 @@
 package starshipfights.game
 
+import starshipfights.data.admiralty.genAI
+import starshipfights.data.admiralty.generateFleet
 import starshipfights.data.admiralty.getAdmiralsShips
 import kotlin.math.PI
 
@@ -21,7 +23,7 @@ suspend fun generateGameStart(hostInfo: InGameAdmiral, guestInfo: InGameAdmiral,
                        PI / 2,
                        PickBoundary.Rectangle(hostDeployCenter, deployWidth2, deployLength2),
                        PI / 2,
-                       getAdmiralsShips(hostInfo.id.reinterpret()).filterValues { it.shipType.weightClass.rank <= battleInfo.size.maxWeightClass.rank }
+                       getAdmiralsShips(hostInfo.id.reinterpret()).filterValues { it.shipType.weightClass.tier <= battleInfo.size.maxWeightClass.tier }
                ),
                
                PlayerStart(
@@ -29,7 +31,58 @@ suspend fun generateGameStart(hostInfo: InGameAdmiral, guestInfo: InGameAdmiral,
                        -PI / 2,
                        PickBoundary.Rectangle(guestDeployCenter, deployWidth2, deployLength2),
                        -PI / 2,
-                       getAdmiralsShips(guestInfo.id.reinterpret()).filterValues { it.shipType.weightClass.rank <= battleInfo.size.maxWeightClass.rank }
+                       getAdmiralsShips(guestInfo.id.reinterpret()).filterValues { it.shipType.weightClass.tier <= battleInfo.size.maxWeightClass.tier }
                ),
        )
 }
+
+suspend fun generateTrainingInitialState(playerInfo: InGameAdmiral, enemyFaction: 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 aiAdmiral = genAI(enemyFaction, battleInfo.size)
+       
+       return GameState(
+               GameStart(
+                       battleWidth, battleLength,
+                       
+                       PlayerStart(
+                               hostDeployCenter,
+                               PI / 2,
+                               PickBoundary.Rectangle(hostDeployCenter, deployWidth2, deployLength2),
+                               PI / 2,
+                               getAdmiralsShips(playerInfo.id.reinterpret())
+                                       .filterValues { it.shipType.weightClass.tier <= battleInfo.size.maxWeightClass.tier }
+                       ),
+                       
+                       PlayerStart(
+                               guestDeployCenter,
+                               -PI / 2,
+                               PickBoundary.Rectangle(guestDeployCenter, deployWidth2, deployLength2),
+                               -PI / 2,
+                               generateFleet(aiAdmiral)
+                                       .associate { it.shipData.id to it.shipData }
+                                       .filterValues { it.shipType.weightClass.tier <= battleInfo.size.maxWeightClass.tier }
+                       )
+               ),
+               playerInfo,
+               InGameAdmiral(
+                       id = aiAdmiral.id.reinterpret(),
+                       user = InGameUser(
+                               id = aiAdmiral.owningUser.reinterpret(),
+                               username = aiAdmiral.name
+                       ),
+                       name = aiAdmiral.name,
+                       isFemale = aiAdmiral.isFemale,
+                       faction = aiAdmiral.faction,
+                       rank = aiAdmiral.rank
+               ),
+               battleInfo
+       )
+}
index 1213db93fb6bb986416a2a90fcc0bc3bc36d6a8d..8361dda34771e84b227064c06c895c9919447789 100644 (file)
@@ -4,7 +4,7 @@ import kotlinx.serialization.Serializable
 import java.time.Instant
 
 @Serializable(with = MomentSerializer::class)
-actual class Moment(val instant: Instant) {
+actual class Moment(val instant: Instant) : Comparable<Moment> {
        actual constructor(millis: Double) : this(
                Instant.ofEpochSecond(
                        (millis / 1000.0).toLong(),
@@ -16,6 +16,8 @@ actual class Moment(val instant: Instant) {
                return (instant.epochSecond * 1000.0) + (instant.nano / 1_000_000.0)
        }
        
+       actual override fun compareTo(other: Moment) = toMillis().compareTo(other.toMillis())
+       
        actual companion object {
                actual val now: Moment
                        get() = Moment(Instant.now())
index 742eb9f7de9503ea7f77b42e1e089ce625961e84..1454a7409f2c95ff728d59d70ba49816cb90f226 100644 (file)
@@ -30,6 +30,9 @@ suspend fun DefaultWebSocketServerSession.matchmakingEndpoint(user: User): Boole
        if (inGameAdmiral.user.id != user.id) closeAndReturn("You do not own that admiral") { return false }
        
        when (val loginMode = playerLogin.login) {
+               is LoginMode.Train -> {
+                       closeAndReturn("Invalid input: LoginMode.Train should redirect you directly to training endpoint") { return false }
+               }
                is LoginMode.Host -> {
                        val battleInfo = loginMode.battleInfo
                        val hostInvitation = HostInvitation(inGameAdmiral, battleInfo)
index 4d224669c4c9efa67c5def10d9e548f4acc58e1e..4db90d2c9e43940fee5cd66f6fcfbac5dbb6043f 100644 (file)
@@ -25,6 +25,7 @@ fun ClientMode.view(): HTML.() -> Unit = {
                
                when (this@view) {
                        is ClientMode.MatchmakingMenu -> title("Starship Fights | Lobby")
+                       is ClientMode.InTrainingGame -> title("Starship Fights | Training")
                        is ClientMode.InGame -> title("Starship Fights | In-Game")
                        is ClientMode.Error -> title("Starship Fights | Error!")
                }
diff --git a/src/jvmMain/kotlin/starshipfights/game/views_training.kt b/src/jvmMain/kotlin/starshipfights/game/views_training.kt
new file mode 100644 (file)
index 0000000..23f8f37
--- /dev/null
@@ -0,0 +1,29 @@
+package starshipfights.game
+
+import io.ktor.application.*
+import io.ktor.request.*
+import starshipfights.auth.getUserSession
+import starshipfights.data.Id
+import starshipfights.data.admiralty.Admiral
+import starshipfights.data.admiralty.getInGameAdmiral
+import starshipfights.redirect
+
+suspend fun ApplicationCall.getTrainingClientMode(): ClientMode {
+       val userId = getUserSession()?.user ?: redirect("/login")
+       val parameters = receiveParameters()
+       
+       val admiralId = parameters["admiral"]?.let { Id<Admiral>(it) } ?: return ClientMode.Error("An admiral must be specified")
+       val admiralData = getInGameAdmiral(admiralId.reinterpret()) ?: return ClientMode.Error("That admiral does not exist")
+       
+       if (admiralData.user.id != userId.reinterpret<InGameUser>()) return ClientMode.Error("You do not own that admiral")
+       
+       val battleSize = BattleSize.values().singleOrNull { it.toUrlSlug() == parameters["battle-size"] } ?: return ClientMode.Error("Invalid battle size")
+       val battleBg = BattleBackground.values().singleOrNull { it.toUrlSlug() == parameters["battle-bg"] } ?: return ClientMode.Error("Invalid battle background")
+       val battleInfo = BattleInfo(battleSize, battleBg)
+       
+       val enemyFaction = Faction.values().singleOrNull { it.toUrlSlug() == parameters["enemy-faction"] } ?: Faction.values().random()
+       
+       val initialState = generateTrainingInitialState(admiralData, enemyFaction, battleInfo)
+       
+       return ClientMode.InTrainingGame(initialState)
+}
index 959ecc26a399072a2c7910291b65e0e77b141af8..87535493d5f387867f79fcbd4b09b534e00452d8 100644 (file)
@@ -37,7 +37,7 @@ suspend fun ApplicationCall.shipsPage(): HTML.() -> Unit = page("Strategema Naut
                        
                        faction.blurbDesc(consumer)
                        
-                       for ((weightClass, weightedShipTypes) in factionShipTypes.groupBy { it.weightClass }.toSortedMap(Comparator.comparingInt(ShipWeightClass::rank))) {
+                       for ((weightClass, weightedShipTypes) in factionShipTypes.groupBy { it.weightClass }.toSortedMap(Comparator.comparingInt(ShipWeightClass::tier))) {
                                h3 { +weightClass.displayName }
                                ul {
                                        for (shipType in weightedShipTypes) {
index dcd762568cb59fff65b469edf345cd1d96a91ed6..c5bbdffcd9eb534175c24062d793feab44b5b06d 100644 (file)
@@ -509,7 +509,7 @@ suspend fun ApplicationCall.admiralPage(): HTML.() -> Unit {
                                }
                                
                                val now = Instant.now()
-                               for (ship in ships.sortedBy { it.name }.sortedBy { it.shipType.weightClass.rank }) {
+                               for (ship in ships.sortedBy { it.name }.sortedBy { it.shipType.weightClass.tier }) {
                                        tr {
                                                td { +ship.shipData.fullName }
                                                td {
@@ -618,7 +618,7 @@ suspend fun ApplicationCall.manageAdmiralPage(): HTML.() -> Unit {
                .mapNotNull { type -> type.buyPriceChecked(admiral, ownedShips)?.let { price -> type to price } }
                .sortedBy { (_, price) -> price }
                .sortedBy { (type, _) -> type.name }
-               .sortedBy { (type, _) -> type.weightClass.rank }
+               .sortedBy { (type, _) -> type.weightClass.tier }
                .sortedBy { (type, _) -> if (type.faction == admiral.faction) -1 else type.faction.ordinal }
                .toMap()
        
@@ -750,7 +750,7 @@ suspend fun ApplicationCall.manageAdmiralPage(): HTML.() -> Unit {
                                }
                                
                                val now = Instant.now()
-                               for (ship in ownedShips.sortedBy { it.name }.sortedBy { it.shipType.weightClass.rank }) {
+                               for (ship in ownedShips.sortedBy { it.name }.sortedBy { it.shipType.weightClass.tier }) {
                                        tr {
                                                td {
                                                        +ship.shipData.fullName