From: TheSaminator Date: Mon, 20 Jun 2022 18:59:08 +0000 (-0400) Subject: Add cooperative multiplayer battles, where two players team up against the AI X-Git-Url: https://gitweb.starshipfights.net/?a=commitdiff_plain;h=b7891dfca668304ca4ea2778b9d7ae7a3f2b459a;p=starship-fights Add cooperative multiplayer battles, where two players team up against the AI --- diff --git a/plan/campaign-spoilers/notes.md b/plan/campaign-spoilers/notes.md index 278da7f..c368912 100644 --- a/plan/campaign-spoilers/notes.md +++ b/plan/campaign-spoilers/notes.md @@ -18,6 +18,32 @@ * 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 diff --git a/src/commonMain/kotlin/net/starshipfights/game/admiralty.kt b/src/commonMain/kotlin/net/starshipfights/game/admiralty.kt index f6203d3..62dcd70 100644 --- a/src/commonMain/kotlin/net/starshipfights/game/admiralty.kt +++ b/src/commonMain/kotlin/net/starshipfights/game/admiralty.kt @@ -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 } diff --git a/src/commonMain/kotlin/net/starshipfights/game/ai/ai_behaviors.kt b/src/commonMain/kotlin/net/starshipfights/game/ai/ai_behaviors.kt index f0d7377..db5b0b8 100644 --- a/src/commonMain/kotlin/net/starshipfights/game/ai/ai_behaviors.kt +++ b/src/commonMain/kotlin/net/starshipfights/game/ai/ai_behaviors.kt @@ -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, Position> { +fun deploy(gameState: GameState, mySide: GlobalShipController, instincts: Instincts): Map, 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) diff --git a/src/commonMain/kotlin/net/starshipfights/game/ai/ai_coroutine.kt b/src/commonMain/kotlin/net/starshipfights/game/ai/ai_coroutine.kt index 3644f13..4eea4ca 100644 --- a/src/commonMain/kotlin/net/starshipfights/game/ai/ai_coroutine.kt +++ b/src/commonMain/kotlin/net/starshipfights/game/ai/ai_coroutine.kt @@ -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, val events: ReceiveChannel, 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() diff --git a/src/commonMain/kotlin/net/starshipfights/game/ai/ai_optimization.kt b/src/commonMain/kotlin/net/starshipfights/game/ai/ai_optimization.kt index 60aea4e..4ef45bb 100644 --- a/src/commonMain/kotlin/net/starshipfights/game/ai/ai_optimization.kt +++ b/src/commonMain/kotlin/net/starshipfights/game/ai/ai_optimization.kt @@ -66,7 +66,7 @@ class TestSession(gameState: GameState) { val gameEnd: Deferred 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() val hostEvents = Channel() - 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() val guestEvents = Channel() - 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(), diff --git a/src/commonMain/kotlin/net/starshipfights/game/client_mode.kt b/src/commonMain/kotlin/net/starshipfights/game/client_mode.kt index 246ab83..32c0c6c 100644 --- a/src/commonMain/kotlin/net/starshipfights/game/client_mode.kt +++ b/src/commonMain/kotlin/net/starshipfights/game/client_mode.kt @@ -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() diff --git a/src/commonMain/kotlin/net/starshipfights/game/game_ability.kt b/src/commonMain/kotlin/net/starshipfights/game/game_ability.kt index 6b7fb1b..2502fc4 100644 --- a/src/commonMain/kotlin/net/starshipfights/game/game_ability.kt +++ b/src/commonMain/kotlin/net/starshipfights/game/game_ability.kt @@ -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) : 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) : 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) : 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, 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) : 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) : 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, override val weapon: Id) : 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, override val weapon: Id) : 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, override val weapon: Id) : 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) : 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) : 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, 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) : 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) : 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 = if (doneWithPhase == forPlayer) +fun GameState.getPossibleAbilities(forPlayer: GlobalShipController): List = 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 diff --git a/src/commonMain/kotlin/net/starshipfights/game/game_chat.kt b/src/commonMain/kotlin/net/starshipfights/game/game_chat.kt index 5426847..e13c326 100644 --- a/src/commonMain/kotlin/net/starshipfights/game/game_chat.kt +++ b/src/commonMain/kotlin/net/starshipfights/game/game_chat.kt @@ -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() diff --git a/src/commonMain/kotlin/net/starshipfights/game/game_initiative.kt b/src/commonMain/kotlin/net/starshipfights/game/game_initiative.kt index 25c2ba3..31eabcb 100644 --- a/src/commonMain/kotlin/net/starshipfights/game/game_initiative.kt +++ b/src/commonMain/kotlin/net/starshipfights/game/game_initiative.kt @@ -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) : 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) = copy( - hostSide = map[GlobalSide.HOST] ?: hostSide, - guestSide = map[GlobalSide.GUEST] ?: guestSide, - ) -} +typealias InitiativeMap = Map -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, Set>> { 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): Boolean { val shipInstance = ships[ship] ?: return false - return currentInitiative != shipInstance.owner.other + return currentInitiative == shipInstance.owner } fun GameState.canShipAttack(ship: Id): Boolean { val shipInstance = ships[ship] ?: return false - return currentInitiative != shipInstance.owner.other + return currentInitiative == shipInstance.owner } diff --git a/src/commonMain/kotlin/net/starshipfights/game/game_packet.kt b/src/commonMain/kotlin/net/starshipfights/game/game_packet.kt index 0634d3e..7b68067 100644 --- a/src/commonMain/kotlin/net/starshipfights/game/game_packet.kt +++ b/src/commonMain/kotlin/net/starshipfights/game/game_packet.kt @@ -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) { diff --git a/src/commonMain/kotlin/net/starshipfights/game/game_start.kt b/src/commonMain/kotlin/net/starshipfights/game/game_start.kt index 744349c..06cd008 100644 --- a/src/commonMain/kotlin/net/starshipfights/game/game_start.kt +++ b/src/commonMain/kotlin/net/starshipfights/game/game_start.kt @@ -8,13 +8,13 @@ data class GameStart( val battlefieldWidth: Double, val battlefieldLength: Double, - val hostStart: PlayerStart, - val guestStart: PlayerStart + val hostStarts: Map, + val guestStarts: Map ) -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, Ship> + val deployableFleet: Map, Ship>, + val deployPointsFactor: Double = 1.0, ) diff --git a/src/commonMain/kotlin/net/starshipfights/game/game_state.kt b/src/commonMain/kotlin/net/starshipfights/game/game_state.kt index 6cfee8f..7240f4c 100644 --- a/src/commonMain/kotlin/net/starshipfights/game/game_state.kt +++ b/src/commonMain/kotlin/net/starshipfights/game/game_state.kt @@ -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, + val guestInfo: Map, val battleInfo: BattleInfo, val subplots: Set, val phase: GamePhase = GamePhase.Deploy, - val doneWithPhase: GlobalSide? = null, - val calculatedInitiative: GlobalSide? = null, + val doneWithPhase: Set = emptySet(), + val calculatedInitiative: GlobalShipController? = null, val ships: Map, ShipInstance> = emptyMap(), val destroyedShips: Map, ShipWreck> = emptyMap(), @@ -27,19 +28,29 @@ data class GameState( fun getShipOwner(id: Id) = destroyedShips[id]?.owner ?: ships.getValue(id).owner fun getShipOwnerOrNull(id: Id) = 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 + get() = (hostInfo.keys.map { GlobalShipController(GlobalSide.HOST, it) } + guestInfo.keys.map { GlobalShipController(GlobalSide.GUEST, it) }).toSet() + +fun GameState.allShipControllersOnSide(side: GlobalSide): Map = 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() - 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 index 0000000..2faed38 --- /dev/null +++ b/src/commonMain/kotlin/net/starshipfights/game/game_state_interceptor.kt @@ -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 +} diff --git a/src/commonMain/kotlin/net/starshipfights/game/game_subplots.kt b/src/commonMain/kotlin/net/starshipfights/game/game_subplots.kt index 165709d..b0baafd 100644 --- a/src/commonMain/kotlin/net/starshipfights/game/game_subplots.kt +++ b/src/commonMain/kotlin/net/starshipfights/game/game_subplots.kt @@ -9,14 +9,14 @@ data class GameObjective( val succeeded: Boolean? ) -fun GameState.objectives(forPlayer: GlobalSide): List = listOf( +fun GameState.objectives(forPlayer: GlobalShipController): List = 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?, private val outcome: SubplotOutcome) : Subplot() { - constructor(forPlayer: GlobalSide) : this(forPlayer, null, SubplotOutcome.UNDECIDED) - constructor(forPlayer: GlobalSide, againstShip: Id) : this(forPlayer, againstShip, SubplotOutcome.UNDECIDED) + class Vendetta private constructor(override val forPlayer: GlobalShipController, private val againstShip: Id?, private val outcome: SubplotOutcome) : Subplot() { + constructor(forPlayer: GlobalShipController) : this(forPlayer, null, SubplotOutcome.UNDECIDED) + constructor(forPlayer: GlobalShipController, againstShip: Id) : 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?, private val outcome: SubplotOutcome) : Subplot() { - constructor(forPlayer: GlobalSide) : this(forPlayer, null, SubplotOutcome.UNDECIDED) - constructor(forPlayer: GlobalSide, againstShip: Id) : this(forPlayer, againstShip, SubplotOutcome.UNDECIDED) + class PlausibleDeniability private constructor(override val forPlayer: GlobalShipController, private val againstShip: Id?, private val outcome: SubplotOutcome) : Subplot() { + constructor(forPlayer: GlobalShipController) : this(forPlayer, null, SubplotOutcome.UNDECIDED) + constructor(forPlayer: GlobalShipController, againstShip: Id) : 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?, private val outcome: SubplotOutcome, private val mostRecentChatMessages: Moment?) : Subplot() { - constructor(forPlayer: GlobalSide) : this(forPlayer, null, SubplotOutcome.UNDECIDED, null) - constructor(forPlayer: GlobalSide, onBoardShip: Id) : this(forPlayer, onBoardShip, SubplotOutcome.UNDECIDED, null) + class RecoverInformant private constructor(override val forPlayer: GlobalShipController, private val onBoardShip: Id?, private val outcome: SubplotOutcome, private val mostRecentChatMessages: Moment?) : Subplot() { + constructor(forPlayer: GlobalShipController) : this(forPlayer, null, SubplotOutcome.UNDECIDED, null) + constructor(forPlayer: GlobalShipController, onBoardShip: Id) : 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 = +fun generateSubplots(battleSize: BattleSize, forPlayer: GlobalShipController): Set = (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): String { +fun victoryTitle(player: GlobalShipController, winner: GlobalSide?, subplotOutcomes: Map): 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 { + player.side.other -> { val isHeroic = myOutcomes.all { (_, outcome) -> outcome == SubplotOutcome.WON } val isHumiliating = myOutcomes.size >= 2 && myOutcomes.none { (_, outcome) -> outcome == SubplotOutcome.WON } diff --git a/src/commonMain/kotlin/net/starshipfights/game/matchmaking.kt b/src/commonMain/kotlin/net/starshipfights/game/matchmaking.kt index 527ef52..a32f86b 100644 --- a/src/commonMain/kotlin/net/starshipfights/game/matchmaking.kt +++ b/src/commonMain/kotlin/net/starshipfights/game/matchmaking.kt @@ -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 diff --git a/src/commonMain/kotlin/net/starshipfights/game/pick_bounds.kt b/src/commonMain/kotlin/net/starshipfights/game/pick_bounds.kt index 1ec2af6..5c6b24e 100644 --- a/src/commonMain/kotlin/net/starshipfights/game/pick_bounds.kt +++ b/src/commonMain/kotlin/net/starshipfights/game/pick_bounds.kt @@ -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 } diff --git a/src/commonMain/kotlin/net/starshipfights/game/ship_boarding.kt b/src/commonMain/kotlin/net/starshipfights/game/ship_boarding.kt index f9e2633..2454030 100644 --- a/src/commonMain/kotlin/net/starshipfights/game/ship_boarding.kt +++ b/src/commonMain/kotlin/net/starshipfights/game/ship_boarding.kt @@ -156,7 +156,7 @@ fun reportBoardingResult(impactResult: ImpactResult, attacker: Id) } 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, diff --git a/src/commonMain/kotlin/net/starshipfights/game/ship_instances.kt b/src/commonMain/kotlin/net/starshipfights/game/ship_instances.kt index c3b5d48..ca20f62 100644 --- a/src/commonMain/kotlin/net/starshipfights/game/ship_instances.kt +++ b/src/commonMain/kotlin/net/starshipfights/game/ship_instances.kt @@ -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 diff --git a/src/commonMain/kotlin/net/starshipfights/game/ship_weapons.kt b/src/commonMain/kotlin/net/starshipfights/game/ship_weapons.kt index bf29d36..e7de6dc 100644 --- a/src/commonMain/kotlin/net/starshipfights/game/ship_weapons.kt +++ b/src/commonMain/kotlin/net/starshipfights/game/ship_weapons.kt @@ -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 ) ) } diff --git a/src/jsMain/kotlin/net/starshipfights/game/client_game.kt b/src/jsMain/kotlin/net/starshipfights/game/client_game.kt index 9b95edb..2ded743 100644 --- a/src/jsMain/kotlin/net/starshipfights/game/client_game.kt +++ b/src/jsMain/kotlin/net/starshipfights/game/client_game.kt @@ -24,7 +24,7 @@ class GameRenderInteraction( val errorMessages: ReceiveChannel ) -lateinit var mySide: GlobalSide +lateinit var mySide: GlobalShipController private val pickContextDeferred = CompletableDeferred() @@ -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) = GameUIResponderImpl(this, actions) -suspend fun gameMain(side: GlobalSide, token: String, state: GameState) { +suspend fun gameMain(side: GlobalShipController, token: String, state: GameState) { interruptExit = true initializePicking() diff --git a/src/jsMain/kotlin/net/starshipfights/game/client_matchmaking.kt b/src/jsMain/kotlin/net/starshipfights/game/client_matchmaking.kt index cb51de3..35b60c4 100644 --- a/src/jsMain/kotlin/net/starshipfights/game/client_matchmaking.kt +++ b/src/jsMain/kotlin/net/starshipfights/game/client_matchmaking.kt @@ -89,10 +89,10 @@ private suspend fun enterGame(connectToken: String): Nothing { private suspend fun usePlayerLogin(admirals: List) { 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) { 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) { 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) { 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) { diff --git a/src/jsMain/kotlin/net/starshipfights/game/client_training.kt b/src/jsMain/kotlin/net/starshipfights/game/client_training.kt index 8086704..c2f3a09 100644 --- a/src/jsMain/kotlin/net/starshipfights/game/client_training.kt +++ b/src/jsMain/kotlin/net/starshipfights/game/client_training.kt @@ -16,24 +16,23 @@ class GameSession(gameState: GameState) { val state = stateMutable.asStateFlow() - private val hostErrorMessages = Channel(Channel.UNLIMITED) - private val guestErrorMessages = Channel(Channel.UNLIMITED) + private val errorMessageChannels = mutableMapOf>() - private fun errorMessageChannel(player: GlobalSide) = when (player) { - GlobalSide.HOST -> hostErrorMessages - GlobalSide.GUEST -> guestErrorMessages - } + private fun errorMessageChannel(player: GlobalShipController) = + errorMessageChannels[player] ?: Channel(Channel.UNLIMITED).also { + errorMessageChannels[player] = it + } - fun errorMessages(player: GlobalSide): ReceiveChannel = when (player) { - GlobalSide.HOST -> hostErrorMessages - GlobalSide.GUEST -> guestErrorMessages - } + fun errorMessages(player: GlobalShipController): ReceiveChannel = + errorMessageChannels[player] ?: Channel(Channel.UNLIMITED).also { + errorMessageChannels[player] = it + } private val gameEndMutable = CompletableDeferred() val gameEnd: Deferred 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() val aiEvents = Channel() 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(Channel.UNLIMITED) diff --git a/src/jsMain/kotlin/net/starshipfights/game/game_resources.kt b/src/jsMain/kotlin/net/starshipfights/game/game_resources.kt index 908df1c..f90dc9e 100644 --- a/src/jsMain/kotlin/net/starshipfights/game/game_resources.kt +++ b/src/jsMain/kotlin/net/starshipfights/game/game_resources.kt @@ -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 @@ -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().apply { + uniforms["outlineColor"]!!.value = Color(LocalSide.BLUE.htmlColor) + } + val redOutlineMaterial = outlineMaterial.clone().unsafeCast().apply { uniforms["outlineColor"]!!.value = Color(LocalSide.RED.htmlColor) } @@ -245,6 +264,9 @@ object RenderResources { val outlineGreen = mesh.clone(true).unsafeCast() outlineGreen.material = greenOutlineMaterial + val outlineBlue = mesh.clone(true).unsafeCast() + outlineBlue.material = blueOutlineMaterial + val outlineRed = mesh.clone(true).unsafeCast() outlineRed.material = redOutlineMaterial @@ -263,6 +285,7 @@ object RenderResources { }, when (side) { LocalSide.GREEN -> outlineGreen + LocalSide.BLUE -> outlineBlue LocalSide.RED -> outlineRed }.clone(true).unsafeCast() ).group diff --git a/src/jsMain/kotlin/net/starshipfights/game/game_ui.kt b/src/jsMain/kotlin/net/starshipfights/game/game_ui.kt index c636247..71ca242 100644 --- a/src/jsMain/kotlin/net/starshipfights/game/game_ui.kt +++ b/src/jsMain/kotlin/net/starshipfights/game/game_ui.kt @@ -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) { 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" diff --git a/src/jsMain/kotlin/net/starshipfights/game/popup.kt b/src/jsMain/kotlin/net/starshipfights/game/popup.kt index be251e0..4a94530 100644 --- a/src/jsMain/kotlin/net/starshipfights/game/popup.kt +++ b/src/jsMain/kotlin/net/starshipfights/game/popup.kt @@ -112,7 +112,7 @@ sealed class Popup { 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 { } } 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 { } } - class HostSelectScreen(private val hosts: Map) : Popup() { + class Host1v1SelectScreen(private val hosts: Map) : Popup() { override fun TagConsumer<*>.render(context: CoroutineContext, callback: (String?) -> Unit) { table { style = "table-layout:fixed;width:100%" @@ -463,6 +479,97 @@ sealed class Popup { } } + class Host2v1SelectScreen(private val hosts: Map) : Popup() { + 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() { override fun TagConsumer<*>.render(context: CoroutineContext, callback: (Unit) -> Unit) { p { diff --git a/src/jsMain/kotlin/net/starshipfights/game/popup_util.kt b/src/jsMain/kotlin/net/starshipfights/game/popup_util.kt index 2530842..8bb498c 100644 --- a/src/jsMain/kotlin/net/starshipfights/game/popup_util.kt +++ b/src/jsMain/kotlin/net/starshipfights/game/popup_util.kt @@ -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 index 0000000..59df113 Binary files /dev/null and b/src/jsMain/resources/textures/neutral-marker.png differ diff --git a/src/jvmMain/kotlin/net/starshipfights/data/admiralty/battle_records.kt b/src/jvmMain/kotlin/net/starshipfights/data/admiralty/battle_records.kt index f65b8c5..6b46719 100644 --- a/src/jvmMain/kotlin/net/starshipfights/data/admiralty/battle_records.kt +++ b/src/jvmMain/kotlin/net/starshipfights/data/admiralty/battle_records.kt @@ -33,7 +33,31 @@ data class BattleRecord( val winner: GlobalSide?, val winMessage: String, + val was2v2: Boolean = false, ) : DataDocument { + fun getSide(admiral: Id) = when (admiral) { + hostAdmiral -> GlobalSide.HOST + guestAdmiral -> GlobalSide.GUEST + else -> null + } + + fun getUserSide(user: Id) = 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) = getSide(admiral)?.let { wasWinner(it) } + + fun didUserWin(user: Id) = getUserSide(user)?.let { wasWinner(it) } + companion object Table : DocumentTable by DocumentTable.create({ index(BattleRecord::hostUser) index(BattleRecord::guestUser) diff --git a/src/jvmMain/kotlin/net/starshipfights/game/game_start_jvm.kt b/src/jvmMain/kotlin/net/starshipfights/game/game_start_jvm.kt index 7ec31ec..60bb206 100644 --- a/src/jvmMain/kotlin/net/starshipfights/game/game_start_jvm.kt +++ b/src/jvmMain/kotlin/net/starshipfights/game/game_start_jvm.kt @@ -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) + ) ) } diff --git a/src/jvmMain/kotlin/net/starshipfights/game/server_game.kt b/src/jvmMain/kotlin/net/starshipfights/game/server_game.kt index 71600a2..189ea60 100644 --- a/src/jvmMain/kotlin/net/starshipfights/game/server_game.kt +++ b/src/jvmMain/kotlin/net/starshipfights/game/server_game.kt @@ -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()) - 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() + val aiEvents = Channel() + 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, val side: GlobalSide, val session: GameSession) +class GameEntry(val userId: Id, 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(Channel.UNLIMITED) - private val guestErrorMessages = Channel(Channel.UNLIMITED) + private val errorMessages = mutableMapOf>() - private fun errorMessageChannel(player: GlobalSide) = when (player) { - GlobalSide.HOST -> hostErrorMessages - GlobalSide.GUEST -> guestErrorMessages + private fun errorMessageChannel(player: GlobalShipController) = errorMessages[player] ?: Channel(Channel.UNLIMITED).also { + errorMessages[player] = it } - fun errorMessages(player: GlobalSide): ReceiveChannel = when (player) { - GlobalSide.HOST -> hostErrorMessages - GlobalSide.GUEST -> guestErrorMessages - } + fun errorMessages(player: GlobalShipController): ReceiveChannel = errorMessageChannel(player) private val gameEndMutable = CompletableDeferred() val gameEnd: Deferred 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() - val guestAdmiralId = gameState.guestInfo.id.reinterpret() + val hostInfo = gameState.hostInfo.values.single() + val guestInfo = gameState.guestInfo.values.single() + + val hostAdmiralId = hostInfo.id.reinterpret() + val guestAdmiralId = guestInfo.id.reinterpret() 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() + val guestAdmiralId = guestInfo.id.reinterpret() + + 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() }.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() }.toSet() + val damagedShips = ships.filterValues { it.hullAmount < it.durability.maxHullPoints || it.troopsAmount < it.durability.troopsDefense }.keys.map { it.reinterpret() }.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) + } + } +} diff --git a/src/jvmMain/kotlin/net/starshipfights/game/server_matchmaking.kt b/src/jvmMain/kotlin/net/starshipfights/game/server_matchmaking.kt index 5157d17..bc836f3 100644 --- a/src/jvmMain/kotlin/net/starshipfights/game/server_matchmaking.kt +++ b/src/jvmMain/kotlin/net/starshipfights/game/server_matchmaking.kt @@ -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()) +private val open1v1Sessions = ConcurrentCurator(mutableListOf()) -class HostInvitation(admiral: InGameAdmiral, battleInfo: BattleInfo) { - val joinable = Joinable(admiral, battleInfo) - val joinInvitations = Channel() +class Host1v1Invitation(admiral: InGameAdmiral, battleInfo: BattleInfo) { + val joinable = Joinable(admiral, battleInfo, null) + val joinInvitations = Channel() val gameIdHandler = CompletableDeferred() } -class JoinInvitation(val joinRequest: JoinRequest, val responseHandler: CompletableDeferred) { +class Join1v1Invitation(val joinRequest: JoinRequest, val responseHandler: CompletableDeferred) { + val gameIdHandler = CompletableDeferred() +} + +private val open2v1Sessions = ConcurrentCurator(mutableListOf()) + +class Host2v1Invitation(admiral: InGameAdmiral, battleInfo: BattleInfo, opponent: TrainingOpponent) { + val joinable = Joinable(admiral, battleInfo, opponent.faction) + val joinInvitations = Channel() + + val gameIdHandler = CompletableDeferred() +} + +class Join2v1Invitation(val joinRequest: JoinRequest, val responseHandler: CompletableDeferred) { val gameIdHandler = CompletableDeferred() } @@ -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() + 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() - val joinInvitation = JoinInvitation(joinRequest, joinResponseHandler) + val joinInvitation = Join2v1Invitation(joinRequest, joinResponseHandler) closeReason.invokeOnCompletion { joinResponseHandler.cancel() } diff --git a/src/jvmMain/kotlin/net/starshipfights/info/views_gdpr.kt b/src/jvmMain/kotlin/net/starshipfights/info/views_gdpr.kt index 6527620..d1be1e0 100644 --- a/src/jvmMain/kotlin/net/starshipfights/info/views_gdpr.kt +++ b/src/jvmMain/kotlin/net/starshipfights/info/views_gdpr.kt @@ -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 { diff --git a/src/jvmMain/kotlin/net/starshipfights/info/views_user.kt b/src/jvmMain/kotlin/net/starshipfights/info/views_user.kt index e9f5eb2..370bc22 100644 --- a/src/jvmMain/kotlin/net/starshipfights/info/views_user.kt +++ b/src/jvmMain/kotlin/net/starshipfights/info/views_user.kt @@ -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 }) {