Add cooperative multiplayer battles, where two players team up against the AI
authorTheSaminator <thesaminator@users.noreply.github.com>
Mon, 20 Jun 2022 18:59:08 +0000 (14:59 -0400)
committerTheSaminator <thesaminator@users.noreply.github.com>
Mon, 20 Jun 2022 18:59:08 +0000 (14:59 -0400)
33 files changed:
plan/campaign-spoilers/notes.md
src/commonMain/kotlin/net/starshipfights/game/admiralty.kt
src/commonMain/kotlin/net/starshipfights/game/ai/ai_behaviors.kt
src/commonMain/kotlin/net/starshipfights/game/ai/ai_coroutine.kt
src/commonMain/kotlin/net/starshipfights/game/ai/ai_optimization.kt
src/commonMain/kotlin/net/starshipfights/game/client_mode.kt
src/commonMain/kotlin/net/starshipfights/game/game_ability.kt
src/commonMain/kotlin/net/starshipfights/game/game_chat.kt
src/commonMain/kotlin/net/starshipfights/game/game_initiative.kt
src/commonMain/kotlin/net/starshipfights/game/game_packet.kt
src/commonMain/kotlin/net/starshipfights/game/game_start.kt
src/commonMain/kotlin/net/starshipfights/game/game_state.kt
src/commonMain/kotlin/net/starshipfights/game/game_state_interceptor.kt [new file with mode: 0644]
src/commonMain/kotlin/net/starshipfights/game/game_subplots.kt
src/commonMain/kotlin/net/starshipfights/game/matchmaking.kt
src/commonMain/kotlin/net/starshipfights/game/pick_bounds.kt
src/commonMain/kotlin/net/starshipfights/game/ship_boarding.kt
src/commonMain/kotlin/net/starshipfights/game/ship_instances.kt
src/commonMain/kotlin/net/starshipfights/game/ship_weapons.kt
src/jsMain/kotlin/net/starshipfights/game/client_game.kt
src/jsMain/kotlin/net/starshipfights/game/client_matchmaking.kt
src/jsMain/kotlin/net/starshipfights/game/client_training.kt
src/jsMain/kotlin/net/starshipfights/game/game_resources.kt
src/jsMain/kotlin/net/starshipfights/game/game_ui.kt
src/jsMain/kotlin/net/starshipfights/game/popup.kt
src/jsMain/kotlin/net/starshipfights/game/popup_util.kt
src/jsMain/resources/textures/neutral-marker.png [new file with mode: 0644]
src/jvmMain/kotlin/net/starshipfights/data/admiralty/battle_records.kt
src/jvmMain/kotlin/net/starshipfights/game/game_start_jvm.kt
src/jvmMain/kotlin/net/starshipfights/game/server_game.kt
src/jvmMain/kotlin/net/starshipfights/game/server_matchmaking.kt
src/jvmMain/kotlin/net/starshipfights/info/views_gdpr.kt
src/jvmMain/kotlin/net/starshipfights/info/views_user.kt

index 278da7f407f0c7c9a973fcf7145bcea0450212f1..c36891240bca0de45f8bcd65c8d20f5bbba43251 100644 (file)
     * Felinae Felices (Masra Draetsen, Vestigium)
     * Isarnareykk (Mechyrdia, Masra Draetsen)
 
+### Characters
+
+The player of the campaign plays as their own admiral character.
+
+#### Mission givers
+
+These characters give missions to the player
+
+* Mechyrdia: Admiral of the Empire Lucius Valerius Maximus, Chancellor Marc Adlerovich Basileiov
+* Masra Draetsen: Envoy Apolluon, Ogus Khan
+* Vestigium: President Alexander Mack
+* NdRC: CEO Lukas van Jastoval
+* Felinae Felices: *Maxima* Lucia Iunia Drusilla, (replaced as Maxima by) Tanaquil Cassia Pulchra
+* Isarnareykk: *Haukmarscal* Demeter Ursalia
+
+#### Lieutenants
+
+These characters advise and hype up the player character
+
+* Mechyrdia: (Captain) Tiberius Kirche
+* Masra Draetsen: (Khod) Uqqans Arrhya
+* Vestigium: (Lieutenant) Joseph Quenn
+* NdRC: (Luitenant) Joris Tijkon
+* Felinae Felices: (Feles Lupata) Gaia Fulvia Agrippina
+* Isarnareykk: (Kapitaen) Ulari Sathalan
+
 ## Plots
 
 ### Mechyrdian campaign
@@ -99,9 +125,9 @@ Destroy the Presidential Moon Base in the Corvus Cluster
 
 Go east and defeat the resurgent Ilkhan Commune, restoring the liberal Ilkhan Republic
 
-##### *The Midnight Iron Sky*
+##### *Midnight of the Iron Sky*
 
-Defeat the Neuia Fulkreykk rebellion in Theudareykk, causing Isarnareykk to enter into negotiations with to Mechyrdia
+Defeat Captain Ulari Sathalan, leader of the Neuia Fulkreykk rebellion in Theudareykk, causing Isarnareykk to enter into negotiations with to Mechyrdia
 
 ##### *Panzerfaustian Bargain* - Isarnareyksk ending #1
 
index f6203d3b4ef271947cbb81ad7979ee6e486fcc46..62dcd70024ce5c15396aeacf58817eedb9d6551a 100644 (file)
@@ -19,15 +19,6 @@ enum class AdmiralRank {
                        LORD_ADMIRAL -> ShipTier.TITAN
                }
        
-       val maxShipWeightClass: ShipWeightClass
-               get() = when (this) {
-                       REAR_ADMIRAL -> ShipWeightClass.CRUISER
-                       VICE_ADMIRAL -> ShipWeightClass.BATTLECRUISER
-                       ADMIRAL -> ShipWeightClass.BATTLESHIP
-                       HIGH_ADMIRAL -> ShipWeightClass.BATTLESHIP
-                       LORD_ADMIRAL -> ShipWeightClass.COLOSSUS
-               }
-       
        val maxBattleSize: BattleSize
                get() = BattleSize.values().last { it.minRank <= this }
        
index f0d7377a00d8d82a1be3dd4bcb1a74f8e28ad958..db5b0b86f4765e71aa50c5bfcf4f785f3006253b 100644 (file)
@@ -19,7 +19,7 @@ data class AIPlayer(
 )
 
 @OptIn(FlowPreview::class)
