}
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) {
--- /dev/null
+package starshipfights.game.ai
+
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.channels.ReceiveChannel
+import kotlinx.coroutines.channels.SendChannel
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withTimeoutOrNull
+import starshipfights.data.Id
+import starshipfights.game.*
+import kotlin.math.pow
+import kotlin.random.Random
+
+data class AIPlayer(
+ val gameState: StateFlow<GameState>,
+ val doActions: SendChannel<PlayerAction>,
+ val getErrors: ReceiveChannel<String>,
+)
+
+suspend fun AIPlayer.behave(instincts: Instincts, mySide: GlobalSide) {
+ try {
+ coroutineScope {
+ val brain = Brain()
+
+ val phasePipe = Channel<Pair<GamePhase, Boolean>>(Channel.CONFLATED)
+
+ launch {
+ var prevSentAt = Moment.now
+
+ gameState.collect { state ->
+ logInfo(jsonSerializer.encodeToString(Brain.serializer(), brain))
+
+ phasePipe.send(state.phase to (state.doneWithPhase != mySide && (!state.phase.usesInitiative || state.currentInitiative != mySide.other)))
+
+ for (msg in state.chatBox.takeLastWhile { msg -> msg.sentAt > prevSentAt }) {
+ if (msg.sentAt > prevSentAt)
+ prevSentAt = msg.sentAt
+
+ when (msg) {
+ is ChatEntry.PlayerMessage -> {
+ // ignore
+ }
+ is ChatEntry.ShipIdentified -> {
+ val identifiedShip = state.ships[msg.ship] ?: continue
+ if (identifiedShip.owner != mySide)
+ brain[shipAttackPriority forShip identifiedShip.id] += identifiedShip.ship.shipType.weightClass.tier.toDouble().pow(instincts[combatTargetShipWeight])
+ }
+ is ChatEntry.ShipEscaped -> {
+ // handle escaping ship
+ }
+ is ChatEntry.ShipAttacked -> {
+ val targetedShip = state.ships[msg.ship] ?: continue
+ if (targetedShip.owner != mySide)
+ brain[shipAttackPriority forShip targetedShip.id] -= Random.nextDouble(msg.damageInflicted - 0.5, msg.damageInflicted + 0.5) * instincts[combatForgiveTarget]
+ else if (msg.attacker is ShipAttacker.EnemyShip)
+ brain[shipAttackPriority forShip msg.attacker.id] += Random.nextDouble(msg.damageInflicted - 0.5, msg.damageInflicted + 0.5) * instincts[combatAvengeAttacks]
+ }
+ is ChatEntry.ShipAttackFailed -> {
+ val targetedShip = state.ships[msg.ship] ?: continue
+ if (targetedShip.owner != mySide)
+ brain[shipAttackPriority forShip targetedShip.id] += instincts[combatFrustratedByFailedAttacks]
+ }
+ is ChatEntry.ShipDestroyed -> {
+ val targetedShip = state.ships[msg.ship] ?: continue
+ if (targetedShip.owner == mySide && msg.destroyedBy is ShipAttacker.EnemyShip)
+ brain[shipAttackPriority forShip msg.destroyedBy.id] += instincts[combatAvengeShipwrecks] * targetedShip.ship.shipType.weightClass.tier.toDouble().pow(instincts[combatAvengeShipWeight])
+ }
+ }
+ }
+ }
+ }
+
+ launch {
+ for ((phase, canAct) in phasePipe) {
+ if (!canAct) continue
+
+ val state = gameState.value
+
+ when (phase) {
+ GamePhase.Deploy -> {
+ for ((shipId, position) in deploy(state, mySide, instincts)) {
+ val abilityType = PlayerAbilityType.DeployShip(shipId.reinterpret())
+ val abilityData = PlayerAbilityData.DeployShip(position)
+
+ doActions.send(PlayerAction.UseAbility(abilityType, abilityData))
+
+ withTimeoutOrNull(50L) { getErrors.receive() }?.let { errorMsg ->
+ logWarning("Error when deploying ship ID $shipId - $errorMsg")
+ }
+ }
+
+ doActions.send(PlayerAction.UseAbility(PlayerAbilityType.DonePhase(phase), PlayerAbilityData.DonePhase))
+ }
+ is GamePhase.Power -> {
+ doActions.send(PlayerAction.UseAbility(PlayerAbilityType.DonePhase(phase), PlayerAbilityData.DonePhase))
+ }
+ is GamePhase.Move -> {
+ val movableShips = state.ships.values.filter { ship ->
+ ship.owner == mySide && !ship.isDoneCurrentPhase
+ }
+
+ val smallestShipTier = movableShips.minOfOrNull { ship -> ship.ship.shipType.weightClass.tier }
+
+ if (smallestShipTier == null)
+ doActions.send(PlayerAction.UseAbility(PlayerAbilityType.DonePhase(phase), PlayerAbilityData.DonePhase))
+ else {
+ val movableSmallestShips = movableShips.filter { ship ->
+ ship.ship.shipType.weightClass.tier == smallestShipTier
+ }
+
+ val moveThisShip = movableSmallestShips.associateWith { it.calculateSuffering() + 1.0 }.weightedRandom()
+ doActions.send(navigate(state, moveThisShip, instincts, brain))
+
+ withTimeoutOrNull(50L) { getErrors.receive() }?.let { error ->
+ logWarning("Error when moving ship ID ${moveThisShip.id} - $error")
+ doActions.send(
+ PlayerAction.UseAbility(
+ PlayerAbilityType.MoveShip(moveThisShip.id),
+ PlayerAbilityData.MoveShip(moveThisShip.position)
+ )
+ )
+ }
+ }
+ }
+ is GamePhase.Attack -> {
+ val attackWith = state.ships.values.flatMap { ship ->
+ if (ship.owner == mySide)
+ ship.armaments.weaponInstances.keys.filter {
+ ship.canUseWeapon(it)
+ }.flatMap { weaponId ->
+ weaponId.validTargets(state, ship).map { target ->
+ Triple(ship, weaponId, target)
+ }
+ }
+ else emptyList()
+ }.associateWith { (ship, weaponId, target) ->
+ weaponId.expectedAdvantageFromWeaponUsage(state, ship, target) * brain[shipAttackPriority forShip target.id].signedPow(instincts[combatPrioritization])
+ }.weightedRandomOrNull()
+
+ if (attackWith == null)
+ doActions.send(PlayerAction.UseAbility(PlayerAbilityType.DonePhase(phase), PlayerAbilityData.DonePhase))
+ else {
+ val (ship, weaponId, target) = attackWith
+ val targetPickResponse = if (ship.armaments.weaponInstances[weaponId]?.weapon is AreaWeapon)
+ PickResponse.Location(target.position.location)
+ else
+ PickResponse.Ship(target.id)
+
+ doActions.send(PlayerAction.UseAbility(PlayerAbilityType.UseWeapon(ship.id, weaponId), PlayerAbilityData.UseWeapon(targetPickResponse)))
+ }
+ }
+ is GamePhase.Repair -> {
+ val repairAbility = state.getPossibleAbilities(mySide).filter {
+ it !is PlayerAbilityType.DonePhase
+ }.randomOrNull()
+
+ if (repairAbility == null)
+ doActions.send(PlayerAction.UseAbility(PlayerAbilityType.DonePhase(phase), PlayerAbilityData.DonePhase))
+ else {
+ when (repairAbility) {
+ is PlayerAbilityType.RepairShipModule -> PlayerAbilityData.RepairShipModule
+ is PlayerAbilityType.ExtinguishFire -> PlayerAbilityData.ExtinguishFire
+ is PlayerAbilityType.Recoalesce -> PlayerAbilityData.Recoalesce
+ else -> null
+ }?.let { repairData ->
+ doActions.send(PlayerAction.UseAbility(repairAbility, repairData))
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ } catch (ex: Exception) {
+ logError(ex)
+ doActions.send(PlayerAction.SendChatMessage(ex.stackTraceToString()))
+ delay(2000L)
+ doActions.send(PlayerAction.Disconnect)
+ }
+}
+
+fun deploy(gameState: GameState, mySide: GlobalSide, instincts: Instincts): Map<Id<ShipInstance>, Position> {
+ val size = gameState.battleInfo.size
+ val totalPoints = size.numPoints
+ val maxWC = size.maxWeightClass
+
+ val myStart = gameState.start.playerStart(mySide)
+
+ val deployable = myStart.deployableFleet.values.filter { it.shipType.weightClass.tier <= maxWC.tier }.toMutableSet()
+ val deployed = mutableSetOf<Ship>()
+
+ while (true) {
+ val deployShip = deployable.filter { ship ->
+ deployed.sumOf { it.pointCost } + ship.pointCost <= totalPoints
+ }.associateWith { ship ->
+ instincts[ship.shipType.weightClass.focus]
+ }.weightedRandomOrNull() ?: break
+
+ deployable -= deployShip
+ deployed += deployShip
+ }
+
+ return placeShips(deployed, myStart.deployZone)
+}
+
+fun navigate(gameState: GameState, ship: ShipInstance, instincts: Instincts, brain: Brain): PlayerAction.UseAbility {
+ val noEnemyShipsSeen = gameState.ships.values.none { it.owner != ship.owner && it.isIdentified }
+
+ if (noEnemyShipsSeen || !ship.isIdentified) return engage(gameState, ship)
+
+ val currPos = ship.position.location
+ val currAngle = ship.position.facing
+
+ val movement = ship.movement
+
+ if (movement is FelinaeShipMovement && Random.nextDouble() < 1.0 / (ship.usedInertialessDriveShots * 3 + 5)) {
+ val maxJump = movement.inertialessDriveRange * 0.99
+
+ val positions = listOf(
+ normalVector(currAngle),
+ normalVector(currAngle).let { (x, y) -> Vec2(-y, x) },
+ -normalVector(currAngle),
+ normalVector(currAngle).let { (x, y) -> Vec2(y, -x) },
+ ).flatMap {
+ listOf(
+ ShipPosition(currPos + (Distance(it) * maxJump), it.angle),
+ ShipPosition(currPos + (Distance(it) * (maxJump * 2 / 3)), it.angle),
+ ShipPosition(currPos + (Distance(it) * (maxJump / 3)), it.angle),
+ )
+ }.filter { shipPos ->
+ (gameState.ships - ship.id).none { (_, otherShip) ->
+ (otherShip.position.location - shipPos.location).length <= SHIP_BASE_SIZE
+ }
+ }
+
+ val position = positions.associateWith {
+ it.score(gameState, ship, instincts, brain)
+ }.weightedRandomOrNull() ?: return pursue(gameState, ship)
+
+ return PlayerAction.UseAbility(
+ PlayerAbilityType.UseInertialessDrive(ship.id),
+ PlayerAbilityData.UseInertialessDrive(position.location)
+ )
+ }
+
+ val maxTurn = movement.turnAngle * 0.99
+ val maxMove = movement.moveSpeed * 0.99
+
+ val positions = (listOf(
+ normalDistance(currAngle) rotatedBy -maxTurn,
+ normalDistance(currAngle) rotatedBy (-maxTurn / 2),
+ normalDistance(currAngle),
+ normalDistance(currAngle) rotatedBy (maxTurn / 2),
+ normalDistance(currAngle) rotatedBy maxTurn,
+ ).flatMap {
+ listOf(
+ ShipPosition(currPos + (it * maxMove), it.angle),
+ ShipPosition(currPos + (it * (maxMove / 2)), it.angle),
+ )
+ } + listOf(ship.position)).filter { shipPos ->
+ (gameState.ships - ship.id).none { (_, otherShip) ->
+ (otherShip.position.location - shipPos.location).length <= SHIP_BASE_SIZE
+ }
+ }
+
+ val position = positions.associateWith {
+ it.score(gameState, ship, instincts, brain)
+ }.weightedRandomOrNull() ?: return pursue(gameState, ship)
+
+ return PlayerAction.UseAbility(
+ PlayerAbilityType.MoveShip(ship.id),
+ PlayerAbilityData.MoveShip(position)
+ )
+}
+
+fun engage(gameState: GameState, ship: ShipInstance): PlayerAction.UseAbility {
+ val mySideMeanPosition = gameState.ships.values
+ .filter { it.owner == ship.owner }
+ .map { it.position.location.vector }
+ .mean()
+
+ val enemySideMeanPosition = gameState.ships.values
+ .filter { it.owner != ship.owner }
+ .map { it.position.location.vector }
+ .mean()
+
+ val angleTo = normalVector(ship.position.facing) angleTo (enemySideMeanPosition - mySideMeanPosition)
+ val maxTurn = ship.movement.turnAngle * 0.99
+ val turnNormal = normalDistance(ship.position.facing) rotatedBy angleTo.coerceIn(-maxTurn..maxTurn)
+
+ val move = (ship.movement.moveSpeed * 0.99) * turnNormal
+ val newLoc = ship.position.location + move
+
+ val position = ShipPosition(newLoc, move.angle)
+
+ return PlayerAction.UseAbility(
+ PlayerAbilityType.MoveShip(ship.id),
+ PlayerAbilityData.MoveShip(position)
+ )
+}
+
+fun pursue(gameState: GameState, ship: ShipInstance): PlayerAction.UseAbility {
+ val myLocation = ship.position.location
+ val targetLocation = gameState.ships.values.filter { it.owner != ship.owner }.map { it.position.location }.minByOrNull { loc ->
+ (loc - myLocation).length
+ } ?: return PlayerAction.UseAbility(
+ PlayerAbilityType.MoveShip(ship.id),
+ PlayerAbilityData.MoveShip(ship.position)
+ )
+
+ val angleTo = normalDistance(ship.position.facing) angleTo (targetLocation - myLocation)
+ val maxTurn = ship.movement.turnAngle * 0.99
+ val turnNormal = normalDistance(ship.position.facing) rotatedBy angleTo.coerceIn(-maxTurn..maxTurn)
+
+ val move = (ship.movement.moveSpeed * if (turnNormal angleBetween (targetLocation - myLocation) < EPSILON) 0.999 else 0.501) * turnNormal
+ val newLoc = ship.position.location + move
+
+ val position = ShipPosition(newLoc, move.angle)
+
+ return PlayerAction.UseAbility(
+ PlayerAbilityType.MoveShip(ship.id),
+ PlayerAbilityData.MoveShip(position)
+ )
+}
--- /dev/null
+package starshipfights.game.ai
+
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.JsonElement
+import starshipfights.data.Id
+import starshipfights.game.ShipInstance
+import starshipfights.game.jsonSerializer
+import kotlin.jvm.JvmInline
+import kotlin.properties.ReadOnlyProperty
+
+@JvmInline
+@Serializable
+value class Instincts private constructor(private val numbers: MutableMap<String, Double>) {
+ constructor() : this(mutableMapOf())
+
+ operator fun get(instinct: Instinct) = numbers.getOrPut(instinct.key, instinct.default)
+}
+
+data class Instinct(val key: String, val default: () -> Double)
+
+fun instinct(default: () -> Double) = ReadOnlyProperty<Any?, Instinct> { _, property ->
+ Instinct(property.name, default)
+}
+
+@JvmInline
+@Serializable
+value class Brain private constructor(private val data: MutableMap<String, JsonElement>) {
+ constructor() : this(mutableMapOf())
+
+ operator fun <T> get(neuron: Neuron<T>) = jsonSerializer.decodeFromJsonElement(
+ neuron.codec,
+ data.getOrPut(neuron.key) {
+ jsonSerializer.encodeToJsonElement(
+ neuron.codec,
+ neuron.default()
+ )
+ }
+ )
+
+ operator fun <T> set(neuron: Neuron<T>, value: T) = data.set(
+ neuron.key,
+ jsonSerializer.encodeToJsonElement(
+ neuron.codec,
+ value
+ )
+ )
+}
+
+data class Neuron<T>(val key: String, val codec: KSerializer<T>, val default: () -> T)
+
+fun <T> neuron(codec: KSerializer<T>, default: () -> T) = ReadOnlyProperty<Any?, Neuron<T>> { _, property ->
+ Neuron(property.name, codec, default)
+}
+
+infix fun <T> Neuron<T>.forShip(ship: Id<ShipInstance>) = copy(key = "$key[$ship]")
--- /dev/null
+package starshipfights.game.ai
+
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancelAndJoin
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.channels.ReceiveChannel
+import kotlinx.coroutines.channels.SendChannel
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.launch
+import starshipfights.game.GameEvent
+import starshipfights.game.GameState
+import starshipfights.game.GlobalSide
+import starshipfights.game.PlayerAction
+
+data class AISession(
+ val mySide: GlobalSide,
+ val actions: SendChannel<PlayerAction>,
+ val events: ReceiveChannel<GameEvent>,
+ val instincts: Instincts = Instincts(),
+)
+
+suspend fun aiPlayer(session: AISession, initialState: GameState) = coroutineScope {
+ val gameDone = Job()
+
+ val errors = Channel<String>()
+ val gameStateFlow = MutableStateFlow(initialState)
+ val aiPlayer = AIPlayer(
+ gameStateFlow,
+ session.actions,
+ errors
+ )
+
+ val behavingJob = launch {
+ aiPlayer.behave(session.instincts, session.mySide)
+ }
+
+ val handlingJob = launch {
+ for (event in session.events) {
+ when (event) {
+ is GameEvent.GameEnd -> gameDone.complete()
+ is GameEvent.InvalidAction -> launch { errors.send(event.message) }
+ is GameEvent.StateChange -> gameStateFlow.value = event.newState
+ }
+ }
+ }
+
+ gameDone.join()
+
+ behavingJob.cancelAndJoin()
+ handlingJob.cancelAndJoin()
+
+ session.actions.close()
+}
--- /dev/null
+package starshipfights.game.ai
+
+import kotlinx.serialization.builtins.serializer
+
+val shipAttackPriority by neuron(Double.serializer()) { 1.0 }
--- /dev/null
+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
+ }
+ }
+}
--- /dev/null
+package starshipfights.game.ai
+
+import starshipfights.data.Id
+import starshipfights.game.*
+import kotlin.math.sign
+import kotlin.random.Random
+
+val deployEscortFocus by instinct { Random.nextDouble(1.0, 5.0) }
+val deployCruiserFocus by instinct { Random.nextDouble(1.0, 5.0) }
+val deployBattleshipFocus by instinct { Random.nextDouble(1.0, 5.0) }
+
+val ShipWeightClass.focus: Instinct
+ get() = when (this) {
+ ShipWeightClass.ESCORT -> deployEscortFocus
+ ShipWeightClass.DESTROYER -> deployCruiserFocus
+ ShipWeightClass.CRUISER -> deployCruiserFocus
+ ShipWeightClass.BATTLECRUISER -> deployCruiserFocus
+ ShipWeightClass.BATTLESHIP -> deployBattleshipFocus
+
+ ShipWeightClass.BATTLE_BARGE -> deployBattleshipFocus
+
+ ShipWeightClass.GRAND_CRUISER -> deployBattleshipFocus
+ ShipWeightClass.COLOSSUS -> deployBattleshipFocus
+
+ ShipWeightClass.FF_ESCORT -> deployEscortFocus
+ ShipWeightClass.FF_DESTROYER -> deployCruiserFocus
+ ShipWeightClass.FF_CRUISER -> deployCruiserFocus
+ ShipWeightClass.FF_BATTLECRUISER -> deployCruiserFocus
+ ShipWeightClass.FF_BATTLESHIP -> deployBattleshipFocus
+
+ ShipWeightClass.AUXILIARY_SHIP -> deployEscortFocus
+ ShipWeightClass.LIGHT_CRUISER -> deployEscortFocus
+ ShipWeightClass.MEDIUM_CRUISER -> deployCruiserFocus
+ ShipWeightClass.HEAVY_CRUISER -> deployBattleshipFocus
+
+ ShipWeightClass.FRIGATE -> deployEscortFocus
+ ShipWeightClass.LINE_SHIP -> deployCruiserFocus
+ ShipWeightClass.DREADNOUGHT -> deployBattleshipFocus
+ }
+
+private val ShipWeightClass.rowIndex: Int
+ get() = when (this) {
+ ShipWeightClass.ESCORT -> 3
+ ShipWeightClass.DESTROYER -> 2
+ ShipWeightClass.CRUISER -> 2
+ ShipWeightClass.BATTLECRUISER -> 1
+ ShipWeightClass.BATTLESHIP -> 0
+
+ ShipWeightClass.BATTLE_BARGE -> 0
+
+ ShipWeightClass.GRAND_CRUISER -> 1
+ ShipWeightClass.COLOSSUS -> 0
+
+ ShipWeightClass.FF_ESCORT -> 3
+ ShipWeightClass.FF_DESTROYER -> 2
+ ShipWeightClass.FF_CRUISER -> 1
+ ShipWeightClass.FF_BATTLECRUISER -> 1
+ ShipWeightClass.FF_BATTLESHIP -> 0
+
+ ShipWeightClass.AUXILIARY_SHIP -> 3
+ ShipWeightClass.LIGHT_CRUISER -> 2
+ ShipWeightClass.MEDIUM_CRUISER -> 1
+ ShipWeightClass.HEAVY_CRUISER -> 0
+
+ ShipWeightClass.FRIGATE -> 2
+ ShipWeightClass.LINE_SHIP -> 1
+ ShipWeightClass.DREADNOUGHT -> 0
+ }
+
+fun placeShips(ships: Set<Ship>, deployRectangle: PickBoundary.Rectangle): Map<Id<ShipInstance>, Position> {
+ val fieldBoundSign = deployRectangle.center.vector.y.sign
+ val fieldBoundary = deployRectangle.center.vector.y + (deployRectangle.length2 * fieldBoundSign)
+ val rows = listOf(125.0, 625.0, 1125.0, 1625.0).map {
+ fieldBoundary - (it * fieldBoundSign)
+ }
+
+ val shipsByRow = ships.groupBy { it.shipType.weightClass.rowIndex }
+ return buildMap {
+ for ((rowIndex, rowShips) in shipsByRow) {
+ val row = rows[rowIndex]
+ val rowMax = rowShips.size - 1
+ for ((shipIndex, ship) in rowShips.withIndex()) {
+ val col = (shipIndex * 500.0) - (rowMax * 250.0)
+ put(ship.id.reinterpret(), Position(Vec2(col, row)))
+ }
+ }
+ }
+}
--- /dev/null
+package starshipfights.game.ai
+
+import starshipfights.data.Id
+import starshipfights.game.*
+import kotlin.math.expm1
+import kotlin.math.pow
+import kotlin.random.Random
+
+val navAggression by instinct { Random.nextDouble(0.5, 1.5) }
+val navPassivity by instinct { Random.nextDouble(0.5, 1.5) }
+val navLustForBlood by instinct { Random.nextDouble(-0.5, 0.5) }
+val navSqueamishness by instinct { Random.nextDouble(0.25, 1.25) }
+val navTunnelVision by instinct { Random.nextDouble(-0.25, 1.25) }
+val navOptimality by instinct { Random.nextDouble(1.25, 2.75) }
+
+fun ShipPosition.score(gameState: GameState, shipInstance: ShipInstance, instincts: Instincts, brain: Brain): Double {
+ val ship = shipInstance.copy(position = this)
+
+ val canAttack = ship.canAttackWithDamage(gameState)
+ val canBeAttackedBy = ship.attackableWithDamageBy(gameState)
+
+ val opportunityScore = canAttack.map { (targetId, potentialDamage) ->
+ brain[shipAttackPriority forShip targetId].signedPow(instincts[navTunnelVision]) * potentialDamage
+ }.sum() + (ship.calculateSuffering() * instincts[navLustForBlood])
+
+ val vulnerabilityScore = canBeAttackedBy.map { (targetId, potentialDamage) ->
+ brain[shipAttackPriority forShip targetId].signedPow(instincts[navTunnelVision]) * potentialDamage
+ }.sum() * -expm1(-ship.calculateSuffering() * instincts[navSqueamishness])
+
+ return instincts[navOptimality].pow(opportunityScore.signedPow(instincts[navAggression]) - vulnerabilityScore.signedPow(instincts[navPassivity]))
+}
+
+fun ShipInstance.canAttackWithDamage(gameState: GameState): Map<Id<ShipInstance>, Double> {
+ return attackableTargets(gameState).mapValues { (targetId, weapons) ->
+ val target = gameState.ships[targetId] ?: return@mapValues 0.0
+
+ weapons.sumOf { weaponId ->
+ weaponId.expectedAdvantageFromWeaponUsage(gameState, this, target)
+ }
+ }
+}
+
+fun ShipInstance.attackableTargets(gameState: GameState): Map<Id<ShipInstance>, Set<Id<ShipWeapon>>> {
+ return armaments.weaponInstances.keys.associateWith { weaponId ->
+ weaponId.validTargets(gameState, this).map { it.id }.toSet()
+ }.transpose()
+}
+
+fun ShipInstance.attackableWithDamageBy(gameState: GameState): Map<Id<ShipInstance>, Double> {
+ return gameState.getValidAttackersWith(this).mapValues { (attackerId, weapons) ->
+ val attacker = gameState.ships[attackerId] ?: return@mapValues 0.0
+
+ weapons.sumOf { weaponId ->
+ weaponId.expectedAdvantageFromWeaponUsage(gameState, attacker, this)
+ }
+ }
+}
+
+fun Id<ShipWeapon>.validTargets(gameState: GameState, ship: ShipInstance): List<ShipInstance> {
+ if (!ship.canUseWeapon(this)) return emptyList()
+ val weaponInstance = ship.armaments.weaponInstances[this] ?: return emptyList()
+
+ return gameState.getValidTargets(ship, weaponInstance)
+}
+
+fun Id<ShipWeapon>.expectedAdvantageFromWeaponUsage(gameState: GameState, ship: ShipInstance, target: ShipInstance): Double {
+ if (!ship.canUseWeapon(this)) return 0.0
+ val weaponInstance = ship.armaments.weaponInstances[this] ?: return 0.0
+ val mustBeSameSide = weaponInstance is ShipWeaponInstance.Hangar && weaponInstance.weapon.wing == StrikeCraftWing.FIGHTERS
+ if ((ship.owner == target.owner) != mustBeSameSide) return 0.0
+
+ return when (weaponInstance) {
+ is ShipWeaponInstance.Cannon -> cannonChanceToHit(ship, target) * weaponInstance.weapon.numShots
+ is ShipWeaponInstance.Lance -> weaponInstance.charge * weaponInstance.weapon.numShots
+ is ShipWeaponInstance.Torpedo -> if (target.shieldAmount > 0) 0.5 else 2.0
+ is ShipWeaponInstance.Hangar -> when (weaponInstance.weapon.wing) {
+ StrikeCraftWing.BOMBERS -> {
+ val calculatedPrevBombing = target.calculateBombing(gameState.ships) ?: 0.0
+ val calculatedNextBombing = target.calculateBombing(gameState.ships, extraBombers = weaponInstance.wingHealth) ?: 0.0
+
+ calculateShipDamageChanceFromBombing(calculatedNextBombing) - calculateShipDamageChanceFromBombing(calculatedPrevBombing)
+ }
+ StrikeCraftWing.FIGHTERS -> {
+ val calculatedPrevBombing = target.calculateBombing(gameState.ships) ?: 0.0
+ val calculatedNextBombing = target.calculateBombing(gameState.ships, extraFighters = weaponInstance.wingHealth) ?: 0.0
+
+ calculateShipDamageChanceFromBombing(calculatedPrevBombing) - calculateShipDamageChanceFromBombing(calculatedNextBombing)
+ }
+ }
+ is ShipWeaponInstance.ParticleClawLauncher -> (cannonChanceToHit(ship, target) + 1) * weaponInstance.weapon.numShots
+ is ShipWeaponInstance.LightningYarn -> weaponInstance.weapon.numShots.toDouble()
+ is ShipWeaponInstance.MegaCannon -> 5.0
+ is ShipWeaponInstance.RevelationGun -> (target.shieldAmount + target.hullAmount).toDouble()
+ is ShipWeaponInstance.EmpAntenna -> target.shieldAmount * 0.5
+ }
+}
+
+private fun calculateShipDamageChanceFromBombing(calculatedBombing: Double): Double {
+ val maxBomberWingOutput = smoothNegative(calculatedBombing)
+ val maxFighterWingOutput = smoothNegative(-calculatedBombing)
+
+ return smoothNegative(maxBomberWingOutput - maxFighterWingOutput)
+}
--- /dev/null
+package starshipfights.game.ai
+
+import starshipfights.game.EPSILON
+import starshipfights.game.Vec2
+import starshipfights.game.div
+import kotlin.math.absoluteValue
+import kotlin.math.pow
+import kotlin.math.sign
+import kotlin.random.Random
+
+expect fun logInfo(message: Any?)
+expect fun logWarning(message: Any?)
+expect fun logError(message: Any?)
+
+fun <T : Any> Map<T, Double>.weightedRandom(random: Random = Random): T {
+ return weightedRandomOrNull(random) ?: error("Cannot take weighted random of effectively-empty collection!")
+}
+
+fun <T : Any> Map<T, Double>.weightedRandomOrNull(random: Random = Random): T? {
+ if (isEmpty()) return null
+
+ val total = values.sum()
+ if (total < EPSILON) return null
+
+ var hasChoice = false
+ var choice = random.nextDouble(total)
+ for ((result, chance) in this) {
+ if (chance < EPSILON) continue
+ if (chance >= choice)
+ return result
+ choice -= chance
+ hasChoice = true
+ }
+
+ return if (hasChoice)
+ keys.last()
+ else null
+}
+
+fun <T, U> Map<T, Set<U>>.transpose(): Map<U, Set<T>> =
+ flatMap { (k, v) -> v.map { it to k } }
+ .groupBy(Pair<U, T>::first, Pair<U, T>::second)
+ .mapValues { (_, it) -> it.toSet() }
+
+fun Iterable<Vec2>.mean(): Vec2 {
+ if (none()) return Vec2(0.0, 0.0)
+
+ val mx = sumOf { it.x }
+ val my = sumOf { it.y }
+ return Vec2(mx, my) / count().toDouble()
+}
+
+fun Double.signedPow(x: Double) = if (absoluteValue < EPSILON) 0.0 else sign * absoluteValue.pow(x)
@Serializable
data class MatchmakingMenu(val admirals: List<InGameAdmiral>) : ClientMode()
+ @Serializable
+ data class InTrainingGame(val initialState: GameState) : ClientMode()
+
@Serializable
data class InGame(val playerSide: GlobalSide, val connectToken: String, val initialState: GameState) : ClientMode()
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)
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")
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)
)
}
)
+fun GameState.getValidAttackersWith(target: ShipInstance): Map<Id<ShipInstance>, Set<Id<ShipWeapon>>> {
+ return ships.mapValues { (_, ship) -> isValidAttackerWith(ship, target) }
+}
+
+fun GameState.isValidAttackerWith(attacker: ShipInstance, target: ShipInstance): Set<Id<ShipWeapon>> {
+ return attacker.armaments.weaponInstances.filterValues {
+ isValidTarget(attacker, it, attacker.getWeaponPickRequest(it.weapon), target)
+ }.keys
+}
+
fun GameState.isValidTarget(ship: ShipInstance, weapon: ShipWeaponInstance, pickRequest: PickRequest, target: ShipInstance): Boolean {
val targetPos = target.position.location
override fun next() = Power(turn + 1)
}
}
+
+val GamePhase.usesInitiative: Boolean
+ get() = this is GamePhase.Move || this is GamePhase.Attack
import kotlinx.serialization.encoding.Encoder
@Serializable(with = MomentSerializer::class)
-expect class Moment(millis: Double) {
+expect class Moment(millis: Double) : Comparable<Moment> {
fun toMillis(): Double
+ override fun compareTo(other: Moment): Int
+
companion object {
val now: Moment
}
@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() {
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
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))
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
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)
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
}
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 -> {
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
ABSENT(false, false)
}
+@JvmInline
@Serializable(with = ShipModulesStatusSerializer::class)
-data class ShipModulesStatus(val statuses: Map<ShipModule, ShipModuleStatus>) {
+value class ShipModulesStatus(val statuses: Map<ShipModule, ShipModuleStatus>) {
operator fun get(module: ShipModule) = statuses[module] ?: ShipModuleStatus.ABSENT
fun repair(module: ShipModule, repairUnrepairable: Boolean = false) = ShipModulesStatus(
enum class ShipWeightClass(
val meshIndex: Int,
- val rank: Int
+ val tier: Int
) {
// General
ESCORT(1, 0),
import kotlinx.serialization.Serializable
import starshipfights.data.Id
+import kotlin.jvm.JvmInline
import kotlin.math.*
import kotlin.random.Random
}
}
+@JvmInline
@Serializable
-data class ShipArmaments(
+value class ShipArmaments(
val weapons: Map<Id<ShipWeapon>, ShipWeapon>
) {
fun instantiate() = ShipInstanceArmaments(weapons.mapValues { (_, weapon) -> weapon.instantiate() })
}
+@JvmInline
@Serializable
-data class ShipInstanceArmaments(
+value class ShipInstanceArmaments(
val weaponInstances: Map<Id<ShipWeapon>, ShipWeaponInstance>
)
--- /dev/null
+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)
+}
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)
}
)
lateinit var mySide: GlobalSide
- private set
private val pickContextDeferred = CompletableDeferred<PickContext>()
-private suspend fun GameRenderInteraction.execute(scope: CoroutineScope) {
+suspend fun GameRenderInteraction.execute(scope: CoroutineScope) {
GameUI.initGameUI(scope.uiResponder(playerActions))
GameUI.drawGameUI(gameState.value)
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)
}
}
+private suspend fun enterTraining(admiral: Id<InGameAdmiral>, battleInfo: BattleInfo, faction: Faction?): Nothing {
+ interruptExit = false
+
+ document.body!!.append.form(action = "/train", method = FormMethod.post, encType = FormEncType.applicationXWwwFormUrlEncoded) {
+ style = "display:none"
+ hiddenInput {
+ name = "admiral"
+ value = admiral.toString()
+ }
+ hiddenInput {
+ name = "battle-size"
+ value = battleInfo.size.toUrlSlug()
+ }
+ hiddenInput {
+ name = "battle-bg"
+ value = battleInfo.bg.toUrlSlug()
+ }
+ hiddenInput {
+ name = "enemy-faction"
+ value = faction?.toUrlSlug() ?: "-random"
+ }
+ }.submit()
+ awaitCancellation()
+}
+
private suspend fun enterGame(connectToken: String): Nothing {
interruptExit = false
private suspend fun usePlayerLogin(admirals: List<InGameAdmiral>) {
val playerLogin = Popup.getPlayerLogin(admirals)
+ val playerLoginSide = playerLogin.login.globalSide
+
+ if (playerLoginSide == null) {
+ val (battleInfo, enemyFaction) = playerLogin.login as LoginMode.Train
+ enterTraining(playerLogin.admiral, battleInfo, enemyFaction)
+ }
+
val admiral = admirals.single { it.id == playerLogin.admiral }
try {
httpClient.webSocket("$rootPathWs/matchmaking") {
sendObject(PlayerLogin.serializer(), playerLogin)
- when (playerLogin.login.globalSide) {
+ when (playerLoginSide) {
GlobalSide.HOST -> {
var loadingText = "Awaiting join request..."
--- /dev/null
+package starshipfights.game
+
+import kotlinx.coroutines.*
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.channels.ReceiveChannel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import starshipfights.game.ai.AISession
+import starshipfights.game.ai.aiPlayer
+
+class GameSession(gameState: GameState) {
+ private val stateMutable = MutableStateFlow(gameState)
+ private val stateMutex = Mutex()
+
+ val state = stateMutable.asStateFlow()
+
+ private val hostErrorMessages = Channel<String>(Channel.UNLIMITED)
+ private val guestErrorMessages = Channel<String>(Channel.UNLIMITED)
+
+ private fun errorMessageChannel(player: GlobalSide) = when (player) {
+ GlobalSide.HOST -> hostErrorMessages
+ GlobalSide.GUEST -> guestErrorMessages
+ }
+
+ fun errorMessages(player: GlobalSide): ReceiveChannel<String> = when (player) {
+ GlobalSide.HOST -> hostErrorMessages
+ GlobalSide.GUEST -> guestErrorMessages
+ }
+
+ private val gameEndMutable = CompletableDeferred<GameEvent.GameEnd>()
+ val gameEnd: Deferred<GameEvent.GameEnd>
+ get() = gameEndMutable
+
+ suspend fun onPacket(player: GlobalSide, packet: PlayerAction) {
+ stateMutex.withLock {
+ when (val result = state.value.after(player, packet)) {
+ is GameEvent.StateChange -> {
+ stateMutable.value = result.newState
+ result.newState.checkVictory()?.let { gameEndMutable.complete(it) }
+ }
+ is GameEvent.InvalidAction -> {
+ errorMessageChannel(player).send(result.message)
+ }
+ is GameEvent.GameEnd -> {
+ gameEndMutable.complete(result)
+ }
+ }
+ }
+ }
+}
+
+private suspend fun GameNetworkInteraction.execute(): Pair<LocalSide?, String> {
+ val gameSession = GameSession(gameState.value)
+
+ val aiSide = mySide.other
+ val aiActions = Channel<PlayerAction>()
+ val aiEvents = Channel<GameEvent>()
+ val aiSession = AISession(aiSide, aiActions, aiEvents)
+
+ return coroutineScope {
+ val aiHandlingJob = launch {
+ launch {
+ listOf(
+ // Game state changes
+ launch {
+ gameSession.state.collect { state ->
+ aiEvents.send(GameEvent.StateChange(state))
+ }
+ },
+ // Invalid action messages
+ launch {
+ for (errorMessage in gameSession.errorMessages(aiSide)) {
+ aiEvents.send(GameEvent.InvalidAction(errorMessage))
+ }
+ }
+ ).joinAll()
+ }
+
+ launch {
+ for (action in aiActions)
+ gameSession.onPacket(aiSide, action)
+ }
+
+ aiPlayer(aiSession, gameState.value)
+ }
+
+ val playerHandlingJob = launch {
+ launch {
+ listOf(
+ // Game state changes
+ launch {
+ gameSession.state.collect { state ->
+ gameState.value = state
+ }
+ },
+ // Invalid action messages
+ launch {
+ for (errorMessage in gameSession.errorMessages(mySide)) {
+ errorMessages.send(errorMessage)
+ }
+ }
+ ).joinAll()
+ }
+
+ for (action in playerActions)
+ gameSession.onPacket(mySide, action)
+ }
+
+ val gameEnd = gameSession.gameEnd.await()
+
+ aiHandlingJob.cancel()
+ playerHandlingJob.cancel()
+
+ gameEnd.winner?.relativeTo(mySide) to gameEnd.message
+ }
+}
+
+suspend fun trainingMain(state: GameState) {
+ interruptExit = true
+
+ initializePicking()
+
+ mySide = GlobalSide.HOST
+
+ val gameState = MutableStateFlow(state)
+ val playerActions = Channel<PlayerAction>(Channel.UNLIMITED)
+ val errorMessages = Channel<String>(Channel.UNLIMITED)
+
+ val gameConnection = GameNetworkInteraction(gameState, playerActions, errorMessages)
+ val gameRendering = GameRenderInteraction(gameState, playerActions, errorMessages)
+
+ coroutineScope {
+ val connectionJob = async { gameConnection.execute() }
+ val renderingJob = launch { gameRendering.execute(this@coroutineScope) }
+
+ val (finalWinner, finalMessage) = connectionJob.await()
+ renderingJob.cancel()
+
+ interruptExit = false
+ Popup.GameOver(finalWinner, finalMessage, gameState.value).display()
+ }
+}
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"
import kotlin.js.Date
@Serializable(with = MomentSerializer::class)
-actual class Moment(val date: Date) {
+actual class Moment(val date: Date) : Comparable<Moment> {
actual constructor(millis: Double) : this(Date(millis))
actual fun toMillis(): Double {
return date.getTime()
}
+ actual override fun compareTo(other: Moment) = toMillis().compareTo(other.toMillis())
+
actual companion object {
actual val now: Moment
get() = Moment(Date())
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}"
}
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") {
}
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") {
+"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"
+ }
}
}
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"
}
}
- class MainMenuScreen(private val admiralInfo: InGameAdmiral) : Popup<GlobalSide?>() {
- override fun TagConsumer<*>.render(context: CoroutineContext, callback: (GlobalSide?) -> Unit) {
+ class MainMenuScreen(private val admiralInfo: InGameAdmiral) : Popup<MainMenuOption?>() {
+ override fun TagConsumer<*>.render(context: CoroutineContext, callback: (MainMenuOption?) -> Unit) {
p {
style = "text-align:center"
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))
}
}
}
}
}
+ object ChooseEnemyFactionScreen : Popup<AIFactionChoice?>() {
+ override fun TagConsumer<*>.render(context: CoroutineContext, callback: (AIFactionChoice?) -> Unit) {
+ p {
+ style = "text-align:center"
+
+ +"Select an enemy faction"
+ }
+
+ div(classes = "button-set col") {
+ button {
+ +"Random"
+ onClickFunction = { e ->
+ e.preventDefault()
+ callback(AIFactionChoice.Random)
+ }
+ }
+ for (faction in Faction.values()) {
+ button {
+ +faction.navyName
+ +Entities.nbsp
+ img(alt = faction.shortName, src = faction.flagUrl) {
+ style = "width:1.2em;height:0.75em"
+ }
+
+ onClickFunction = { e ->
+ e.preventDefault()
+ callback(AIFactionChoice.Chosen(faction))
+ }
+ }
+ }
+ button {
+ +"Cancel"
+ onClickFunction = { e ->
+ e.preventDefault()
+ callback(null)
+ }
+ }
+ }
+ }
+ }
+
class GuestRequestScreen(private val hostInfo: InGameAdmiral, private val guestInfo: InGameAdmiral) : Popup<Boolean?>() {
override fun TagConsumer<*>.render(context: CoroutineContext, callback: (Boolean?) -> Unit) {
p {
td { +Entities.nbsp }
td { +Entities.nbsp }
td { +Entities.nbsp }
+ td { +Entities.nbsp }
td {
style = "text-align:center"
package starshipfights.game
+sealed class MainMenuOption {
+ object Singleplayer : MainMenuOption()
+
+ data class Multiplayer(val side: GlobalSide) : MainMenuOption()
+}
+
+sealed class AIFactionChoice {
+ object Random : AIFactionChoice()
+
+ data class Chosen(val faction: Faction) : AIFactionChoice()
+}
+
private suspend fun Popup.Companion.getPlayerInfo(admirals: List<InGameAdmiral>): InGameAdmiral {
return Popup.ChooseAdmiralScreen(admirals).display()
}
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
+ }
}
}
})
}
+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
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]
}
}
}
fun ShipType.buyPrice(admiral: Admiral, ownedShips: List<ShipInDrydock>): Int? {
- if (weightClass.rank > admiral.rank.maxShipWeightClass.rank) return null
+ if (weightClass.tier > admiral.rank.maxShipWeightClass.tier) return null
if (weightClass.isUnique && ownedShips.any { it.shipType.weightClass == weightClass }) return null
return when {
admiral.faction == faction -> buyPrice
--- /dev/null
+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())
+}
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)
package starshipfights.game
+import starshipfights.data.admiralty.genAI
+import starshipfights.data.admiralty.generateFleet
import starshipfights.data.admiralty.getAdmiralsShips
import kotlin.math.PI
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(
-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
+ )
+}
import java.time.Instant
@Serializable(with = MomentSerializer::class)
-actual class Moment(val instant: Instant) {
+actual class Moment(val instant: Instant) : Comparable<Moment> {
actual constructor(millis: Double) : this(
Instant.ofEpochSecond(
(millis / 1000.0).toLong(),
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())
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)
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!")
}
--- /dev/null
+package starshipfights.game
+
+import io.ktor.application.*
+import io.ktor.request.*
+import starshipfights.auth.getUserSession
+import starshipfights.data.Id
+import starshipfights.data.admiralty.Admiral
+import starshipfights.data.admiralty.getInGameAdmiral
+import starshipfights.redirect
+
+suspend fun ApplicationCall.getTrainingClientMode(): ClientMode {
+ val userId = getUserSession()?.user ?: redirect("/login")
+ val parameters = receiveParameters()
+
+ val admiralId = parameters["admiral"]?.let { Id<Admiral>(it) } ?: return ClientMode.Error("An admiral must be specified")
+ val admiralData = getInGameAdmiral(admiralId.reinterpret()) ?: return ClientMode.Error("That admiral does not exist")
+
+ if (admiralData.user.id != userId.reinterpret<InGameUser>()) return ClientMode.Error("You do not own that admiral")
+
+ val battleSize = BattleSize.values().singleOrNull { it.toUrlSlug() == parameters["battle-size"] } ?: return ClientMode.Error("Invalid battle size")
+ val battleBg = BattleBackground.values().singleOrNull { it.toUrlSlug() == parameters["battle-bg"] } ?: return ClientMode.Error("Invalid battle background")
+ val battleInfo = BattleInfo(battleSize, battleBg)
+
+ val enemyFaction = Faction.values().singleOrNull { it.toUrlSlug() == parameters["enemy-faction"] } ?: Faction.values().random()
+
+ val initialState = generateTrainingInitialState(admiralData, enemyFaction, battleInfo)
+
+ return ClientMode.InTrainingGame(initialState)
+}
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) {
}
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 {
.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()
}
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