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