-suspend fun AIPlayer.behave(instincts: Instincts, mySide: GlobalSide) {
+suspend fun AIPlayer.behave(instincts: Instincts, mySide: GlobalShipController) {
        try {
                coroutineScope {
                        val brain = Brain()
@@ -30,7 +30,7 @@ suspend fun AIPlayer.behave(instincts: Instincts, mySide: GlobalSide) {
                                var prevSentAt = Moment.now
                                
                                for (state in gameState.produceIn(this)) {
-                                       phasePipe.send(state.phase to (state.doneWithPhase != mySide && (!state.phase.usesInitiative || state.currentInitiative != mySide.other)))
+                                       phasePipe.send(state.phase to (mySide !in state.doneWithPhase && (!state.phase.usesInitiative || state.currentInitiative == mySide)))
                                        
                                        for (msg in state.chatBox.takeLastWhile { msg -> msg.sentAt > prevSentAt }) {
                                                if (msg.sentAt > prevSentAt)
@@ -189,7 +189,7 @@ suspend fun AIPlayer.behave(instincts: Instincts, mySide: GlobalSide) {
                                                                        if (ship.owner == mySide && ship.canSendBoardingParty) {
                                                                                val pickRequest = ship.getBoardingPickRequest()
                                                                                state.ships.values.filter { target ->
-                                                                                       target.owner == mySide.other && target.position.location in pickRequest.boundary
+                                                                                       target.owner.side == mySide.side.other && target.position.location in pickRequest.boundary
                                                                                }.map { target -> ship to target }
                                                                        } else emptyList()
                                                                }.associateWith { (ship, target) ->
@@ -206,7 +206,7 @@ suspend fun AIPlayer.behave(instincts: Instincts, mySide: GlobalSide) {
                                                                                logWarning("Error when boarding target ship ID ${target.id} with assault parties of ship ID ${ship.id} - $error")
                                                                                
                                                                                val nextState = gameState.value
-                                                                               phasePipe.send(nextState.phase to (nextState.doneWithPhase != mySide && (!nextState.phase.usesInitiative || nextState.currentInitiative != mySide.other)))
+                                                                               phasePipe.send(nextState.phase to (mySide !in nextState.doneWithPhase && (!nextState.phase.usesInitiative || nextState.currentInitiative == mySide)))
                                                                        }
                                                                        
                                                                        continue@loop
@@ -259,7 +259,7 @@ suspend fun AIPlayer.behave(instincts: Instincts, mySide: GlobalSide) {
                                                                        doActions.send(PlayerAction.UseAbility(PlayerAbilityType.DonePhase(phase), PlayerAbilityData.DonePhase))
                                                                else {
                                                                        val nextState = gameState.value
-                                                                       phasePipe.send(nextState.phase to (nextState.doneWithPhase != mySide && (!nextState.phase.usesInitiative || nextState.currentInitiative != mySide.other)))
+                                                                       phasePipe.send(nextState.phase to (mySide !in nextState.doneWithPhase && (!nextState.phase.usesInitiative || nextState.currentInitiative == mySide)))
                                                                }
                                                        }
                                                }
@@ -294,9 +294,9 @@ suspend fun AIPlayer.behave(instincts: Instincts, mySide: GlobalSide) {
        }
 }
 
-fun deploy(gameState: GameState, mySide: GlobalSide, instincts: Instincts): Map<Id<ShipInstance>, Position> {
+fun deploy(gameState: GameState, mySide: GlobalShipController, instincts: Instincts): Map<Id<ShipInstance>, Position> {
        val size = gameState.battleInfo.size
-       val totalPoints = size.numPoints
+       val totalPoints = gameState.getUsablePoints(mySide)
        val maxTier = size.maxTier
        
        val myStart = gameState.start.playerStart(mySide)
index 3644f139a444d873c15081599329c13ddafe3709..4eea4caecc698ee6f1de48afe602df07a1700dbe 100644 (file)
@@ -10,17 +10,17 @@ import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.launch
 import net.starshipfights.game.GameEvent
 import net.starshipfights.game.GameState
-import net.starshipfights.game.GlobalSide
+import net.starshipfights.game.GlobalShipController
 import net.starshipfights.game.PlayerAction
 
 data class AISession(
-       val mySide: GlobalSide,
+       val mySide: GlobalShipController,
        val actions: SendChannel<PlayerAction>,
        val events: ReceiveChannel<GameEvent>,
        val instincts: Instincts = Instincts(),
 )
 
-suspend fun aiPlayer(session: AISession, initialState: GameState) = coroutineScope {
+suspend fun aiPlayer(session: AISession, initialState: GameState): Unit = coroutineScope {
        val gameDone = Job()
        
        val errors = Channel<String>()
index 60aea4e40ecedba4ebad9824e6fdc92e00a56c80..4ef45bb2d012f9e25e91ac5bd1cb37c8bd082f25 100644 (file)
@@ -66,7 +66,7 @@ class TestSession(gameState: GameState) {
        val gameEnd: Deferred<GameEvent.GameEnd>
                get() = gameEndMutable
        
-       suspend fun onPacket(player: GlobalSide, packet: PlayerAction) {
+       suspend fun onPacket(player: GlobalShipController, packet: PlayerAction) {
                stateMutex.withLock {
                        when (val result = state.value.after(player, packet)) {
                                is GameEvent.StateChange -> {
@@ -74,7 +74,7 @@ class TestSession(gameState: GameState) {
                                        result.newState.checkVictory()?.let { gameEndMutable.complete(it) }
                                }
                                is GameEvent.InvalidAction -> {
-                                       errorMessageChannel(player).send(result.message)
+                                       errorMessageChannel(player.side).send(result.message)
                                }
                                is GameEvent.GameEnd -> {
                                        gameEndMutable.complete(result)
@@ -89,11 +89,13 @@ suspend fun performTestSession(gameState: GameState, hostInstincts: Instincts, g
        
        val hostActions = Channel<PlayerAction>()
        val hostEvents = Channel<GameEvent>()
-       val hostSession = AISession(GlobalSide.HOST, hostActions, hostEvents, hostInstincts)
+       val hostSide = GlobalShipController(GlobalSide.HOST, GlobalShipController.Player1Disambiguation)
+       val hostSession = AISession(hostSide, hostActions, hostEvents, hostInstincts)
        
        val guestActions = Channel<PlayerAction>()
        val guestEvents = Channel<GameEvent>()
-       val guestSession = AISession(GlobalSide.GUEST, guestActions, guestEvents, guestInstincts)
+       val guestSide = GlobalShipController(GlobalSide.HOST, GlobalShipController.Player1Disambiguation)
+       val guestSession = AISession(guestSide, guestActions, guestEvents, guestInstincts)
        
        return coroutineScope {
                val hostHandlingJob = launch {
@@ -116,7 +118,7 @@ suspend fun performTestSession(gameState: GameState, hostInstincts: Instincts, g
                        
                        launch {
                                for (action in hostActions)
-                                       testSession.onPacket(GlobalSide.HOST, action)
+                                       testSession.onPacket(hostSide, action)
                        }
                        
                        aiPlayer(hostSession, testSession.state.value)
@@ -142,7 +144,7 @@ suspend fun performTestSession(gameState: GameState, hostInstincts: Instincts, g
                        
                        launch {
                                for (action in guestActions)
-                                       testSession.onPacket(GlobalSide.GUEST, action)
+                                       testSession.onPacket(guestSide, action)
                        }
                        
                        aiPlayer(guestSession, testSession.state.value)
@@ -200,43 +202,51 @@ fun generateOptimizationInitialState(hostFaction: Faction, guestFaction: Faction
                start = GameStart(
                        battleWidth, battleLength,
                        
-                       PlayerStart(
-                               hostDeployCenter,
-                               PI / 2,
-                               PickBoundary.Rectangle(hostDeployCenter, deployWidth2, deployLength2),
-                               PI / 2,
-                               generateFleet(hostFaction, rank, GlobalSide.HOST)
+                       mapOf(
+                               GlobalShipController.Player1Disambiguation to PlayerStart(
+                                       hostDeployCenter,
+                                       PI / 2,
+                                       PickBoundary.Rectangle(hostDeployCenter, deployWidth2, deployLength2),
+                                       PI / 2,
+                                       generateFleet(hostFaction, rank, GlobalSide.HOST)
+                               )
                        ),
                        
-                       PlayerStart(
-                               guestDeployCenter,
-                               -PI / 2,
-                               PickBoundary.Rectangle(guestDeployCenter, deployWidth2, deployLength2),
-                               -PI / 2,
-                               generateFleet(guestFaction, rank, GlobalSide.GUEST)
+                       mapOf(
+                               GlobalShipController.Player1Disambiguation to PlayerStart(
+                                       guestDeployCenter,
+                                       -PI / 2,
+                                       PickBoundary.Rectangle(guestDeployCenter, deployWidth2, deployLength2),
+                                       -PI / 2,
+                                       generateFleet(guestFaction, rank, GlobalSide.GUEST)
+                               )
                        )
                ),
-               hostInfo = InGameAdmiral(
-                       id = Id(GlobalSide.HOST.name),
-                       user = InGameUser(
+               hostInfo = mapOf(
+                       GlobalShipController.Player1Disambiguation to InGameAdmiral(
                                id = Id(GlobalSide.HOST.name),
-                               username = GlobalSide.HOST.name
-                       ),
-                       name = GlobalSide.HOST.name,
-                       isFemale = false,
-                       faction = hostFaction,
-                       rank = rank
+                               user = InGameUser(
+                                       id = Id(GlobalSide.HOST.name),
+                                       username = GlobalSide.HOST.name
+                               ),
+                               name = GlobalSide.HOST.name,
+                               isFemale = false,
+                               faction = hostFaction,
+                               rank = rank
+                       )
                ),
-               guestInfo = InGameAdmiral(
-                       id = Id(GlobalSide.GUEST.name),
-                       user = InGameUser(
+               guestInfo = mapOf(
+                       GlobalShipController.Player1Disambiguation to InGameAdmiral(
                                id = Id(GlobalSide.GUEST.name),
-                               username = GlobalSide.GUEST.name
-                       ),
-                       name = GlobalSide.GUEST.name,
-                       isFemale = false,
-                       faction = guestFaction,
-                       rank = rank
+                               user = InGameUser(
+                                       id = Id(GlobalSide.GUEST.name),
+                                       username = GlobalSide.GUEST.name
+                               ),
+                               name = GlobalSide.GUEST.name,
+                               isFemale = false,
+                               faction = guestFaction,
+                               rank = rank
+                       )
                ),
                battleInfo = battleInfo,
                subplots = emptySet(),
index 246ab8378ed473bdee6dd6b9a4565d2c7ba59cfd..32c0c6c379a68874b99fe8cd3581338103fa0a39 100644 (file)
@@ -11,7 +11,7 @@ sealed class ClientMode {
        data class InTrainingGame(val initialState: GameState) : ClientMode()
        
        @Serializable
-       data class InGame(val playerSide: GlobalSide, val connectToken: String, val initialState: GameState) : ClientMode()
+       data class InGame(val playerSide: GlobalShipController, val connectToken: String, val initialState: GameState) : ClientMode()
        
        @Serializable
        data class Error(val message: String) : ClientMode()
index 6b7fb1bb265a4ddca19490f89d4f5b44e6f6f3f9..2502fc4622b946d3e1685d70a818c7150197abb1 100644 (file)
@@ -16,19 +16,19 @@ sealed interface CombatAbility {
 
 @Serializable
 sealed class PlayerAbilityType {
-       abstract suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData?
-       abstract fun finishOnServer(gameState: GameState, playerSide: GlobalSide, data: PlayerAbilityData): GameEvent
+       abstract suspend fun beginOnClient(gameState: GameState, playerSide: GlobalShipController, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData?
+       abstract fun finishOnServer(gameState: GameState, playerSide: GlobalShipController, data: PlayerAbilityData): GameEvent
        
        @Serializable
        data class DonePhase(val phase: GamePhase) : PlayerAbilityType() {
-               override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? {
+               override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalShipController, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? {
                        if (gameState.phase != phase) return null
                        return if (gameState.canFinishPhase(playerSide))
                                PlayerAbilityData.DonePhase
                        else null
                }
                
-               override fun finishOnServer(gameState: GameState, playerSide: GlobalSide, data: PlayerAbilityData): GameEvent {
+               override fun finishOnServer(gameState: GameState, playerSide: GlobalShipController, data: PlayerAbilityData): GameEvent {
                        return if (phase == gameState.phase) {
                                if (gameState.canFinishPhase(playerSide))
                                        GameEvent.StateChange(gameState.afterPlayerReady(playerSide))
@@ -39,9 +39,9 @@ sealed class PlayerAbilityType {
        
        @Serializable
        data class DeployShip(val ship: Id<Ship>) : PlayerAbilityType() {
-               override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? {
+               override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalShipController, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? {
                        if (gameState.phase != GamePhase.Deploy) return null
-                       if (gameState.doneWithPhase == playerSide) return null
+                       if (playerSide in gameState.doneWithPhase) return null
                        val pickBoundary = gameState.start.playerStart(playerSide).deployZone
                        
                        val playerStart = gameState.start.playerStart(playerSide)
@@ -53,7 +53,7 @@ sealed class PlayerAbilityType {
                        return PlayerAbilityData.DeployShip(shipPosition)
                }
                
-               override fun finishOnServer(gameState: GameState, playerSide: GlobalSide, data: PlayerAbilityData): GameEvent {
+               override fun finishOnServer(gameState: GameState, playerSide: GlobalShipController, data: PlayerAbilityData): GameEvent {
                        if (data !is PlayerAbilityData.DeployShip) return GameEvent.InvalidAction("Internal error from using player ability")
                        val playerStart = gameState.start.playerStart(playerSide)
                        val shipData = playerStart.deployableFleet[ship] ?: return GameEvent.InvalidAction("That ship does not exist")
@@ -73,7 +73,7 @@ sealed class PlayerAbilityType {
                        
                        val newShipSet = gameState.ships + mapOf(shipInstance.id to shipInstance)
                        
-                       if (newShipSet.values.filter { it.owner == playerSide }.sumOf { it.ship.pointCost } > gameState.battleInfo.size.numPoints)
+                       if (newShipSet.values.filter { it.owner == playerSide }.sumOf { it.ship.pointCost } > gameState.getUsablePoints(playerSide))
                                return GameEvent.InvalidAction("Not enough points to deploy this ship")
                        
                        val deployableShips = playerStart.deployableFleet - ship
@@ -82,9 +82,9 @@ sealed class PlayerAbilityType {
                        return GameEvent.StateChange(
                                with(gameState) {
                                        copy(
-                                               start = when (playerSide) {
-                                                       GlobalSide.HOST -> start.copy(hostStart = newPlayerStart)
-                                                       GlobalSide.GUEST -> start.copy(guestStart = newPlayerStart)
+                                               start = when (playerSide.side) {
+                                                       GlobalSide.HOST -> start.copy(hostStarts = start.hostStarts + mapOf(playerSide.disambiguation to newPlayerStart))
+                                                       GlobalSide.GUEST -> start.copy(guestStarts = start.guestStarts + mapOf(playerSide.disambiguation to newPlayerStart))
                                                },
                                                ships = newShipSet
                                        )
@@ -95,11 +95,11 @@ sealed class PlayerAbilityType {
        
        @Serializable
        data class UndeployShip(val ship: Id<ShipInstance>) : PlayerAbilityType() {
-               override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? {
-                       return if (gameState.phase == GamePhase.Deploy && gameState.doneWithPhase != playerSide) PlayerAbilityData.UndeployShip else null
+               override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalShipController, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? {
+                       return if (gameState.phase == GamePhase.Deploy && playerSide !in gameState.doneWithPhase) PlayerAbilityData.UndeployShip else null
                }
                
-               override fun finishOnServer(gameState: GameState, playerSide: GlobalSide, data: PlayerAbilityData): GameEvent {
+               override fun finishOnServer(gameState: GameState, playerSide: GlobalShipController, data: PlayerAbilityData): GameEvent {
                        val shipInstance = gameState.ships[ship] ?: return GameEvent.InvalidAction("That ship is not deployed")
                        val shipData = shipInstance.ship
                        
@@ -113,9 +113,9 @@ sealed class PlayerAbilityType {
                        return GameEvent.StateChange(
                                with(gameState) {
                                        copy(
-                                               start = when (playerSide) {
-                                                       GlobalSide.HOST -> start.copy(hostStart = newPlayerStart)
-                                                       GlobalSide.GUEST -> start.copy(guestStart = newPlayerStart)
+                                               start = when (playerSide.side) {
+                                                       GlobalSide.HOST -> start.copy(hostStarts = start.hostStarts + mapOf(playerSide.disambiguation to newPlayerStart))
+                                                       GlobalSide.GUEST -> start.copy(guestStarts = start.guestStarts + mapOf(playerSide.disambiguation to newPlayerStart))
                                                },
                                                ships = newShipSet
                                        )
@@ -126,7 +126,7 @@ sealed class PlayerAbilityType {
        
        @Serializable
        data class DistributePower(override val ship: Id<ShipInstance>) : PlayerAbilityType(), ShipAbility {
-               override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? {
+               override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalShipController, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? {
                        if (gameState.phase !is GamePhase.Power) return null
                        
                        val shipInstance = gameState.ships[ship] ?: return null
@@ -138,7 +138,7 @@ sealed class PlayerAbilityType {
                        return PlayerAbilityData.DistributePower(data)
                }
                
-               override fun finishOnServer(gameState: GameState, playerSide: GlobalSide, data: PlayerAbilityData): GameEvent {
+               override fun finishOnServer(gameState: GameState, playerSide: GlobalShipController, data: PlayerAbilityData): GameEvent {
                        if (data !is PlayerAbilityData.DistributePower) return GameEvent.InvalidAction("Internal error from using player ability")
                        
                        val shipInstance = gameState.ships[ship] ?: return GameEvent.InvalidAction("That ship does not exist")
@@ -166,7 +166,7 @@ sealed class PlayerAbilityType {
        
        @Serializable
        data class ConfigurePower(override val ship: Id<ShipInstance>, val powerMode: FelinaeShipPowerMode) : PlayerAbilityType(), ShipAbility {
-               override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? {
+               override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalShipController, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? {
                        if (gameState.phase !is GamePhase.Power) return null
                        
                        val shipInstance = gameState.ships[ship] ?: return null
@@ -175,7 +175,7 @@ sealed class PlayerAbilityType {
                        return PlayerAbilityData.ConfigurePower
                }
                
-               override fun finishOnServer(gameState: GameState, playerSide: GlobalSide, data: PlayerAbilityData): GameEvent {
+               override fun finishOnServer(gameState: GameState, playerSide: GlobalShipController, data: PlayerAbilityData): GameEvent {
                        val shipInstance = gameState.ships[ship] ?: return GameEvent.InvalidAction("That ship does not exist")
                        if (shipInstance.ship.reactor != FelinaeShipReactor) return GameEvent.InvalidAction("Invalid ship reactor type")
                        
@@ -192,7 +192,7 @@ sealed class PlayerAbilityType {
        
        @Serializable
        data class MoveShip(override val ship: Id<ShipInstance>) : PlayerAbilityType(), ShipAbility {
-               override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? {
+               override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalShipController, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? {
                        if (gameState.phase !is GamePhase.Move) return null
                        if (!gameState.canShipMove(ship)) return null
                        
@@ -231,7 +231,7 @@ sealed class PlayerAbilityType {
                        return PlayerAbilityData.MoveShip(newPosition)
                }
                
-               override fun finishOnServer(gameState: GameState, playerSide: GlobalSide, data: PlayerAbilityData): GameEvent {
+               override fun finishOnServer(gameState: GameState, playerSide: GlobalShipController, data: PlayerAbilityData): GameEvent {
                        if (data !is PlayerAbilityData.MoveShip) return GameEvent.InvalidAction("Internal error from using player ability")
                        if (!gameState.canShipMove(ship)) return GameEvent.InvalidAction("You do not have the initiative")
                        
@@ -309,7 +309,7 @@ sealed class PlayerAbilityType {
        
        @Serializable
        data class UseInertialessDrive(override val ship: Id<ShipInstance>) : PlayerAbilityType(), ShipAbility {
-               override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? {
+               override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalShipController, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? {
                        if (gameState.phase !is GamePhase.Move) return null
                        if (!gameState.canShipMove(ship)) return null
                        
@@ -332,7 +332,7 @@ sealed class PlayerAbilityType {
                        return PlayerAbilityData.UseInertialessDrive(positionPickRes.position)
                }
                
-               override fun finishOnServer(gameState: GameState, playerSide: GlobalSide, data: PlayerAbilityData): GameEvent {
+               override fun finishOnServer(gameState: GameState, playerSide: GlobalShipController, data: PlayerAbilityData): GameEvent {
                        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")
                        
@@ -410,7 +410,7 @@ sealed class PlayerAbilityType {
        
        @Serializable
        data class ChargeLance(override val ship: Id<ShipInstance>, override val weapon: Id<ShipWeapon>) : PlayerAbilityType(), CombatAbility {
-               override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? {
+               override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalShipController, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? {
                        if (gameState.phase !is GamePhase.Attack) return null
                        
                        val shipInstance = gameState.ships[ship] ?: return null
@@ -423,7 +423,7 @@ sealed class PlayerAbilityType {
                        return PlayerAbilityData.ChargeLance
                }
                
-               override fun finishOnServer(gameState: GameState, playerSide: GlobalSide, data: PlayerAbilityData): GameEvent {
+               override fun finishOnServer(gameState: GameState, playerSide: GlobalShipController, data: PlayerAbilityData): GameEvent {
                        if (gameState.phase !is GamePhase.Attack) return GameEvent.InvalidAction("Ships can only charge lances during Phase III")
                        
                        val shipInstance = gameState.ships[ship] ?: return GameEvent.InvalidAction("That ship does not exist")
@@ -450,7 +450,7 @@ sealed class PlayerAbilityType {
        
        @Serializable
        data class UseWeapon(override val ship: Id<ShipInstance>, override val weapon: Id<ShipWeapon>) : PlayerAbilityType(), CombatAbility {
-               override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? {
+               override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalShipController, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? {
                        if (gameState.phase !is GamePhase.Attack) return null
                        
                        val shipInstance = gameState.ships[ship] ?: return null
@@ -463,7 +463,7 @@ sealed class PlayerAbilityType {
                        return pickResponse?.let { PlayerAbilityData.UseWeapon(it) }
                }
                
-               override fun finishOnServer(gameState: GameState, playerSide: GlobalSide, data: PlayerAbilityData): GameEvent {
+               override fun finishOnServer(gameState: GameState, playerSide: GlobalShipController, data: PlayerAbilityData): GameEvent {
                        if (data !is PlayerAbilityData.UseWeapon) return GameEvent.InvalidAction("Internal error from using player ability")
                        
                        if (gameState.phase !is GamePhase.Attack) return GameEvent.InvalidAction("Ships can only attack during Phase III")
@@ -484,7 +484,7 @@ sealed class PlayerAbilityType {
        
        @Serializable
        data class RecallStrikeCraft(override val ship: Id<ShipInstance>, override val weapon: Id<ShipWeapon>) : PlayerAbilityType(), CombatAbility {
-               override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? {
+               override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalShipController, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? {
                        if (gameState.phase !is GamePhase.Attack) return null
                        
                        val shipInstance = gameState.ships[ship] ?: return null
@@ -496,7 +496,7 @@ sealed class PlayerAbilityType {
                        return PlayerAbilityData.RecallStrikeCraft
                }
                
-               override fun finishOnServer(gameState: GameState, playerSide: GlobalSide, data: PlayerAbilityData): GameEvent {
+               override fun finishOnServer(gameState: GameState, playerSide: GlobalShipController, data: PlayerAbilityData): GameEvent {
                        if (gameState.phase !is GamePhase.Attack) return GameEvent.InvalidAction("Ships can only recall strike craft during Phase III")
                        
                        val shipInstance = gameState.ships[ship] ?: return GameEvent.InvalidAction("That ship does not exist")
@@ -526,7 +526,7 @@ sealed class PlayerAbilityType {
        
        @Serializable
        data class DisruptionPulse(override val ship: Id<ShipInstance>) : PlayerAbilityType(), ShipAbility {
-               override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? {
+               override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalShipController, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? {
                        if (gameState.phase !is GamePhase.Attack) return null
                        
                        val shipInstance = gameState.ships[ship] ?: return null
@@ -536,7 +536,7 @@ sealed class PlayerAbilityType {
                        return PlayerAbilityData.DisruptionPulse
                }
                
-               override fun finishOnServer(gameState: GameState, playerSide: GlobalSide, data: PlayerAbilityData): GameEvent {
+               override fun finishOnServer(gameState: GameState, playerSide: GlobalShipController, data: PlayerAbilityData): GameEvent {
                        if (gameState.phase !is GamePhase.Attack) return GameEvent.InvalidAction("Ships can only emit Disruption Pulses during Phase III")
                        
                        val shipInstance = gameState.ships[ship] ?: return GameEvent.InvalidAction("That ship does not exist")
@@ -581,7 +581,7 @@ sealed class PlayerAbilityType {
        
        @Serializable
        data class BoardingParty(override val ship: Id<ShipInstance>) : PlayerAbilityType(), ShipAbility {
-               override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? {
+               override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalShipController, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? {
                        if (gameState.phase !is GamePhase.Attack) return null
                        
                        val shipInstance = gameState.ships[ship] ?: return null
@@ -591,7 +591,7 @@ sealed class PlayerAbilityType {
                        return PlayerAbilityData.BoardingParty(pickResponse.id)
                }
                
-               override fun finishOnServer(gameState: GameState, playerSide: GlobalSide, data: PlayerAbilityData): GameEvent {
+               override fun finishOnServer(gameState: GameState, playerSide: GlobalShipController, data: PlayerAbilityData): GameEvent {
                        if (data !is PlayerAbilityData.BoardingParty) return GameEvent.InvalidAction("Internal error from using player ability")
                        
                        if (gameState.phase !is GamePhase.Attack) return GameEvent.InvalidAction("Ships can only send Boarding Parties during Phase III")
@@ -626,7 +626,7 @@ sealed class PlayerAbilityType {
        
        @Serializable
        data class RepairShipModule(override val ship: Id<ShipInstance>, val module: ShipModule) : PlayerAbilityType(), ShipAbility {
-               override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? {
+               override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalShipController, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? {
                        if (gameState.phase !is GamePhase.Repair) return null
                        
                        val shipInstance = gameState.ships[ship] ?: return null
@@ -637,7 +637,7 @@ sealed class PlayerAbilityType {
                        return PlayerAbilityData.RepairShipModule
                }
                
-               override fun finishOnServer(gameState: GameState, playerSide: GlobalSide, data: PlayerAbilityData): GameEvent {
+               override fun finishOnServer(gameState: GameState, playerSide: GlobalShipController, data: PlayerAbilityData): GameEvent {
                        if (gameState.phase !is GamePhase.Repair) return GameEvent.InvalidAction("Ships can only repair modules during Phase IV")
                        
                        val shipInstance = gameState.ships[ship] ?: return GameEvent.InvalidAction("That ship does not exist")
@@ -662,7 +662,7 @@ sealed class PlayerAbilityType {
        
        @Serializable
        data class ExtinguishFire(override val ship: Id<ShipInstance>) : PlayerAbilityType(), ShipAbility {
-               override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? {
+               override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalShipController, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? {
                        if (gameState.phase !is GamePhase.Repair) return null
                        
                        val shipInstance = gameState.ships[ship] ?: return null
@@ -673,7 +673,7 @@ sealed class PlayerAbilityType {
                        return PlayerAbilityData.ExtinguishFire
                }
                
-               override fun finishOnServer(gameState: GameState, playerSide: GlobalSide, data: PlayerAbilityData): GameEvent {
+               override fun finishOnServer(gameState: GameState, playerSide: GlobalShipController, data: PlayerAbilityData): GameEvent {
                        if (gameState.phase !is GamePhase.Repair) return GameEvent.InvalidAction("Ships can only extinguish fires during Phase IV")
                        
                        val shipInstance = gameState.ships[ship] ?: return GameEvent.InvalidAction("That ship does not exist")
@@ -698,7 +698,7 @@ sealed class PlayerAbilityType {
        
        @Serializable
        data class Recoalesce(override val ship: Id<ShipInstance>) : PlayerAbilityType(), ShipAbility {
-               override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? {
+               override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalShipController, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? {
                        if (gameState.phase !is GamePhase.Repair) return null
                        
                        val shipInstance = gameState.ships[ship] ?: return null
@@ -708,7 +708,7 @@ sealed class PlayerAbilityType {
                        return PlayerAbilityData.Recoalesce
                }
                
-               override fun finishOnServer(gameState: GameState, playerSide: GlobalSide, data: PlayerAbilityData): GameEvent {
+               override fun finishOnServer(gameState: GameState, playerSide: GlobalShipController, data: PlayerAbilityData): GameEvent {
                        if (gameState.phase !is GamePhase.Repair) return GameEvent.InvalidAction("Ships can only extinguish fires during Phase IV")
                        
                        val shipInstance = gameState.ships[ship] ?: return GameEvent.InvalidAction("That ship does not exist")
@@ -797,7 +797,7 @@ sealed class PlayerAbilityData {
        object Recoalesce : PlayerAbilityData()
 }
 
-fun GameState.getPossibleAbilities(forPlayer: GlobalSide): List<PlayerAbilityType> = if (doneWithPhase == forPlayer)
+fun GameState.getPossibleAbilities(forPlayer: GlobalShipController): List<PlayerAbilityType> = if (forPlayer in doneWithPhase)
        emptyList()
 else when (phase) {
        GamePhase.Deploy -> {
@@ -806,7 +806,7 @@ else when (phase) {
                        .sumOf { it.ship.pointCost }
                
                val deployShips = start.playerStart(forPlayer).deployableFleet
-                       .filterValues { usedPoints + it.pointCost <= battleInfo.size.numPoints }.keys
+                       .filterValues { usedPoints + it.pointCost <= getUsablePoints(forPlayer) }.keys
                        .map { PlayerAbilityType.DeployShip(it) }
                
                val undeployShips = ships
index 54268479cf4b193f3859d018b0759de808417bda..e13c32667b1dc57e41339d08164c71d18e14e3c5 100644 (file)
@@ -9,7 +9,7 @@ sealed class ChatEntry {
        
        @Serializable
        data class PlayerMessage(
-               val senderSide: GlobalSide,
+               val senderSide: GlobalShipController,
                override val sentAt: Moment,
                val message: String
        ) : ChatEntry()
index 25c2ba3f2e0acb4d164160835b518069f24da073..31eabcb0699e13a2217f4120bd0ac520f887436e 100644 (file)
@@ -1,30 +1,10 @@
 package net.starshipfights.game
 
-import kotlinx.serialization.Serializable
 import net.starshipfights.data.Id
 
-@Serializable
-data class InitiativePair(
-       val hostSide: Double,
-       val guestSide: Double
-) {
-       constructor(map: Map<GlobalSide, Double>) : this(
-               map[GlobalSide.HOST] ?: 0.0,
-               map[GlobalSide.GUEST] ?: 0.0,
-       )
-       
-       operator fun get(side: GlobalSide) = when (side) {
-               GlobalSide.HOST -> hostSide
-               GlobalSide.GUEST -> guestSide
-       }
-       
-       fun copy(map: Map<GlobalSide, Double>) = copy(
-               hostSide = map[GlobalSide.HOST] ?: hostSide,
-               guestSide = map[GlobalSide.GUEST] ?: guestSide,
-       )
-}
+typealias InitiativeMap = Map<GlobalShipController, Double>
 
-fun GameState.calculateMovePhaseInitiative(): InitiativePair = InitiativePair(
+fun GameState.calculateMovePhaseInitiative(): InitiativeMap =
        ships
                .values
                .groupBy { it.owner }
@@ -33,7 +13,6 @@ fun GameState.calculateMovePhaseInitiative(): InitiativePair = InitiativePair(
                                .filter { !it.isDoneCurrentPhase }
                                .sumOf { it.ship.pointCost * it.movementCoefficient }
                }
-)
 
 fun GameState.getValidAttackersWith(target: ShipInstance): Map<Id<ShipInstance>, Set<Id<ShipWeapon>>> {
        return ships.mapValues { (_, ship) -> isValidAttackerWith(ship, target) }
@@ -50,9 +29,9 @@ fun GameState.isValidTarget(ship: ShipInstance, weapon: ShipWeaponInstance, pick
        
        return when (val weaponSpec = weapon.weapon) {
                is AreaWeapon ->
-                       target.owner != ship.owner && (targetPos - pickRequest.boundary.closestPointTo(targetPos)).length < weaponSpec.areaRadius
+                       target.owner.side != ship.owner.side && (targetPos - pickRequest.boundary.closestPointTo(targetPos)).length < weaponSpec.areaRadius
                else ->
-                       target.owner in (pickRequest.type as PickType.Ship).allowSides && isValidPick(pickRequest, PickResponse.Ship(target.id))
+                       target.owner.side in (pickRequest.type as PickType.Ship).allowSides && isValidPick(pickRequest, PickResponse.Ship(target.id))
        }
 }
 
@@ -69,7 +48,7 @@ fun GameState.getValidTargets(ship: ShipInstance, weapon: ShipWeaponInstance): L
        return aggregateValidTargets(ship, weapon) { filter(it) }
 }
 
-fun GameState.calculateAttackPhaseInitiative(): InitiativePair = InitiativePair(
+fun GameState.calculateAttackPhaseInitiative(): InitiativeMap =
        ships
                .values
                .groupBy { it.owner }
@@ -87,26 +66,22 @@ fun GameState.calculateAttackPhaseInitiative(): InitiativePair = InitiativePair(
                                        ship.ship.pointCost * (usableWeaponShots.toDouble() / allWeaponShots)
                                }
                }
-)
 
-fun GameState.withRecalculatedInitiative(initiativePairAccessor: GameState.() -> InitiativePair): GameState {
-       val initiativePair = initiativePairAccessor()
+
+fun GameState.withRecalculatedInitiative(initiativeMapAccessor: GameState.() -> InitiativeMap): GameState {
+       val initiativePair = initiativeMapAccessor()
        
        return copy(
-               calculatedInitiative = when {
-                       initiativePair.hostSide > initiativePair.guestSide -> GlobalSide.HOST
-                       initiativePair.hostSide < initiativePair.guestSide -> GlobalSide.GUEST
-                       else -> calculatedInitiative?.other
-               }
+               calculatedInitiative = (initiativePair - doneWithPhase).maxByOrNull { (_, it) -> it }?.key
        )
 }
 
 fun GameState.canShipMove(ship: Id<ShipInstance>): Boolean {
        val shipInstance = ships[ship] ?: return false
-       return currentInitiative != shipInstance.owner.other
+       return currentInitiative == shipInstance.owner
 }
 
 fun GameState.canShipAttack(ship: Id<ShipInstance>): Boolean {
        val shipInstance = ships[ship] ?: return false
-       return currentInitiative != shipInstance.owner.other
+       return currentInitiative == shipInstance.owner
 }
index 0634d3e8515ee8c27ec6a5b837ac150c47f3e082..7b68067e38455721298f440c34893d470c54fe8d 100644 (file)
@@ -39,7 +39,7 @@ sealed class GameEvent {
        ) : GameEvent()
 }
 
-fun GameState.after(player: GlobalSide, packet: PlayerAction): GameEvent = when (packet) {
+fun GameState.after(player: GlobalShipController, packet: PlayerAction): GameEvent = when (packet) {
        is PlayerAction.SendChatMessage -> {
                GameEvent.StateChange(
                        copy(
@@ -58,16 +58,14 @@ fun GameState.after(player: GlobalSide, packet: PlayerAction): GameEvent = when
                        GameEvent.InvalidAction("That ability cannot be used right now")
        }
        PlayerAction.TimeOut -> {
-               val loserName = admiralInfo(player).fullName
-               val winnerName = admiralInfo(player.other).fullName
+               val noShowName = admiralInfo(player).fullName
                
-               GameEvent.GameEnd(player.other, "$loserName never joined the battle, yielding victory to $winnerName!", emptyMap())
+               GameEvent.GameEnd(null, "$noShowName never joined the battle", emptyMap())
        }
        PlayerAction.Disconnect -> {
-               val loserName = admiralInfo(player).fullName
-               val winnerName = admiralInfo(player.other).fullName
+               val quitterName = admiralInfo(player).fullName
                
-               GameEvent.GameEnd(player.other, "$loserName has disconnected from the battle, yielding victory to $winnerName!", emptyMap())
+               GameEvent.GameEnd(null, "$quitterName has disconnected from the battle", emptyMap())
        }
 }.let { event ->
        if (event is GameEvent.StateChange) {
index 744349cb281687a59d8548d073dfb3ecf9ca877b..06cd00867f8d280d073f2ff43c44ef8bfb3bb332 100644 (file)
@@ -8,13 +8,13 @@ data class GameStart(
        val battlefieldWidth: Double,
        val battlefieldLength: Double,
        
-       val hostStart: PlayerStart,
-       val guestStart: PlayerStart
+       val hostStarts: Map<String, PlayerStart>,
+       val guestStarts: Map<String, PlayerStart>
 )
 
-fun GameStart.playerStart(side: GlobalSide) = when (side) {
-       GlobalSide.HOST -> hostStart
-       GlobalSide.GUEST -> guestStart
+fun GameStart.playerStart(side: GlobalShipController) = when (side.side) {
+       GlobalSide.HOST -> hostStarts.getValue(side.disambiguation)
+       GlobalSide.GUEST -> guestStarts.getValue(side.disambiguation)
 }
 
 @Serializable
@@ -25,5 +25,6 @@ data class PlayerStart(
        val deployZone: PickBoundary.Rectangle,
        val deployFacing: Double,
        
-       val deployableFleet: Map<Id<Ship>, Ship>
+       val deployableFleet: Map<Id<Ship>, Ship>,
+       val deployPointsFactor: Double = 1.0,
 )
index 6cfee8fed53d07933f59c4dd1a404e0eb05b0948..7240f4c000b37c5d258068beb2571923931f0fc0 100644 (file)
@@ -2,20 +2,21 @@ package net.starshipfights.game
 
 import kotlinx.serialization.Serializable
 import net.starshipfights.data.Id
+import kotlin.math.roundToInt
 
 @Serializable
 data class GameState(
        val start: GameStart,
        
-       val hostInfo: InGameAdmiral,
-       val guestInfo: InGameAdmiral,
+       val hostInfo: Map<String, InGameAdmiral>,
+       val guestInfo: Map<String, InGameAdmiral>,
        val battleInfo: BattleInfo,
        
        val subplots: Set<Subplot>,
        
        val phase: GamePhase = GamePhase.Deploy,
-       val doneWithPhase: GlobalSide? = null,
-       val calculatedInitiative: GlobalSide? = null,
+       val doneWithPhase: Set<GlobalShipController> = emptySet(),
+       val calculatedInitiative: GlobalShipController? = null,
        
        val ships: Map<Id<ShipInstance>, ShipInstance> = emptyMap(),
        val destroyedShips: Map<Id<ShipInstance>, ShipWreck> = emptyMap(),
@@ -27,19 +28,29 @@ data class GameState(
        
        fun getShipOwner(id: Id<ShipInstance>) = destroyedShips[id]?.owner ?: ships.getValue(id).owner
        fun getShipOwnerOrNull(id: Id<ShipInstance>) = destroyedShips[id]?.owner ?: ships[id]?.owner
+       
+       fun getUsablePoints(side: GlobalShipController) = (battleInfo.size.numPoints * start.playerStart(side).deployPointsFactor).roundToInt()
 }
 
-val GameState.currentInitiative: GlobalSide?
-       get() = calculatedInitiative?.takeIf { it != doneWithPhase }
+val GameState.allShipControllers: Set<GlobalShipController>
+       get() = (hostInfo.keys.map { GlobalShipController(GlobalSide.HOST, it) } + guestInfo.keys.map { GlobalShipController(GlobalSide.GUEST, it) }).toSet()
+
+fun GameState.allShipControllersOnSide(side: GlobalSide): Map<GlobalShipController, InGameAdmiral> = when (side) {
+       GlobalSide.HOST -> hostInfo
+       GlobalSide.GUEST -> guestInfo
+}.mapKeys { (it, _) -> GlobalShipController(side, it) }
+
+val GameState.currentInitiative: GlobalShipController?
+       get() = calculatedInitiative?.takeIf { it !in doneWithPhase }
 
-fun GameState.canFinishPhase(side: GlobalSide): Boolean {
+fun GameState.canFinishPhase(side: GlobalShipController): Boolean {
        return when (phase) {
                GamePhase.Deploy -> {
                        val usedPoints = ships.values
                                .filter { it.owner == side }
                                .sumOf { it.ship.pointCost }
                        
-                       start.playerStart(side).deployableFleet.values.none { usedPoints + it.pointCost <= battleInfo.size.numPoints }
+                       start.playerStart(side).deployableFleet.values.none { usedPoints + it.pointCost <= getUsablePoints(side) }
                }
                else -> true
        }
@@ -49,7 +60,7 @@ private fun GameState.afterPhase(): GameState {
        var newShips = ships
        val newWrecks = destroyedShips.toMutableMap()
        val newChatEntries = mutableListOf<ChatEntry>()
-       var newInitiative: GameState.() -> InitiativePair = { InitiativePair(emptyMap()) }
+       var newInitiative: GameState.() -> InitiativeMap = { emptyMap() }
        
        when (phase) {
                GamePhase.Deploy -> {
@@ -160,23 +171,34 @@ private fun GameState.afterPhase(): GameState {
        ).withRecalculatedInitiative(newInitiative)
 }
 
-fun GameState.afterPlayerReady(playerSide: GlobalSide) = if (doneWithPhase == playerSide.other) {
-       afterPhase().copy(doneWithPhase = null)
-} else
-       copy(doneWithPhase = playerSide)
+fun GameState.afterPlayerReady(playerSide: GlobalShipController) = if ((doneWithPhase + playerSide) == allShipControllers) {
+       afterPhase().copy(doneWithPhase = emptySet())
+} else if (phase.usesInitiative)
+       copy(doneWithPhase = doneWithPhase + playerSide).withRecalculatedInitiative(
+               when (phase) {
+                       is GamePhase.Move -> ({ calculateMovePhaseInitiative() })
+                       is GamePhase.Attack -> ({ calculateAttackPhaseInitiative() })
+                       else -> ({ emptyMap() })
+               }
+       )
+else
+       copy(doneWithPhase = doneWithPhase + playerSide)
 
 private fun GameState.victoryMessage(winner: GlobalSide): String {
-       val winnerName = admiralInfo(winner).fullName
-       val loserName = admiralInfo(winner.other).fullName
+       val winnerName = allShipControllersOnSide(winner).mapValues { (_, it) -> it.fullName }.values
+       val loserName = allShipControllersOnSide(winner.other).mapValues { (_, it) -> it.fullName }.values
        
-       return "$winnerName has won the battle by destroying the fleet of $loserName!"
+       val winnerIsPlural = winnerName.size != 1
+       val loserIsPlural = loserName.size != 1
+       
+       return "${winnerName.joinToDisplayString()} ${if (winnerIsPlural) "have" else "has"} won the battle by destroying the fleet${if (loserIsPlural) "s" else ""} of ${loserName.joinToDisplayString()}!"
 }
 
 fun GameState.checkVictory(): GameEvent.GameEnd? {
        if (phase == GamePhase.Deploy) return null
        
-       val hostDefeated = ships.none { (_, it) -> it.owner == GlobalSide.HOST }
-       val guestDefeated = ships.none { (_, it) -> it.owner == GlobalSide.GUEST }
+       val hostDefeated = ships.none { (_, it) -> it.owner.side == GlobalSide.HOST }
+       val guestDefeated = ships.none { (_, it) -> it.owner.side == GlobalSide.GUEST }
        
        val winner = if (hostDefeated && guestDefeated)
                null
@@ -191,7 +213,7 @@ fun GameState.checkVictory(): GameEvent.GameEnd? {
        }
        
        return if (hostDefeated && guestDefeated)
-               GameEvent.GameEnd(null, "Stalemate: both sides have been completely destroyed!", subplotsOutcomes)
+               GameEvent.GameEnd(null, "Both sides have been completely destroyed!", subplotsOutcomes)
        else if (hostDefeated)
                GameEvent.GameEnd(GlobalSide.GUEST, victoryMessage(GlobalSide.GUEST), subplotsOutcomes)
        else if (guestDefeated)
@@ -200,9 +222,9 @@ fun GameState.checkVictory(): GameEvent.GameEnd? {
                null
 }
 
-fun GameState.admiralInfo(side: GlobalSide) = when (side) {
-       GlobalSide.HOST -> hostInfo
-       GlobalSide.GUEST -> guestInfo
+fun GameState.admiralInfo(side: GlobalShipController) = when (side.side) {
+       GlobalSide.HOST -> hostInfo.getValue(side.disambiguation)
+       GlobalSide.GUEST -> guestInfo.getValue(side.disambiguation)
 }
 
 enum class GlobalSide {
@@ -215,20 +237,28 @@ enum class GlobalSide {
                }
 }
 
-fun GlobalSide.relativeTo(me: GlobalSide) = if (this == me) LocalSide.GREEN else LocalSide.RED
+@Serializable
+data class GlobalShipController(val side: GlobalSide, val disambiguation: String) {
+       companion object {
+               val Player1Disambiguation = "PLAYER 1"
+               val Player2Disambiguation = "PLAYER 2"
+       }
+}
+
+fun GlobalShipController.relativeTo(me: GlobalShipController) = if (this == me)
+       LocalSide.GREEN
+else if (side == me.side)
+       LocalSide.BLUE
+else
+       LocalSide.RED
 
 enum class LocalSide {
-       GREEN, RED;
-       
-       val other: LocalSide
-               get() = when (this) {
-                       GREEN -> RED
-                       RED -> GREEN
-               }
+       GREEN, BLUE, RED
 }
 
 val LocalSide.htmlColor: String
        get() = when (this) {
                LocalSide.GREEN -> "#55FF55"
+               LocalSide.BLUE -> "#5555FF"
                LocalSide.RED -> "#FF5555"
        }
diff --git a/src/commonMain/kotlin/net/starshipfights/game/game_state_interceptor.kt b/src/commonMain/kotlin/net/starshipfights/game/game_state_interceptor.kt
new file mode 100644 (file)
index 0000000..2faed38
--- /dev/null
@@ -0,0 +1,9 @@
+package net.starshipfights.game
+
+fun interface GameStateInterceptor {
+       fun onStateChange(previous: GameState, current: GameState): GameState
+}
+
+object NoopInterceptor : GameStateInterceptor {
+       override fun onStateChange(previous: GameState, current: GameState) = current
+}
index 165709d05f51ee08701980608876860e2c168f2f..b0baafd94f881904ae8bec6e40338029cf940b10 100644 (file)
@@ -9,14 +9,14 @@ data class GameObjective(
        val succeeded: Boolean?
 )
 
-fun GameState.objectives(forPlayer: GlobalSide): List<GameObjective> = listOf(
+fun GameState.objectives(forPlayer: GlobalShipController): List<GameObjective> = listOf(
        GameObjective("Destroy or rout the enemy fleet", null)
 ) + subplots.filter { it.forPlayer == forPlayer }.mapNotNull { it.displayObjective(this) }
 
 @Serializable
 data class SubplotKey(
        val type: SubplotType,
-       val player: GlobalSide,
+       val player: GlobalShipController,
 )
 
 val Subplot.key: SubplotKey
@@ -25,7 +25,7 @@ val Subplot.key: SubplotKey
 @Serializable
 sealed class Subplot {
        abstract val type: SubplotType
-       abstract val forPlayer: GlobalSide
+       abstract val forPlayer: GlobalShipController
        
        override fun equals(other: Any?): Boolean {
                return other is Subplot && other.key == key
@@ -44,7 +44,7 @@ sealed class Subplot {
        protected fun GameState.modifySubplotData(newSubplot: Subplot) = copy(subplots = (subplots - this@Subplot) + newSubplot)
        
        @Serializable
-       class ExtendedDuty(override val forPlayer: GlobalSide) : Subplot() {
+       class ExtendedDuty(override val forPlayer: GlobalShipController) : Subplot() {
                override val type: SubplotType
                        get() = SubplotType.EXTENDED_DUTY
                
@@ -85,13 +85,13 @@ sealed class Subplot {
        }
        
        @Serializable
-       class NoQuarter(override val forPlayer: GlobalSide) : Subplot() {
+       class NoQuarter(override val forPlayer: GlobalShipController) : Subplot() {
                override val type: SubplotType
                        get() = SubplotType.NO_QUARTER
                
                override fun displayObjective(gameState: GameState): GameObjective {
-                       val enemyShips = gameState.ships.values.filter { it.owner == forPlayer.other }
-                       val enemyWrecks = gameState.destroyedShips.values.filter { it.owner == forPlayer.other }
+                       val enemyShips = gameState.ships.values.filter { it.owner.side == forPlayer.side.other }
+                       val enemyWrecks = gameState.destroyedShips.values.filter { it.owner.side == forPlayer.side.other }
                        
                        val totalEnemyShipPointCount = enemyShips.sumOf { it.ship.pointCost } + enemyWrecks.sumOf { it.ship.pointCost }
                        val escapedShipPointCount = enemyWrecks.filter { it.isEscape }.sumOf { it.ship.pointCost }
@@ -112,8 +112,8 @@ sealed class Subplot {
                override fun onGameStateChanged(gameState: GameState) = gameState
                
                override fun getFinalGameResult(gameState: GameState, winner: GlobalSide?): SubplotOutcome {
-                       val enemyShips = gameState.ships.values.filter { it.owner == forPlayer.other }
-                       val enemyWrecks = gameState.destroyedShips.values.filter { it.owner == forPlayer.other }
+                       val enemyShips = gameState.ships.values.filter { it.owner.side == forPlayer.side.other }
+                       val enemyWrecks = gameState.destroyedShips.values.filter { it.owner.side == forPlayer.side.other }
                        
                        val totalEnemyShipPointCount = enemyShips.sumOf { it.ship.pointCost } + enemyWrecks.sumOf { it.ship.pointCost }
                        val destroyedShipPointCount = enemyWrecks.filter { !it.isEscape }.sumOf { it.ship.pointCost }
@@ -126,9 +126,9 @@ sealed class Subplot {
        }
        
        @Serializable
-       class Vendetta private constructor(override val forPlayer: GlobalSide, private val againstShip: Id<ShipInstance>?, private val outcome: SubplotOutcome) : Subplot() {
-               constructor(forPlayer: GlobalSide) : this(forPlayer, null, SubplotOutcome.UNDECIDED)
-               constructor(forPlayer: GlobalSide, againstShip: Id<ShipInstance>) : this(forPlayer, againstShip, SubplotOutcome.UNDECIDED)
+       class Vendetta private constructor(override val forPlayer: GlobalShipController, private val againstShip: Id<ShipInstance>?, private val outcome: SubplotOutcome) : Subplot() {
+               constructor(forPlayer: GlobalShipController) : this(forPlayer, null, SubplotOutcome.UNDECIDED)
+               constructor(forPlayer: GlobalShipController, againstShip: Id<ShipInstance>) : this(forPlayer, againstShip, SubplotOutcome.UNDECIDED)
                
                override val type: SubplotType
                        get() = SubplotType.VENDETTA
@@ -141,7 +141,7 @@ sealed class Subplot {
                override fun onAfterDeployShips(gameState: GameState): GameState {
                        if (gameState.ships[againstShip] != null) return gameState
                        
-                       val enemyShips = gameState.ships.values.filter { it.owner == forPlayer.other }
+                       val enemyShips = gameState.ships.values.filter { it.owner.side == forPlayer.side.other }
                        val highestEnemyShipTier = enemyShips.maxOf { it.ship.shipType.weightClass.tier }
                        val enemyShipsOfHighestTier = enemyShips.filter { it.ship.shipType.weightClass.tier == highestEnemyShipTier }
                        
@@ -165,9 +165,9 @@ sealed class Subplot {
        }
        
        @Serializable
-       class PlausibleDeniability private constructor(override val forPlayer: GlobalSide, private val againstShip: Id<ShipInstance>?, private val outcome: SubplotOutcome) : Subplot() {
-               constructor(forPlayer: GlobalSide) : this(forPlayer, null, SubplotOutcome.UNDECIDED)
-               constructor(forPlayer: GlobalSide, againstShip: Id<ShipInstance>) : this(forPlayer, againstShip, SubplotOutcome.UNDECIDED)
+       class PlausibleDeniability private constructor(override val forPlayer: GlobalShipController, private val againstShip: Id<ShipInstance>?, private val outcome: SubplotOutcome) : Subplot() {
+               constructor(forPlayer: GlobalShipController) : this(forPlayer, null, SubplotOutcome.UNDECIDED)
+               constructor(forPlayer: GlobalShipController, againstShip: Id<ShipInstance>) : this(forPlayer, againstShip, SubplotOutcome.UNDECIDED)
                
                override val type: SubplotType
                        get() = SubplotType.PLAUSIBLE_DENIABILITY
@@ -204,9 +204,9 @@ sealed class Subplot {
        }
        
        @Serializable
-       class RecoverInformant private constructor(override val forPlayer: GlobalSide, private val onBoardShip: Id<ShipInstance>?, private val outcome: SubplotOutcome, private val mostRecentChatMessages: Moment?) : Subplot() {
-               constructor(forPlayer: GlobalSide) : this(forPlayer, null, SubplotOutcome.UNDECIDED, null)
-               constructor(forPlayer: GlobalSide, onBoardShip: Id<ShipInstance>) : this(forPlayer, onBoardShip, SubplotOutcome.UNDECIDED, null)
+       class RecoverInformant private constructor(override val forPlayer: GlobalShipController, private val onBoardShip: Id<ShipInstance>?, private val outcome: SubplotOutcome, private val mostRecentChatMessages: Moment?) : Subplot() {
+               constructor(forPlayer: GlobalShipController) : this(forPlayer, null, SubplotOutcome.UNDECIDED, null)
+               constructor(forPlayer: GlobalShipController, onBoardShip: Id<ShipInstance>) : this(forPlayer, onBoardShip, SubplotOutcome.UNDECIDED, null)
                
                override val type: SubplotType
                        get() = SubplotType.RECOVER_INFORMANT
@@ -219,7 +219,7 @@ sealed class Subplot {
                override fun onAfterDeployShips(gameState: GameState): GameState {
                        if (gameState.ships[onBoardShip] != null) return gameState
                        
-                       val enemyShips = gameState.ships.values.filter { it.owner == forPlayer.other }
+                       val enemyShips = gameState.ships.values.filter { it.owner.side != forPlayer.side.other }
                        val lowestEnemyShipTier = enemyShips.minOf { it.ship.shipType.weightClass.tier }
                        val enemyShipsNotOfLowestTier = enemyShips.filter { it.ship.shipType.weightClass.tier != lowestEnemyShipTier }.ifEmpty { enemyShips }
                        
@@ -260,7 +260,7 @@ sealed class Subplot {
        }
 }
 
-enum class SubplotType(val factory: (GlobalSide) -> Subplot) {
+enum class SubplotType(val factory: (GlobalShipController) -> Subplot) {
        EXTENDED_DUTY(Subplot::ExtendedDuty),
        NO_QUARTER(Subplot::NoQuarter),
        VENDETTA(Subplot::Vendetta),
@@ -268,7 +268,7 @@ enum class SubplotType(val factory: (GlobalSide) -> Subplot) {
        RECOVER_INFORMANT(Subplot::RecoverInformant),
 }
 
-fun generateSubplots(battleSize: BattleSize, forPlayer: GlobalSide): Set<Subplot> =
+fun generateSubplots(battleSize: BattleSize, forPlayer: GlobalShipController): Set<Subplot> =
        (1..battleSize.numSubplotsPerPlayer).map {
                SubplotType.values().random().factory(forPlayer)
        }.toSet()
@@ -285,19 +285,19 @@ enum class SubplotOutcome {
                }
        
        companion object {
-               fun fromBattleWinner(winner: GlobalSide?, subplotForPlayer: GlobalSide) = when (winner) {
-                       subplotForPlayer -> WON
-                       subplotForPlayer.other -> LOST
+               fun fromBattleWinner(winner: GlobalSide?, subplotForPlayer: GlobalShipController) = when (winner) {
+                       subplotForPlayer.side -> WON
+                       subplotForPlayer.side.other -> LOST
                        else -> UNDECIDED
                }
        }
 }
 
-fun victoryTitle(player: GlobalSide, winner: GlobalSide?, subplotOutcomes: Map<SubplotKey, SubplotOutcome>): String {
+fun victoryTitle(player: GlobalShipController, winner: GlobalSide?, subplotOutcomes: Map<SubplotKey, SubplotOutcome>): String {
        val myOutcomes = subplotOutcomes.filterKeys { it.player == player }
        
        return when (winner) {
-               player -> {
+               player.side -> {
                        val isGlorious = myOutcomes.all { (_, outcome) -> outcome == SubplotOutcome.WON }
                        val isPyrrhic = myOutcomes.size >= 2 && myOutcomes.none { (_, outcome) -> outcome == SubplotOutcome.WON }
                        
@@ -308,7 +308,7 @@ fun victoryTitle(player: GlobalSide, winner: GlobalSide?, subplotOutcomes: Map<S
                        else
                                "Victory"
                }
-               player.other -> {
+               player.side.other -> {
                        val isHeroic = myOutcomes.all { (_, outcome) -> outcome == SubplotOutcome.WON }
                        val isHumiliating = myOutcomes.size >= 2 && myOutcomes.none { (_, outcome) -> outcome == SubplotOutcome.WON }
                        
index 527ef5298a4707df782632d2ec88f6b4fd8adb30..a32f86b7034cb16987b82bd589c8e5aeba74b666 100644 (file)
@@ -76,24 +76,52 @@ sealed class TrainingOpponent {
 
 @Serializable
 sealed class LoginMode {
-       abstract val globalSide: GlobalSide?
+       abstract val mySide: GlobalShipController?
+       abstract val otherPlayerSide: GlobalShipController?
        
        @Serializable
        data class Train(val battleInfo: BattleInfo, val enemyFaction: TrainingOpponent) : LoginMode() {
-               override val globalSide: GlobalSide?
+               override val mySide: GlobalShipController?
                        get() = null
+               
+               override val otherPlayerSide: GlobalShipController?
+                       get() = null
+       }
+       
+       @Serializable
+       data class Host1v1(val battleInfo: BattleInfo) : LoginMode() {
+               override val mySide: GlobalShipController
+                       get() = GlobalShipController(GlobalSide.HOST, GlobalShipController.Player1Disambiguation)
+               
+               override val otherPlayerSide: GlobalShipController
+                       get() = GlobalShipController(GlobalSide.GUEST, GlobalShipController.Player1Disambiguation)
        }
        
        @Serializable
-       data class Host(val battleInfo: BattleInfo) : LoginMode() {
-               override val globalSide: GlobalSide
-                       get() = GlobalSide.HOST
+       object Join1v1 : LoginMode() {
+               override val mySide: GlobalShipController
+                       get() = GlobalShipController(GlobalSide.GUEST, GlobalShipController.Player1Disambiguation)
+               
+               override val otherPlayerSide: GlobalShipController
+                       get() = GlobalShipController(GlobalSide.HOST, GlobalShipController.Player1Disambiguation)
+       }
+       
+       @Serializable
+       data class Host2v1(val battleInfo: BattleInfo, val enemyFaction: TrainingOpponent) : LoginMode() {
+               override val mySide: GlobalShipController
+                       get() = GlobalShipController(GlobalSide.HOST, GlobalShipController.Player1Disambiguation)
+               
+               override val otherPlayerSide: GlobalShipController
+                       get() = GlobalShipController(GlobalSide.HOST, GlobalShipController.Player2Disambiguation)
        }
        
        @Serializable
-       object Join : LoginMode() {
-               override val globalSide: GlobalSide
-                       get() = GlobalSide.GUEST
+       object Join2v1 : LoginMode() {
+               override val mySide: GlobalShipController
+                       get() = GlobalShipController(GlobalSide.HOST, GlobalShipController.Player2Disambiguation)
+               
+               override val otherPlayerSide: GlobalShipController
+                       get() = GlobalShipController(GlobalSide.HOST, GlobalShipController.Player1Disambiguation)
        }
 }
 
@@ -126,6 +154,7 @@ data class JoinListing(
 data class Joinable(
        val admiral: InGameAdmiral,
        val battleInfo: BattleInfo,
+       val enemyFaction: Faction?,
 )
 
 @Serializable
index 1ec2af6967905b3c58a98d7c64232b1772104012..5c6b24e0783ed5cd8e98ce97f3dc85ec07e652bd 100644 (file)
@@ -41,7 +41,7 @@ fun GameState.isValidPick(request: PickRequest, response: PickResponse): Boolean
                        
                        val ship = ships.getValue(response.id)
                        if (ship.position.location !in request.boundary) return false
-                       if (ship.owner !in request.type.allowSides) return false
+                       if (ship.owner.side !in request.type.allowSides) return false
                        
                        return true
                }
index f9e2633e807dabc227eaa8f39ace01412e3b13e0..24540302cc3150956884059779152a8266e08550 100644 (file)
@@ -156,7 +156,7 @@ fun reportBoardingResult(impactResult: ImpactResult, attacker: Id<ShipInstance>)
 }
 
 fun ShipInstance.getBoardingPickRequest() = PickRequest(
-       PickType.Ship(allowSides = setOf(owner.other)),
+       PickType.Ship(allowSides = setOf(owner.side.other)),
        PickBoundary.WeaponsFire(
                center = position.location,
                facing = position.facing,
index c3b5d480742aa4fd7f7f9dc0385f893fa4b59af4..ca20f624abb6c153d9f6cb1a4058389a6b297dca 100644 (file)
@@ -9,7 +9,7 @@ import kotlin.math.sqrt
 @Serializable
 data class ShipInstance(
        val ship: Ship,
-       val owner: GlobalSide,
+       val owner: GlobalShipController,
        val position: ShipPosition,
        
        val isIdentified: Boolean = false,
@@ -113,7 +113,7 @@ data class ShipInstance(
 @Serializable
 data class ShipWreck(
        val ship: Ship,
-       val owner: GlobalSide,
+       val owner: GlobalShipController,
        val isEscape: Boolean = false,
        val wreckedAt: Moment = Moment.now
 ) {
@@ -257,7 +257,7 @@ enum class ShipRenderMode {
        FULL;
 }
 
-fun GameState.renderShipAs(ship: ShipInstance, forPlayer: GlobalSide) = if (ship.owner == forPlayer)
+fun GameState.renderShipAs(ship: ShipInstance, forPlayer: GlobalShipController) = if (ship.owner.side == forPlayer.side)
        ShipRenderMode.FULL
 else if (phase == GamePhase.Deploy)
        ShipRenderMode.NONE
index bf29d36e758e5eb4dd1b9de4504d79bb2fc27ee2..e7de6dcfe669f8de84b9bc6cfa3079d6da890fa0 100644 (file)
@@ -606,9 +606,9 @@ fun ShipInstance.getWeaponPickRequest(weapon: ShipWeapon): PickRequest = when (w
        )
        else -> {
                val targetSet = if ((weapon as? ShipWeapon.Hangar)?.wing == StrikeCraftWing.FIGHTERS)
-                       setOf(owner)
+                       setOf(owner.side)
                else
-                       setOf(owner.other)
+                       setOf(owner.side.other)
                
                val weaponRangeMult = when (weapon) {
                        is ShipWeapon.Cannon -> firepower.rangeMultiplier
@@ -626,7 +626,7 @@ fun ShipInstance.getWeaponPickRequest(weapon: ShipWeapon): PickRequest = when (w
                                minDistance = weapon.minRange,
                                maxDistance = weapon.maxRange * weaponRangeMult,
                                firingArcs = weapon.firingArcs,
-                               canSelfSelect = owner in targetSet
+                               canSelfSelect = owner.side in targetSet
                        )
                )
        }
index 9b95edb99b4cb6f84e9f387f854a478f10cf5c73..2ded74310aab525eea331035288ea9ebdbd91612 100644 (file)
@@ -24,7 +24,7 @@ class GameRenderInteraction(
        val errorMessages: ReceiveChannel<String>
 )
 
-lateinit var mySide: GlobalSide
+lateinit var mySide: GlobalShipController
 
 private val pickContextDeferred = CompletableDeferred<PickContext>()
 
@@ -124,7 +124,7 @@ private suspend fun GameNetworkInteraction.execute(token: String): GameEvent.Gam
                        }.display()
                        
                        if (!opponentJoined)
-                               Popup.GameOver(mySide, "Unfortunately, your opponent never entered the battle.", emptyMap(), gameState.value).display()
+                               Popup.GameOver(mySide.side, "Unfortunately, your opponent never entered the battle.", emptyMap(), gameState.value).display()
                        
                        val sendActionsJob = launch {
                                for (action in playerActions)
@@ -177,7 +177,7 @@ private class GameUIResponderImpl(scope: CoroutineScope, private val actions: Se
 
 private fun CoroutineScope.uiResponder(actions: SendChannel<PlayerAction>) = GameUIResponderImpl(this, actions)
 
-suspend fun gameMain(side: GlobalSide, token: String, state: GameState) {
+suspend fun gameMain(side: GlobalShipController, token: String, state: GameState) {
        interruptExit = true
        
        initializePicking()
index cb51de38abc8e7c1356ea9c2f48b7297ea5e53d1..35b60c4a9fb0fc47d61f12ff0e0ca7d39eb1316b 100644 (file)
@@ -89,10 +89,10 @@ private suspend fun enterGame(connectToken: String): Nothing {
 
 private suspend fun usePlayerLogin(admirals: List<InGameAdmiral>) {
        val playerLogin = Popup.getPlayerLogin(admirals)
-       val playerLoginSide = playerLogin.login.globalSide
+       val playerLoginSide = playerLogin.login
        
-       if (playerLoginSide == null) {
-               val (battleInfo, enemyFaction) = playerLogin.login as LoginMode.Train
+       if (playerLoginSide is LoginMode.Train) {
+               val (battleInfo, enemyFaction) = playerLoginSide
                enterTraining(playerLogin.admiral, battleInfo, enemyFaction)
        }
        
@@ -103,7 +103,7 @@ private suspend fun usePlayerLogin(admirals: List<InGameAdmiral>) {
                        sendObject(PlayerLogin.serializer(), playerLogin)
                        
                        when (playerLoginSide) {
-                               GlobalSide.HOST -> {
+                               is LoginMode.Host1v1 -> {
                                        var loadingText = "Awaiting join request..."
                                        
                                        do {
@@ -125,11 +125,11 @@ private suspend fun usePlayerLogin(admirals: List<InGameAdmiral>) {
                                        val connectToken = receiveObject(GameReady.serializer()) { closeAndReturn { return@webSocket } }.connectToken
                                        enterGame(connectToken)
                                }
-                               GlobalSide.GUEST -> {
+                               is LoginMode.Join1v1 -> {
                                        val listOfHosts = receiveObject(JoinListing.serializer()) { closeAndReturn { return@webSocket } }.openGames
                                        
                                        do {
-                                               val selectedHost = Popup.HostSelectScreen(listOfHosts).display() ?: closeAndReturn("Battle joining cancelled") { return@webSocket }
+                                               val selectedHost = Popup.Host1v1SelectScreen(listOfHosts).display() ?: closeAndReturn("Battle joining cancelled") { return@webSocket }
                                                sendObject(JoinSelection.serializer(), JoinSelection(selectedHost))
                                                
                                                val joinAcceptance = Popup.CancellableLoadingScreen("Awaiting join response...") {
@@ -145,6 +145,51 @@ private suspend fun usePlayerLogin(admirals: List<InGameAdmiral>) {
                                        val connectToken = receiveObject(GameReady.serializer()) { closeAndReturn { return@webSocket } }.connectToken
                                        enterGame(connectToken)
                                }
+                               is LoginMode.Host2v1 -> {
+                                       var loadingText = "Awaiting join request..."
+                                       
+                                       do {
+                                               val joinRequest = Popup.CancellableLoadingScreen(loadingText) {
+                                                       receiveObject(JoinRequest.serializer()) { closeAndReturn { return@CancellableLoadingScreen null } }
+                                               }.display() ?: closeAndReturn("Battle hosting cancelled") { return@webSocket }
+                                               
+                                               val joinAcceptance = Popup.GuestRequestScreen(admiral, joinRequest.joiner).display() ?: closeAndReturn("Battle hosting cancelled") { return@webSocket }
+                                               sendObject(JoinResponse.serializer(), JoinResponse(joinAcceptance))
+                                               
+                                               val joinConnected = joinAcceptance && receiveObject(JoinResponseResponse.serializer()) { closeAndReturn { return@webSocket } }.connected
+                                               
+                                               loadingText = if (joinAcceptance)
+                                                       "${joinRequest.joiner.name} cancelled join. Awaiting join request..."
+                                               else
+                                                       "Awaiting join request..."
+                                       } while (!joinConnected)
+                                       
+                                       val connectToken = receiveObject(GameReady.serializer()) { closeAndReturn { return@webSocket } }.connectToken
+                                       enterGame(connectToken)
+                               }
+                               is LoginMode.Join2v1 -> {
+                                       val listOfHosts = receiveObject(JoinListing.serializer()) { closeAndReturn { return@webSocket } }.openGames
+                                       
+                                       do {
+                                               val selectedHost = Popup.Host2v1SelectScreen(listOfHosts).display() ?: closeAndReturn("Battle joining cancelled") { return@webSocket }
+                                               sendObject(JoinSelection.serializer(), JoinSelection(selectedHost))
+                                               
+                                               val joinAcceptance = Popup.CancellableLoadingScreen("Awaiting join response...") {
+                                                       receiveObject(JoinResponse.serializer()) { closeAndReturn { return@CancellableLoadingScreen null } }.accepted
+                                               }.display() ?: closeAndReturn("Battle joining cancelled") { return@webSocket }
+                                               
+                                               if (!joinAcceptance) {
+                                                       val hostInfo = listOfHosts.getValue(selectedHost).admiral
+                                                       Popup.JoinRejectedScreen(hostInfo).display()
+                                               }
+                                       } while (!joinAcceptance)
+                                       
+                                       val connectToken = receiveObject(GameReady.serializer()) { closeAndReturn { return@webSocket } }.connectToken
+                                       enterGame(connectToken)
+                               }
+                               else -> {
+                                       closeAndReturn { return@webSocket }
+                               }
                        }
                }
        } catch (ex: WebSocketException) {
index 8086704d70ad8c7f43f2ab23d02bbaffb85c1db7..c2f3a0953f700a164d26feec3ed1e5d5f5b6b9b5 100644 (file)
@@ -16,24 +16,23 @@ class GameSession(gameState: GameState) {
        
        val state = stateMutable.asStateFlow()
        
-       private val hostErrorMessages = Channel<String>(Channel.UNLIMITED)
-       private val guestErrorMessages = Channel<String>(Channel.UNLIMITED)
+       private val errorMessageChannels = mutableMapOf<GlobalShipController, Channel<String>>()
        
-       private fun errorMessageChannel(player: GlobalSide) = when (player) {
-               GlobalSide.HOST -> hostErrorMessages
-               GlobalSide.GUEST -> guestErrorMessages
-       }
+       private fun errorMessageChannel(player: GlobalShipController) =
+               errorMessageChannels[player] ?: Channel<String>(Channel.UNLIMITED).also {
+                       errorMessageChannels[player] = it
+               }
        
-       fun errorMessages(player: GlobalSide): ReceiveChannel<String> = when (player) {
-               GlobalSide.HOST -> hostErrorMessages
-               GlobalSide.GUEST -> guestErrorMessages
-       }
+       fun errorMessages(player: GlobalShipController): ReceiveChannel<String> =
+               errorMessageChannels[player] ?: Channel<String>(Channel.UNLIMITED).also {
+                       errorMessageChannels[player] = it
+               }
        
        private val gameEndMutable = CompletableDeferred<GameEvent.GameEnd>()
        val gameEnd: Deferred<GameEvent.GameEnd>
                get() = gameEndMutable
        
-       suspend fun onPacket(player: GlobalSide, packet: PlayerAction) {
+       suspend fun onPacket(player: GlobalShipController, packet: PlayerAction) {
                stateMutex.withLock {
                        when (val result = state.value.after(player, packet)) {
                                is GameEvent.StateChange -> {
@@ -54,7 +53,7 @@ class GameSession(gameState: GameState) {
 private suspend fun GameNetworkInteraction.execute(): GameEvent.GameEnd {
        val gameSession = GameSession(gameState.value)
        
-       val aiSide = mySide.other
+       val aiSide = GlobalShipController(mySide.side.other, GlobalShipController.Player1Disambiguation)
        val aiActions = Channel<PlayerAction>()
        val aiEvents = Channel<GameEvent>()
        val aiSession = AISession(aiSide, aiActions, aiEvents)
@@ -122,7 +121,7 @@ suspend fun trainingMain(state: GameState) {
        
        initializePicking()
        
-       mySide = GlobalSide.HOST
+       mySide = GlobalShipController(GlobalSide.HOST, GlobalShipController.Player1Disambiguation)
        
        val gameState = MutableStateFlow(state)
        val playerActions = Channel<PlayerAction>(Channel.UNLIMITED)
index 908df1c9582ecd892ee59d73c592b499f9be0db7..f90dc9ed95a30e379c62db15282f6f3146222c5b 100644 (file)
@@ -39,6 +39,7 @@ object RenderResources {
                private set
        
        private const val friendlyMarkerUrl = "friendly-marker"
+       private const val neutralMarkerUrl = "neutral-marker"
        private const val hostileMarkerUrl = "hostile-marker"
        
        lateinit var markerFactory: CustomRenderFactory<LocalSide>
@@ -156,12 +157,17 @@ object RenderResources {
                        
                        launch {
                                val friendlyMarkerPromise = async { loadTexture(friendlyMarkerUrl) }
+                               val neutralMarkerPromise = async { loadTexture(neutralMarkerUrl) }
                                val hostileMarkerPromise = async { loadTexture(hostileMarkerUrl) }
                                
                                val friendlyMarkerTexture = friendlyMarkerPromise.await()
                                friendlyMarkerTexture.minFilter = LinearFilter
                                friendlyMarkerTexture.magFilter = LinearFilter
                                
+                               val neutralMarkerTexture = neutralMarkerPromise.await()
+                               neutralMarkerTexture.minFilter = LinearFilter
+                               neutralMarkerTexture.magFilter = LinearFilter
+                               
                                val hostileMarkerTexture = hostileMarkerPromise.await()
                                hostileMarkerTexture.minFilter = LinearFilter
                                hostileMarkerTexture.magFilter = LinearFilter
@@ -172,6 +178,12 @@ object RenderResources {
                                        side = DoubleSide
                                })
                                
+                               val neutralMarkerMaterial = MeshBasicMaterial(configure {
+                                       map = neutralMarkerTexture
+                                       alphaTest = 0.5
+                                       side = DoubleSide
+                               })
+                               
                                val hostileMarkerMaterial = MeshBasicMaterial(configure {
                                        map = hostileMarkerTexture
                                        alphaTest = 0.5
@@ -181,14 +193,17 @@ object RenderResources {
                                val plane = PlaneGeometry(4, 4)
                                
                                val friendlyMarkerMesh = Mesh(plane, friendlyMarkerMaterial)
+                               val neutralMarkerMesh = Mesh(plane, neutralMarkerMaterial)
                                val hostileMarkerMesh = Mesh(plane, hostileMarkerMaterial)
                                
                                friendlyMarkerMesh.rotateX(PI / 2)
+                               neutralMarkerMesh.rotateX(PI / 2)
                                hostileMarkerMesh.rotateX(PI / 2)
                                
                                markerFactory = CustomRenderFactory { side ->
                                        when (side) {
                                                LocalSide.GREEN -> friendlyMarkerMesh
+                                               LocalSide.BLUE -> neutralMarkerMesh
                                                LocalSide.RED -> hostileMarkerMesh
                                        }.clone(true)
                                }
@@ -238,6 +253,10 @@ object RenderResources {
                                                uniforms["outlineColor"]!!.value = Color(LocalSide.GREEN.htmlColor)
                                        }
                                        
+                                       val blueOutlineMaterial = outlineMaterial.clone().unsafeCast<ShaderMaterial>().apply {
+                                               uniforms["outlineColor"]!!.value = Color(LocalSide.BLUE.htmlColor)
+                                       }
+                                       
                                        val redOutlineMaterial = outlineMaterial.clone().unsafeCast<ShaderMaterial>().apply {
                                                uniforms["outlineColor"]!!.value = Color(LocalSide.RED.htmlColor)
                                        }
@@ -245,6 +264,9 @@ object RenderResources {
                                        val outlineGreen = mesh.clone(true).unsafeCast<Mesh>()
                                        outlineGreen.material = greenOutlineMaterial
                                        
+                                       val outlineBlue = mesh.clone(true).unsafeCast<Mesh>()
+                                       outlineBlue.material = blueOutlineMaterial
+                                       
                                        val outlineRed = mesh.clone(true).unsafeCast<Mesh>()
                                        outlineRed.material = redOutlineMaterial
                                        
@@ -263,6 +285,7 @@ object RenderResources {
                                                        },
                                                        when (side) {
                                                                LocalSide.GREEN -> outlineGreen
+                                                               LocalSide.BLUE -> outlineBlue
                                                                LocalSide.RED -> outlineRed
                                                        }.clone(true).unsafeCast<Mesh>()
                                                ).group
index c6362476cc8e267739b81b7ba6c6d46d68ce65e9..71ca242c9b97953363e6bb2ec884027955b217b0 100644 (file)
@@ -193,12 +193,14 @@ object GameUI {
                                                        +"The "
                                                        if (owner == LocalSide.RED)
                                                                +"enemy ship "
+                                                       else if (owner == LocalSide.BLUE)
+                                                               +"allied ship "
                                                        strong {
                                                                style = "color:${owner.htmlColor}"
                                                                +ship.fullName
                                                        }
                                                        +" has been sighted"
-                                                       if (owner == LocalSide.GREEN)
+                                                       if (owner != LocalSide.RED)
                                                                +" by the enemy"
                                                        +"!"
                                                }
@@ -244,6 +246,7 @@ object GameUI {
                                                        +" damage from "
                                                        when (entry.attacker) {
                                                                is ShipAttacker.EnemyShip -> {
+                                                                       val attackerSide = state.getShipOwner(entry.attacker.id).relativeTo(mySide)
                                                                        if (entry.weapon != null) {
                                                                                +"the "
                                                                                +when (entry.weapon) {
@@ -261,7 +264,7 @@ object GameUI {
                                                                        }
                                                                        +"the "
                                                                        strong {
-                                                                               style = "color:${owner.other.htmlColor}"
+                                                                               style = "color:${attackerSide.htmlColor}"
                                                                                +state.getShipInfo(entry.attacker.id).fullName
                                                                        }
                                                                }
@@ -299,6 +302,7 @@ object GameUI {
                                                        +" has ignored an attack from "
                                                        when (entry.attacker) {
                                                                is ShipAttacker.EnemyShip -> {
+                                                                       val attackerSide = state.getShipOwner(entry.attacker.id).relativeTo(mySide)
                                                                        if (entry.weapon != null) {
                                                                                +"the "
                                                                                +when (entry.weapon) {
@@ -316,7 +320,7 @@ object GameUI {
                                                                        }
                                                                        +"the "
                                                                        strong {
-                                                                               style = "color:${owner.other.htmlColor}"
+                                                                               style = "color:${attackerSide.htmlColor}"
                                                                                +state.getShipInfo(entry.attacker.id).fullName
                                                                        }
                                                                }
@@ -340,6 +344,7 @@ object GameUI {
                                                is ChatEntry.ShipBoarded -> {
                                                        val ship = state.getShipInfo(entry.ship)
                                                        val owner = state.getShipOwner(entry.ship).relativeTo(mySide)
+                                                       val attackerOwner = state.getShipOwner(entry.boarder).relativeTo(mySide)
                                                        +if (owner == LocalSide.RED)
                                                                "The enemy ship "
                                                        else
@@ -351,7 +356,7 @@ object GameUI {
                                                        
                                                        +" has been boarded by the "
                                                        strong {
-                                                               style = "color:${owner.other.htmlColor}"
+                                                               style = "color:${attackerOwner.htmlColor}"
                                                                +state.getShipInfo(entry.boarder).fullName
                                                        }
                                                        
@@ -378,9 +383,10 @@ object GameUI {
                                                        +" has been destroyed by "
                                                        when (entry.destroyedBy) {
                                                                is ShipAttacker.EnemyShip -> {
+                                                                       val attackerOwner = state.getShipOwner(entry.destroyedBy.id).relativeTo(mySide)
                                                                        +"the "
                                                                        strong {
-                                                                               style = "color:${owner.other.htmlColor}"
+                                                                               style = "color:${attackerOwner.htmlColor}"
                                                                                +state.getShipInfo(entry.destroyedBy.id).fullName
                                                                        }
                                                                }
@@ -459,12 +465,15 @@ object GameUI {
                                
                                if (state.phase.usesInitiative) {
                                        br
-                                       +if (state.doneWithPhase == mySide)
+                                       +if (mySide in state.doneWithPhase)
                                                "You have ended your phase"
-                                       else if (state.currentInitiative != mySide.other)
+                                       else if (state.currentInitiative == null || state.currentInitiative == mySide)
                                                "You have the initiative!"
-                                       else "Your opponent has the initiative"
-                               } else if (state.doneWithPhase == mySide) {
+                                       else if (state.currentInitiative?.side == mySide.side)
+                                               "Your ally has the initiative!"
+                                       else
+                                               "Your opponent has the initiative"
+                               } else if (mySide in state.doneWithPhase) {
                                        br
                                        +"You have ended your phase"
                                }
@@ -563,14 +572,20 @@ object GameUI {
                                                style = "width:100%;table-layout:fixed;background-color:#555;margin:0;margin-bottom:10px"
                                                
                                                tr {
+                                                       val (activeHullColor, downHullColor) = when (ship.owner.relativeTo(mySide)) {
+                                                               LocalSide.GREEN -> "#5F5" to "#262"
+                                                               LocalSide.BLUE -> "#55F" to "#226"
+                                                               LocalSide.RED -> "#F55" to "#622"
+                                                       }
+                                                       
                                                        repeat(activeHull) {
                                                                td {
-                                                                       style = "background-color:${if (ship.owner == mySide) "#5F5" else "#F55"};height:15px;box-shadow:inset 0 0 0 3px #555"
+                                                                       style = "background-color:$activeHullColor;height:15px;box-shadow:inset 0 0 0 3px #555"
                                                                }
                                                        }
                                                        repeat(downHull) {
                                                                td {
-                                                                       style = "background-color:${if (ship.owner == mySide) "#262" else "#622"};height:15px;box-shadow:inset 0 0 0 3px #555"
+                                                                       style = "background-color:$downHullColor;height:15px;box-shadow:inset 0 0 0 3px #555"
                                                                }
                                                        }
                                                }
@@ -598,7 +613,7 @@ object GameUI {
                                        }
                                        
                                        if (ship.ship.reactor is StandardShipReactor) {
-                                               if (ship.owner == mySide) {
+                                               if (ship.owner.side == mySide.side) {
                                                        val totalWeapons = ship.powerMode.weapons
                                                        val activeWeapons = ship.weaponAmount
                                                        val downWeapons = totalWeapons - activeWeapons
@@ -630,13 +645,18 @@ object GameUI {
                                style = "margin:0;white-space:nowrap;text-align:center"
                                br
                                
-                               val fighterSide = ship.owner.relativeTo(mySide)
-                               val bomberSide = ship.owner.other.relativeTo(mySide)
+                               val shipSide = ship.owner.relativeTo(mySide)
+                               val (fighterSide, bomberSide) = when (shipSide) {
+                                       LocalSide.GREEN -> LocalSide.GREEN to LocalSide.RED
+                                       LocalSide.BLUE -> LocalSide.GREEN to LocalSide.RED
+                                       LocalSide.RED -> LocalSide.RED to LocalSide.GREEN
+                               }
                                
                                if (ship.fighterWings.isNotEmpty()) {
                                        span {
                                                val (borderColor, fillColor) = when (fighterSide) {
                                                        LocalSide.GREEN -> "#5F5" to "#262"
+                                                       LocalSide.BLUE -> "#5F5" to "#262"
                                                        LocalSide.RED -> "#F55" to "#622"
                                                }
                                                
@@ -660,6 +680,7 @@ object GameUI {
                                        span {
                                                val (borderColor, fillColor) = when (bomberSide) {
                                                        LocalSide.GREEN -> "#5F5" to "#262"
+                                                       LocalSide.BLUE -> "#5F5" to "#262"
                                                        LocalSide.RED -> "#F55" to "#622"
                                                }
                                                
@@ -682,7 +703,7 @@ object GameUI {
        
        private fun TagConsumer<*>.drawDeployPhase(state: GameState, abilities: List<PlayerAbilityType>) {
                val deployableShips = state.start.playerStart(mySide).deployableFleet
-               val remainingPoints = state.battleInfo.size.numPoints - state.ships.values.filter { it.owner == mySide }.sumOf { it.ship.pointCost }
+               val remainingPoints = state.getUsablePoints(mySide) - state.ships.values.filter { it.owner == mySide }.sumOf { it.ship.pointCost }
                
                div {
                        style = "height:19%;font-size:0.9em"
index be251e0b9dac8ca3efbedd4853ea55f1a419fbc2..4a945305b393089263ad6f7bc5b0fff55e353591 100644 (file)
@@ -112,7 +112,7 @@ sealed class Popup<out T> {
                                style = "text-align:center"
                                
                                img(alt = "Starship Fights", src = RenderResources.LOGO_URL) {
-                                       style = "width:70%"
+                                       style = "width:50%"
                                }
                        }
                        
@@ -143,19 +143,35 @@ sealed class Popup<out T> {
                                        }
                                }
                                button {
-                                       +"Host Multiplayer Battle"
+                                       +"Host Competitive Battle"
                                        onClickFunction = { e ->
                                                e.preventDefault()
                                                
-                                               callback(MainMenuOption.Multiplayer(GlobalSide.HOST))
+                                               callback(MainMenuOption.Multiplayer1v1(GlobalSide.HOST))
                                        }
                                }
                                button {
-                                       +"Join Multiplayer Battle"
+                                       +"Join Competitive Battle"
                                        onClickFunction = { e ->
                                                e.preventDefault()
                                                
-                                               callback(MainMenuOption.Multiplayer(GlobalSide.GUEST))
+                                               callback(MainMenuOption.Multiplayer1v1(GlobalSide.GUEST))
+                                       }
+                               }
+                               button {
+                                       +"Host Cooperative Battle"
+                                       onClickFunction = { e ->
+                                               e.preventDefault()
+                                               
+                                               callback(MainMenuOption.Multiplayer2v1(Player2v1.PLAYER_1))
+                                       }
+                               }
+                               button {
+                                       +"Join Cooperative Battle"
+                                       onClickFunction = { e ->
+                                               e.preventDefault()
+                                               
+                                               callback(MainMenuOption.Multiplayer2v1(Player2v1.PLAYER_2))
                                        }
                                }
                        }
@@ -383,7 +399,7 @@ sealed class Popup<out T> {
                }
        }
        
-       class HostSelectScreen(private val hosts: Map<String, Joinable>) : Popup<String?>() {
+       class Host1v1SelectScreen(private val hosts: Map<String, Joinable>) : Popup<String?>() {
                override fun TagConsumer<*>.render(context: CoroutineContext, callback: (String?) -> Unit) {
                        table {
                                style = "table-layout:fixed;width:100%"
@@ -463,6 +479,97 @@ sealed class Popup<out T> {
                }
        }
        
+       class Host2v1SelectScreen(private val hosts: Map<String, Joinable>) : Popup<String?>() {
+               override fun TagConsumer<*>.render(context: CoroutineContext, callback: (String?) -> Unit) {
+                       table {
+                               style = "table-layout:fixed;width:100%"
+                               
+                               tr {
+                                       th { +"Host Player" }
+                                       th { +"Host Admiral" }
+                                       th { +"Host Faction" }
+                                       th { +"Enemy Faction" }
+                                       th { +"Battle Size" }
+                                       th { +"Battle Background" }
+                                       th { +Entities.nbsp }
+                               }
+                               for ((id, joinable) in hosts) {
+                                       tr {
+                                               td {
+                                                       style = "text-align:center"
+                                                       
+                                                       +joinable.admiral.user.username
+                                               }
+                                               td {
+                                                       style = "text-align:center"
+                                                       
+                                                       +joinable.admiral.fullName
+                                               }
+                                               td {
+                                                       style = "text-align:center"
+                                                       
+                                                       img(alt = joinable.admiral.faction.shortName, src = joinable.admiral.faction.flagUrl) {
+                                                               style = "width:4em;height:2.5em"
+                                                       }
+                                               }
+                                               td {
+                                                       style = "text-align:center"
+                                                       
+                                                       joinable.enemyFaction?.let { enemy ->
+                                                               img(alt = enemy.shortName, src = enemy.flagUrl) {
+                                                                       style = "width:4em;height:2.5em"
+                                                               }
+                                                       } ?: (+"Random")
+                                               }
+                                               td {
+                                                       style = "text-align:center"
+                                                       
+                                                       +joinable.battleInfo.size.displayName
+                                                       +" ("
+                                                       +joinable.battleInfo.size.numPoints.toString()
+                                                       +")"
+                                               }
+                                               td {
+                                                       style = "text-align:center"
+                                                       
+                                                       +joinable.battleInfo.bg.displayName
+                                               }
+                                               td {
+                                                       style = "text-align:center"
+                                                       
+                                                       a(href = "#") {
+                                                               +"Join"
+                                                               onClickFunction = { e ->
+                                                                       e.preventDefault()
+                                                                       callback(id)
+                                                               }
+                                                       }
+                                               }
+                                       }
+                               }
+                               tr {
+                                       td { +Entities.nbsp }
+                                       td { +Entities.nbsp }
+                                       td { +Entities.nbsp }
+                                       td { +Entities.nbsp }
+                                       td { +Entities.nbsp }
+                                       td { +Entities.nbsp }
+                                       td {
+                                               style = "text-align:center"
+                                               
+                                               a(href = "#") {
+                                                       +"Cancel"
+                                                       onClickFunction = { e ->
+                                                               e.preventDefault()
+                                                               callback(null)
+                                                       }
+                                               }
+                                       }
+                               }
+                       }
+               }
+       }
+       
        class JoinRejectedScreen(private val hostInfo: InGameAdmiral) : Popup<Unit>() {
                override fun TagConsumer<*>.render(context: CoroutineContext, callback: (Unit) -> Unit) {
                        p {
index 25308427bf556c58549934e7e3b2a36f956b2caa..8bb498c83999518658c20f8b5cce32f429039aad 100644 (file)
@@ -3,7 +3,14 @@ package net.starshipfights.game
 sealed class MainMenuOption {
        object Singleplayer : MainMenuOption()
        
-       data class Multiplayer(val side: GlobalSide) : MainMenuOption()
+       data class Multiplayer1v1(val side: GlobalSide) : MainMenuOption()
+       
+       data class Multiplayer2v1(val player: Player2v1) : MainMenuOption()
+}
+
+enum class Player2v1 {
+       PLAYER_1,
+       PLAYER_2;
 }
 
 sealed class AIFactionChoice {
@@ -49,13 +56,24 @@ private suspend fun Popup.Companion.getTrainingInfo(admiral: InGameAdmiral): Log
        return LoginMode.Train(battleInfo, opponent)
 }
 
+private suspend fun Popup.Companion.get2v1HostInfo(admiral: InGameAdmiral): LoginMode? {
+       val battleInfo = getBattleInfo(admiral) ?: return getLoginMode(admiral)
+       val opponent = getTrainingOpponent() ?: return get2v1HostInfo(admiral)
+       
+       return LoginMode.Host2v1(battleInfo, opponent)
+}
+
 private suspend fun Popup.Companion.getLoginMode(admiral: InGameAdmiral): LoginMode? {
        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
+               is MainMenuOption.Multiplayer1v1 -> when (mainMenuOption.side) {
+                       GlobalSide.HOST -> LoginMode.Host1v1(getBattleInfo(admiral) ?: return getLoginMode(admiral))
+                       GlobalSide.GUEST -> LoginMode.Join1v1
+               }
+               is MainMenuOption.Multiplayer2v1 -> when (mainMenuOption.player) {
+                       Player2v1.PLAYER_1 -> get2v1HostInfo(admiral)
+                       Player2v1.PLAYER_2 -> LoginMode.Join2v1
                }
        }
 }
diff --git a/src/jsMain/resources/textures/neutral-marker.png b/src/jsMain/resources/textures/neutral-marker.png
new file mode 100644 (file)
index 0000000..59df113
Binary files /dev/null and b/src/jsMain/resources/textures/neutral-marker.png differ
index f65b8c501667fadaae88db7d68a89063cbc6c922..6b4671993d0575786ba9f37515aae3179e2b86bb 100644 (file)
@@ -33,7 +33,31 @@ data class BattleRecord(
        
        val winner: GlobalSide?,
        val winMessage: String,
+       val was2v2: Boolean = false,
 ) : DataDocument<BattleRecord> {
+       fun getSide(admiral: Id<Admiral>) = when (admiral) {
+               hostAdmiral -> GlobalSide.HOST
+               guestAdmiral -> GlobalSide.GUEST
+               else -> null
+       }
+       
+       fun getUserSide(user: Id<User>) = when (user) {
+               hostUser -> GlobalSide.HOST
+               guestUser -> GlobalSide.GUEST
+               else -> null
+       }
+       
+       fun wasWinner(side: GlobalSide) = if (winner == null)
+               null
+       else if (was2v2)
+               winner == GlobalSide.HOST
+       else
+               winner == side
+       
+       fun didAdmiralWin(admiral: Id<Admiral>) = getSide(admiral)?.let { wasWinner(it) }
+       
+       fun didUserWin(user: Id<User>) = getUserSide(user)?.let { wasWinner(it) }
+       
        companion object Table : DocumentTable<BattleRecord> by DocumentTable.create({
                index(BattleRecord::hostUser)
                index(BattleRecord::guestUser)
index 7ec31ec89cc30871c66e78887ebce10959d209d3..60bb206903ebb6826a1364ccddea7a49dfdbaf61 100644 (file)
@@ -5,7 +5,7 @@ import net.starshipfights.data.admiralty.generateFleet
 import net.starshipfights.data.admiralty.getAdmiralsShips
 import kotlin.math.PI
 
-suspend fun generateGameStart(hostInfo: InGameAdmiral, guestInfo: InGameAdmiral, battleInfo: BattleInfo): GameStart {
+suspend fun generate1v1GameInitialState(hostInfo: InGameAdmiral, guestInfo: InGameAdmiral, battleInfo: BattleInfo): GameState {
        val battleWidth = (25..35).random() * 500.0
        val battleLength = (15..45).random() * 500.0
        
@@ -15,24 +15,113 @@ suspend fun generateGameStart(hostInfo: InGameAdmiral, guestInfo: InGameAdmiral,
        val hostDeployCenter = Position(Vec2(0.0, (-battleLength / 2) + deployLength2))
        val guestDeployCenter = Position(Vec2(0.0, (battleLength / 2) - deployLength2))
        
-       return GameStart(
-               battleWidth, battleLength,
+       val gameStart = GameStart(
+               battlefieldWidth = battleWidth, battlefieldLength = battleLength,
                
-               PlayerStart(
-                       hostDeployCenter,
-                       PI / 2,
-                       PickBoundary.Rectangle(hostDeployCenter, deployWidth2, deployLength2),
-                       PI / 2,
-                       getAdmiralsShips(hostInfo.id.reinterpret()).filterValues { it.shipType.weightClass.tier <= battleInfo.size.maxTier }
+               hostStarts = mapOf(
+                       GlobalShipController.Player1Disambiguation to PlayerStart(
+                               cameraPosition = hostDeployCenter,
+                               cameraFacing = PI / 2,
+                               deployZone = PickBoundary.Rectangle(hostDeployCenter, deployWidth2, deployLength2),
+                               deployFacing = PI / 2,
+                               deployableFleet = getAdmiralsShips(hostInfo.id.reinterpret()).filterValues { it.shipType.weightClass.tier <= battleInfo.size.maxTier }
+                       )
+               ),
+               guestStarts = mapOf(
+                       GlobalShipController.Player1Disambiguation to PlayerStart(
+                               cameraPosition = guestDeployCenter,
+                               cameraFacing = -PI / 2,
+                               deployZone = PickBoundary.Rectangle(guestDeployCenter, deployWidth2, deployLength2),
+                               deployFacing = -PI / 2,
+                               deployableFleet = getAdmiralsShips(guestInfo.id.reinterpret()).filterValues { it.shipType.weightClass.tier <= battleInfo.size.maxTier }
+                       )
                ),
+       )
+       
+       return GameState(
+               start = gameStart,
+               hostInfo = mapOf(GlobalShipController.Player1Disambiguation to hostInfo),
+               guestInfo = mapOf(GlobalShipController.Player1Disambiguation to guestInfo),
+               battleInfo = battleInfo,
+               subplots = generateSubplots(
+                       battleInfo.size,
+                       GlobalShipController(GlobalSide.HOST, GlobalShipController.Player1Disambiguation)
+               ) + generateSubplots(
+                       battleInfo.size,
+                       GlobalShipController(GlobalSide.GUEST, GlobalShipController.Player1Disambiguation)
+               )
+       )
+}
+
+suspend fun generate2v1GameInitialState(player1Info: InGameAdmiral, player2Info: InGameAdmiral, enemyFaction: Faction, enemyFlavor: FactionFlavor, 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)
+       
+       val gameStart = GameStart(
+               battlefieldWidth = battleWidth, battlefieldLength = battleLength,
                
-               PlayerStart(
-                       guestDeployCenter,
-                       -PI / 2,
-                       PickBoundary.Rectangle(guestDeployCenter, deployWidth2, deployLength2),
-                       -PI / 2,
-                       getAdmiralsShips(guestInfo.id.reinterpret()).filterValues { it.shipType.weightClass.tier <= battleInfo.size.maxTier }
+               hostStarts = mapOf(
+                       GlobalShipController.Player1Disambiguation to PlayerStart(
+                               cameraPosition = hostDeployCenter,
+                               cameraFacing = PI / 2,
+                               deployZone = PickBoundary.Rectangle(hostDeployCenter, deployWidth2, deployLength2),
+                               deployFacing = PI / 2,
+                               deployableFleet = getAdmiralsShips(player1Info.id.reinterpret()).filterValues { it.shipType.weightClass.tier <= battleInfo.size.maxTier },
+                               deployPointsFactor = 0.75
+                       ),
+                       GlobalShipController.Player2Disambiguation to PlayerStart(
+                               cameraPosition = hostDeployCenter,
+                               cameraFacing = PI / 2,
+                               deployZone = PickBoundary.Rectangle(hostDeployCenter, deployWidth2, deployLength2),
+                               deployFacing = PI / 2,
+                               deployableFleet = getAdmiralsShips(player2Info.id.reinterpret()).filterValues { it.shipType.weightClass.tier <= battleInfo.size.maxTier },
+                               deployPointsFactor = 0.75
+                       )
+               ),
+               
+               guestStarts = mapOf(
+                       GlobalShipController.Player1Disambiguation to PlayerStart(
+                               cameraPosition = guestDeployCenter,
+                               cameraFacing = -PI / 2,
+                               deployZone = PickBoundary.Rectangle(guestDeployCenter, deployWidth2, deployLength2),
+                               deployFacing = -PI / 2,
+                               deployableFleet = generateFleet(aiAdmiral, enemyFlavor)
+                                       .associate { it.shipData.id to it.shipData }
+                                       .filterValues { it.shipType.weightClass.tier <= battleInfo.size.maxTier },
+                               deployPointsFactor = 1.75
+                       )
+               ),
+       )
+       
+       return GameState(
+               start = gameStart,
+               hostInfo = mapOf(
+                       GlobalShipController.Player1Disambiguation to player1Info,
+                       GlobalShipController.Player2Disambiguation to player2Info,
+               ),
+               guestInfo = mapOf(
+                       GlobalShipController.Player1Disambiguation to 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 = battleInfo,
+               subplots = generateSubplots(battleInfo.size, GlobalShipController(GlobalSide.HOST, GlobalShipController.Player1Disambiguation))
        )
 }
 
@@ -52,38 +141,50 @@ suspend fun generateTrainingInitialState(playerInfo: InGameAdmiral, enemyFaction
                start = 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.maxTier }
+                       mapOf(
+                               GlobalShipController.Player1Disambiguation to PlayerStart(
+                                       cameraPosition = hostDeployCenter,
+                                       cameraFacing = PI / 2,
+                                       deployZone = PickBoundary.Rectangle(hostDeployCenter, deployWidth2, deployLength2),
+                                       deployFacing = PI / 2,
+                                       deployableFleet = getAdmiralsShips(playerInfo.id.reinterpret())
+                                               .filterValues { it.shipType.weightClass.tier <= battleInfo.size.maxTier }
+                               )
                        ),
                        
-                       PlayerStart(
-                               guestDeployCenter,
-                               -PI / 2,
-                               PickBoundary.Rectangle(guestDeployCenter, deployWidth2, deployLength2),
-                               -PI / 2,
-                               generateFleet(aiAdmiral, enemyFlavor)
-                                       .associate { it.shipData.id to it.shipData }
-                                       .filterValues { it.shipType.weightClass.tier <= battleInfo.size.maxTier }
+                       mapOf(
+                               GlobalShipController.Player1Disambiguation to PlayerStart(
+                                       cameraPosition = guestDeployCenter,
+                                       cameraFacing = -PI / 2,
+                                       deployZone = PickBoundary.Rectangle(guestDeployCenter, deployWidth2, deployLength2),
+                                       deployFacing = -PI / 2,
+                                       deployableFleet = generateFleet(aiAdmiral, enemyFlavor)
+                                               .associate { it.shipData.id to it.shipData }
+                                               .filterValues { it.shipType.weightClass.tier <= battleInfo.size.maxTier }
+                               )
                        )
                ),
-               hostInfo = playerInfo,
-               guestInfo = 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
+               hostInfo = mapOf(GlobalShipController.Player1Disambiguation to playerInfo),
+               guestInfo = mapOf(
+                       GlobalShipController.Player1Disambiguation to 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 = battleInfo,
-               subplots = generateSubplots(battleInfo.size, GlobalSide.HOST)
+               subplots = generateSubplots(
+                       battleInfo.size,
+                       GlobalShipController(GlobalSide.HOST, GlobalShipController.Player1Disambiguation)
+               ) + generateSubplots(
+                       battleInfo.size,
+                       GlobalShipController(GlobalSide.HOST, GlobalShipController.Player2Disambiguation)
+               )
        )
 }
index 71600a21df79c27f2104a743127b8554bb5fe252..189ea602a7fafda063ca9c4f06f713469dae6e73 100644 (file)
@@ -16,6 +16,8 @@ import net.starshipfights.data.admiralty.ShipInDrydock
 import net.starshipfights.data.admiralty.ShipMemorial
 import net.starshipfights.data.auth.User
 import net.starshipfights.data.createToken
+import net.starshipfights.game.ai.AISession
+import net.starshipfights.game.ai.aiPlayer
 import org.litote.kmongo.combine
 import org.litote.kmongo.`in`
 import org.litote.kmongo.inc
@@ -29,31 +31,79 @@ data class GameToken(val hostToken: String, val joinToken: String)
 object GameManager {
        private val games = ConcurrentCurator(mutableMapOf<String, GameEntry>())
        
-       suspend fun initGame(hostInfo: InGameAdmiral, guestInfo: InGameAdmiral, battleInfo: BattleInfo): GameToken {
-               val gameState = GameState(
-                       start = generateGameStart(hostInfo, guestInfo, battleInfo),
-                       hostInfo = hostInfo,
-                       guestInfo = guestInfo,
-                       battleInfo = battleInfo,
-                       subplots = generateSubplots(battleInfo.size, GlobalSide.HOST) + generateSubplots(battleInfo.size, GlobalSide.GUEST)
-               )
+       suspend fun init1v1Game(hostInfo: InGameAdmiral, guestInfo: InGameAdmiral, battleInfo: BattleInfo): GameToken {
+               val gameState = generate1v1GameInitialState(hostInfo, guestInfo, battleInfo)
+               
+               val session = GameSession1v1(gameState)
+               DocumentTable.launch {
+                       session.gameStart.join()
+                       val startedAt = Instant.now()
+                       
+                       val end = session.gameEnd.await()
+                       val endedAt = Instant.now()
+                       
+                       on1v1GameEnd(session.state.value, end, startedAt, endedAt)
+               }
+               
+               val hostId = createToken()
+               val joinId = createToken()
+               games.use {
+                       it[hostId] = GameEntry(hostInfo.user.id.reinterpret(), GlobalShipController(GlobalSide.HOST, GlobalShipController.Player1Disambiguation), session)
+                       it[joinId] = GameEntry(guestInfo.user.id.reinterpret(), GlobalShipController(GlobalSide.GUEST, GlobalShipController.Player1Disambiguation), session)
+               }
                
-               val session = GameSession(gameState)
+               return GameToken(hostId, joinId)
+       }
+       
+       suspend fun init2v1Game(hostInfo: InGameAdmiral, guestInfo: InGameAdmiral, enemyFaction: Faction, enemyFlavor: FactionFlavor, battleInfo: BattleInfo): GameToken {
+               val gameState = generate2v1GameInitialState(hostInfo, guestInfo, enemyFaction, enemyFlavor, battleInfo)
+               
+               val session = GameSession2v1(gameState)
                DocumentTable.launch {
                        session.gameStart.join()
                        val startedAt = Instant.now()
                        
+                       val aiJob = launch {
+                               session.gameStart.join()
+                               
+                               val aiSide = GlobalShipController(GlobalSide.GUEST, GlobalShipController.Player1Disambiguation)
+                               val aiActions = Channel<PlayerAction>()
+                               val aiEvents = Channel<GameEvent>()
+                               val aiSession = AISession(aiSide, aiActions, aiEvents)
+                               
+                               listOf(
+                                       launch {
+                                               session.state.collect { state ->
+                                                       aiEvents.send(GameEvent.StateChange(state))
+                                               }
+                                       },
+                                       launch {
+                                               for (errorMessage in session.errorMessages(aiSide))
+                                                       aiEvents.send(GameEvent.InvalidAction(errorMessage))
+                                       },
+                                       launch {
+                                               for (action in aiActions)
+                                                       session.onPacket(aiSide, action)
+                                       },
+                                       launch {
+                                               aiPlayer(aiSession, gameState)
+                                       }
+                               ).joinAll()
+                       }
+                       
                        val end = session.gameEnd.await()
                        val endedAt = Instant.now()
                        
-                       onGameEnd(session.state.value, end, startedAt, endedAt)
+                       aiJob.cancel()
+                       
+                       on2v1GameEnd(session.state.value, end, startedAt, endedAt)
                }
                
                val hostId = createToken()
                val joinId = createToken()
                games.use {
-                       it[hostId] = GameEntry(hostInfo.user.id.reinterpret(), GlobalSide.HOST, session)
-                       it[joinId] = GameEntry(guestInfo.user.id.reinterpret(), GlobalSide.GUEST, session)
+                       it[hostId] = GameEntry(hostInfo.user.id.reinterpret(), GlobalShipController(GlobalSide.HOST, GlobalShipController.Player1Disambiguation), session)
+                       it[joinId] = GameEntry(guestInfo.user.id.reinterpret(), GlobalShipController(GlobalSide.HOST, GlobalShipController.Player2Disambiguation), session)
                }
                
                return GameToken(hostId, joinId)
@@ -64,86 +114,114 @@ object GameManager {
        }
 }
 
-class GameEntry(val userId: Id<User>, val side: GlobalSide, val session: GameSession)
+class GameEntry(val userId: Id<User>, val side: GlobalShipController, val session: GameSession)
 
-class GameSession(gameState: GameState) {
-       private val hostEnter = Job()
-       private val guestEnter = Job()
-       
-       suspend fun enter(player: GlobalSide) = when (player) {
-               GlobalSide.HOST -> {
-                       hostEnter.complete()
-                       withTimeoutOrNull(30_000L) {
-                               guestEnter.join()
-                               true
-                       } ?: false
-               }
-               GlobalSide.GUEST -> {
-                       guestEnter.complete()
-                       withTimeoutOrNull(30_000L) {
-                               hostEnter.join()
-                               true
-                       } ?: false
-               }
-       }.also {
-               if (it)
-                       gameStartMutable.complete()
-               else
-                       onPacket(player.other, PlayerAction.TimeOut)
-       }
-       
-       private val gameStartMutable = Job()
+sealed class GameSession(gameState: GameState, private val stateInterceptor: GameStateInterceptor = NoopInterceptor) {
+       protected val gameStartMutable = Job()
        val gameStart: Job
                get() = gameStartMutable
        
+       abstract suspend fun enter(player: GlobalShipController): Boolean
+       
        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 val errorMessages = mutableMapOf<GlobalShipController, Channel<String>>()
        
-       private fun errorMessageChannel(player: GlobalSide) = when (player) {
-               GlobalSide.HOST -> hostErrorMessages
-               GlobalSide.GUEST -> guestErrorMessages
+       private fun errorMessageChannel(player: GlobalShipController) = errorMessages[player] ?: Channel<String>(Channel.UNLIMITED).also {
+               errorMessages[player] = it
        }
        
-       fun errorMessages(player: GlobalSide): ReceiveChannel<String> = when (player) {
-               GlobalSide.HOST -> hostErrorMessages
-               GlobalSide.GUEST -> guestErrorMessages
-       }
+       fun errorMessages(player: GlobalShipController): ReceiveChannel<String> = errorMessageChannel(player)
        
        private val gameEndMutable = CompletableDeferred<GameEvent.GameEnd>()
        val gameEnd: Deferred<GameEvent.GameEnd>
                get() = gameEndMutable
        
-       suspend fun onPacket(player: GlobalSide, packet: PlayerAction) {
+       suspend fun onPacket(player: GlobalShipController, packet: PlayerAction) {
+               if (gameEnd.isCompleted) return
+               
                stateMutex.withLock {
                        when (val result = state.value.after(player, packet)) {
                                is GameEvent.StateChange -> {
-                                       stateMutable.value = result.newState
+                                       stateMutable.value = stateInterceptor.onStateChange(stateMutable.value, result.newState)
                                        result.newState.checkVictory()?.let { gameEndMutable.complete(it) }
                                }
                                is GameEvent.InvalidAction -> {
                                        errorMessageChannel(player).send(result.message)
                                }
                                is GameEvent.GameEnd -> {
-                                       if (gameStartMutable.isActive)
-                                               gameStartMutable.cancel()
                                        gameEndMutable.complete(result)
+                                       gameStartMutable.complete()
                                }
                        }
                }
        }
        
-       suspend fun onClose(player: GlobalSide) {
-               if (gameEnd.isCompleted) return
-               
+       suspend fun onClose(player: GlobalShipController) {
                onPacket(player, PlayerAction.Disconnect)
        }
 }
 
+class GameSession1v1(gameState: GameState, stateInterceptor: GameStateInterceptor = NoopInterceptor) : GameSession(gameState, stateInterceptor) {
+       private val hostEnter = Job()
+       private val guestEnter = Job()
+       
+       override suspend fun enter(player: GlobalShipController) = when (player.side) {
+               GlobalSide.HOST -> {
+                       hostEnter.complete()
+                       withTimeoutOrNull(30_000L) {
+                               guestEnter.join()
+                               true
+                       } ?: false
+               }
+               GlobalSide.GUEST -> {
+                       guestEnter.complete()
+                       withTimeoutOrNull(30_000L) {
+                               hostEnter.join()
+                               true
+                       } ?: false
+               }
+       }.also {
+               if (it)
+                       gameStartMutable.complete()
+               else
+                       onPacket(GlobalShipController(player.side.other, GlobalShipController.Player1Disambiguation), PlayerAction.TimeOut)
+       }
+}
+
+class GameSession2v1(gameState: GameState, stateInterceptor: GameStateInterceptor = NoopInterceptor) : GameSession(gameState, stateInterceptor) {
+       private val player1Enter = Job()
+       private val player2Enter = Job()
+       
+       override suspend fun enter(player: GlobalShipController) = when (player.disambiguation) {
+               GlobalShipController.Player1Disambiguation -> {
+                       player1Enter.complete()
+                       withTimeoutOrNull(30_000L) {
+                               player2Enter.join()
+                               true
+                       } ?: false
+               }
+               GlobalShipController.Player2Disambiguation -> {
+                       player2Enter.complete()
+                       withTimeoutOrNull(30_000L) {
+                               player1Enter.join()
+                               true
+                       } ?: false
+               }
+               else -> null
+       }?.also {
+               if (it)
+                       gameStartMutable.complete()
+               else if (player.disambiguation == GlobalShipController.Player1Disambiguation)
+                       onPacket(GlobalShipController(player.side, GlobalShipController.Player2Disambiguation), PlayerAction.TimeOut)
+               else
+                       onPacket(GlobalShipController(player.side, GlobalShipController.Player1Disambiguation), PlayerAction.TimeOut)
+       } ?: false
+}
+
 suspend fun DefaultWebSocketServerSession.gameEndpoint(user: User, token: String) {
        val gameEntry = GameManager.joinGame(user.id, token, true) ?: closeAndReturn("That battle is not available") { return }
        val playerSide = gameEntry.side
@@ -153,6 +231,12 @@ suspend fun DefaultWebSocketServerSession.gameEndpoint(user: User, token: String
        sendObject(GameBeginning.serializer(), GameBeginning(opponentEntered))
        if (!opponentEntered) return
        
+       val closeHandler = closeReason.invokeOnCompletion {
+               DocumentTable.launch {
+                       gameSession.onClose(playerSide)
+               }
+       }
+       
        val sendEventsJob = launch {
                listOf(
                        // Game state changes
@@ -172,12 +256,7 @@ suspend fun DefaultWebSocketServerSession.gameEndpoint(user: User, token: String
        
        val receiveActionsJob = launch {
                while (true) {
-                       val packet = receiveObject(PlayerAction.serializer()) {
-                               closeAndReturn {
-                                       gameSession.onClose(playerSide)
-                                       return@launch
-                               }
-                       }
+                       val packet = receiveObject(PlayerAction.serializer()) { closeAndReturn { return@launch } }
                        
                        if (isInternalPlayerAction(packet))
                                sendObject(GameEvent.serializer(), GameEvent.InvalidAction("Invalid packet sent over wire - packet type is for internal use only"))
@@ -191,6 +270,8 @@ suspend fun DefaultWebSocketServerSession.gameEndpoint(user: User, token: String
        
        sendEventsJob.cancelAndJoin()
        receiveActionsJob.cancelAndJoin()
+       
+       closeHandler.dispose()
 }
 
 private val BattleSize.shipPointsPerAcumen: Int
@@ -208,15 +289,18 @@ private val BattleSize.shipPointsPerAcumen: Int
 private val BattleSize.acumenPerSubplotWon: Int
        get() = numPoints / 100
 
-private suspend fun onGameEnd(gameState: GameState, gameEnd: GameEvent.GameEnd, startedAt: Instant, endedAt: Instant) {
+private suspend fun on1v1GameEnd(gameState: GameState, gameEnd: GameEvent.GameEnd, startedAt: Instant, endedAt: Instant) {
        val damagedShipReadyAt = endedAt.plus(6, ChronoUnit.HOURS)
        val escapedShipReadyAt = endedAt.plus(4, ChronoUnit.HOURS)
        
        val shipWrecks = gameState.destroyedShips
        val ships = gameState.ships
        
-       val hostAdmiralId = gameState.hostInfo.id.reinterpret<Admiral>()
-       val guestAdmiralId = gameState.guestInfo.id.reinterpret<Admiral>()
+       val hostInfo = gameState.hostInfo.values.single()
+       val guestInfo = gameState.guestInfo.values.single()
+       
+       val hostAdmiralId = hostInfo.id.reinterpret<Admiral>()
+       val guestAdmiralId = guestInfo.id.reinterpret<Admiral>()
        
        val battleRecord = BattleRecord(
                battleInfo = gameState.battleInfo,
@@ -224,14 +308,14 @@ private suspend fun onGameEnd(gameState: GameState, gameEnd: GameEvent.GameEnd,
                whenStarted = startedAt,
                whenEnded = endedAt,
                
-               hostUser = gameState.hostInfo.user.id.reinterpret(),
-               guestUser = gameState.guestInfo.user.id.reinterpret(),
+               hostUser = hostInfo.user.id.reinterpret(),
+               guestUser = guestInfo.user.id.reinterpret(),
                
                hostAdmiral = hostAdmiralId,
                guestAdmiral = guestAdmiralId,
                
-               hostEndingMessage = victoryTitle(GlobalSide.HOST, gameEnd.winner, gameEnd.subplotOutcomes),
-               guestEndingMessage = victoryTitle(GlobalSide.GUEST, gameEnd.winner, gameEnd.subplotOutcomes),
+               hostEndingMessage = victoryTitle(GlobalShipController(GlobalSide.HOST, GlobalShipController.Player1Disambiguation), gameEnd.winner, gameEnd.subplotOutcomes),
+               guestEndingMessage = victoryTitle(GlobalShipController(GlobalSide.GUEST, GlobalShipController.Player1Disambiguation), gameEnd.winner, gameEnd.subplotOutcomes),
                
                winner = gameEnd.winner,
                winMessage = gameEnd.message
@@ -245,7 +329,7 @@ private suspend fun onGameEnd(gameState: GameState, gameEnd: GameEvent.GameEnd,
                        name = wreck.ship.name,
                        shipType = wreck.ship.shipType,
                        destroyedAt = wreck.wreckedAt.instant,
-                       owningAdmiral = when (wreck.owner) {
+                       owningAdmiral = when (wreck.owner.side) {
                                GlobalSide.HOST -> hostAdmiralId
                                GlobalSide.GUEST -> guestAdmiralId
                        },
@@ -258,13 +342,13 @@ private suspend fun onGameEnd(gameState: GameState, gameEnd: GameEvent.GameEnd,
        
        val battleSize = gameState.battleInfo.size
        
-       val hostAcumenGainFromShips = shipWrecks.values.filter { it.owner == GlobalSide.GUEST && !it.isEscape }.sumOf { it.ship.pointCost / battleSize.shipPointsPerAcumen }
-       val hostAcumenGainFromSubplots = gameEnd.subplotOutcomes.filterKeys { it.player == GlobalSide.HOST }.count { (_, outcome) -> outcome == SubplotOutcome.WON } * battleSize.acumenPerSubplotWon
+       val hostAcumenGainFromShips = shipWrecks.values.filter { it.owner.side == GlobalSide.GUEST && !it.isEscape }.sumOf { it.ship.pointCost / battleSize.shipPointsPerAcumen }
+       val hostAcumenGainFromSubplots = gameEnd.subplotOutcomes.filterKeys { it.player.side == GlobalSide.HOST }.count { (_, outcome) -> outcome == SubplotOutcome.WON } * battleSize.acumenPerSubplotWon
        val hostAcumenGain = hostAcumenGainFromShips + hostAcumenGainFromSubplots
        val hostPayment = hostAcumenGain * 2
        
-       val guestAcumenGainFromShips = shipWrecks.values.filter { it.owner == GlobalSide.HOST && !it.isEscape }.sumOf { it.ship.pointCost / battleSize.shipPointsPerAcumen }
-       val guestAcumenGainFromSubplots = gameEnd.subplotOutcomes.filterKeys { it.player == GlobalSide.GUEST }.count { (_, outcome) -> outcome == SubplotOutcome.WON } * battleSize.acumenPerSubplotWon
+       val guestAcumenGainFromShips = shipWrecks.values.filter { it.owner.side == GlobalSide.HOST && !it.isEscape }.sumOf { it.ship.pointCost / battleSize.shipPointsPerAcumen }
+       val guestAcumenGainFromSubplots = gameEnd.subplotOutcomes.filterKeys { it.player.side == GlobalSide.GUEST }.count { (_, outcome) -> outcome == SubplotOutcome.WON } * battleSize.acumenPerSubplotWon
        val guestAcumenGain = guestAcumenGainFromShips + guestAcumenGainFromSubplots
        val guestPayment = guestAcumenGain * 2
        
@@ -304,3 +388,98 @@ private suspend fun onGameEnd(gameState: GameState, gameEnd: GameEvent.GameEnd,
                }
        }
 }
+
+private suspend fun on2v1GameEnd(gameState: GameState, gameEnd: GameEvent.GameEnd, startedAt: Instant, endedAt: Instant) {
+       val damagedShipReadyAt = endedAt.plus(6, ChronoUnit.HOURS)
+       val escapedShipReadyAt = endedAt.plus(4, ChronoUnit.HOURS)
+       
+       val shipWrecks = gameState.destroyedShips
+       val ships = gameState.ships
+       
+       val hostInfo = gameState.hostInfo.getValue(GlobalShipController.Player1Disambiguation)
+       val guestInfo = gameState.hostInfo.getValue(GlobalShipController.Player2Disambiguation)
+       
+       val hostAdmiralId = hostInfo.id.reinterpret<Admiral>()
+       val guestAdmiralId = guestInfo.id.reinterpret<Admiral>()
+       
+       val battleRecord = BattleRecord(
+               battleInfo = gameState.battleInfo,
+               
+               whenStarted = startedAt,
+               whenEnded = endedAt,
+               
+               hostUser = hostInfo.user.id.reinterpret(),
+               guestUser = guestInfo.user.id.reinterpret(),
+               
+               hostAdmiral = hostAdmiralId,
+               guestAdmiral = guestAdmiralId,
+               
+               hostEndingMessage = victoryTitle(GlobalShipController(GlobalSide.HOST, GlobalShipController.Player1Disambiguation), gameEnd.winner, gameEnd.subplotOutcomes),
+               guestEndingMessage = victoryTitle(GlobalShipController(GlobalSide.HOST, GlobalShipController.Player2Disambiguation), gameEnd.winner, gameEnd.subplotOutcomes),
+               
+               winner = gameEnd.winner,
+               winMessage = gameEnd.message
+       )
+       
+       val destructions = shipWrecks.filterValues { !it.isEscape }
+       val destroyedShips = destructions.keys.map { it.reinterpret<ShipInDrydock>() }.toSet()
+       val rememberedShips = destructions.values.map { wreck ->
+               ShipMemorial(
+                       id = Id("RIP_${wreck.id.id}"),
+                       name = wreck.ship.name,
+                       shipType = wreck.ship.shipType,
+                       destroyedAt = wreck.wreckedAt.instant,
+                       owningAdmiral = when (wreck.owner.side) {
+                               GlobalSide.HOST -> hostAdmiralId
+                               GlobalSide.GUEST -> guestAdmiralId
+                       },
+                       destroyedIn = battleRecord.id
+               )
+       }
+       
+       val escapedShips = shipWrecks.filterValues { it.isEscape }.keys.map { it.reinterpret<ShipInDrydock>() }.toSet()
+       val damagedShips = ships.filterValues { it.hullAmount < it.durability.maxHullPoints || it.troopsAmount < it.durability.troopsDefense }.keys.map { it.reinterpret<ShipInDrydock>() }.toSet()
+       
+       val battleSize = gameState.battleInfo.size
+       
+       val playersAcumenGainFromShips = shipWrecks.values.filter { it.owner.side == GlobalSide.GUEST && !it.isEscape }.sumOf { it.ship.pointCost / battleSize.shipPointsPerAcumen }
+       val playersAcumenGainFromSubplots = gameEnd.subplotOutcomes.filterKeys { it.player.side == GlobalSide.HOST }.count { (_, outcome) -> outcome == SubplotOutcome.WON } * battleSize.acumenPerSubplotWon
+       val playersAcumenGain = playersAcumenGainFromShips + playersAcumenGainFromSubplots
+       val playersPayment = playersAcumenGain * 2
+       
+       coroutineScope {
+               launch {
+                       ShipMemorial.put(rememberedShips)
+               }
+               launch {
+                       ShipInDrydock.remove(ShipInDrydock::id `in` destroyedShips)
+               }
+               launch {
+                       ShipInDrydock.update(ShipInDrydock::id `in` damagedShips, setValue(ShipInDrydock::readyAt, damagedShipReadyAt))
+               }
+               launch {
+                       ShipInDrydock.update(ShipInDrydock::id `in` escapedShips, setValue(ShipInDrydock::readyAt, escapedShipReadyAt))
+               }
+               
+               launch {
+                       Admiral.set(
+                               hostAdmiralId, combine(
+                                       inc(Admiral::acumen, playersAcumenGain),
+                                       inc(Admiral::money, playersPayment),
+                               )
+                       )
+               }
+               launch {
+                       Admiral.set(
+                               guestAdmiralId, combine(
+                                       inc(Admiral::acumen, playersAcumenGain),
+                                       inc(Admiral::money, playersPayment),
+                               )
+                       )
+               }
+               
+               launch {
+                       BattleRecord.put(battleRecord)
+               }
+       }
+}
index 5157d17fa9b2be8927d8631780ff9d52bb275a78..bc836f3a09745288b863020e38f385d6005fadc1 100644 (file)
@@ -10,16 +10,29 @@ import kotlinx.coroutines.launch
 import net.starshipfights.data.admiralty.getInGameAdmiral
 import net.starshipfights.data.auth.User
 
-private val openSessions = ConcurrentCurator(mutableListOf<HostInvitation>())
+private val open1v1Sessions = ConcurrentCurator(mutableListOf<Host1v1Invitation>())
 
-class HostInvitation(admiral: InGameAdmiral, battleInfo: BattleInfo) {
-       val joinable = Joinable(admiral, battleInfo)
-       val joinInvitations = Channel<JoinInvitation>()
+class Host1v1Invitation(admiral: InGameAdmiral, battleInfo: BattleInfo) {
+       val joinable = Joinable(admiral, battleInfo, null)
+       val joinInvitations = Channel<Join1v1Invitation>()
        
        val gameIdHandler = CompletableDeferred<String>()
 }
 
-class JoinInvitation(val joinRequest: JoinRequest, val responseHandler: CompletableDeferred<JoinResponse>) {
+class Join1v1Invitation(val joinRequest: JoinRequest, val responseHandler: CompletableDeferred<JoinResponse>) {
+       val gameIdHandler = CompletableDeferred<String>()
+}
+
+private val open2v1Sessions = ConcurrentCurator(mutableListOf<Host2v1Invitation>())
+
+class Host2v1Invitation(admiral: InGameAdmiral, battleInfo: BattleInfo, opponent: TrainingOpponent) {
+       val joinable = Joinable(admiral, battleInfo, opponent.faction)
+       val joinInvitations = Channel<Join2v1Invitation>()
+       
+       val gameIdHandler = CompletableDeferred<String>()
+}
+
+class Join2v1Invitation(val joinRequest: JoinRequest, val responseHandler: CompletableDeferred<JoinResponse>) {
        val gameIdHandler = CompletableDeferred<String>()
 }
 
@@ -33,18 +46,105 @@ suspend fun DefaultWebSocketServerSession.matchmakingEndpoint(user: User): Boole
                is LoginMode.Train -> {
                        closeAndReturn("Invalid input: LoginMode.Train should redirect you directly to training endpoint") { return false }
                }
-               is LoginMode.Host -> {
+               is LoginMode.Host1v1 -> {
+                       val battleInfo = loginMode.battleInfo
+                       val hostInvitation = Host1v1Invitation(inGameAdmiral, battleInfo)
+                       
+                       open1v1Sessions.use { it.add(hostInvitation) }
+                       
+                       closeReason.invokeOnCompletion {
+                               hostInvitation.joinInvitations.close()
+                               
+                               @OptIn(DelicateCoroutinesApi::class)
+                               GlobalScope.launch {
+                                       open1v1Sessions.use {
+                                               it.remove(hostInvitation)
+                                       }
+                               }
+                       }
+                       
+                       for (joinInvitation in hostInvitation.joinInvitations) {
+                               sendObject(JoinRequest.serializer(), joinInvitation.joinRequest)
+                               val joinResponse = receiveObject(JoinResponse.serializer()) {
+                                       closeAndReturn {
+                                               joinInvitation.responseHandler.complete(JoinResponse(false))
+                                               return false
+                                       }
+                               }
+                               
+                               if (joinInvitation.responseHandler.isCancelled) {
+                                       if (joinResponse.accepted)
+                                               sendObject(JoinResponseResponse.serializer(), JoinResponseResponse(false))
+                                       continue
+                               }
+                               
+                               joinInvitation.responseHandler.complete(joinResponse)
+                               
+                               if (joinResponse.accepted) {
+                                       sendObject(JoinResponseResponse.serializer(), JoinResponseResponse(true))
+                                       
+                                       val (hostId, joinId) = GameManager.init1v1Game(inGameAdmiral, joinInvitation.joinRequest.joiner, loginMode.battleInfo)
+                                       hostInvitation.gameIdHandler.complete(hostId)
+                                       joinInvitation.gameIdHandler.complete(joinId)
+                                       
+                                       break
+                               }
+                       }
+                       
+                       val gameId = hostInvitation.gameIdHandler.await()
+                       sendObject(GameReady.serializer(), GameReady(gameId))
+               }
+               LoginMode.Join1v1 -> {
+                       val joinRequest = JoinRequest(inGameAdmiral)
+                       
+                       while (true) {
+                               val openGames = open1v1Sessions.use {
+                                       it.toList()
+                               }.filter { sess ->
+                                       sess.joinable.battleInfo.size <= inGameAdmiral.rank.maxBattleSize
+                               }.mapIndexed { i, host -> "$i" to host }.toMap()
+                               
+                               val joinListing = JoinListing(openGames.mapValues { (_, invitation) -> invitation.joinable })
+                               sendObject(JoinListing.serializer(), joinListing)
+                               
+                               val joinSelection = receiveObject(JoinSelection.serializer()) { closeAndReturn { return false } }
+                               val hostInvitation = openGames.getValue(joinSelection.selectedId)
+                               
+                               val joinResponseHandler = CompletableDeferred<JoinResponse>()
+                               val joinInvitation = Join1v1Invitation(joinRequest, joinResponseHandler)
+                               closeReason.invokeOnCompletion {
+                                       joinResponseHandler.cancel()
+                               }
+                               
+                               try {
+                                       hostInvitation.joinInvitations.send(joinInvitation)
+                               } catch (ex: ClosedSendChannelException) {
+                                       sendObject(JoinResponse.serializer(), JoinResponse(false))
+                                       continue
+                               }
+                               
+                               val joinResponse = joinResponseHandler.await()
+                               sendObject(JoinResponse.serializer(), joinResponse)
+                               
+                               if (joinResponse.accepted) {
+                                       val gameId = joinInvitation.gameIdHandler.await()
+                                       sendObject(GameReady.serializer(), GameReady(gameId))
+                                       break
+                               }
+                       }
+               }
+               is LoginMode.Host2v1 -> {
                        val battleInfo = loginMode.battleInfo
-                       val hostInvitation = HostInvitation(inGameAdmiral, battleInfo)
+                       val hostInvitation = Host2v1Invitation(inGameAdmiral, battleInfo, loginMode.enemyFaction)
                        
-                       openSessions.use { it.add(hostInvitation) }
+                       open2v1Sessions.use { it.add(hostInvitation) }
                        
                        closeReason.invokeOnCompletion {
                                hostInvitation.joinInvitations.close()
                                
                                @OptIn(DelicateCoroutinesApi::class)
                                GlobalScope.launch {
-                                       openSessions.use {
+                                       open2v1Sessions.use {
                                                it.remove(hostInvitation)
                                        }
                                }
@@ -70,7 +170,10 @@ suspend fun DefaultWebSocketServerSession.matchmakingEndpoint(user: User): Boole
                                if (joinResponse.accepted) {
                                        sendObject(JoinResponseResponse.serializer(), JoinResponseResponse(true))
                                        
-                                       val (hostId, joinId) = GameManager.initGame(inGameAdmiral, joinInvitation.joinRequest.joiner, loginMode.battleInfo)
+                                       val enemyFaction = loginMode.enemyFaction.faction ?: Faction.values().random()
+                                       val enemyFlavor = loginMode.enemyFaction.flavor ?: FactionFlavor.optionsForAiEnemy(enemyFaction).random()
+                                       
+                                       val (hostId, joinId) = GameManager.init2v1Game(inGameAdmiral, joinInvitation.joinRequest.joiner, enemyFaction, enemyFlavor, loginMode.battleInfo)
                                        hostInvitation.gameIdHandler.complete(hostId)
                                        joinInvitation.gameIdHandler.complete(joinId)
                                        
@@ -81,11 +184,11 @@ suspend fun DefaultWebSocketServerSession.matchmakingEndpoint(user: User): Boole
                        val gameId = hostInvitation.gameIdHandler.await()
                        sendObject(GameReady.serializer(), GameReady(gameId))
                }
-               LoginMode.Join -> {
+               LoginMode.Join2v1 -> {
                        val joinRequest = JoinRequest(inGameAdmiral)
                        
                        while (true) {
-                               val openGames = openSessions.use {
+                               val openGames = open2v1Sessions.use {
                                        it.toList()
                                }.filter { sess ->
                                        sess.joinable.battleInfo.size <= inGameAdmiral.rank.maxBattleSize
@@ -98,7 +201,7 @@ suspend fun DefaultWebSocketServerSession.matchmakingEndpoint(user: User): Boole
                                val hostInvitation = openGames.getValue(joinSelection.selectedId)
                                
                                val joinResponseHandler = CompletableDeferred<JoinResponse>()
-                               val joinInvitation = JoinInvitation(joinRequest, joinResponseHandler)
+                               val joinInvitation = Join2v1Invitation(joinRequest, joinResponseHandler)
                                closeReason.invokeOnCompletion {
                                        joinResponseHandler.cancel()
                                }
index 65276205e6bf332508b7a49c7823cfbff6074367..d1be1e0dc6c161445548013c1ce65f6b33e3df9f 100644 (file)
@@ -13,7 +13,6 @@ import net.starshipfights.data.admiralty.ShipInDrydock
 import net.starshipfights.data.admiralty.ShipMemorial
 import net.starshipfights.data.auth.User
 import net.starshipfights.data.auth.UserSession
-import net.starshipfights.game.GlobalSide
 import net.starshipfights.redirect
 import org.litote.kmongo.eq
 import org.litote.kmongo.or
@@ -44,11 +43,7 @@ suspend fun ApplicationCall.privateInfo(): String {
        user ?: redirect("/login")
        
        val battleEndings = userBattles.associate { record ->
-               record.id to when (record.winner) {
-                       GlobalSide.HOST -> record.hostUser == userId
-                       GlobalSide.GUEST -> record.guestUser == userId
-                       null -> null
-               }
+               record.id to record.didUserWin(userId)
        }
        
        val (admiralShips, battleOpponents, battleAdmirals) = coroutineScope {
index e9f5eb2536cf04c4dd9b96c01178a8db060081ae..370bc22a3596db3101c6c44230dfad6bc9b8409f 100644 (file)
@@ -592,7 +592,7 @@ suspend fun ApplicationCall.admiralPage(): HTML.() -> Unit {
                                        th { +"When" }
                                        th { +"Size" }
                                        th { +"Role" }
-                                       th { +"Against" }
+                                       th { +"With" }
                                        th { +"Result" }
                                }
                                for (record in records.sortedBy { it.whenEnded }) {