diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsConstants.kt b/Server/src/main/content/minigame/castlewars/CastleWarsConstants.kt index 7d9b92cf1..4ce8e7fc0 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsConstants.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsConstants.kt @@ -42,6 +42,9 @@ object CastleWarsConstants { /** Minimum players per team required to start a game */ const val MIN_PLAYERS_PER_TEAM = 2 + /** Combat-level gap that opens a Castle Wars substitution vacancy */ + const val COMBAT_LEVEL_VACANCY_THRESHOLD = 60 + /** Catapult targeting inaccuracy radius */ const val CATAPULT_INACCURACY_RADIUS = 2 diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsListeners.kt b/Server/src/main/content/minigame/castlewars/CastleWarsListeners.kt index 56ce42fc0..c1daeb0a0 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsListeners.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsListeners.kt @@ -216,26 +216,9 @@ class CastleWarsListeners : InteractionListener { on(CastleWars.joinGuthixTeamPortal, IntType.SCENERY, "Enter") { player, _ -> if (GameWorld.settings?.enable_castle_wars != true) return@on false if (!player.isArtificial && joinError(player)) return@on true - val saraTotal = - CastleWarsWaitingArea.waitingSaradominPlayers.size + CastleWarsGameArea.saradominPlayers.size - val zamTotal = CastleWarsWaitingArea.waitingZamorakPlayers.size + CastleWarsGameArea.zamorakPlayers.size - when { - saraTotal < zamTotal -> { - player.properties.teleportLocation = CastleWarsWaitingArea.saradominWaitingRoom.randomWalkableLoc - } - - saraTotal > zamTotal -> { - player.properties.teleportLocation = CastleWarsWaitingArea.zamorakWaitingRoom.randomWalkableLoc - } - - else -> { - if (Math.random() < 0.5) { - player.properties.teleportLocation = - CastleWarsWaitingArea.saradominWaitingRoom.randomWalkableLoc - } else { - player.properties.teleportLocation = CastleWarsWaitingArea.zamorakWaitingRoom.randomWalkableLoc - } - } + player.properties.teleportLocation = when (CastleWarsGameArea.selectGuthixTeam()) { + CastleWarsTeam.SARADOMIN -> CastleWarsWaitingArea.saradominWaitingRoom.randomWalkableLoc + CastleWarsTeam.ZAMORAK -> CastleWarsWaitingArea.zamorakWaitingRoom.randomWalkableLoc } player.setAttribute(CastleWars.portalAttribute, CastleWars.guthixName) return@on true @@ -390,11 +373,13 @@ class CastleWarsListeners : InteractionListener { } on(CastleWars.saradominExitPortal, IntType.SCENERY, "Leave") { player, _ -> + CastleWarsGameArea.removePlayerFromGame(player) player.properties.teleportLocation = CastleWars.lobbyBankArea.randomWalkableLoc sendMessage(player, "You have left the Castle Wars game.") return@on true } on(CastleWars.zamorakExitPortal, IntType.SCENERY, "Leave") { player, _ -> + CastleWarsGameArea.removePlayerFromGame(player) player.properties.teleportLocation = CastleWars.lobbyBankArea.randomWalkableLoc sendMessage(player, "You have left the Castle Wars game.") return@on true diff --git a/Server/src/main/content/minigame/castlewars/areas/CastleWarsGameArea.kt b/Server/src/main/content/minigame/castlewars/areas/CastleWarsGameArea.kt index 78f98f1cb..5114d4e29 100644 --- a/Server/src/main/content/minigame/castlewars/areas/CastleWarsGameArea.kt +++ b/Server/src/main/content/minigame/castlewars/areas/CastleWarsGameArea.kt @@ -69,7 +69,11 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener { var ticksLeftInGame = -1 + private val activeVacancyOffers = mutableMapOf>() + private val declinedSubstitutionOffers = mutableMapOf>() + fun startGame() { + clearSubstitutionState() CastleWarsGameState.resetGame() CastleWarsItemHandler.clearAllBarricades() CastleWarsWaitingArea.gameStartCountdown = -1 @@ -101,32 +105,120 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener { return CastleWarsGameZone.handleCastleWarsDeath(player) } + fun clearSubstitutionState() { + activeVacancyOffers.clear() + declinedSubstitutionOffers.clear() + } + + fun selectGuthixTeam(): CastleWarsTeam { + combatLevelVacancyTeam()?.let { return it } + + val saraTotal = CastleWarsWaitingArea.waitingSaradominPlayers.size + saradominPlayers.size + val zamTotal = CastleWarsWaitingArea.waitingZamorakPlayers.size + zamorakPlayers.size + return when { + saraTotal < zamTotal -> CastleWarsTeam.SARADOMIN + saraTotal > zamTotal -> CastleWarsTeam.ZAMORAK + Math.random() < 0.5 -> CastleWarsTeam.SARADOMIN + else -> CastleWarsTeam.ZAMORAK + } + } + + fun combatLevelVacancyTeam(): CastleWarsTeam? { + if (ticksLeftInGame <= 0) return null + val saraTotal = teamCombatLevelTotal(saradominPlayers) + val zamTotal = teamCombatLevelTotal(zamorakPlayers) + return when { + zamTotal - saraTotal > CastleWarsConstants.COMBAT_LEVEL_VACANCY_THRESHOLD -> CastleWarsTeam.SARADOMIN + saraTotal - zamTotal > CastleWarsConstants.COMBAT_LEVEL_VACANCY_THRESHOLD -> CastleWarsTeam.ZAMORAK + else -> null + } + } + + private fun teamCombatLevelTotal(players: Set): Int { + return players.sumOf { it.properties.currentCombatLevel } + } + /** - * Try to fill a vacancy on the given team by pulling a player from the waiting room. + * Offer a vacancy on the given team to every eligible player in that team's waiting room. * Called when a player leaves an active game. * @param team The team that lost a player */ fun fillVacancy(team: CastleWarsTeam) { if (ticksLeftInGame <= 0) return + if (activeVacancyOffers[team]?.isNotEmpty() == true) return val waitingSet = when (team) { CastleWarsTeam.SARADOMIN -> CastleWarsWaitingArea.waitingSaradominPlayers CastleWarsTeam.ZAMORAK -> CastleWarsWaitingArea.waitingZamorakPlayers } + val declined = declinedSubstitutionOffers[team].orEmpty() + val candidates = waitingSet.filter { it !in declined } + if (candidates.isEmpty()) return + activeVacancyOffers[team] = candidates.toMutableSet() + for (candidate in candidates) { + openDialogue(candidate, CastleWarsSubstitutionDialogue(team)) + } + } + + fun offerCombatLevelVacancyIfNeeded() { + val team = combatLevelVacancyTeam() ?: return + fillVacancy(team) + } + + fun claimVacancy(player: Player, team: CastleWarsTeam): Boolean { + val offer = activeVacancyOffers[team] ?: return false + if (!offer.contains(player)) return false + val waitingSet = when (team) { + CastleWarsTeam.SARADOMIN -> CastleWarsWaitingArea.waitingSaradominPlayers + CastleWarsTeam.ZAMORAK -> CastleWarsWaitingArea.waitingZamorakPlayers + } + if (!waitingSet.remove(player)) return false + activeVacancyOffers.remove(team) + declinedSubstitutionOffers[team]?.remove(player) val gameSet = when (team) { CastleWarsTeam.SARADOMIN -> saradominPlayers CastleWarsTeam.ZAMORAK -> zamorakPlayers } - if (waitingSet.isEmpty()) return - val replacement = waitingSet.first() - waitingSet.remove(replacement) - gameSet.add(replacement) - setAttribute(replacement, CastleWarsConstants.ATTR_JOIN_TICK, getWorldTicks()) + gameSet.add(player) + setAttribute(player, CastleWarsConstants.ATTR_JOIN_TICK, getWorldTicks()) val respawnLoc = when (team) { CastleWarsTeam.SARADOMIN -> CastleWarsRespawnArea.saradominRespawnRoom.randomWalkableLoc CastleWarsTeam.ZAMORAK -> CastleWarsRespawnArea.zamorakRespawnRoom.randomWalkableLoc } - replacement.properties.teleportLocation = respawnLoc - sendMessage(replacement, "A spot has opened up! You have been moved into the Castle Wars game.") + player.properties.teleportLocation = respawnLoc + sendMessage(player, "A spot has opened up! You have been moved into the Castle Wars game.") + offerCombatLevelVacancyIfNeeded() + return true + } + + fun declineVacancyOffer(player: Player, team: CastleWarsTeam) { + activeVacancyOffers[team]?.remove(player) + declinedSubstitutionOffers.getOrPut(team) { mutableSetOf() }.add(player) + if (activeVacancyOffers[team]?.isEmpty() == true) { + activeVacancyOffers.remove(team) + } + } + + fun removePlayerFromGame(player: Player, offerReplacement: Boolean = true): CastleWarsTeam? { + val leavingTeam = when { + saradominPlayers.contains(player) -> CastleWarsTeam.SARADOMIN + zamorakPlayers.contains(player) -> CastleWarsTeam.ZAMORAK + else -> null + } + if (leavingTeam == null) return null + + CastleWarsGameState.handlePlayerLeave(player) + player.unhook(SaradominInterfaceCloseHook) + player.unhook(ZamorakInterfaceCloseHook) + loggedOutPlayers.remove(player) + saradominPlayers.remove(player) + zamorakPlayers.remove(player) + CastleWarsGameState.clearHintIconForPlayer(player) + player.interaction.remove(Option._P_ATTACK) + if (offerReplacement) { + fillVacancy(leavingTeam) + offerCombatLevelVacancyIfNeeded() + } + return leavingTeam } object SaradominInterfaceCloseHook : EventHook { // TODO: this might be useless. @@ -160,6 +252,7 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener { CastleWarsWaitingArea.postGameCooldown = true CastleWarsGameState.endGame() CastleWarsItemHandler.clearAllBarricades() + clearSubstitutionState() ticksLeftInGame = -1 saradominPlayers.forEach { player -> player.unhook(SaradominInterfaceCloseHook) @@ -218,17 +311,7 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener { CastleWarsRespawnArea.saradominRespawnRoom.insideBorder(player.location) || CastleWarsRespawnArea.zamorakRespawnRoom.insideBorder(player.location) if (!stillInCastleWars) { - val leavingTeam = CastleWarsGameState.getPlayerTeam(player) - CastleWarsGameState.handlePlayerLeave(player) - player.unhook(SaradominInterfaceCloseHook) - player.unhook(ZamorakInterfaceCloseHook) - saradominPlayers.remove(player) - zamorakPlayers.remove(player) - CastleWarsGameState.clearHintIconForPlayer(player) - player.interaction.remove(Option._P_ATTACK) - if (leavingTeam != null) { - fillVacancy(leavingTeam) - } + removePlayerFromGame(player) } } @@ -282,6 +365,7 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener { } CastleWarsGameState.checkFlagCarriers() CastleWarsGameState.checkDroppedFlagTimeouts() + offerCombatLevelVacancyIfNeeded() for (player in saradominPlayers) { val currentOverlayId = player.interfaceManager.overlay?.id if (currentOverlayId == null || currentOverlayId != 58) { diff --git a/Server/src/main/content/minigame/castlewars/areas/CastleWarsWaitingArea.kt b/Server/src/main/content/minigame/castlewars/areas/CastleWarsWaitingArea.kt index 6564cc55d..3e1106ba1 100644 --- a/Server/src/main/content/minigame/castlewars/areas/CastleWarsWaitingArea.kt +++ b/Server/src/main/content/minigame/castlewars/areas/CastleWarsWaitingArea.kt @@ -104,6 +104,8 @@ class CastleWarsWaitingArea : CastleWarsArea(), TickListener { // Open the white text overlay player.interfaceManager.openOverlay(Component(Components.CASTLEWARS_STATUS_OVERLAY_57)) + + CastleWarsGameArea.offerCombatLevelVacancyIfNeeded() } override fun exitArea(player: Player) { diff --git a/Server/src/test/kotlin/content/minigame/castlewars/CastleWarsTests.kt b/Server/src/test/kotlin/content/minigame/castlewars/CastleWarsTests.kt index dba17d5f4..8c7206a7c 100644 --- a/Server/src/test/kotlin/content/minigame/castlewars/CastleWarsTests.kt +++ b/Server/src/test/kotlin/content/minigame/castlewars/CastleWarsTests.kt @@ -1,15 +1,17 @@ package content.minigame.castlewars import TestUtils -import content.minigame.castlewars.areas.CastleWarsGameArea -import content.minigame.castlewars.areas.CastleWarsWaitingArea import content.global.skill.thieving.Pickpockets +import content.minigame.castlewars.areas.CastleWarsGameArea +import content.minigame.castlewars.areas.CastleWarsRespawnArea +import content.minigame.castlewars.areas.CastleWarsWaitingArea import core.api.EquipmentSlot import core.game.node.entity.skill.Skills import core.game.global.action.EquipHandler import core.game.interaction.IntType import core.game.interaction.InteractionListener import core.game.interaction.InteractionListeners +import core.game.node.entity.player.Player import core.game.node.item.Item import core.game.node.scenery.Scenery import core.game.world.map.Location @@ -50,9 +52,105 @@ class CastleWarsTests { CastleWarsWaitingArea.gameStartCountdown = -1 CastleWarsWaitingArea.postGameCooldown = false CastleWarsGameArea.ticksLeftInGame = -1 + CastleWarsGameArea.clearSubstitutionState() CastleWarsGameState.resetGame() } + @Test + fun guthixPortalRoutesToNumericallySmallerTeamWhenNoCombatVacancyExists() { + TestUtils.getMockPlayer("cwsara1").use { sara1 -> + TestUtils.getMockPlayer("cwsara2").use { sara2 -> + TestUtils.getMockPlayer("cwzam1").use { zam1 -> + CastleWarsGameArea.saradominPlayers.add(sara1) + CastleWarsWaitingArea.waitingSaradominPlayers.add(sara2) + CastleWarsGameArea.zamorakPlayers.add(zam1) + + assertEquals(CastleWarsTeam.ZAMORAK, CastleWarsGameArea.selectGuthixTeam()) + } + } + } + } + + @Test + fun guthixPortalRoutesToCombatDisadvantagedTeamDuringActiveMatch() { + TestUtils.getMockPlayer("cwweak").use { saradomin -> + TestUtils.getMockPlayer("cwstrong").use { zamorak -> + CastleWarsGameArea.ticksLeftInGame = 100 + setCombatLevel(saradomin, 50) + setCombatLevel(zamorak, 120) + CastleWarsGameArea.saradominPlayers.add(saradomin) + CastleWarsGameArea.zamorakPlayers.add(zamorak) + + assertEquals(CastleWarsTeam.SARADOMIN, CastleWarsGameArea.selectGuthixTeam()) + } + } + } + + @Test + fun spawnRoomPortalOffersVacancyAndFirstAcceptanceWins() { + TestUtils.getMockPlayer("cwleaver").use { leaver -> + TestUtils.getMockPlayer("cwsub1").use { firstWaiting -> + TestUtils.getMockPlayer("cwsub2").use { secondWaiting -> + CastleWarsGameArea.ticksLeftInGame = 100 + CastleWarsGameArea.saradominPlayers.add(leaver) + CastleWarsWaitingArea.waitingSaradominPlayers.add(firstWaiting) + CastleWarsWaitingArea.waitingSaradominPlayers.add(secondWaiting) + + CastleWarsGameArea.removePlayerFromGame(leaver) + + assertFalse(CastleWarsGameArea.saradominPlayers.contains(leaver)) + assertSubstitutionDialogue(firstWaiting) + assertSubstitutionDialogue(secondWaiting) + + assertTrue(CastleWarsGameArea.claimVacancy(secondWaiting, CastleWarsTeam.SARADOMIN)) + assertFalse(CastleWarsGameArea.claimVacancy(firstWaiting, CastleWarsTeam.SARADOMIN)) + assertTrue(CastleWarsGameArea.saradominPlayers.contains(secondWaiting)) + assertFalse(CastleWarsWaitingArea.waitingSaradominPlayers.contains(secondWaiting)) + assertTrue(CastleWarsWaitingArea.waitingSaradominPlayers.contains(firstWaiting)) + assertTrue(CastleWarsRespawnArea.saradominRespawnRoom.insideBorder(secondWaiting.properties.teleportLocation)) + } + } + } + } + + @Test + fun decliningSubstitutionKeepsPlayerInWaitingRoom() { + TestUtils.getMockPlayer("cwdecline").use { waiting -> + CastleWarsGameArea.ticksLeftInGame = 100 + CastleWarsWaitingArea.waitingZamorakPlayers.add(waiting) + + CastleWarsGameArea.fillVacancy(CastleWarsTeam.ZAMORAK) + CastleWarsGameArea.declineVacancyOffer(waiting, CastleWarsTeam.ZAMORAK) + + assertTrue(CastleWarsWaitingArea.waitingZamorakPlayers.contains(waiting)) + assertFalse(CastleWarsGameArea.zamorakPlayers.contains(waiting)) + assertFalse(CastleWarsGameArea.claimVacancy(waiting, CastleWarsTeam.ZAMORAK)) + } + } + + @Test + fun combatLevelImbalanceAutomaticallyOffersVacancyToDisadvantagedWaitingRoom() { + TestUtils.getMockPlayer("cwweak2").use { saradomin -> + TestUtils.getMockPlayer("cwstrong2").use { zamorak -> + TestUtils.getMockPlayer("cwsub3").use { waiting -> + CastleWarsGameArea.ticksLeftInGame = 100 + setCombatLevel(saradomin, 50) + setCombatLevel(zamorak, 120) + CastleWarsGameArea.saradominPlayers.add(saradomin) + CastleWarsGameArea.zamorakPlayers.add(zamorak) + CastleWarsWaitingArea.waitingSaradominPlayers.add(waiting) + + CastleWarsGameArea.offerCombatLevelVacancyIfNeeded() + + assertSubstitutionDialogue(waiting) + assertTrue(CastleWarsGameArea.claimVacancy(waiting, CastleWarsTeam.SARADOMIN)) + assertTrue(CastleWarsGameArea.saradominPlayers.contains(waiting)) + assertFalse(CastleWarsWaitingArea.waitingSaradominPlayers.contains(waiting)) + } + } + } + } + @Test fun idleGameAreaTickDoesNotCountDownForever() { CastleWarsGameArea.ticksLeftInGame = -1 @@ -274,4 +372,13 @@ class CastleWarsTests { } } } + + private fun assertSubstitutionDialogue(player: Player) { + assertTrue(player.dialogueInterpreter.dialogue.file is CastleWarsSubstitutionDialogue) + } + + @Suppress("DEPRECATION") + private fun setCombatLevel(player: Player, level: Int) { + player.properties.setCombatLevel(level) + } }