* 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
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
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 }
)
@OptIn(FlowPreview::class)
-suspend fun AIPlayer.behave(instincts: Instincts, mySide: GlobalSide) {
+suspend fun AIPlayer.behave(instincts: Instincts, mySide: GlobalShipController) {
try {
coroutineScope {
val brain = Brain()
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)
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) ->
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
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)))
}
}
}
}
}
-fun deploy(gameState: GameState, mySide: GlobalSide, instincts: Instincts): Map<Id<ShipInstance>, Position> {
+fun deploy(gameState: GameState, mySide: GlobalShipController, instincts: Instincts): Map<Id<ShipInstance>, Position> {
val size = gameState.battleInfo.size
- val totalPoints = size.numPoints
+ val totalPoints = gameState.getUsablePoints(mySide)
val maxTier = size.maxTier
val myStart = gameState.start.playerStart(mySide)
import kotlinx.coroutines.launch
import net.starshipfights.game.GameEvent
import net.starshipfights.game.GameState
-import net.starshipfights.game.GlobalSide
+import net.starshipfights.game.GlobalShipController
import net.starshipfights.game.PlayerAction
data class AISession(
- val mySide: GlobalSide,
+ val mySide: GlobalShipController,
val actions: SendChannel<PlayerAction>,
val events: ReceiveChannel<GameEvent>,
val instincts: Instincts = Instincts(),
)
-suspend fun aiPlayer(session: AISession, initialState: GameState) = coroutineScope {
+suspend fun aiPlayer(session: AISession, initialState: GameState): Unit = coroutineScope {
val gameDone = Job()
val errors = Channel<String>()
val gameEnd: Deferred<GameEvent.GameEnd>
get() = gameEndMutable
- suspend fun onPacket(player: GlobalSide, packet: PlayerAction) {
+ suspend fun onPacket(player: GlobalShipController, packet: PlayerAction) {
stateMutex.withLock {
when (val result = state.value.after(player, packet)) {
is GameEvent.StateChange -> {
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)
val hostActions = Channel<PlayerAction>()
val hostEvents = Channel<GameEvent>()
- val hostSession = AISession(GlobalSide.HOST, hostActions, hostEvents, hostInstincts)
+ val hostSide = GlobalShipController(GlobalSide.HOST, GlobalShipController.Player1Disambiguation)
+ val hostSession = AISession(hostSide, hostActions, hostEvents, hostInstincts)
val guestActions = Channel<PlayerAction>()
val guestEvents = Channel<GameEvent>()
- val guestSession = AISession(GlobalSide.GUEST, guestActions, guestEvents, guestInstincts)
+ val guestSide = GlobalShipController(GlobalSide.HOST, GlobalShipController.Player1Disambiguation)
+ val guestSession = AISession(guestSide, guestActions, guestEvents, guestInstincts)
return coroutineScope {
val hostHandlingJob = launch {
launch {
for (action in hostActions)
- testSession.onPacket(GlobalSide.HOST, action)
+ testSession.onPacket(hostSide, action)
}
aiPlayer(hostSession, testSession.state.value)
launch {
for (action in guestActions)
- testSession.onPacket(GlobalSide.GUEST, action)
+ testSession.onPacket(guestSide, action)
}
aiPlayer(guestSession, testSession.state.value)
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(),
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()
@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))
@Serializable
data class DeployShip(val ship: Id<Ship>) : PlayerAbilityType() {
- override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? {
+ override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalShipController, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? {
if (gameState.phase != GamePhase.Deploy) return null
- if (gameState.doneWithPhase == playerSide) return null
+ if (playerSide in gameState.doneWithPhase) return null
val pickBoundary = gameState.start.playerStart(playerSide).deployZone
val playerStart = gameState.start.playerStart(playerSide)
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")
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
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
)
@Serializable
data class UndeployShip(val ship: Id<ShipInstance>) : PlayerAbilityType() {
- override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? {
- return if (gameState.phase == GamePhase.Deploy && gameState.doneWithPhase != playerSide) PlayerAbilityData.UndeployShip else null
+ override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalShipController, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? {
+ return if (gameState.phase == GamePhase.Deploy && playerSide !in gameState.doneWithPhase) PlayerAbilityData.UndeployShip else null
}
- override fun finishOnServer(gameState: GameState, playerSide: GlobalSide, data: PlayerAbilityData): GameEvent {
+ override fun finishOnServer(gameState: GameState, playerSide: GlobalShipController, data: PlayerAbilityData): GameEvent {
val shipInstance = gameState.ships[ship] ?: return GameEvent.InvalidAction("That ship is not deployed")
val shipData = shipInstance.ship
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
)
@Serializable
data class DistributePower(override val ship: Id<ShipInstance>) : PlayerAbilityType(), ShipAbility {
- override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? {
+ override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalShipController, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? {
if (gameState.phase !is GamePhase.Power) return null
val shipInstance = gameState.ships[ship] ?: return null
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")
@Serializable
data class ConfigurePower(override val ship: Id<ShipInstance>, val powerMode: FelinaeShipPowerMode) : PlayerAbilityType(), ShipAbility {
- override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? {
+ override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalShipController, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? {
if (gameState.phase !is GamePhase.Power) return null
val shipInstance = gameState.ships[ship] ?: return null
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")
@Serializable
data class MoveShip(override val ship: Id<ShipInstance>) : PlayerAbilityType(), ShipAbility {
- override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? {
+ override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalShipController, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? {
if (gameState.phase !is GamePhase.Move) return null
if (!gameState.canShipMove(ship)) return null
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")
@Serializable
data class UseInertialessDrive(override val ship: Id<ShipInstance>) : PlayerAbilityType(), ShipAbility {
- override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? {
+ override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalShipController, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? {
if (gameState.phase !is GamePhase.Move) return null
if (!gameState.canShipMove(ship)) return null
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")
@Serializable
data class ChargeLance(override val ship: Id<ShipInstance>, override val weapon: Id<ShipWeapon>) : PlayerAbilityType(), CombatAbility {
- override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? {
+ override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalShipController, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? {
if (gameState.phase !is GamePhase.Attack) return null
val shipInstance = gameState.ships[ship] ?: return null
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")
@Serializable
data class UseWeapon(override val ship: Id<ShipInstance>, override val weapon: Id<ShipWeapon>) : PlayerAbilityType(), CombatAbility {
- override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? {
+ override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalShipController, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? {
if (gameState.phase !is GamePhase.Attack) return null
val shipInstance = gameState.ships[ship] ?: return null
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")
@Serializable
data class RecallStrikeCraft(override val ship: Id<ShipInstance>, override val weapon: Id<ShipWeapon>) : PlayerAbilityType(), CombatAbility {
- override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? {
+ override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalShipController, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? {
if (gameState.phase !is GamePhase.Attack) return null
val shipInstance = gameState.ships[ship] ?: return null
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")
@Serializable
data class DisruptionPulse(override val ship: Id<ShipInstance>) : PlayerAbilityType(), ShipAbility {
- override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? {
+ override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalShipController, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? {
if (gameState.phase !is GamePhase.Attack) return null
val shipInstance = gameState.ships[ship] ?: return null
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")
@Serializable
data class BoardingParty(override val ship: Id<ShipInstance>) : PlayerAbilityType(), ShipAbility {
- override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? {
+ override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalShipController, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? {
if (gameState.phase !is GamePhase.Attack) return null
val shipInstance = gameState.ships[ship] ?: return null
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")
@Serializable
data class RepairShipModule(override val ship: Id<ShipInstance>, val module: ShipModule) : PlayerAbilityType(), ShipAbility {
- override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? {
+ override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalShipController, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? {
if (gameState.phase !is GamePhase.Repair) return null
val shipInstance = gameState.ships[ship] ?: return null
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")
@Serializable
data class ExtinguishFire(override val ship: Id<ShipInstance>) : PlayerAbilityType(), ShipAbility {
- override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? {
+ override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalShipController, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? {
if (gameState.phase !is GamePhase.Repair) return null
val shipInstance = gameState.ships[ship] ?: return null
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")
@Serializable
data class Recoalesce(override val ship: Id<ShipInstance>) : PlayerAbilityType(), ShipAbility {
- override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? {
+ override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalShipController, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? {
if (gameState.phase !is GamePhase.Repair) return null
val shipInstance = gameState.ships[ship] ?: return null
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")
object Recoalesce : PlayerAbilityData()
}
-fun GameState.getPossibleAbilities(forPlayer: GlobalSide): List<PlayerAbilityType> = if (doneWithPhase == forPlayer)
+fun GameState.getPossibleAbilities(forPlayer: GlobalShipController): List<PlayerAbilityType> = if (forPlayer in doneWithPhase)
emptyList()
else when (phase) {
GamePhase.Deploy -> {
.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
@Serializable
data class PlayerMessage(
- val senderSide: GlobalSide,
+ val senderSide: GlobalShipController,
override val sentAt: Moment,
val message: String
) : ChatEntry()
package net.starshipfights.game
-import kotlinx.serialization.Serializable
import net.starshipfights.data.Id
-@Serializable
-data class InitiativePair(
- val hostSide: Double,
- val guestSide: Double
-) {
- constructor(map: Map<GlobalSide, Double>) : this(
- map[GlobalSide.HOST] ?: 0.0,
- map[GlobalSide.GUEST] ?: 0.0,
- )
-
- operator fun get(side: GlobalSide) = when (side) {
- GlobalSide.HOST -> hostSide
- GlobalSide.GUEST -> guestSide
- }
-
- fun copy(map: Map<GlobalSide, Double>) = copy(
- hostSide = map[GlobalSide.HOST] ?: hostSide,
- guestSide = map[GlobalSide.GUEST] ?: guestSide,
- )
-}
+typealias InitiativeMap = Map<GlobalShipController, Double>
-fun GameState.calculateMovePhaseInitiative(): InitiativePair = InitiativePair(
+fun GameState.calculateMovePhaseInitiative(): InitiativeMap =
ships
.values
.groupBy { it.owner }
.filter { !it.isDoneCurrentPhase }
.sumOf { it.ship.pointCost * it.movementCoefficient }
}
-)
fun GameState.getValidAttackersWith(target: ShipInstance): Map<Id<ShipInstance>, Set<Id<ShipWeapon>>> {
return ships.mapValues { (_, ship) -> isValidAttackerWith(ship, target) }
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))
}
}
return aggregateValidTargets(ship, weapon) { filter(it) }
}
-fun GameState.calculateAttackPhaseInitiative(): InitiativePair = InitiativePair(
+fun GameState.calculateAttackPhaseInitiative(): InitiativeMap =
ships
.values
.groupBy { it.owner }
ship.ship.pointCost * (usableWeaponShots.toDouble() / allWeaponShots)
}
}
-)
-fun GameState.withRecalculatedInitiative(initiativePairAccessor: GameState.() -> InitiativePair): GameState {
- val initiativePair = initiativePairAccessor()
+
+fun GameState.withRecalculatedInitiative(initiativeMapAccessor: GameState.() -> InitiativeMap): GameState {
+ val initiativePair = initiativeMapAccessor()
return copy(
- calculatedInitiative = when {
- initiativePair.hostSide > initiativePair.guestSide -> GlobalSide.HOST
- initiativePair.hostSide < initiativePair.guestSide -> GlobalSide.GUEST
- else -> calculatedInitiative?.other
- }
+ calculatedInitiative = (initiativePair - doneWithPhase).maxByOrNull { (_, it) -> it }?.key
)
}
fun GameState.canShipMove(ship: Id<ShipInstance>): Boolean {
val shipInstance = ships[ship] ?: return false
- return currentInitiative != shipInstance.owner.other
+ return currentInitiative == shipInstance.owner
}
fun GameState.canShipAttack(ship: Id<ShipInstance>): Boolean {
val shipInstance = ships[ship] ?: return false
- return currentInitiative != shipInstance.owner.other
+ return currentInitiative == shipInstance.owner
}
) : 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(
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) {
val battlefieldWidth: Double,
val battlefieldLength: Double,
- val hostStart: PlayerStart,
- val guestStart: PlayerStart
+ val hostStarts: Map<String, PlayerStart>,
+ val guestStarts: Map<String, PlayerStart>
)
-fun GameStart.playerStart(side: GlobalSide) = when (side) {
- GlobalSide.HOST -> hostStart
- GlobalSide.GUEST -> guestStart
+fun GameStart.playerStart(side: GlobalShipController) = when (side.side) {
+ GlobalSide.HOST -> hostStarts.getValue(side.disambiguation)
+ GlobalSide.GUEST -> guestStarts.getValue(side.disambiguation)
}
@Serializable
val deployZone: PickBoundary.Rectangle,
val deployFacing: Double,
- val deployableFleet: Map<Id<Ship>, Ship>
+ val deployableFleet: Map<Id<Ship>, Ship>,
+ val deployPointsFactor: Double = 1.0,
)
import kotlinx.serialization.Serializable
import net.starshipfights.data.Id
+import kotlin.math.roundToInt
@Serializable
data class GameState(
val start: GameStart,
- val hostInfo: InGameAdmiral,
- val guestInfo: InGameAdmiral,
+ val hostInfo: Map<String, InGameAdmiral>,
+ val guestInfo: Map<String, InGameAdmiral>,
val battleInfo: BattleInfo,
val subplots: Set<Subplot>,
val phase: GamePhase = GamePhase.Deploy,
- val doneWithPhase: GlobalSide? = null,
- val calculatedInitiative: GlobalSide? = null,
+ val doneWithPhase: Set<GlobalShipController> = emptySet(),
+ val calculatedInitiative: GlobalShipController? = null,
val ships: Map<Id<ShipInstance>, ShipInstance> = emptyMap(),
val destroyedShips: Map<Id<ShipInstance>, ShipWreck> = emptyMap(),
fun getShipOwner(id: Id<ShipInstance>) = destroyedShips[id]?.owner ?: ships.getValue(id).owner
fun getShipOwnerOrNull(id: Id<ShipInstance>) = destroyedShips[id]?.owner ?: ships[id]?.owner
+
+ fun getUsablePoints(side: GlobalShipController) = (battleInfo.size.numPoints * start.playerStart(side).deployPointsFactor).roundToInt()
}
-val GameState.currentInitiative: GlobalSide?
- get() = calculatedInitiative?.takeIf { it != doneWithPhase }
+val GameState.allShipControllers: Set<GlobalShipController>
+ get() = (hostInfo.keys.map { GlobalShipController(GlobalSide.HOST, it) } + guestInfo.keys.map { GlobalShipController(GlobalSide.GUEST, it) }).toSet()
+
+fun GameState.allShipControllersOnSide(side: GlobalSide): Map<GlobalShipController, InGameAdmiral> = when (side) {
+ GlobalSide.HOST -> hostInfo
+ GlobalSide.GUEST -> guestInfo
+}.mapKeys { (it, _) -> GlobalShipController(side, it) }
+
+val GameState.currentInitiative: GlobalShipController?
+ get() = calculatedInitiative?.takeIf { it !in doneWithPhase }
-fun GameState.canFinishPhase(side: GlobalSide): Boolean {
+fun GameState.canFinishPhase(side: GlobalShipController): Boolean {
return when (phase) {
GamePhase.Deploy -> {
val usedPoints = ships.values
.filter { it.owner == side }
.sumOf { it.ship.pointCost }
- start.playerStart(side).deployableFleet.values.none { usedPoints + it.pointCost <= battleInfo.size.numPoints }
+ start.playerStart(side).deployableFleet.values.none { usedPoints + it.pointCost <= getUsablePoints(side) }
}
else -> true
}
var newShips = ships
val newWrecks = destroyedShips.toMutableMap()
val newChatEntries = mutableListOf<ChatEntry>()
- var newInitiative: GameState.() -> InitiativePair = { InitiativePair(emptyMap()) }
+ var newInitiative: GameState.() -> InitiativeMap = { emptyMap() }
when (phase) {
GamePhase.Deploy -> {
).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
}
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)
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 {
}
}
-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"
}
--- /dev/null
+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
+}
val succeeded: Boolean?
)
-fun GameState.objectives(forPlayer: GlobalSide): List<GameObjective> = listOf(
+fun GameState.objectives(forPlayer: GlobalShipController): List<GameObjective> = listOf(
GameObjective("Destroy or rout the enemy fleet", null)
) + subplots.filter { it.forPlayer == forPlayer }.mapNotNull { it.displayObjective(this) }
@Serializable
data class SubplotKey(
val type: SubplotType,
- val player: GlobalSide,
+ val player: GlobalShipController,
)
val Subplot.key: SubplotKey
@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
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
}
@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 }
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 }
}
@Serializable
- class Vendetta private constructor(override val forPlayer: GlobalSide, private val againstShip: Id<ShipInstance>?, private val outcome: SubplotOutcome) : Subplot() {
- constructor(forPlayer: GlobalSide) : this(forPlayer, null, SubplotOutcome.UNDECIDED)
- constructor(forPlayer: GlobalSide, againstShip: Id<ShipInstance>) : this(forPlayer, againstShip, SubplotOutcome.UNDECIDED)
+ class Vendetta private constructor(override val forPlayer: GlobalShipController, private val againstShip: Id<ShipInstance>?, private val outcome: SubplotOutcome) : Subplot() {
+ constructor(forPlayer: GlobalShipController) : this(forPlayer, null, SubplotOutcome.UNDECIDED)
+ constructor(forPlayer: GlobalShipController, againstShip: Id<ShipInstance>) : this(forPlayer, againstShip, SubplotOutcome.UNDECIDED)
override val type: SubplotType
get() = SubplotType.VENDETTA
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 }
}
@Serializable
- class PlausibleDeniability private constructor(override val forPlayer: GlobalSide, private val againstShip: Id<ShipInstance>?, private val outcome: SubplotOutcome) : Subplot() {
- constructor(forPlayer: GlobalSide) : this(forPlayer, null, SubplotOutcome.UNDECIDED)
- constructor(forPlayer: GlobalSide, againstShip: Id<ShipInstance>) : this(forPlayer, againstShip, SubplotOutcome.UNDECIDED)
+ class PlausibleDeniability private constructor(override val forPlayer: GlobalShipController, private val againstShip: Id<ShipInstance>?, private val outcome: SubplotOutcome) : Subplot() {
+ constructor(forPlayer: GlobalShipController) : this(forPlayer, null, SubplotOutcome.UNDECIDED)
+ constructor(forPlayer: GlobalShipController, againstShip: Id<ShipInstance>) : this(forPlayer, againstShip, SubplotOutcome.UNDECIDED)
override val type: SubplotType
get() = SubplotType.PLAUSIBLE_DENIABILITY
}
@Serializable
- class RecoverInformant private constructor(override val forPlayer: GlobalSide, private val onBoardShip: Id<ShipInstance>?, private val outcome: SubplotOutcome, private val mostRecentChatMessages: Moment?) : Subplot() {
- constructor(forPlayer: GlobalSide) : this(forPlayer, null, SubplotOutcome.UNDECIDED, null)
- constructor(forPlayer: GlobalSide, onBoardShip: Id<ShipInstance>) : this(forPlayer, onBoardShip, SubplotOutcome.UNDECIDED, null)
+ class RecoverInformant private constructor(override val forPlayer: GlobalShipController, private val onBoardShip: Id<ShipInstance>?, private val outcome: SubplotOutcome, private val mostRecentChatMessages: Moment?) : Subplot() {
+ constructor(forPlayer: GlobalShipController) : this(forPlayer, null, SubplotOutcome.UNDECIDED, null)
+ constructor(forPlayer: GlobalShipController, onBoardShip: Id<ShipInstance>) : this(forPlayer, onBoardShip, SubplotOutcome.UNDECIDED, null)
override val type: SubplotType
get() = SubplotType.RECOVER_INFORMANT
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 }
}
}
-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),
RECOVER_INFORMANT(Subplot::RecoverInformant),
}
-fun generateSubplots(battleSize: BattleSize, forPlayer: GlobalSide): Set<Subplot> =
+fun generateSubplots(battleSize: BattleSize, forPlayer: GlobalShipController): Set<Subplot> =
(1..battleSize.numSubplotsPerPlayer).map {
SubplotType.values().random().factory(forPlayer)
}.toSet()
}
companion object {
- fun fromBattleWinner(winner: GlobalSide?, subplotForPlayer: GlobalSide) = when (winner) {
- subplotForPlayer -> WON
- subplotForPlayer.other -> LOST
+ fun fromBattleWinner(winner: GlobalSide?, subplotForPlayer: GlobalShipController) = when (winner) {
+ subplotForPlayer.side -> WON
+ subplotForPlayer.side.other -> LOST
else -> UNDECIDED
}
}
}
-fun victoryTitle(player: GlobalSide, winner: GlobalSide?, subplotOutcomes: Map<SubplotKey, SubplotOutcome>): String {
+fun victoryTitle(player: GlobalShipController, winner: GlobalSide?, subplotOutcomes: Map<SubplotKey, SubplotOutcome>): String {
val myOutcomes = subplotOutcomes.filterKeys { it.player == player }
return when (winner) {
- player -> {
+ player.side -> {
val isGlorious = myOutcomes.all { (_, outcome) -> outcome == SubplotOutcome.WON }
val isPyrrhic = myOutcomes.size >= 2 && myOutcomes.none { (_, outcome) -> outcome == SubplotOutcome.WON }
else
"Victory"
}
- player.other -> {
+ player.side.other -> {
val isHeroic = myOutcomes.all { (_, outcome) -> outcome == SubplotOutcome.WON }
val isHumiliating = myOutcomes.size >= 2 && myOutcomes.none { (_, outcome) -> outcome == SubplotOutcome.WON }
@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)
}
}
data class Joinable(
val admiral: InGameAdmiral,
val battleInfo: BattleInfo,
+ val enemyFaction: Faction?,
)
@Serializable
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
}
}
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,
@Serializable
data class ShipInstance(
val ship: Ship,
- val owner: GlobalSide,
+ val owner: GlobalShipController,
val position: ShipPosition,
val isIdentified: Boolean = false,
@Serializable
data class ShipWreck(
val ship: Ship,
- val owner: GlobalSide,
+ val owner: GlobalShipController,
val isEscape: Boolean = false,
val wreckedAt: Moment = Moment.now
) {
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
)
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
minDistance = weapon.minRange,
maxDistance = weapon.maxRange * weaponRangeMult,
firingArcs = weapon.firingArcs,
- canSelfSelect = owner in targetSet
+ canSelfSelect = owner.side in targetSet
)
)
}
val errorMessages: ReceiveChannel<String>
)
-lateinit var mySide: GlobalSide
+lateinit var mySide: GlobalShipController
private val pickContextDeferred = CompletableDeferred<PickContext>()
}.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)
private fun CoroutineScope.uiResponder(actions: SendChannel<PlayerAction>) = GameUIResponderImpl(this, actions)
-suspend fun gameMain(side: GlobalSide, token: String, state: GameState) {
+suspend fun gameMain(side: GlobalShipController, token: String, state: GameState) {
interruptExit = true
initializePicking()
private suspend fun usePlayerLogin(admirals: List<InGameAdmiral>) {
val playerLogin = Popup.getPlayerLogin(admirals)
- val playerLoginSide = playerLogin.login.globalSide
+ val playerLoginSide = playerLogin.login
- if (playerLoginSide == null) {
- val (battleInfo, enemyFaction) = playerLogin.login as LoginMode.Train
+ if (playerLoginSide is LoginMode.Train) {
+ val (battleInfo, enemyFaction) = playerLoginSide
enterTraining(playerLogin.admiral, battleInfo, enemyFaction)
}
sendObject(PlayerLogin.serializer(), playerLogin)
when (playerLoginSide) {
- GlobalSide.HOST -> {
+ is LoginMode.Host1v1 -> {
var loadingText = "Awaiting join request..."
do {
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...") {
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) {
val state = stateMutable.asStateFlow()
- private val hostErrorMessages = Channel<String>(Channel.UNLIMITED)
- private val guestErrorMessages = Channel<String>(Channel.UNLIMITED)
+ private val errorMessageChannels = mutableMapOf<GlobalShipController, Channel<String>>()
- private fun errorMessageChannel(player: GlobalSide) = when (player) {
- GlobalSide.HOST -> hostErrorMessages
- GlobalSide.GUEST -> guestErrorMessages
- }
+ private fun errorMessageChannel(player: GlobalShipController) =
+ errorMessageChannels[player] ?: Channel<String>(Channel.UNLIMITED).also {
+ errorMessageChannels[player] = it
+ }
- fun errorMessages(player: GlobalSide): ReceiveChannel<String> = when (player) {
- GlobalSide.HOST -> hostErrorMessages
- GlobalSide.GUEST -> guestErrorMessages
- }
+ fun errorMessages(player: GlobalShipController): ReceiveChannel<String> =
+ errorMessageChannels[player] ?: Channel<String>(Channel.UNLIMITED).also {
+ errorMessageChannels[player] = it
+ }
private val gameEndMutable = CompletableDeferred<GameEvent.GameEnd>()
val gameEnd: Deferred<GameEvent.GameEnd>
get() = gameEndMutable
- suspend fun onPacket(player: GlobalSide, packet: PlayerAction) {
+ suspend fun onPacket(player: GlobalShipController, packet: PlayerAction) {
stateMutex.withLock {
when (val result = state.value.after(player, packet)) {
is GameEvent.StateChange -> {
private suspend fun GameNetworkInteraction.execute(): GameEvent.GameEnd {
val gameSession = GameSession(gameState.value)
- val aiSide = mySide.other
+ val aiSide = GlobalShipController(mySide.side.other, GlobalShipController.Player1Disambiguation)
val aiActions = Channel<PlayerAction>()
val aiEvents = Channel<GameEvent>()
val aiSession = AISession(aiSide, aiActions, aiEvents)
initializePicking()
- mySide = GlobalSide.HOST
+ mySide = GlobalShipController(GlobalSide.HOST, GlobalShipController.Player1Disambiguation)
val gameState = MutableStateFlow(state)
val playerActions = Channel<PlayerAction>(Channel.UNLIMITED)
private set
private const val friendlyMarkerUrl = "friendly-marker"
+ private const val neutralMarkerUrl = "neutral-marker"
private const val hostileMarkerUrl = "hostile-marker"
lateinit var markerFactory: CustomRenderFactory<LocalSide>
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
side = DoubleSide
})
+ val neutralMarkerMaterial = MeshBasicMaterial(configure {
+ map = neutralMarkerTexture
+ alphaTest = 0.5
+ side = DoubleSide
+ })
+
val hostileMarkerMaterial = MeshBasicMaterial(configure {
map = hostileMarkerTexture
alphaTest = 0.5
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)
}
uniforms["outlineColor"]!!.value = Color(LocalSide.GREEN.htmlColor)
}
+ val blueOutlineMaterial = outlineMaterial.clone().unsafeCast<ShaderMaterial>().apply {
+ uniforms["outlineColor"]!!.value = Color(LocalSide.BLUE.htmlColor)
+ }
+
val redOutlineMaterial = outlineMaterial.clone().unsafeCast<ShaderMaterial>().apply {
uniforms["outlineColor"]!!.value = Color(LocalSide.RED.htmlColor)
}
val outlineGreen = mesh.clone(true).unsafeCast<Mesh>()
outlineGreen.material = greenOutlineMaterial
+ val outlineBlue = mesh.clone(true).unsafeCast<Mesh>()
+ outlineBlue.material = blueOutlineMaterial
+
val outlineRed = mesh.clone(true).unsafeCast<Mesh>()
outlineRed.material = redOutlineMaterial
},
when (side) {
LocalSide.GREEN -> outlineGreen
+ LocalSide.BLUE -> outlineBlue
LocalSide.RED -> outlineRed
}.clone(true).unsafeCast<Mesh>()
).group
+"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"
+"!"
}
+" 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) {
}
+"the "
strong {
- style = "color:${owner.other.htmlColor}"
+ style = "color:${attackerSide.htmlColor}"
+state.getShipInfo(entry.attacker.id).fullName
}
}
+" 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) {
}
+"the "
strong {
- style = "color:${owner.other.htmlColor}"
+ style = "color:${attackerSide.htmlColor}"
+state.getShipInfo(entry.attacker.id).fullName
}
}
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
+" has been boarded by the "
strong {
- style = "color:${owner.other.htmlColor}"
+ style = "color:${attackerOwner.htmlColor}"
+state.getShipInfo(entry.boarder).fullName
}
+" 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
}
}
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"
}
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"
}
}
}
}
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
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"
}
span {
val (borderColor, fillColor) = when (bomberSide) {
LocalSide.GREEN -> "#5F5" to "#262"
+ LocalSide.BLUE -> "#5F5" to "#262"
LocalSide.RED -> "#F55" to "#622"
}
private fun TagConsumer<*>.drawDeployPhase(state: GameState, abilities: List<PlayerAbilityType>) {
val deployableShips = state.start.playerStart(mySide).deployableFleet
- val remainingPoints = state.battleInfo.size.numPoints - state.ships.values.filter { it.owner == mySide }.sumOf { it.ship.pointCost }
+ val remainingPoints = state.getUsablePoints(mySide) - state.ships.values.filter { it.owner == mySide }.sumOf { it.ship.pointCost }
div {
style = "height:19%;font-size:0.9em"
style = "text-align:center"
img(alt = "Starship Fights", src = RenderResources.LOGO_URL) {
- style = "width:70%"
+ style = "width:50%"
}
}
}
}
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))
}
}
}
}
}
- class HostSelectScreen(private val hosts: Map<String, Joinable>) : Popup<String?>() {
+ class Host1v1SelectScreen(private val hosts: Map<String, Joinable>) : Popup<String?>() {
override fun TagConsumer<*>.render(context: CoroutineContext, callback: (String?) -> Unit) {
table {
style = "table-layout:fixed;width:100%"
}
}
+ class Host2v1SelectScreen(private val hosts: Map<String, Joinable>) : Popup<String?>() {
+ override fun TagConsumer<*>.render(context: CoroutineContext, callback: (String?) -> Unit) {
+ table {
+ style = "table-layout:fixed;width:100%"
+
+ tr {
+ th { +"Host Player" }
+ th { +"Host Admiral" }
+ th { +"Host Faction" }
+ th { +"Enemy Faction" }
+ th { +"Battle Size" }
+ th { +"Battle Background" }
+ th { +Entities.nbsp }
+ }
+ for ((id, joinable) in hosts) {
+ tr {
+ td {
+ style = "text-align:center"
+
+ +joinable.admiral.user.username
+ }
+ td {
+ style = "text-align:center"
+
+ +joinable.admiral.fullName
+ }
+ td {
+ style = "text-align:center"
+
+ img(alt = joinable.admiral.faction.shortName, src = joinable.admiral.faction.flagUrl) {
+ style = "width:4em;height:2.5em"
+ }
+ }
+ td {
+ style = "text-align:center"
+
+ joinable.enemyFaction?.let { enemy ->
+ img(alt = enemy.shortName, src = enemy.flagUrl) {
+ style = "width:4em;height:2.5em"
+ }
+ } ?: (+"Random")
+ }
+ td {
+ style = "text-align:center"
+
+ +joinable.battleInfo.size.displayName
+ +" ("
+ +joinable.battleInfo.size.numPoints.toString()
+ +")"
+ }
+ td {
+ style = "text-align:center"
+
+ +joinable.battleInfo.bg.displayName
+ }
+ td {
+ style = "text-align:center"
+
+ a(href = "#") {
+ +"Join"
+ onClickFunction = { e ->
+ e.preventDefault()
+ callback(id)
+ }
+ }
+ }
+ }
+ }
+ tr {
+ td { +Entities.nbsp }
+ td { +Entities.nbsp }
+ td { +Entities.nbsp }
+ td { +Entities.nbsp }
+ td { +Entities.nbsp }
+ td { +Entities.nbsp }
+ td {
+ style = "text-align:center"
+
+ a(href = "#") {
+ +"Cancel"
+ onClickFunction = { e ->
+ e.preventDefault()
+ callback(null)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
class JoinRejectedScreen(private val hostInfo: InGameAdmiral) : Popup<Unit>() {
override fun TagConsumer<*>.render(context: CoroutineContext, callback: (Unit) -> Unit) {
p {
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 {
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
}
}
}
val winner: GlobalSide?,
val winMessage: String,
+ val was2v2: Boolean = false,
) : DataDocument<BattleRecord> {
+ fun getSide(admiral: Id<Admiral>) = when (admiral) {
+ hostAdmiral -> GlobalSide.HOST
+ guestAdmiral -> GlobalSide.GUEST
+ else -> null
+ }
+
+ fun getUserSide(user: Id<User>) = when (user) {
+ hostUser -> GlobalSide.HOST
+ guestUser -> GlobalSide.GUEST
+ else -> null
+ }
+
+ fun wasWinner(side: GlobalSide) = if (winner == null)
+ null
+ else if (was2v2)
+ winner == GlobalSide.HOST
+ else
+ winner == side
+
+ fun didAdmiralWin(admiral: Id<Admiral>) = getSide(admiral)?.let { wasWinner(it) }
+
+ fun didUserWin(user: Id<User>) = getUserSide(user)?.let { wasWinner(it) }
+
companion object Table : DocumentTable<BattleRecord> by DocumentTable.create({
index(BattleRecord::hostUser)
index(BattleRecord::guestUser)
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
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))
)
}
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)
+ )
)
}
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
object GameManager {
private val games = ConcurrentCurator(mutableMapOf<String, GameEntry>())
- suspend fun initGame(hostInfo: InGameAdmiral, guestInfo: InGameAdmiral, battleInfo: BattleInfo): GameToken {
- val gameState = GameState(
- start = generateGameStart(hostInfo, guestInfo, battleInfo),
- hostInfo = hostInfo,
- guestInfo = guestInfo,
- battleInfo = battleInfo,
- subplots = generateSubplots(battleInfo.size, GlobalSide.HOST) + generateSubplots(battleInfo.size, GlobalSide.GUEST)
- )
+ suspend fun init1v1Game(hostInfo: InGameAdmiral, guestInfo: InGameAdmiral, battleInfo: BattleInfo): GameToken {
+ val gameState = generate1v1GameInitialState(hostInfo, guestInfo, battleInfo)
+
+ val session = GameSession1v1(gameState)
+ DocumentTable.launch {
+ session.gameStart.join()
+ val startedAt = Instant.now()
+
+ val end = session.gameEnd.await()
+ val endedAt = Instant.now()
+
+ on1v1GameEnd(session.state.value, end, startedAt, endedAt)
+ }
+
+ val hostId = createToken()
+ val joinId = createToken()
+ games.use {
+ it[hostId] = GameEntry(hostInfo.user.id.reinterpret(), GlobalShipController(GlobalSide.HOST, GlobalShipController.Player1Disambiguation), session)
+ it[joinId] = GameEntry(guestInfo.user.id.reinterpret(), GlobalShipController(GlobalSide.GUEST, GlobalShipController.Player1Disambiguation), session)
+ }
- val session = GameSession(gameState)
+ return GameToken(hostId, joinId)
+ }
+
+ suspend fun init2v1Game(hostInfo: InGameAdmiral, guestInfo: InGameAdmiral, enemyFaction: Faction, enemyFlavor: FactionFlavor, battleInfo: BattleInfo): GameToken {
+ val gameState = generate2v1GameInitialState(hostInfo, guestInfo, enemyFaction, enemyFlavor, battleInfo)
+
+ val session = GameSession2v1(gameState)
DocumentTable.launch {
session.gameStart.join()
val startedAt = Instant.now()
+ val aiJob = launch {
+ session.gameStart.join()
+
+ val aiSide = GlobalShipController(GlobalSide.GUEST, GlobalShipController.Player1Disambiguation)
+ val aiActions = Channel<PlayerAction>()
+ val aiEvents = Channel<GameEvent>()
+ val aiSession = AISession(aiSide, aiActions, aiEvents)
+
+ listOf(
+ launch {
+ session.state.collect { state ->
+ aiEvents.send(GameEvent.StateChange(state))
+ }
+ },
+ launch {
+ for (errorMessage in session.errorMessages(aiSide))
+ aiEvents.send(GameEvent.InvalidAction(errorMessage))
+ },
+ launch {
+ for (action in aiActions)
+ session.onPacket(aiSide, action)
+ },
+ launch {
+ aiPlayer(aiSession, gameState)
+ }
+ ).joinAll()
+ }
+
val end = session.gameEnd.await()
val endedAt = Instant.now()
- onGameEnd(session.state.value, end, startedAt, endedAt)
+ aiJob.cancel()
+
+ on2v1GameEnd(session.state.value, end, startedAt, endedAt)
}
val hostId = createToken()
val joinId = createToken()
games.use {
- it[hostId] = GameEntry(hostInfo.user.id.reinterpret(), GlobalSide.HOST, session)
- it[joinId] = GameEntry(guestInfo.user.id.reinterpret(), GlobalSide.GUEST, session)
+ it[hostId] = GameEntry(hostInfo.user.id.reinterpret(), GlobalShipController(GlobalSide.HOST, GlobalShipController.Player1Disambiguation), session)
+ it[joinId] = GameEntry(guestInfo.user.id.reinterpret(), GlobalShipController(GlobalSide.HOST, GlobalShipController.Player2Disambiguation), session)
}
return GameToken(hostId, joinId)
}
}
-class GameEntry(val userId: Id<User>, val side: GlobalSide, val session: GameSession)
+class GameEntry(val userId: Id<User>, val side: GlobalShipController, val session: GameSession)
-class GameSession(gameState: GameState) {
- private val hostEnter = Job()
- private val guestEnter = Job()
-
- suspend fun enter(player: GlobalSide) = when (player) {
- GlobalSide.HOST -> {
- hostEnter.complete()
- withTimeoutOrNull(30_000L) {
- guestEnter.join()
- true
- } ?: false
- }
- GlobalSide.GUEST -> {
- guestEnter.complete()
- withTimeoutOrNull(30_000L) {
- hostEnter.join()
- true
- } ?: false
- }
- }.also {
- if (it)
- gameStartMutable.complete()
- else
- onPacket(player.other, PlayerAction.TimeOut)
- }
-
- private val gameStartMutable = Job()
+sealed class GameSession(gameState: GameState, private val stateInterceptor: GameStateInterceptor = NoopInterceptor) {
+ protected val gameStartMutable = Job()
val gameStart: Job
get() = gameStartMutable
+ abstract suspend fun enter(player: GlobalShipController): Boolean
+
private val stateMutable = MutableStateFlow(gameState)
private val stateMutex = Mutex()
val state = stateMutable.asStateFlow()
- private val hostErrorMessages = Channel<String>(Channel.UNLIMITED)
- private val guestErrorMessages = Channel<String>(Channel.UNLIMITED)
+ private val errorMessages = mutableMapOf<GlobalShipController, Channel<String>>()
- private fun errorMessageChannel(player: GlobalSide) = when (player) {
- GlobalSide.HOST -> hostErrorMessages
- GlobalSide.GUEST -> guestErrorMessages
+ private fun errorMessageChannel(player: GlobalShipController) = errorMessages[player] ?: Channel<String>(Channel.UNLIMITED).also {
+ errorMessages[player] = it
}
- fun errorMessages(player: GlobalSide): ReceiveChannel<String> = when (player) {
- GlobalSide.HOST -> hostErrorMessages
- GlobalSide.GUEST -> guestErrorMessages
- }
+ fun errorMessages(player: GlobalShipController): ReceiveChannel<String> = errorMessageChannel(player)
private val gameEndMutable = CompletableDeferred<GameEvent.GameEnd>()
val gameEnd: Deferred<GameEvent.GameEnd>
get() = gameEndMutable
- suspend fun onPacket(player: GlobalSide, packet: PlayerAction) {
+ suspend fun onPacket(player: GlobalShipController, packet: PlayerAction) {
+ if (gameEnd.isCompleted) return
+
stateMutex.withLock {
when (val result = state.value.after(player, packet)) {
is GameEvent.StateChange -> {
- stateMutable.value = result.newState
+ stateMutable.value = stateInterceptor.onStateChange(stateMutable.value, result.newState)
result.newState.checkVictory()?.let { gameEndMutable.complete(it) }
}
is GameEvent.InvalidAction -> {
errorMessageChannel(player).send(result.message)
}
is GameEvent.GameEnd -> {
- if (gameStartMutable.isActive)
- gameStartMutable.cancel()
gameEndMutable.complete(result)
+ gameStartMutable.complete()
}
}
}
}
- suspend fun onClose(player: GlobalSide) {
- if (gameEnd.isCompleted) return
-
+ suspend fun onClose(player: GlobalShipController) {
onPacket(player, PlayerAction.Disconnect)
}
}
+class GameSession1v1(gameState: GameState, stateInterceptor: GameStateInterceptor = NoopInterceptor) : GameSession(gameState, stateInterceptor) {
+ private val hostEnter = Job()
+ private val guestEnter = Job()
+
+ override suspend fun enter(player: GlobalShipController) = when (player.side) {
+ GlobalSide.HOST -> {
+ hostEnter.complete()
+ withTimeoutOrNull(30_000L) {
+ guestEnter.join()
+ true
+ } ?: false
+ }
+ GlobalSide.GUEST -> {
+ guestEnter.complete()
+ withTimeoutOrNull(30_000L) {
+ hostEnter.join()
+ true
+ } ?: false
+ }
+ }.also {
+ if (it)
+ gameStartMutable.complete()
+ else
+ onPacket(GlobalShipController(player.side.other, GlobalShipController.Player1Disambiguation), PlayerAction.TimeOut)
+ }
+}
+
+class GameSession2v1(gameState: GameState, stateInterceptor: GameStateInterceptor = NoopInterceptor) : GameSession(gameState, stateInterceptor) {
+ private val player1Enter = Job()
+ private val player2Enter = Job()
+
+ override suspend fun enter(player: GlobalShipController) = when (player.disambiguation) {
+ GlobalShipController.Player1Disambiguation -> {
+ player1Enter.complete()
+ withTimeoutOrNull(30_000L) {
+ player2Enter.join()
+ true
+ } ?: false
+ }
+ GlobalShipController.Player2Disambiguation -> {
+ player2Enter.complete()
+ withTimeoutOrNull(30_000L) {
+ player1Enter.join()
+ true
+ } ?: false
+ }
+ else -> null
+ }?.also {
+ if (it)
+ gameStartMutable.complete()
+ else if (player.disambiguation == GlobalShipController.Player1Disambiguation)
+ onPacket(GlobalShipController(player.side, GlobalShipController.Player2Disambiguation), PlayerAction.TimeOut)
+ else
+ onPacket(GlobalShipController(player.side, GlobalShipController.Player1Disambiguation), PlayerAction.TimeOut)
+ } ?: false
+}
+
suspend fun DefaultWebSocketServerSession.gameEndpoint(user: User, token: String) {
val gameEntry = GameManager.joinGame(user.id, token, true) ?: closeAndReturn("That battle is not available") { return }
val playerSide = gameEntry.side
sendObject(GameBeginning.serializer(), GameBeginning(opponentEntered))
if (!opponentEntered) return
+ val closeHandler = closeReason.invokeOnCompletion {
+ DocumentTable.launch {
+ gameSession.onClose(playerSide)
+ }
+ }
+
val sendEventsJob = launch {
listOf(
// Game state changes
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"))
sendEventsJob.cancelAndJoin()
receiveActionsJob.cancelAndJoin()
+
+ closeHandler.dispose()
}
private val BattleSize.shipPointsPerAcumen: Int
private val BattleSize.acumenPerSubplotWon: Int
get() = numPoints / 100
-private suspend fun onGameEnd(gameState: GameState, gameEnd: GameEvent.GameEnd, startedAt: Instant, endedAt: Instant) {
+private suspend fun on1v1GameEnd(gameState: GameState, gameEnd: GameEvent.GameEnd, startedAt: Instant, endedAt: Instant) {
val damagedShipReadyAt = endedAt.plus(6, ChronoUnit.HOURS)
val escapedShipReadyAt = endedAt.plus(4, ChronoUnit.HOURS)
val shipWrecks = gameState.destroyedShips
val ships = gameState.ships
- val hostAdmiralId = gameState.hostInfo.id.reinterpret<Admiral>()
- val guestAdmiralId = gameState.guestInfo.id.reinterpret<Admiral>()
+ val hostInfo = gameState.hostInfo.values.single()
+ val guestInfo = gameState.guestInfo.values.single()
+
+ val hostAdmiralId = hostInfo.id.reinterpret<Admiral>()
+ val guestAdmiralId = guestInfo.id.reinterpret<Admiral>()
val battleRecord = BattleRecord(
battleInfo = gameState.battleInfo,
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
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
},
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
}
}
}
+
+private suspend fun on2v1GameEnd(gameState: GameState, gameEnd: GameEvent.GameEnd, startedAt: Instant, endedAt: Instant) {
+ val damagedShipReadyAt = endedAt.plus(6, ChronoUnit.HOURS)
+ val escapedShipReadyAt = endedAt.plus(4, ChronoUnit.HOURS)
+
+ val shipWrecks = gameState.destroyedShips
+ val ships = gameState.ships
+
+ val hostInfo = gameState.hostInfo.getValue(GlobalShipController.Player1Disambiguation)
+ val guestInfo = gameState.hostInfo.getValue(GlobalShipController.Player2Disambiguation)
+
+ val hostAdmiralId = hostInfo.id.reinterpret<Admiral>()
+ val guestAdmiralId = guestInfo.id.reinterpret<Admiral>()
+
+ val battleRecord = BattleRecord(
+ battleInfo = gameState.battleInfo,
+
+ whenStarted = startedAt,
+ whenEnded = endedAt,
+
+ hostUser = hostInfo.user.id.reinterpret(),
+ guestUser = guestInfo.user.id.reinterpret(),
+
+ hostAdmiral = hostAdmiralId,
+ guestAdmiral = guestAdmiralId,
+
+ hostEndingMessage = victoryTitle(GlobalShipController(GlobalSide.HOST, GlobalShipController.Player1Disambiguation), gameEnd.winner, gameEnd.subplotOutcomes),
+ guestEndingMessage = victoryTitle(GlobalShipController(GlobalSide.HOST, GlobalShipController.Player2Disambiguation), gameEnd.winner, gameEnd.subplotOutcomes),
+
+ winner = gameEnd.winner,
+ winMessage = gameEnd.message
+ )
+
+ val destructions = shipWrecks.filterValues { !it.isEscape }
+ val destroyedShips = destructions.keys.map { it.reinterpret<ShipInDrydock>() }.toSet()
+ val rememberedShips = destructions.values.map { wreck ->
+ ShipMemorial(
+ id = Id("RIP_${wreck.id.id}"),
+ name = wreck.ship.name,
+ shipType = wreck.ship.shipType,
+ destroyedAt = wreck.wreckedAt.instant,
+ owningAdmiral = when (wreck.owner.side) {
+ GlobalSide.HOST -> hostAdmiralId
+ GlobalSide.GUEST -> guestAdmiralId
+ },
+ destroyedIn = battleRecord.id
+ )
+ }
+
+ val escapedShips = shipWrecks.filterValues { it.isEscape }.keys.map { it.reinterpret<ShipInDrydock>() }.toSet()
+ val damagedShips = ships.filterValues { it.hullAmount < it.durability.maxHullPoints || it.troopsAmount < it.durability.troopsDefense }.keys.map { it.reinterpret<ShipInDrydock>() }.toSet()
+
+ val battleSize = gameState.battleInfo.size
+
+ val playersAcumenGainFromShips = shipWrecks.values.filter { it.owner.side == GlobalSide.GUEST && !it.isEscape }.sumOf { it.ship.pointCost / battleSize.shipPointsPerAcumen }
+ val playersAcumenGainFromSubplots = gameEnd.subplotOutcomes.filterKeys { it.player.side == GlobalSide.HOST }.count { (_, outcome) -> outcome == SubplotOutcome.WON } * battleSize.acumenPerSubplotWon
+ val playersAcumenGain = playersAcumenGainFromShips + playersAcumenGainFromSubplots
+ val playersPayment = playersAcumenGain * 2
+
+ coroutineScope {
+ launch {
+ ShipMemorial.put(rememberedShips)
+ }
+ launch {
+ ShipInDrydock.remove(ShipInDrydock::id `in` destroyedShips)
+ }
+ launch {
+ ShipInDrydock.update(ShipInDrydock::id `in` damagedShips, setValue(ShipInDrydock::readyAt, damagedShipReadyAt))
+ }
+ launch {
+ ShipInDrydock.update(ShipInDrydock::id `in` escapedShips, setValue(ShipInDrydock::readyAt, escapedShipReadyAt))
+ }
+
+ launch {
+ Admiral.set(
+ hostAdmiralId, combine(
+ inc(Admiral::acumen, playersAcumenGain),
+ inc(Admiral::money, playersPayment),
+ )
+ )
+ }
+ launch {
+ Admiral.set(
+ guestAdmiralId, combine(
+ inc(Admiral::acumen, playersAcumenGain),
+ inc(Admiral::money, playersPayment),
+ )
+ )
+ }
+
+ launch {
+ BattleRecord.put(battleRecord)
+ }
+ }
+}
import net.starshipfights.data.admiralty.getInGameAdmiral
import net.starshipfights.data.auth.User
-private val openSessions = ConcurrentCurator(mutableListOf<HostInvitation>())
+private val open1v1Sessions = ConcurrentCurator(mutableListOf<Host1v1Invitation>())
-class HostInvitation(admiral: InGameAdmiral, battleInfo: BattleInfo) {
- val joinable = Joinable(admiral, battleInfo)
- val joinInvitations = Channel<JoinInvitation>()
+class Host1v1Invitation(admiral: InGameAdmiral, battleInfo: BattleInfo) {
+ val joinable = Joinable(admiral, battleInfo, null)
+ val joinInvitations = Channel<Join1v1Invitation>()
val gameIdHandler = CompletableDeferred<String>()
}
-class JoinInvitation(val joinRequest: JoinRequest, val responseHandler: CompletableDeferred<JoinResponse>) {
+class Join1v1Invitation(val joinRequest: JoinRequest, val responseHandler: CompletableDeferred<JoinResponse>) {
+ val gameIdHandler = CompletableDeferred<String>()
+}
+
+private val open2v1Sessions = ConcurrentCurator(mutableListOf<Host2v1Invitation>())
+
+class Host2v1Invitation(admiral: InGameAdmiral, battleInfo: BattleInfo, opponent: TrainingOpponent) {
+ val joinable = Joinable(admiral, battleInfo, opponent.faction)
+ val joinInvitations = Channel<Join2v1Invitation>()
+
+ val gameIdHandler = CompletableDeferred<String>()
+}
+
+class Join2v1Invitation(val joinRequest: JoinRequest, val responseHandler: CompletableDeferred<JoinResponse>) {
val gameIdHandler = CompletableDeferred<String>()
}
is LoginMode.Train -> {
closeAndReturn("Invalid input: LoginMode.Train should redirect you directly to training endpoint") { return false }
}
- is LoginMode.Host -> {
+ is LoginMode.Host1v1 -> {
+ val battleInfo = loginMode.battleInfo
+ val hostInvitation = Host1v1Invitation(inGameAdmiral, battleInfo)
+
+ open1v1Sessions.use { it.add(hostInvitation) }
+
+ closeReason.invokeOnCompletion {
+ hostInvitation.joinInvitations.close()
+
+ @OptIn(DelicateCoroutinesApi::class)
+ GlobalScope.launch {
+ open1v1Sessions.use {
+ it.remove(hostInvitation)
+ }
+ }
+ }
+
+ for (joinInvitation in hostInvitation.joinInvitations) {
+ sendObject(JoinRequest.serializer(), joinInvitation.joinRequest)
+ val joinResponse = receiveObject(JoinResponse.serializer()) {
+ closeAndReturn {
+ joinInvitation.responseHandler.complete(JoinResponse(false))
+ return false
+ }
+ }
+
+ if (joinInvitation.responseHandler.isCancelled) {
+ if (joinResponse.accepted)
+ sendObject(JoinResponseResponse.serializer(), JoinResponseResponse(false))
+ continue
+ }
+
+ joinInvitation.responseHandler.complete(joinResponse)
+
+ if (joinResponse.accepted) {
+ sendObject(JoinResponseResponse.serializer(), JoinResponseResponse(true))
+
+ val (hostId, joinId) = GameManager.init1v1Game(inGameAdmiral, joinInvitation.joinRequest.joiner, loginMode.battleInfo)
+ hostInvitation.gameIdHandler.complete(hostId)
+ joinInvitation.gameIdHandler.complete(joinId)
+
+ break
+ }
+ }
+
+ val gameId = hostInvitation.gameIdHandler.await()
+ sendObject(GameReady.serializer(), GameReady(gameId))
+ }
+ LoginMode.Join1v1 -> {
+ val joinRequest = JoinRequest(inGameAdmiral)
+
+ while (true) {
+ val openGames = open1v1Sessions.use {
+ it.toList()
+ }.filter { sess ->
+ sess.joinable.battleInfo.size <= inGameAdmiral.rank.maxBattleSize
+ }.mapIndexed { i, host -> "$i" to host }.toMap()
+
+ val joinListing = JoinListing(openGames.mapValues { (_, invitation) -> invitation.joinable })
+ sendObject(JoinListing.serializer(), joinListing)
+
+ val joinSelection = receiveObject(JoinSelection.serializer()) { closeAndReturn { return false } }
+ val hostInvitation = openGames.getValue(joinSelection.selectedId)
+
+ val joinResponseHandler = CompletableDeferred<JoinResponse>()
+ val joinInvitation = Join1v1Invitation(joinRequest, joinResponseHandler)
+ closeReason.invokeOnCompletion {
+ joinResponseHandler.cancel()
+ }
+
+ try {
+ hostInvitation.joinInvitations.send(joinInvitation)
+ } catch (ex: ClosedSendChannelException) {
+ sendObject(JoinResponse.serializer(), JoinResponse(false))
+ continue
+ }
+
+ val joinResponse = joinResponseHandler.await()
+ sendObject(JoinResponse.serializer(), joinResponse)
+
+ if (joinResponse.accepted) {
+ val gameId = joinInvitation.gameIdHandler.await()
+ sendObject(GameReady.serializer(), GameReady(gameId))
+ break
+ }
+ }
+ }
+ is LoginMode.Host2v1 -> {
val battleInfo = loginMode.battleInfo
- val hostInvitation = HostInvitation(inGameAdmiral, battleInfo)
+ val hostInvitation = Host2v1Invitation(inGameAdmiral, battleInfo, loginMode.enemyFaction)
- openSessions.use { it.add(hostInvitation) }
+ open2v1Sessions.use { it.add(hostInvitation) }
closeReason.invokeOnCompletion {
hostInvitation.joinInvitations.close()
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch {
- openSessions.use {
+ open2v1Sessions.use {
it.remove(hostInvitation)
}
}
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)
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
val hostInvitation = openGames.getValue(joinSelection.selectedId)
val joinResponseHandler = CompletableDeferred<JoinResponse>()
- val joinInvitation = JoinInvitation(joinRequest, joinResponseHandler)
+ val joinInvitation = Join2v1Invitation(joinRequest, joinResponseHandler)
closeReason.invokeOnCompletion {
joinResponseHandler.cancel()
}
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
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 {
th { +"When" }
th { +"Size" }
th { +"Role" }
- th { +"Against" }
+ th { +"With" }
th { +"Result" }
}
for (record in records.sortedBy { it.whenEnded }) {