From: TheSaminator Date: Sat, 4 Jun 2022 22:11:33 +0000 (-0400) Subject: Add ship boarding X-Git-Url: https://gitweb.starshipfights.net/?a=commitdiff_plain;h=cfdd909ccd2aa155fe9ecaf8f50f5f83b5a1a4d1;p=starship-fights Add ship boarding --- diff --git a/plan/icons/assault-action.svg b/plan/icons/assault-action.svg new file mode 100644 index 0000000..70d13e9 --- /dev/null +++ b/plan/icons/assault-action.svg @@ -0,0 +1,76 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/src/commonMain/kotlin/starshipfights/game/ai/ai_behaviors.kt b/src/commonMain/kotlin/starshipfights/game/ai/ai_behaviors.kt index 8384b75..e025c6a 100644 --- a/src/commonMain/kotlin/starshipfights/game/ai/ai_behaviors.kt +++ b/src/commonMain/kotlin/starshipfights/game/ai/ai_behaviors.kt @@ -60,6 +60,13 @@ suspend fun AIPlayer.behave(instincts: Instincts, mySide: GlobalSide) { if (targetedShip.owner != mySide) brain[shipAttackPriority forShip targetedShip.id] += instincts[combatFrustratedByFailedAttacks] } + is ChatEntry.ShipBoarded -> { + val targetedShip = state.ships[msg.ship] ?: continue + if (targetedShip.owner != mySide) + brain[shipAttackPriority forShip targetedShip.id] -= Random.nextDouble(msg.damageAmount - 0.5, msg.damageAmount + 0.5) * instincts[combatForgiveTarget] + else + brain[shipAttackPriority forShip msg.boarder] += Random.nextDouble(msg.damageAmount - 0.5, msg.damageAmount + 0.5) * instincts[combatAvengeAttacks] + } is ChatEntry.ShipDestroyed -> { val targetedShip = state.ships[msg.ship] ?: continue if (targetedShip.owner == mySide && msg.destroyedBy is ShipAttacker.EnemyShip) @@ -71,8 +78,8 @@ suspend fun AIPlayer.behave(instincts: Instincts, mySide: GlobalSide) { } launch(onGameEnd) { - for ((phase, canAct) in phasePipe) { - if (!canAct) continue + loop@ for ((phase, canAct) in phasePipe) { + if (!canAct) continue@loop val state = gameState.value @@ -92,13 +99,11 @@ suspend fun AIPlayer.behave(instincts: Instincts, mySide: GlobalSide) { doActions.send(PlayerAction.UseAbility(PlayerAbilityType.DonePhase(phase), PlayerAbilityData.DonePhase)) } is GamePhase.Power -> { - val repowerableShips = state.ships.values.filter { ship -> + val powerableShips = state.ships.values.filter { ship -> ship.owner == mySide && !ship.isDoneCurrentPhase } - if (repowerableShips.isEmpty()) - doActions.send(PlayerAction.UseAbility(PlayerAbilityType.DonePhase(phase), PlayerAbilityData.DonePhase)) - else for (ship in repowerableShips) + for (ship in powerableShips) when (val reactor = ship.ship.reactor) { FelinaeShipReactor -> { val newPowerMode = if (ship.hullAmount < ship.durability.maxHullPoints) @@ -127,6 +132,8 @@ suspend fun AIPlayer.behave(instincts: Instincts, mySide: GlobalSide) { doActions.send(PlayerAction.UseAbility(PlayerAbilityType.DistributePower(ship.id), PlayerAbilityData.DistributePower(chosenPower))) } } + + doActions.send(PlayerAction.UseAbility(PlayerAbilityType.DonePhase(phase), PlayerAbilityData.DonePhase)) } is GamePhase.Move -> { val movableShips = state.ships.values.filter { ship -> @@ -135,31 +142,32 @@ suspend fun AIPlayer.behave(instincts: Instincts, mySide: GlobalSide) { val smallestShipTier = movableShips.minOfOrNull { ship -> ship.ship.shipType.weightClass.tier } - if (smallestShipTier == null) + 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) - ) + continue@loop + } + + 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 potentialAttacks = state.ships.values.flatMap { ship -> if (ship.owner == mySide) - ship.armaments.weaponInstances.keys.filter { + ship.armaments.keys.filter { ship.canUseWeapon(it) }.flatMap { weaponId -> weaponId.validTargets(state, ship).map { target -> @@ -171,44 +179,74 @@ suspend fun AIPlayer.behave(instincts: Instincts, mySide: GlobalSide) { weaponId.expectedAdvantageFromWeaponUsage(state, ship, target) * smoothNegative(brain[shipAttackPriority forShip target.id].signedPow(instincts[combatPrioritization])) * (1 + target.calculateSuffering()).signedPow(instincts[combatPreyOnTheWeak]) } - val attackWith = potentialAttacks.weightedRandomOrNull() - - if (attackWith == null) - doActions.send(PlayerAction.UseAbility(PlayerAbilityType.DonePhase(phase), PlayerAbilityData.DonePhase)) - else { - val (ship, weaponId, target) = attackWith - val targetPickResponse = when (val weaponSpec = ship.armaments.weaponInstances[weaponId]?.weapon) { - is AreaWeapon -> { - val pickRequest = ship.getWeaponPickRequest(weaponSpec) - val targetLocation = target.position.location - val closestValidLocation = pickRequest.boundary.closestPointTo(targetLocation) - - val chosenLocation = if ((targetLocation - closestValidLocation).length >= EPSILON) - closestValidLocation + ((closestValidLocation - targetLocation) * 0.2) - else closestValidLocation - - PickResponse.Location(chosenLocation) - } - else -> PickResponse.Ship(target.id) + if (potentialAttacks.isEmpty() || Random.nextInt(3) == 0) { + val potentialBoardings = state.ships.values.flatMap { ship -> + if (ship.owner == mySide && ship.canSendBoardingParty) { + val pickRequest = ship.getBoardingPickRequest() + state.ships.values.filter { target -> + target.owner == mySide.other && target.position.location in pickRequest.boundary + }.map { target -> ship to target } + } else emptyList() + }.associateWith { (ship, target) -> + ship.expectedBoardingSuccess(target) } - doActions.send(PlayerAction.UseAbility(PlayerAbilityType.UseWeapon(ship.id, weaponId), PlayerAbilityData.UseWeapon(targetPickResponse))) + val board = potentialBoardings.weightedRandomOrNull() - withTimeoutOrNull(50L) { getErrors.receive() }?.let { error -> - logWarning("Error when attacking target ship ID ${target.id} with weapon $weaponId of ship ID ${ship.id} - $error") - - val remainingAllAreaWeapons = potentialAttacks.keys.map { (attacker, weaponId, _) -> - attacker to weaponId - }.toSet().all { (attacker, weaponId) -> - attacker.armaments.weaponInstances[weaponId]?.weapon is AreaWeapon - } + if (board != null) { + val (ship, target) = board + doActions.send(PlayerAction.UseAbility(PlayerAbilityType.BoardingParty(ship.id), PlayerAbilityData.BoardingParty(target.id))) - if (remainingAllAreaWeapons) - doActions.send(PlayerAction.UseAbility(PlayerAbilityType.DonePhase(phase), PlayerAbilityData.DonePhase)) - else { + withTimeoutOrNull(50L) { getErrors.receive() }?.let { error -> + logWarning("Error when boarding target ship ID ${target.id} with assault parties of ship ID ${ship.id} - $error") + val nextState = gameState.value phasePipe.send(nextState.phase to (nextState.doneWithPhase != mySide && (!nextState.phase.usesInitiative || nextState.currentInitiative != mySide.other))) } + + continue@loop + } + } + + val attackWith = potentialAttacks.weightedRandomOrNull() + + if (attackWith == null) { + doActions.send(PlayerAction.UseAbility(PlayerAbilityType.DonePhase(phase), PlayerAbilityData.DonePhase)) + continue@loop + } + + val (ship, weaponId, target) = attackWith + val targetPickResponse = when (val weaponSpec = ship.armaments[weaponId]?.weapon) { + is AreaWeapon -> { + val pickRequest = ship.getWeaponPickRequest(weaponSpec) + val targetLocation = target.position.location + val closestValidLocation = pickRequest.boundary.closestPointTo(targetLocation) + + val chosenLocation = if ((targetLocation - closestValidLocation).length >= EPSILON) + closestValidLocation + ((closestValidLocation - targetLocation) * 0.2) + else closestValidLocation + + PickResponse.Location(chosenLocation) + } + else -> PickResponse.Ship(target.id) + } + + doActions.send(PlayerAction.UseAbility(PlayerAbilityType.UseWeapon(ship.id, weaponId), PlayerAbilityData.UseWeapon(targetPickResponse))) + + withTimeoutOrNull(50L) { getErrors.receive() }?.let { error -> + logWarning("Error when attacking target ship ID ${target.id} with weapon $weaponId of ship ID ${ship.id} - $error") + + val remainingAllAreaWeapons = potentialAttacks.keys.map { (attacker, weaponId, _) -> + attacker to weaponId + }.toSet().all { (attacker, weaponId) -> + attacker.armaments[weaponId]?.weapon is AreaWeapon + } + + if (remainingAllAreaWeapons) + doActions.send(PlayerAction.UseAbility(PlayerAbilityType.DonePhase(phase), PlayerAbilityData.DonePhase)) + else { + val nextState = gameState.value + phasePipe.send(nextState.phase to (nextState.doneWithPhase != mySide && (!nextState.phase.usesInitiative || nextState.currentInitiative != mySide.other))) } } } @@ -217,17 +255,18 @@ suspend fun AIPlayer.behave(instincts: Instincts, mySide: GlobalSide) { it !is PlayerAbilityType.DonePhase }.randomOrNull() - if (repairAbility == null) + 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)) - } + continue@loop + } + + 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)) } } } diff --git a/src/commonMain/kotlin/starshipfights/game/ai/ai_util_combat.kt b/src/commonMain/kotlin/starshipfights/game/ai/ai_util_combat.kt index e8050c0..926ed08 100644 --- a/src/commonMain/kotlin/starshipfights/game/ai/ai_util_combat.kt +++ b/src/commonMain/kotlin/starshipfights/game/ai/ai_util_combat.kt @@ -1,9 +1,6 @@ package starshipfights.game.ai -import starshipfights.game.FelinaeShipReactor -import starshipfights.game.ShipInstance -import starshipfights.game.ShipModuleStatus -import starshipfights.game.durability +import starshipfights.game.* val combatTargetShipWeight by instinct(0.5..2.5) @@ -30,3 +27,7 @@ fun ShipInstance.calculateSuffering(): Double { } } } + +fun ShipInstance.expectedBoardingSuccess(against: ShipInstance): Double { + return smoothNegative((assaultModifier - against.defenseModifier).toDouble()) +} diff --git a/src/commonMain/kotlin/starshipfights/game/ai/ai_util_nav.kt b/src/commonMain/kotlin/starshipfights/game/ai/ai_util_nav.kt index fe7b662..dd1c8d1 100644 --- a/src/commonMain/kotlin/starshipfights/game/ai/ai_util_nav.kt +++ b/src/commonMain/kotlin/starshipfights/game/ai/ai_util_nav.kt @@ -40,7 +40,7 @@ fun ShipInstance.canAttackWithDamage(gameState: GameState): Map } fun ShipInstance.attackableTargets(gameState: GameState): Map, Set>> { - return armaments.weaponInstances.keys.associateWith { weaponId -> + return armaments.keys.associateWith { weaponId -> weaponId.validTargets(gameState, this).map { it.id }.toSet() }.transpose() } @@ -57,14 +57,14 @@ fun ShipInstance.attackableWithDamageBy(gameState: GameState): Map.validTargets(gameState: GameState, ship: ShipInstance): List { if (!ship.canUseWeapon(this)) return emptyList() - val weaponInstance = ship.armaments.weaponInstances[this] ?: return emptyList() + val weaponInstance = ship.armaments[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 weaponInstance = ship.armaments[this] ?: return 0.0 val mustBeSameSide = weaponInstance is ShipWeaponInstance.Hangar && weaponInstance.weapon.wing == StrikeCraftWing.FIGHTERS if ((ship.owner == target.owner) != mustBeSameSide) return 0.0 diff --git a/src/commonMain/kotlin/starshipfights/game/game_ability.kt b/src/commonMain/kotlin/starshipfights/game/game_ability.kt index 04f7745..3d23d6a 100644 --- a/src/commonMain/kotlin/starshipfights/game/game_ability.kt +++ b/src/commonMain/kotlin/starshipfights/game/game_ability.kt @@ -417,7 +417,7 @@ sealed class PlayerAbilityType { if (shipInstance.weaponAmount <= 0) return null if (weapon in shipInstance.usedArmaments) return null - val shipWeapon = shipInstance.armaments.weaponInstances[weapon] ?: return null + val shipWeapon = shipInstance.armaments[weapon] ?: return null if (shipWeapon !is ShipWeaponInstance.Lance) return null return PlayerAbilityData.ChargeLance @@ -430,7 +430,7 @@ sealed class PlayerAbilityType { if (shipInstance.weaponAmount <= 0) return GameEvent.InvalidAction("Not enough power to charge lances") if (weapon in shipInstance.usedArmaments) return GameEvent.InvalidAction("Cannot charge used lances") - val shipWeapon = shipInstance.armaments.weaponInstances[weapon] ?: return GameEvent.InvalidAction("That weapon does not exist") + val shipWeapon = shipInstance.armaments[weapon] ?: return GameEvent.InvalidAction("That weapon does not exist") if (shipWeapon !is ShipWeaponInstance.Lance) return GameEvent.InvalidAction("Cannot charge non-lance weapons") return GameEvent.StateChange( @@ -438,10 +438,8 @@ sealed class PlayerAbilityType { ships = gameState.ships + mapOf( ship to shipInstance.copy( weaponAmount = shipInstance.weaponAmount - 1, - armaments = ShipInstanceArmaments( - weaponInstances = shipInstance.armaments.weaponInstances + mapOf( - weapon to shipWeapon.copy(numCharges = shipWeapon.numCharges + shipInstance.firepower.lanceCharging) - ) + armaments = shipInstance.armaments + mapOf( + weapon to shipWeapon.copy(numCharges = shipWeapon.numCharges + shipInstance.firepower.lanceCharging) ) ) ) @@ -458,7 +456,7 @@ sealed class PlayerAbilityType { val shipInstance = gameState.ships[ship] ?: return null if (!shipInstance.canUseWeapon(weapon)) return null - val shipWeapon = shipInstance.armaments.weaponInstances[weapon] ?: return null + val shipWeapon = shipInstance.armaments[weapon] ?: return null val pickResponse = pick(shipInstance.getWeaponPickRequest(shipWeapon.weapon)) @@ -473,7 +471,7 @@ sealed class PlayerAbilityType { val shipInstance = gameState.ships[ship] ?: return GameEvent.InvalidAction("That attacking ship does not exist") if (!shipInstance.canUseWeapon(weapon)) return GameEvent.InvalidAction("That weapon cannot be used") - val shipWeapon = shipInstance.armaments.weaponInstances[weapon] ?: return GameEvent.InvalidAction("That weapon does not exist") + val shipWeapon = shipInstance.armaments[weapon] ?: return GameEvent.InvalidAction("That weapon does not exist") val pickRequest = shipInstance.getWeaponPickRequest(shipWeapon.weapon) val pickResponse = data.target @@ -492,7 +490,7 @@ sealed class PlayerAbilityType { val shipInstance = gameState.ships[ship] ?: return null if (weapon !in shipInstance.usedArmaments) return null - val shipWeapon = shipInstance.armaments.weaponInstances[weapon] ?: return null + val shipWeapon = shipInstance.armaments[weapon] ?: return null if (shipWeapon !is ShipWeaponInstance.Hangar) return null return PlayerAbilityData.RecallStrikeCraft @@ -504,7 +502,7 @@ sealed class PlayerAbilityType { val shipInstance = gameState.ships[ship] ?: return GameEvent.InvalidAction("That ship does not exist") if (weapon !in shipInstance.usedArmaments) return GameEvent.InvalidAction("Cannot recall unused strike craft") - val shipWeapon = shipInstance.armaments.weaponInstances[weapon] ?: return GameEvent.InvalidAction("That weapon does not exist") + val shipWeapon = shipInstance.armaments[weapon] ?: return GameEvent.InvalidAction("That weapon does not exist") if (shipWeapon !is ShipWeaponInstance.Hangar) return GameEvent.InvalidAction("Cannot recall non-hangar weapons") val hangarWing = ShipHangarWing(ship, weapon) @@ -521,7 +519,7 @@ sealed class PlayerAbilityType { bomberWings = targetShip.bomberWings - hangarWing, ) } + mapOf(ship to newShip) - ) + ).withRecalculatedInitiative { calculateAttackPhaseInitiative() } ) } } @@ -559,14 +557,12 @@ sealed class PlayerAbilityType { val changedShips = hangars.groupBy { it.ship }.mapNotNull { (shipId, hangarWings) -> val changedShip = gameState.ships[shipId] ?: return@mapNotNull null changedShip.copy( - armaments = ShipInstanceArmaments( - changedShip.armaments.weaponInstances + hangarWings.associate { - it.hangar to ShipWeaponInstance.Hangar( - changedShip.ship.armaments.weapons[it.hangar] as ShipWeapon.Hangar, - 0.0 - ) - } - ) + armaments = changedShip.armaments + hangarWings.associate { + it.hangar to ShipWeaponInstance.Hangar( + changedShip.ship.armaments[it.hangar] as ShipWeapon.Hangar, + 0.0 + ) + } ) }.associateBy { it.id } + mapOf( ship to shipInstance.copy( @@ -578,7 +574,52 @@ sealed class PlayerAbilityType { return GameEvent.StateChange( gameState.copy( ships = gameState.ships + changedShips - ) + ).withRecalculatedInitiative { calculateAttackPhaseInitiative() } + ) + } + } + + @Serializable + data class BoardingParty(override val ship: Id) : PlayerAbilityType(), ShipAbility { + override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? { + if (gameState.phase !is GamePhase.Attack) return null + + val shipInstance = gameState.ships[ship] ?: return null + if (!shipInstance.canSendBoardingParty) return null + + val pickResponse = pick(shipInstance.getBoardingPickRequest()) as? PickResponse.Ship ?: return null + return PlayerAbilityData.BoardingParty(pickResponse.id) + } + + override fun finishOnServer(gameState: GameState, playerSide: GlobalSide, data: PlayerAbilityData): GameEvent { + if (data !is PlayerAbilityData.BoardingParty) return GameEvent.InvalidAction("Internal error from using player ability") + + if (gameState.phase !is GamePhase.Attack) return GameEvent.InvalidAction("Ships can only send Boarding Parties during Phase III") + + val shipInstance = gameState.ships[ship] ?: return GameEvent.InvalidAction("That ship does not exist") + if (!shipInstance.canSendBoardingParty) return GameEvent.InvalidAction("Cannot send a boarding party") + + val afterBoarding = shipInstance.afterBoarding() ?: return GameEvent.InvalidAction("Cannot send a boarding party") + + val boarded = gameState.ships[data.target] ?: return GameEvent.InvalidAction("That ship does not exist") + val afterBoarded = shipInstance.board(boarded) + + val newShips = (if (afterBoarded is ImpactResult.Damaged) + gameState.ships + mapOf(data.target to afterBoarded.ship) + else gameState.ships - data.target) + mapOf(ship to afterBoarding) + + val newWrecks = gameState.destroyedShips + (if (afterBoarded is ImpactResult.Destroyed) + mapOf(data.target to afterBoarded.ship) + else emptyMap()) + + val newChatEntries = gameState.chatBox + reportBoardingResult(afterBoarded, ship) + + return GameEvent.StateChange( + gameState.copy( + ships = newShips, + destroyedShips = newWrecks, + chatBox = newChatEntries + ).withRecalculatedInitiative { calculateAttackPhaseInitiative() } ) } } @@ -743,6 +784,9 @@ sealed class PlayerAbilityData { @Serializable object DisruptionPulse : PlayerAbilityData() + @Serializable + data class BoardingParty(val target: Id) : PlayerAbilityData() + @Serializable object RepairShipModule : PlayerAbilityData() @@ -820,7 +864,7 @@ else when (phase) { val chargeableLances = ships .filterValues { it.owner == forPlayer && it.weaponAmount > 0 } .flatMap { (id, ship) -> - ship.armaments.weaponInstances.mapNotNull { (weaponId, weapon) -> + ship.armaments.mapNotNull { (weaponId, weapon) -> PlayerAbilityType.ChargeLance(id, weaponId).takeIf { when (weapon) { is ShipWeaponInstance.Lance -> weapon.numCharges < 7.0 && weaponId !in ship.usedArmaments @@ -834,7 +878,7 @@ else when (phase) { .filterKeys { canShipAttack(it) } .filterValues { it.owner == forPlayer } .flatMap { (id, ship) -> - ship.armaments.weaponInstances.keys.mapNotNull { weaponId -> + ship.armaments.keys.mapNotNull { weaponId -> PlayerAbilityType.UseWeapon(id, weaponId).takeIf { weaponId !in ship.usedArmaments && ship.canUseWeapon(weaponId) } @@ -847,10 +891,16 @@ else when (phase) { .keys .map { PlayerAbilityType.DisruptionPulse(it) } + val usableBoardingTransportaria = ships + .filterKeys { canShipAttack(it) } + .filterValues { it.owner == forPlayer && !it.isDoneCurrentPhase && it.canSendBoardingParty } + .keys + .map { PlayerAbilityType.BoardingParty(it) } + val recallableStrikeWings = ships .filterValues { it.owner == forPlayer } .flatMap { (id, ship) -> - ship.armaments.weaponInstances.mapNotNull { (weaponId, weapon) -> + ship.armaments.mapNotNull { (weaponId, weapon) -> PlayerAbilityType.RecallStrikeCraft(id, weaponId).takeIf { weaponId in ship.usedArmaments && weapon is ShipWeaponInstance.Hangar } @@ -861,7 +911,7 @@ else when (phase) { listOf(PlayerAbilityType.DonePhase(GamePhase.Attack(phase.turn))) else emptyList() - chargeableLances + usableWeapons + recallableStrikeWings + usableDisruptionPulses + finishAttacking + usableBoardingTransportaria + chargeableLances + usableWeapons + recallableStrikeWings + usableDisruptionPulses + finishAttacking } is GamePhase.Repair -> { val repairableModules = ships diff --git a/src/commonMain/kotlin/starshipfights/game/game_chat.kt b/src/commonMain/kotlin/starshipfights/game/game_chat.kt index 74ea87e..a1b2d68 100644 --- a/src/commonMain/kotlin/starshipfights/game/game_chat.kt +++ b/src/commonMain/kotlin/starshipfights/game/game_chat.kt @@ -45,6 +45,15 @@ sealed class ChatEntry { val damageIgnoreType: DamageIgnoreType, ) : ChatEntry() + @Serializable + data class ShipBoarded( + val ship: Id, + val boarder: Id, + override val sentAt: Moment, + val critical: ShipCritical?, + val damageAmount: Int = 0, + ) : ChatEntry() + @Serializable data class ShipDestroyed( val ship: Id, @@ -73,6 +82,9 @@ sealed class ShipCritical { @Serializable object Fire : ShipCritical() + @Serializable + data class TroopsKilled(val number: Int) : ShipCritical() + @Serializable data class ModulesHit(val module: Set) : ShipCritical() } @@ -81,6 +93,7 @@ fun CritResult.report(): ShipCritical? = when (this) { CritResult.NoEffect -> null is CritResult.FireStarted -> ShipCritical.Fire is CritResult.ModulesDisabled -> ShipCritical.ModulesHit(modules) + is CritResult.TroopsKilled -> ShipCritical.TroopsKilled(amount) is CritResult.HullDamaged -> ShipCritical.ExtraDamage is CritResult.Destroyed -> null } diff --git a/src/commonMain/kotlin/starshipfights/game/game_initiative.kt b/src/commonMain/kotlin/starshipfights/game/game_initiative.kt index 0a31e5b..3e9b700 100644 --- a/src/commonMain/kotlin/starshipfights/game/game_initiative.kt +++ b/src/commonMain/kotlin/starshipfights/game/game_initiative.kt @@ -40,7 +40,7 @@ fun GameState.getValidAttackersWith(target: ShipInstance): Map, } fun GameState.isValidAttackerWith(attacker: ShipInstance, target: ShipInstance): Set> { - return attacker.armaments.weaponInstances.filterValues { + return attacker.armaments.filterValues { isValidTarget(attacker, it, attacker.getWeaponPickRequest(it.weapon), target) }.keys } @@ -77,12 +77,12 @@ fun GameState.calculateAttackPhaseInitiative(): InitiativePair = InitiativePair( shipList .filter { !it.isDoneCurrentPhase } .sumOf { ship -> - val allWeapons = ship.armaments.weaponInstances + val allWeapons = ship.armaments .filterValues { weapon -> hasValidTargets(ship, weapon) } val usableWeapons = allWeapons - ship.usedArmaments - val allWeaponShots = allWeapons.values.sumOf { it.weapon.numShots } - val usableWeaponShots = usableWeapons.values.sumOf { it.weapon.numShots } + val allWeaponShots = allWeapons.values.sumOf { it.weapon.numShots } + ship.troopsAmount + val usableWeaponShots = usableWeapons.values.sumOf { it.weapon.numShots } + (if (ship.canSendBoardingParty) ship.troopsAmount else 0) ship.ship.pointCost * (usableWeaponShots.toDouble() / allWeaponShots) } diff --git a/src/commonMain/kotlin/starshipfights/game/game_state.kt b/src/commonMain/kotlin/starshipfights/game/game_state.kt index eb64f21..7558530 100644 --- a/src/commonMain/kotlin/starshipfights/game/game_state.kt +++ b/src/commonMain/kotlin/starshipfights/game/game_state.kt @@ -2,8 +2,6 @@ package starshipfights.game import kotlinx.serialization.Serializable import starshipfights.data.Id -import kotlin.random.Random -import kotlin.random.nextInt @Serializable data class GameState( @@ -99,7 +97,7 @@ private fun GameState.afterPhase(): GameState { if (ship.numFires <= 0) return@fireDamage id to ship - val hits = Random.nextInt(0..ship.numFires) + val hits = (0..ship.numFires).random() val impactResult = ship.impact(hits, true) newChatEntries += listOfNotNull(impactResult.toChatEntry(ShipAttacker.Fire, null)) @@ -126,6 +124,8 @@ private fun GameState.afterPhase(): GameState { fighterWings = emptySet(), bomberWings = emptySet(), usedArmaments = emptySet(), + + hasSentBoardingParty = false, ) } } diff --git a/src/commonMain/kotlin/starshipfights/game/ship.kt b/src/commonMain/kotlin/starshipfights/game/ship.kt index 6811e5c..123b197 100644 --- a/src/commonMain/kotlin/starshipfights/game/ship.kt +++ b/src/commonMain/kotlin/starshipfights/game/ship.kt @@ -142,18 +142,21 @@ val ShipWeightClass.movement: ShipMovement sealed class ShipDurability { abstract val maxHullPoints: Int abstract val turretDefense: Double + abstract val troopsDefense: Int } @Serializable data class StandardShipDurability( override val maxHullPoints: Int, override val turretDefense: Double, + override val troopsDefense: Int, val repairTokens: Int, ) : ShipDurability() @Serializable data class FelinaeShipDurability( override val maxHullPoints: Int, + override val troopsDefense: Int, val disruptionPulseRange: Double, val disruptionPulseShots: Int ) : ShipDurability() { @@ -163,31 +166,31 @@ data class FelinaeShipDurability( val ShipWeightClass.durability: ShipDurability get() = when (this) { - ShipWeightClass.ESCORT -> StandardShipDurability(4, 0.5, 1) - ShipWeightClass.DESTROYER -> StandardShipDurability(8, 0.5, 1) - ShipWeightClass.CRUISER -> StandardShipDurability(12, 1.0, 2) - ShipWeightClass.BATTLECRUISER -> StandardShipDurability(14, 1.5, 2) - ShipWeightClass.BATTLESHIP -> StandardShipDurability(16, 2.0, 3) - - ShipWeightClass.BATTLE_BARGE -> StandardShipDurability(16, 1.5, 3) - - ShipWeightClass.GRAND_CRUISER -> StandardShipDurability(15, 1.75, 3) - ShipWeightClass.COLOSSUS -> StandardShipDurability(27, 3.0, 4) - - ShipWeightClass.FF_ESCORT -> FelinaeShipDurability(6, 1000.0, 3) - ShipWeightClass.FF_DESTROYER -> FelinaeShipDurability(9, 1000.0, 4) - ShipWeightClass.FF_CRUISER -> FelinaeShipDurability(12, 750.0, 2) - ShipWeightClass.FF_BATTLECRUISER -> FelinaeShipDurability(15, 875.0, 2) - ShipWeightClass.FF_BATTLESHIP -> FelinaeShipDurability(18, 1250.0, 3) - - ShipWeightClass.AUXILIARY_SHIP -> StandardShipDurability(4, 2.0, 1) - ShipWeightClass.LIGHT_CRUISER -> StandardShipDurability(8, 3.0, 2) - ShipWeightClass.MEDIUM_CRUISER -> StandardShipDurability(12, 3.5, 2) - ShipWeightClass.HEAVY_CRUISER -> StandardShipDurability(16, 4.0, 3) - - ShipWeightClass.FRIGATE -> StandardShipDurability(10, 1.5, 1) - ShipWeightClass.LINE_SHIP -> StandardShipDurability(15, 2.0, 1) - ShipWeightClass.DREADNOUGHT -> StandardShipDurability(20, 2.5, 1) + ShipWeightClass.ESCORT -> StandardShipDurability(4, 0.5, 5, 1) + ShipWeightClass.DESTROYER -> StandardShipDurability(8, 0.5, 7, 1) + ShipWeightClass.CRUISER -> StandardShipDurability(12, 1.0, 10, 2) + ShipWeightClass.BATTLECRUISER -> StandardShipDurability(14, 1.5, 10, 2) + ShipWeightClass.BATTLESHIP -> StandardShipDurability(16, 2.0, 15, 3) + + ShipWeightClass.BATTLE_BARGE -> StandardShipDurability(16, 1.5, 15, 3) + + ShipWeightClass.GRAND_CRUISER -> StandardShipDurability(15, 1.75, 12, 3) + ShipWeightClass.COLOSSUS -> StandardShipDurability(27, 3.0, 25, 4) + + ShipWeightClass.FF_ESCORT -> FelinaeShipDurability(6, 3, 1000.0, 3) + ShipWeightClass.FF_DESTROYER -> FelinaeShipDurability(9, 4, 1000.0, 4) + ShipWeightClass.FF_CRUISER -> FelinaeShipDurability(12, 5, 750.0, 2) + ShipWeightClass.FF_BATTLECRUISER -> FelinaeShipDurability(15, 6, 875.0, 2) + ShipWeightClass.FF_BATTLESHIP -> FelinaeShipDurability(18, 7, 1250.0, 3) + + ShipWeightClass.AUXILIARY_SHIP -> StandardShipDurability(4, 2.0, 6, 1) + ShipWeightClass.LIGHT_CRUISER -> StandardShipDurability(8, 3.0, 9, 2) + ShipWeightClass.MEDIUM_CRUISER -> StandardShipDurability(12, 3.5, 12, 2) + ShipWeightClass.HEAVY_CRUISER -> StandardShipDurability(16, 4.0, 15, 3) + + ShipWeightClass.FRIGATE -> StandardShipDurability(10, 1.5, 7, 1) + ShipWeightClass.LINE_SHIP -> StandardShipDurability(15, 2.0, 9, 1) + ShipWeightClass.DREADNOUGHT -> StandardShipDurability(20, 2.5, 11, 1) } @Serializable diff --git a/src/commonMain/kotlin/starshipfights/game/ship_boarding.kt b/src/commonMain/kotlin/starshipfights/game/ship_boarding.kt new file mode 100644 index 0000000..16c1194 --- /dev/null +++ b/src/commonMain/kotlin/starshipfights/game/ship_boarding.kt @@ -0,0 +1,169 @@ +package starshipfights.game + +import starshipfights.data.Id +import kotlin.math.roundToInt + +fun factionBoardingModifier(faction: Faction): Int = when (faction) { + Faction.MECHYRDIA -> 7 + Faction.NDRC -> 10 + Faction.MASRA_DRAETSEN -> 8 + Faction.FELINAE_FELICES -> 0 + Faction.ISARNAREYKK -> 3 + Faction.VESTIGIUM -> 2 +} + +fun weightClassBoardingModifier(weightClass: ShipWeightClass): Int = when (weightClass) { + ShipWeightClass.ESCORT -> 2 + ShipWeightClass.DESTROYER -> 2 + ShipWeightClass.CRUISER -> 4 + ShipWeightClass.BATTLECRUISER -> 4 + ShipWeightClass.BATTLESHIP -> 6 + + ShipWeightClass.BATTLE_BARGE -> 8 + + ShipWeightClass.GRAND_CRUISER -> 6 + ShipWeightClass.COLOSSUS -> 10 + + ShipWeightClass.FF_ESCORT -> 0 + ShipWeightClass.FF_DESTROYER -> 2 + ShipWeightClass.FF_CRUISER -> 2 + ShipWeightClass.FF_BATTLECRUISER -> 4 + ShipWeightClass.FF_BATTLESHIP -> 6 + + ShipWeightClass.AUXILIARY_SHIP -> 0 + ShipWeightClass.LIGHT_CRUISER -> 2 + ShipWeightClass.MEDIUM_CRUISER -> 4 + ShipWeightClass.HEAVY_CRUISER -> 6 + + ShipWeightClass.FRIGATE -> 0 + ShipWeightClass.LINE_SHIP -> 2 + ShipWeightClass.DREADNOUGHT -> 4 +} + +fun troopsBoardingModifier(troopsAmount: Int, totalTroops: Int): Int = when { + troopsAmount < totalTroops / 3 -> 0 + troopsAmount < (totalTroops * 2) / 3 -> 2 + troopsAmount < totalTroops -> 3 + troopsAmount == totalTroops -> 4 + else -> 4 +} + +fun hullBoardingModifier(hullAmount: Int, totalHull: Int): Int = when { + hullAmount < totalHull / 2 -> 1 + hullAmount < totalHull -> 3 + hullAmount == totalHull -> 5 + else -> 5 +} + +fun turretsBoardingModifier(turretsDefense: Double, turretsStatus: ShipModuleStatus): Int = when (turretsStatus) { + ShipModuleStatus.INTACT -> turretsDefense.roundToInt() + ShipModuleStatus.DAMAGED -> (turretsDefense * 0.5).roundToInt() + else -> 0 +} + +fun shieldsBoardingAssaultModifier(shieldsAmount: Int, totalShields: Int): Int = when { + shieldsAmount == 0 -> 2 + shieldsAmount < totalShields -> 1 + else -> 0 +} + +fun shieldsBoardingDefenseModifier(shieldsAmount: Int, totalShields: Int): Int = when { + shieldsAmount == 0 -> 0 + shieldsAmount <= totalShields / 2 -> 1 + shieldsAmount < totalShields -> 2 + else -> 3 +} + +fun assaultBoardingModifier(assaultModuleStatus: ShipModuleStatus): Int = when (assaultModuleStatus) { + ShipModuleStatus.INTACT -> 5 + ShipModuleStatus.DAMAGED -> 3 + ShipModuleStatus.DESTROYED -> 0 + else -> 0 +} + +fun defenseBoardingModifier(defenseModuleStatus: ShipModuleStatus): Int = when (defenseModuleStatus) { + ShipModuleStatus.INTACT -> 3 + ShipModuleStatus.DAMAGED -> 2 + ShipModuleStatus.DESTROYED -> 0 + else -> 0 +} + +val ShipInstance.assaultModifier: Int + get() = listOf( + factionBoardingModifier(ship.shipType.faction), + weightClassBoardingModifier(ship.shipType.weightClass), + troopsBoardingModifier(troopsAmount, durability.troopsDefense), + hullBoardingModifier(hullAmount, durability.maxHullPoints), + turretsBoardingModifier(durability.turretDefense, modulesStatus[ShipModule.Turrets]), + if (canUseShields) + shieldsBoardingAssaultModifier(shieldAmount, powerMode.shields) + else shieldsBoardingAssaultModifier(0, powerMode.shields), + assaultBoardingModifier(modulesStatus[ShipModule.Assault]), + ).sum() + +val ShipInstance.defenseModifier: Int + get() = listOf( + factionBoardingModifier(ship.shipType.faction), + weightClassBoardingModifier(ship.shipType.weightClass), + troopsBoardingModifier(troopsAmount, durability.troopsDefense), + hullBoardingModifier(hullAmount, durability.maxHullPoints), + turretsBoardingModifier(durability.turretDefense, modulesStatus[ShipModule.Turrets]), + if (canUseShields) + shieldsBoardingDefenseModifier(shieldAmount, powerMode.shields) + else shieldsBoardingDefenseModifier(0, powerMode.shields), + defenseBoardingModifier(modulesStatus[ShipModule.Defense]), + ).sum() + +fun boardingRoll(): Int = (0..8).random() + (0..8).random() + +fun ShipInstance.board(defender: ShipInstance): ImpactResult { + val myValue = assaultModifier + boardingRoll() + val otherValue = defender.defenseModifier + boardingRoll() + + return when { + otherValue * 2 < myValue -> { + when (val firstImpact = ImpactResult.Intact(defender).withCritResult(defender.doCriticalDamage())) { + is ImpactResult.Damaged -> firstImpact.withCritResult(firstImpact.ship.doCriticalDamage()) + else -> firstImpact + } + } + otherValue <= myValue -> { + ImpactResult.Intact(defender).withCritResult(defender.doCriticalDamage()) + } + else -> { + val troopsKilled = (1..(myValue / 2)).randomOrNull() ?: 0 + ImpactResult.Intact(defender).withCritResult(defender.killTroops(troopsKilled)) + } + } +} + +fun ShipInstance.afterBoarding() = if (troopsAmount <= 1) null else copy( + troopsAmount = troopsAmount - 1, + hasSentBoardingParty = true, +) + +fun reportBoardingResult(impactResult: ImpactResult, attacker: Id) = when (impactResult) { + is ImpactResult.Destroyed -> ChatEntry.ShipDestroyed( + ship = impactResult.ship.id, + sentAt = Moment.now, + destroyedBy = ShipAttacker.EnemyShip(attacker) + ) + is ImpactResult.Damaged -> ChatEntry.ShipBoarded( + ship = impactResult.ship.id, + boarder = attacker, + sentAt = Moment.now, + critical = impactResult.critical.report(), + damageAmount = impactResult.damage.amount + ) +} + +fun ShipInstance.getBoardingPickRequest() = PickRequest( + PickType.Ship(allowSides = setOf(owner.other)), + PickBoundary.WeaponsFire( + center = position.location, + facing = position.facing, + minDistance = SHIP_BASE_SIZE, + maxDistance = firepower.rangeMultiplier * SHIP_TRANSPORTARIUM_RANGE, + firingArcs = FiringArc.FIRE_FORE_270, + ) +) diff --git a/src/commonMain/kotlin/starshipfights/game/ship_instances.kt b/src/commonMain/kotlin/starshipfights/game/ship_instances.kt index 9c2351e..8944127 100644 --- a/src/commonMain/kotlin/starshipfights/game/ship_instances.kt +++ b/src/commonMain/kotlin/starshipfights/game/ship_instances.kt @@ -21,6 +21,7 @@ data class ShipInstance( val weaponAmount: Int = powerMode.weapons, val shieldAmount: Int = powerMode.shields, val hullAmount: Int = ship.durability.maxHullPoints, + val troopsAmount: Int = ship.durability.troopsDefense, val modulesStatus: ShipModulesStatus = ShipModulesStatus.forShip(ship), val numFires: Int = 0, @@ -38,6 +39,8 @@ data class ShipInstance( val fighterWings: Set = emptySet(), val bomberWings: Set = emptySet(), + + val hasSentBoardingParty: Boolean = false, ) { val canUseShields: Boolean get() = ship.hasShields && modulesStatus[ShipModule.Shields].canBeUsed @@ -45,8 +48,8 @@ data class ShipInstance( val canUseTurrets: Boolean get() = modulesStatus[ShipModule.Turrets].canBeUsed - val canCatchFire: Boolean - get() = ship.shipType.faction != Faction.FELINAE_FELICES + val canSendBoardingParty: Boolean + get() = modulesStatus[ShipModule.Assault].canBeUsed && troopsAmount > 1 && !hasSentBoardingParty val canUseInertialessDrive: Boolean get() = ship.canUseInertialessDrive && modulesStatus[ShipModule.Engines].canBeUsed && when (val movement = ship.movement) { @@ -82,7 +85,7 @@ data class ShipInstance( if (!modulesStatus[ShipModule.Weapon(weaponId)].canBeUsed) return false - val weapon = armaments.weaponInstances[weaponId] ?: return false + val weapon = armaments[weaponId] ?: return false return when (weapon) { is ShipWeaponInstance.Cannon -> weaponAmount > 0 @@ -264,6 +267,8 @@ else const val SHIP_BASE_SIZE = 250.0 +const val SHIP_TRANSPORTARIUM_RANGE = 1_500.0 + const val SHIP_TORPEDO_RANGE = 2_000.0 const val SHIP_CANNON_RANGE = 2_500.0 const val SHIP_LANCE_RANGE = 3_000.0 diff --git a/src/commonMain/kotlin/starshipfights/game/ship_modules.kt b/src/commonMain/kotlin/starshipfights/game/ship_modules.kt index b3f78b9..ded8f80 100644 --- a/src/commonMain/kotlin/starshipfights/game/ship_modules.kt +++ b/src/commonMain/kotlin/starshipfights/game/ship_modules.kt @@ -9,8 +9,6 @@ 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 @Serializable sealed class ShipModule { @@ -19,7 +17,21 @@ sealed class ShipModule { @Serializable data class Weapon(val weaponId: Id) : ShipModule() { override fun getDisplayName(ship: Ship): String { - return ship.armaments.weapons[weaponId]?.displayName ?: "" + return ship.armaments[weaponId]?.displayName ?: "" + } + } + + @Serializable + object Assault : ShipModule() { + override fun getDisplayName(ship: Ship): String { + return "Boarding Transportarium" + } + } + + @Serializable + object Defense : ShipModule() { + override fun getDisplayName(ship: Ship): String { + return "Internal Defenses" } } @@ -59,7 +71,7 @@ value class ShipModulesStatus(val statuses: Map) { operator fun get(module: ShipModule) = statuses[module] ?: ShipModuleStatus.ABSENT fun repair(module: ShipModule, repairUnrepairable: Boolean = false) = ShipModulesStatus( - statuses + if (this[module].canBeRepaired || (repairUnrepairable && !this[module].canBeUsed)) + statuses + if (this[module].canBeRepaired || (repairUnrepairable && this[module] in ShipModuleStatus.DAMAGED..ShipModuleStatus.DESTROYED)) mapOf(module to ShipModuleStatus.values()[this[module].ordinal - 1]) else emptyMap() ) @@ -89,10 +101,12 @@ value class ShipModulesStatus(val statuses: Map) { companion object { fun forShip(ship: Ship) = ShipModulesStatus( mapOf( + ShipModule.Assault to ShipModuleStatus.INTACT, + ShipModule.Defense to ShipModuleStatus.INTACT, ShipModule.Shields to if (ship.hasShields) ShipModuleStatus.INTACT else ShipModuleStatus.ABSENT, ShipModule.Engines to ShipModuleStatus.INTACT, ShipModule.Turrets to ShipModuleStatus.INTACT, - ) + ship.armaments.weapons.keys.associate { + ) + ship.armaments.keys.associate { ShipModule.Weapon(it) to ShipModuleStatus.INTACT } ) @@ -118,6 +132,7 @@ sealed class CritResult { object NoEffect : CritResult() data class FireStarted(val ship: ShipInstance) : CritResult() data class ModulesDisabled(val ship: ShipInstance, val modules: Set) : CritResult() + data class TroopsKilled(val ship: ShipInstance, val amount: Int) : CritResult() data class HullDamaged(val ship: ShipInstance, val amount: Int) : CritResult() data class Destroyed(val ship: ShipWreck) : CritResult() @@ -130,10 +145,10 @@ sealed class CritResult { } fun ShipInstance.doCriticalDamage(): CritResult { - if (!canCatchFire) - return doCriticalDamageUninflammable() + if (ship.shipType.faction == Faction.FELINAE_FELICES) + return doCriticalDamageFelinae() - return when (Random.nextInt(0..6) + Random.nextInt(0..6)) { // Ranges in 0..12, probability density peaks at 6 + return when ((0..8).random() + (0..8).random()) { // Ranges in 0..16, probability density peaks at 8 0 -> { // Damage ALL the modules! val modulesDamaged = modulesStatus.statuses.filter { (k, v) -> k !is ShipModule.Weapon && v != ShipModuleStatus.ABSENT }.keys @@ -144,7 +159,7 @@ fun ShipInstance.doCriticalDamage(): CritResult { } 1 -> { // Damage 3 weapons - val modulesDamaged = armaments.weaponInstances.keys.shuffled().take(3).map { ShipModule.Weapon(it) } + val modulesDamaged = armaments.keys.shuffled().take(3).map { ShipModule.Weapon(it) } CritResult.ModulesDisabled( copy(modulesStatus = modulesStatus.damageMany(modulesDamaged)), modulesDamaged.toSet() @@ -152,7 +167,7 @@ fun ShipInstance.doCriticalDamage(): CritResult { } 2 -> { // Damage 2 weapons - val modulesDamaged = armaments.weaponInstances.keys.shuffled().take(2).map { ShipModule.Weapon(it) } + val modulesDamaged = armaments.keys.shuffled().take(2).map { ShipModule.Weapon(it) } CritResult.ModulesDisabled( copy(modulesStatus = modulesStatus.damageMany(modulesDamaged)), modulesDamaged.toSet() @@ -168,7 +183,7 @@ fun ShipInstance.doCriticalDamage(): CritResult { } 4 -> { // Damage 1 weapon - val modulesDamaged = armaments.weaponInstances.keys.shuffled().take(1).map { ShipModule.Weapon(it) } + val modulesDamaged = armaments.keys.shuffled().take(1).map { ShipModule.Weapon(it) } CritResult.ModulesDisabled( copy(modulesStatus = modulesStatus.damageMany(modulesDamaged)), modulesDamaged.toSet() @@ -183,18 +198,31 @@ fun ShipInstance.doCriticalDamage(): CritResult { ) } 6 -> { + // Damage transportarium + val moduleDamaged = ShipModule.Assault + CritResult.ModulesDisabled( + copy(modulesStatus = modulesStatus.damage(moduleDamaged)), + setOf(moduleDamaged) + ) + } + 7 -> { + // Lose a few troops + val deaths = (1..2).random() + killTroops(deaths) + } + 8 -> { // Fire! CritResult.FireStarted( copy(numFires = numFires + 1) ) } - 7 -> { + 9 -> { // Two fires! CritResult.FireStarted( copy(numFires = numFires + 2) ) } - 8 -> { + 10 -> { // Damage turrets val moduleDamaged = ShipModule.Turrets CritResult.ModulesDisabled( @@ -202,7 +230,20 @@ fun ShipInstance.doCriticalDamage(): CritResult { setOf(moduleDamaged) ) } - 9 -> { + 11 -> { + // Lose many troops + val deaths = (1..2).random() + (1..2).random() + killTroops(deaths) + } + 12 -> { + // Damage security system + val moduleDamaged = ShipModule.Defense + CritResult.ModulesDisabled( + copy(modulesStatus = modulesStatus.damage(moduleDamaged)), + setOf(moduleDamaged) + ) + } + 13 -> { // Damage random module val moduleDamaged = modulesStatus.statuses.keys.filter { it !is ShipModule.Weapon }.random() CritResult.ModulesDisabled( @@ -210,7 +251,7 @@ fun ShipInstance.doCriticalDamage(): CritResult { setOf(moduleDamaged) ) } - 10 -> { + 14 -> { // Damage shields val moduleDamaged = ShipModule.Shields if (ship.hasShields) @@ -224,22 +265,22 @@ fun ShipInstance.doCriticalDamage(): CritResult { else CritResult.NoEffect } - 11 -> { + 15 -> { // Hull breach - val damage = Random.nextInt(0..2) + Random.nextInt(1..3) - CritResult.fromImpactResult(impact(damage)) + val damage = (0..2).random() + (1..3).random() + CritResult.fromImpactResult(impact(damage, true)) } - 12 -> { + 16 -> { // Bulkhead collapse - val damage = Random.nextInt(2..4) + Random.nextInt(3..5) - CritResult.fromImpactResult(impact(damage)) + val damage = (2..4).random() + (3..5).random() + CritResult.fromImpactResult(impact(damage, true)) } else -> CritResult.NoEffect } } -private fun ShipInstance.doCriticalDamageUninflammable(): CritResult { - return when (Random.nextInt(0..5) + Random.nextInt(0..5)) { +private fun ShipInstance.doCriticalDamageFelinae(): CritResult { + return when ((0..5).random() + (0..5).random()) { 0 -> { // Damage ALL the modules! val modulesDamaged = modulesStatus.statuses.filter { (k, v) -> k !is ShipModule.Weapon && v != ShipModuleStatus.ABSENT }.keys @@ -250,7 +291,7 @@ private fun ShipInstance.doCriticalDamageUninflammable(): CritResult { } 1 -> { // Damage 3 weapons - val modulesDamaged = armaments.weaponInstances.keys.shuffled().take(3).map { ShipModule.Weapon(it) } + val modulesDamaged = armaments.keys.shuffled().take(3).map { ShipModule.Weapon(it) } CritResult.ModulesDisabled( copy(modulesStatus = modulesStatus.damageMany(modulesDamaged)), modulesDamaged.toSet() @@ -258,7 +299,7 @@ private fun ShipInstance.doCriticalDamageUninflammable(): CritResult { } 2 -> { // Damage 2 weapons - val modulesDamaged = armaments.weaponInstances.keys.shuffled().take(2).map { ShipModule.Weapon(it) } + val modulesDamaged = armaments.keys.shuffled().take(2).map { ShipModule.Weapon(it) } CritResult.ModulesDisabled( copy(modulesStatus = modulesStatus.damageMany(modulesDamaged)), modulesDamaged.toSet() @@ -274,7 +315,7 @@ private fun ShipInstance.doCriticalDamageUninflammable(): CritResult { } 4 -> { // Damage 1 weapon - val modulesDamaged = armaments.weaponInstances.keys.shuffled().take(1).map { ShipModule.Weapon(it) } + val modulesDamaged = armaments.keys.shuffled().take(1).map { ShipModule.Weapon(it) } CritResult.ModulesDisabled( copy(modulesStatus = modulesStatus.damageMany(modulesDamaged)), modulesDamaged.toSet() @@ -305,27 +346,18 @@ private fun ShipInstance.doCriticalDamageUninflammable(): CritResult { ) } 8 -> { - // Damage shields - val moduleDamaged = ShipModule.Shields - if (ship.hasShields) - CritResult.ModulesDisabled( - copy( - shieldAmount = 0, - modulesStatus = modulesStatus.damage(moduleDamaged) - ), - setOf(moduleDamaged) - ) - else - CritResult.NoEffect + // Lose some troops + val deaths = (1..3).random() + killTroops(deaths) } 9 -> { // Hull breach - val damage = Random.nextInt(0..2) + Random.nextInt(1..3) + val damage = (0..2).random() + (1..3).random() CritResult.fromImpactResult(impact(damage)) } 10 -> { // Bulkhead collapse - val damage = Random.nextInt(2..4) + Random.nextInt(3..5) + val damage = (2..4).random() + (3..5).random() CritResult.fromImpactResult(impact(damage)) } else -> CritResult.NoEffect diff --git a/src/commonMain/kotlin/starshipfights/game/ship_types.kt b/src/commonMain/kotlin/starshipfights/game/ship_types.kt index 4dc941b..dace42a 100644 --- a/src/commonMain/kotlin/starshipfights/game/ship_types.kt +++ b/src/commonMain/kotlin/starshipfights/game/ship_types.kt @@ -200,7 +200,7 @@ enum class ShipType( } val ShipType.pointCost: Int - get() = weightClass.basePointCost + armaments.weapons.values.sumOf { it.addsPointCost } + get() = weightClass.basePointCost + armaments.values.sumOf { it.addsPointCost } val ShipType.meshName: String get() = "${faction.meshTag}-${weightClass.meshIndex}-${toUrlSlug()}-class" diff --git a/src/commonMain/kotlin/starshipfights/game/ship_weapons.kt b/src/commonMain/kotlin/starshipfights/game/ship_weapons.kt index 97407c2..73cf8f9 100644 --- a/src/commonMain/kotlin/starshipfights/game/ship_weapons.kt +++ b/src/commonMain/kotlin/starshipfights/game/ship_weapons.kt @@ -2,7 +2,6 @@ package starshipfights.game import kotlinx.serialization.Serializable import starshipfights.data.Id -import kotlin.jvm.JvmInline import kotlin.math.* import kotlin.random.Random @@ -282,19 +281,11 @@ sealed class ShipWeaponInstance { } } -@JvmInline -@Serializable -value class ShipArmaments( - val weapons: Map, ShipWeapon> -) { - fun instantiate() = ShipInstanceArmaments(weapons.mapValues { (_, weapon) -> weapon.instantiate() }) -} +typealias ShipArmaments = Map, ShipWeapon> -@JvmInline -@Serializable -value class ShipInstanceArmaments( - val weaponInstances: Map, ShipWeaponInstance> -) +fun ShipArmaments.instantiate() = mapValues { (_, weapon) -> weapon.instantiate() } + +typealias ShipInstanceArmaments = Map, ShipWeaponInstance> fun cannonChanceToHit(attacker: ShipInstance, targeted: ShipInstance): Double { val relativeDistance = attacker.position.location - targeted.position.location @@ -349,6 +340,10 @@ fun ShipInstance.felinaeArmorIgnoreDamageChance(): Double { return -expm1(-exponent) } +fun ShipInstance.killTroops(damage: Int) = if (damage >= troopsAmount) + CritResult.Destroyed(ShipWreck(ship, owner)) +else CritResult.TroopsKilled(copy(troopsAmount = troopsAmount - damage), damage) + fun ShipInstance.impact(damage: Int, ignoreShields: Boolean = false) = if (durability is FelinaeShipDurability && Random.nextDouble() < felinaeArmorIgnoreDamageChance()) ImpactResult.Intact(this, DamageIgnoreType.FELINAE_ARMOR) else if (ignoreShields) { @@ -367,42 +362,42 @@ data class ShipHangarWing( val hangar: Id ) -fun ShipInstance.afterUsing(weaponId: Id) = when (val weapon = armaments.weaponInstances.getValue(weaponId)) { +fun ShipInstance.afterUsing(weaponId: Id) = when (val weapon = armaments.getValue(weaponId)) { is ShipWeaponInstance.Cannon -> { copy(weaponAmount = weaponAmount - 1, usedArmaments = usedArmaments + setOf(weaponId)) } is ShipWeaponInstance.Lance -> { - val newWeapons = armaments.weaponInstances + mapOf( + val newWeapons = armaments + mapOf( weaponId to weapon.copy(numCharges = 0.0) ) - copy(armaments = ShipInstanceArmaments(newWeapons), usedArmaments = usedArmaments + setOf(weaponId)) + copy(armaments = newWeapons, usedArmaments = usedArmaments + setOf(weaponId)) } is ShipWeaponInstance.MegaCannon -> { - val newWeapons = armaments.weaponInstances + mapOf( + val newWeapons = armaments + mapOf( weaponId to weapon.copy(remainingShots = weapon.remainingShots - 1) ) - copy(armaments = ShipInstanceArmaments(newWeapons), usedArmaments = usedArmaments + setOf(weaponId)) + copy(armaments = newWeapons, usedArmaments = usedArmaments + setOf(weaponId)) } is ShipWeaponInstance.RevelationGun -> { - val newWeapons = armaments.weaponInstances + mapOf( + val newWeapons = armaments + mapOf( weaponId to weapon.copy(remainingShots = weapon.remainingShots - 1) ) - copy(armaments = ShipInstanceArmaments(newWeapons), usedArmaments = usedArmaments + setOf(weaponId)) + copy(armaments = newWeapons, usedArmaments = usedArmaments + setOf(weaponId)) } is ShipWeaponInstance.EmpAntenna -> { - val newWeapons = armaments.weaponInstances + mapOf( + val newWeapons = armaments + mapOf( weaponId to weapon.copy(remainingShots = weapon.remainingShots - 1) ) - copy(armaments = ShipInstanceArmaments(newWeapons), usedArmaments = usedArmaments + setOf(weaponId)) + copy(armaments = newWeapons, usedArmaments = usedArmaments + setOf(weaponId)) } else -> copy(usedArmaments = usedArmaments + setOf(weaponId)) } -fun ShipInstance.afterTargeted(by: ShipInstance, weaponId: Id) = when (val weapon = by.armaments.weaponInstances.getValue(weaponId)) { +fun ShipInstance.afterTargeted(by: ShipInstance, weaponId: Id) = when (val weapon = by.armaments.getValue(weaponId)) { is ShipWeaponInstance.Cannon -> { var hits = 0 @@ -479,11 +474,11 @@ fun ShipInstance.calculateBombing(otherShips: Map, ShipInstance return null val totalFighterHealth = fighterWings.sumOf { (carrierId, wingId) -> - (otherShips[carrierId]?.armaments?.weaponInstances?.get(wingId) as? ShipWeaponInstance.Hangar)?.wingHealth ?: 0.0 + (otherShips[carrierId]?.armaments?.get(wingId) as? ShipWeaponInstance.Hangar)?.wingHealth ?: 0.0 } + durability.turretDefense + extraFighters val totalBomberHealth = bomberWings.sumOf { (carrierId, wingId) -> - (otherShips[carrierId]?.armaments?.weaponInstances?.get(wingId) as? ShipWeaponInstance.Hangar)?.wingHealth ?: 0.0 + (otherShips[carrierId]?.armaments?.get(wingId) as? ShipWeaponInstance.Hangar)?.wingHealth ?: 0.0 } + extraBombers if (totalBomberHealth < EPSILON) @@ -514,13 +509,13 @@ fun ShipInstance.afterBombed(otherShips: Map, ShipInstance>, st } fun ShipInstance.afterBombing(strikeWingDamage: Map): ShipInstance { - val newArmaments = armaments.weaponInstances.mapValues { (weaponId, weapon) -> + val newArmaments = armaments.mapValues { (weaponId, weapon) -> if (weapon is ShipWeaponInstance.Hangar) weapon.copy(wingHealth = weapon.wingHealth - (strikeWingDamage[ShipHangarWing(id, weaponId)] ?: 0.0)) else weapon }.filterValues { it !is ShipWeaponInstance.Hangar || it.wingHealth > 0.0 } - return copy(armaments = ShipInstanceArmaments(newArmaments)) + return copy(armaments = newArmaments) } fun ImpactResult.Damaged.withCritResult(critical: CritResult): ImpactResult = when (critical) { @@ -535,6 +530,11 @@ fun ImpactResult.Damaged.withCritResult(critical: CritResult): ImpactResult = wh damage = damage, critical = critical ) + is CritResult.TroopsKilled -> copy( + ship = critical.ship, + damage = damage, + critical = critical + ) is CritResult.HullDamaged -> copy( ship = critical.ship, damage = damage + critical.amount, @@ -573,7 +573,7 @@ fun ImpactResult.applyStrikeCraftCriticals(criticalChance: Double): ImpactResult fun criticalChance(attacker: ShipInstance, weaponId: Id, targeted: ShipInstance): Double { val targetHasShields = targeted.canUseShields && targeted.shieldAmount > 0 - val weapon = attacker.armaments.weaponInstances[weaponId] ?: return 0.0 + val weapon = attacker.armaments[weaponId] ?: return 0.0 return when (weapon) { is ShipWeaponInstance.Torpedo -> if (targetHasShields) 0.0 else 0.375 @@ -635,33 +635,33 @@ fun ShipInstance.getWeaponPickRequest(weapon: ShipWeapon): PickRequest = when (w fun ImpactResult.toChatEntry(attacker: ShipAttacker, weapon: ShipWeaponInstance?) = when (this) { is ImpactResult.Damaged -> when (damage) { is ImpactDamage.Success -> ChatEntry.ShipAttacked( - ship.id, - attacker, - Moment.now, - damage.amount, - weapon?.weapon, - critical.report(), + ship = ship.id, + attacker = attacker, + sentAt = Moment.now, + damageInflicted = damage.amount, + weapon = weapon?.weapon, + critical = critical.report(), ) is ImpactDamage.Failed -> ChatEntry.ShipAttackFailed( - ship.id, - attacker, - Moment.now, - weapon?.weapon, - damage.ignore + ship = ship.id, + attacker = attacker, + sentAt = Moment.now, + weapon = weapon?.weapon, + damageIgnoreType = damage.ignore ) else -> null } is ImpactResult.Destroyed -> { ChatEntry.ShipDestroyed( - ship.id, - Moment.now, - attacker, + ship = ship.id, + sentAt = Moment.now, + destroyedBy = attacker, ) } } fun GameState.useWeaponPickResponse(attacker: ShipInstance, weaponId: Id, target: PickResponse): GameEvent { - val weapon = attacker.armaments.weaponInstances[weaponId] ?: return GameEvent.InvalidAction("That weapon does not exist") + val weapon = attacker.armaments[weaponId] ?: return GameEvent.InvalidAction("That weapon does not exist") return when (val weaponType = weapon.weapon) { is AreaWeapon -> { diff --git a/src/commonMain/kotlin/starshipfights/game/ship_weapons_formats.kt b/src/commonMain/kotlin/starshipfights/game/ship_weapons_formats.kt index 989e920..4218eb3 100644 --- a/src/commonMain/kotlin/starshipfights/game/ship_weapons_formats.kt +++ b/src/commonMain/kotlin/starshipfights/game/ship_weapons_formats.kt @@ -68,7 +68,7 @@ fun mechyrdiaShipWeapons( idCounter.add(weapons, ShipWeapon.Lance(1, FiringArc.FIRE_BROADSIDE, "Dorsal lance turrets")) } - return ShipArmaments(weapons) + return weapons } fun mechyrdiaNanoClassWeapons(): ShipArmaments { @@ -77,7 +77,7 @@ fun mechyrdiaNanoClassWeapons(): ShipArmaments { idCounter.add(weapons, ShipWeapon.Lance(2, FiringArc.FIRE_FORE_270, "Dorsal lance turrets")) - return ShipArmaments(weapons) + return weapons } fun mechyrdiaPicoClassWeapons(): ShipArmaments { @@ -87,7 +87,7 @@ fun mechyrdiaPicoClassWeapons(): ShipArmaments { idCounter.add(weapons, ShipWeapon.Cannon(2, FiringArc.FIRE_FORE_270, "Double-barrel cannon turret")) idCounter.add(weapons, ShipWeapon.Torpedo(setOf(FiringArc.BOW), "Fore torpedo launcher")) - return ShipArmaments(weapons) + return weapons } fun ndrcShipWeapons( @@ -123,7 +123,7 @@ fun ndrcShipWeapons( idCounter.add(weapons, ShipWeapon.Lance(numBroadsideLances, setOf(FiringArc.ABEAM_STARBOARD), "Starboard lance battery")) } - return ShipArmaments(weapons) + return weapons } fun diadochiShipWeapons( @@ -166,7 +166,7 @@ fun diadochiShipWeapons( idCounter.add(weapons, ShipWeapon.Lance(2, FiringArc.FIRE_FORE_270, "Dorsal lance batteries")) } - return ShipArmaments(weapons) + return weapons } fun felinaeShipWeapons( @@ -185,7 +185,7 @@ fun felinaeShipWeapons( idCounter.add(weapons, ShipWeapon.LightningYarn(num, arcs, "$displayName lightning yarn")) } - return ShipArmaments(weapons) + return weapons } fun fulkreykkShipWeapons( @@ -214,7 +214,7 @@ fun fulkreykkShipWeapons( idCounter.add(weapons, ShipWeapon.Lance(2, FiringArc.FIRE_BROADSIDE, "Broadside lance battery")) } - return ShipArmaments(weapons) + return weapons } fun vestigiumShipWeapons( @@ -242,5 +242,5 @@ fun vestigiumShipWeapons( idCounter.add(weapons, ShipWeapon.Hangar(StrikeCraftWing.BOMBERS, "Bomber complement")) } - return ShipArmaments(weapons) + return weapons } diff --git a/src/jsMain/kotlin/starshipfights/game/game_ui.kt b/src/jsMain/kotlin/starshipfights/game/game_ui.kt index 0c6d319..8b1be0a 100644 --- a/src/jsMain/kotlin/starshipfights/game/game_ui.kt +++ b/src/jsMain/kotlin/starshipfights/game/game_ui.kt @@ -271,6 +271,7 @@ object GameUI { +when (entry.critical) { ShipCritical.Fire -> ", starting a fire" + is ShipCritical.TroopsKilled -> ", killing ${entry.critical.number} troops" is ShipCritical.ModulesHit -> ", disabling ${entry.critical.module.joinToDisplayString { it.getDisplayName(ship) }}" else -> "" } @@ -328,6 +329,33 @@ object GameUI { } +"." } + is ChatEntry.ShipBoarded -> { + val ship = state.getShipInfo(entry.ship) + val owner = state.getShipOwner(entry.ship).relativeTo(mySide) + +if (owner == LocalSide.RED) + "The enemy ship " + else + "Our ship, the " + strong { + style = "color:${owner.htmlColor}" + +ship.fullName + } + + +" has been boarded by the " + strong { + style = "color:${owner.other.htmlColor}" + +state.getShipInfo(entry.boarder).fullName + } + + +when (entry.critical) { + ShipCritical.ExtraDamage -> ", dealing ${entry.damageAmount} hull damage" + ShipCritical.Fire -> ", starting a fire" + is ShipCritical.TroopsKilled -> ", killing ${entry.critical.number} troops" + is ShipCritical.ModulesHit -> ", disabling ${entry.critical.module.joinToDisplayString { it.getDisplayName(ship) }}" + else -> ", to no effect" + } + +"." + } is ChatEntry.ShipDestroyed -> { val ship = state.getShipInfo(entry.ship) val owner = state.getShipOwner(entry.ship).relativeTo(mySide) @@ -488,7 +516,7 @@ object GameUI { val downShield = totalShield - activeShield table { - style = "width:100%;table-layout:fixed;background-color:#555;margin:0;margin-bottom:25px" + style = "width:100%;table-layout:fixed;background-color:#555;margin:0;margin-bottom:10px" tr { repeat(activeShield) { @@ -510,7 +538,7 @@ object GameUI { val downHull = totalHull - activeHull table { - style = "width:100%;table-layout:fixed;background-color:#555;margin:0;margin-bottom:25px" + style = "width:100%;table-layout:fixed;background-color:#555;margin:0;margin-bottom:10px" tr { repeat(activeHull) { @@ -526,6 +554,27 @@ object GameUI { } } + val totalTroops = ship.durability.troopsDefense + val activeTroops = ship.troopsAmount + val downTroops = totalTroops - activeTroops + + table { + style = "width:100%;table-layout:fixed;background-color:#555;margin:0;margin-bottom:10px" + + tr { + repeat(activeTroops) { + td { + style = "background-color:#AAA;height:15px;box-shadow:inset 0 0 0 3px #555" + } + } + repeat(downTroops) { + td { + style = "background-color:#444;height:15px;box-shadow:inset 0 0 0 3px #555" + } + } + } + } + if (ship.ship.reactor is StandardShipReactor) { if (ship.owner == mySide) { val totalWeapons = ship.powerMode.weapons @@ -578,7 +627,7 @@ object GameUI { +Entities.nbsp +ship.fighterWings.sumOf { (carrierId, wingId) -> - (state.ships[carrierId]?.armaments?.weaponInstances?.get(wingId) as? ShipWeaponInstance.Hangar)?.wingHealth ?: 0.0 + (state.ships[carrierId]?.armaments?.get(wingId) as? ShipWeaponInstance.Hangar)?.wingHealth ?: 0.0 }.toPercent() } } @@ -601,7 +650,7 @@ object GameUI { +Entities.nbsp +ship.bomberWings.sumOf { (carrierId, wingId) -> - (state.ships[carrierId]?.armaments?.weaponInstances?.get(wingId) as? ShipWeaponInstance.Hangar)?.wingHealth ?: 0.0 + (state.ships[carrierId]?.armaments?.get(wingId) as? ShipWeaponInstance.Hangar)?.wingHealth ?: 0.0 }.toPercent() } } @@ -908,6 +957,16 @@ object GameUI { } br } + is PlayerAbilityType.BoardingParty -> { + a(href = "#") { + +"Board Enemy Vessel" + onClickFunction = { e -> + e.preventDefault() + responder.useAbility(ability) + } + } + br + } is PlayerAbilityType.RepairShipModule -> { a(href = "#") { +"Repair ${ability.module.getDisplayName(ship.ship)}" @@ -944,7 +1003,7 @@ object GameUI { for (ability in combatAbilities) { br - val weaponInstance = ship.armaments.weaponInstances.getValue(ability.weapon) + val weaponInstance = ship.armaments.getValue(ability.weapon) val weaponVerb = if (weaponInstance is ShipWeaponInstance.Hangar) "Release" else "Fire" val weaponDesc = weaponInstance.displayName diff --git a/src/jsMain/resources/images/assault-action.svg b/src/jsMain/resources/images/assault-action.svg new file mode 100644 index 0000000..1330c6f --- /dev/null +++ b/src/jsMain/resources/images/assault-action.svg @@ -0,0 +1,11 @@ + + + + diff --git a/src/jsMain/resources/images/defense-action.svg b/src/jsMain/resources/images/defense-action.svg new file mode 100644 index 0000000..784bd75 --- /dev/null +++ b/src/jsMain/resources/images/defense-action.svg @@ -0,0 +1,11 @@ + + + + diff --git a/src/jvmMain/kotlin/starshipfights/info/views_ships.kt b/src/jvmMain/kotlin/starshipfights/info/views_ships.kt index 8753549..3d87560 100644 --- a/src/jvmMain/kotlin/starshipfights/info/views_ships.kt +++ b/src/jvmMain/kotlin/starshipfights/info/views_ships.kt @@ -164,7 +164,7 @@ suspend fun ApplicationCall.shipPage(shipType: ShipType): HTML.() -> Unit = page th { +"Firepower" } } - for ((label, weapons) in shipType.armaments.weapons.values.groupBy { it.groupLabel }) { + for ((label, weapons) in shipType.armaments.values.groupBy { it.groupLabel }) { val weapon = weapons.distinct().single() val numShots = weapons.sumOf { it.numShots }