diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsCommands.kt b/Server/src/main/content/minigame/castlewars/CastleWarsCommands.kt index 9f6dff6aa..0a29cd9be 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsCommands.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsCommands.kt @@ -32,10 +32,12 @@ class CastleWarsCommands : Commands { } val saraCount = CastleWarsWaitingArea.waitingSaradominPlayers.size val zamCount = CastleWarsWaitingArea.waitingZamorakPlayers.size - if (saraCount == 0 || zamCount == 0) { + if (saraCount < CastleWarsConstants.MIN_PLAYERS_PER_TEAM || + zamCount < CastleWarsConstants.MIN_PLAYERS_PER_TEAM + ) { sendMessage( player, - "Both teams need at least 1 player. Saradomin: $saraCount, Zamorak: $zamCount" + "Both teams need at least ${CastleWarsConstants.MIN_PLAYERS_PER_TEAM} players. Saradomin: $saraCount, Zamorak: $zamCount" ) return@define } diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsConstants.kt b/Server/src/main/content/minigame/castlewars/CastleWarsConstants.kt index 4ce8e7fc0..3dc4e1bb8 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsConstants.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsConstants.kt @@ -42,8 +42,8 @@ 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 + /** Castle Wars timing uses the server's 600ms game tick: 100 ticks = 1 minute */ + const val TICKS_PER_MINUTE = 100 /** Catapult targeting inaccuracy radius */ const val CATAPULT_INACCURACY_RADIUS = 2 @@ -120,21 +120,8 @@ object CastleWarsConstants { const val GAME_COOLDOWN_MINUTES = 5 // ==================== Waiting Room ==================== - /** Authentic waiting room countdown: GAME_COOLDOWN_MINUTES in ticks (5 min = 500 ticks) */ - const val WAITING_ROOM_COUNTDOWN_TICKS = GAME_COOLDOWN_MINUTES * 100 // ticksPerMinute = 100 - - /** Dev-mode fast countdown for testing (~10 seconds) */ - const val DEV_COUNTDOWN_TICKS = 17 - - /** Post-game shortened countdown in ticks (~2 minutes) for quick re-start */ - const val POST_GAME_COUNTDOWN_TICKS = 200 // 2 minutes = 200 ticks - - // ==================== Ticket Reward Requirements ==================== - /** Minimum play time in minutes to be eligible for tickets */ - const val MIN_PLAY_TIME_MINUTES = 10 - - /** Minimum team size for tickets to be awarded */ - const val MIN_TEAM_SIZE_FOR_TICKETS = 5 + /** Waiting-room cycle while no game is active: 2 minutes = 200 ticks */ + const val WAITING_ROOM_COUNTDOWN_TICKS = 2 * TICKS_PER_MINUTE // ==================== Player Attribute Keys ==================== /** Attribute key: world tick when player joined the current game */ @@ -145,11 +132,23 @@ object CastleWarsConstants { // ==================== Logout Grace Period ==================== /** Grace period after logout before removing player from team (2 minutes = 200 ticks) */ - const val LOGOUT_GRACE_PERIOD_TICKS = 200 + const val LOGOUT_GRACE_PERIOD_TICKS = 2 * TICKS_PER_MINUTE + + /** Wall-clock logout grace for saved login state (2 minutes) */ + const val LOGOUT_GRACE_PERIOD_MILLIS = 120_000L + + /** Saved attribute key: wall-clock millis when player logged out of an active match */ + const val ATTR_LOGOUT_TIME_MILLIS = "/save:cw:logout_time_millis" + + /** Saved attribute key: team name for an active-match logout */ + const val ATTR_LOGOUT_TEAM = "/save:cw:logout_team" + + /** Saved attribute key: exact active-match logout location */ + const val ATTR_LOGOUT_LOCATION = "/save:cw:logout_location" // ==================== Flag Steal Timer ==================== /** Time in ticks before teammates can steal the flag from a carrier (3 minutes = 300 ticks) */ - const val FLAG_STEAL_TIMER_TICKS = 300 + const val FLAG_STEAL_TIMER_TICKS = 3 * TICKS_PER_MINUTE /** Player attribute key for tracking when enemy flag was picked up */ const val ATTR_FLAG_PICKUP_TICK = "cw:flag_pickup_tick" diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsGameState.kt b/Server/src/main/content/minigame/castlewars/CastleWarsGameState.kt index aceccf790..7ccf9e916 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsGameState.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsGameState.kt @@ -18,7 +18,6 @@ import core.game.world.map.Location import core.game.world.map.RegionManager import core.game.world.map.zone.ZoneBorders import core.game.world.update.flag.chunk.ObjectUpdateFlag -import core.tools.ticksPerMinute import org.rs09.consts.Items /** @@ -226,7 +225,8 @@ object CastleWarsGameState { * Get the remaining game time in minutes */ val remainingMinutes: Int - get() = (CastleWarsGameArea.ticksLeftInGame + ticksPerMinute - 1) / ticksPerMinute + get() = (CastleWarsGameArea.ticksLeftInGame + CastleWarsConstants.TICKS_PER_MINUTE - 1) / + CastleWarsConstants.TICKS_PER_MINUTE /** * Reset the game state for a new game @@ -1083,24 +1083,14 @@ object CastleWarsGameState { */ fun endGame() { val allPlayers = getAllGamePlayers() - val saraCount = allPlayers.count { getPlayerTeam(it) == CastleWarsTeam.SARADOMIN } - val zamCount = allPlayers.count { getPlayerTeam(it) == CastleWarsTeam.ZAMORAK } - val teamsLargeEnough = - saraCount >= CastleWarsConstants.MIN_TEAM_SIZE_FOR_TICKETS && zamCount >= CastleWarsConstants.MIN_TEAM_SIZE_FOR_TICKETS for (player in allPlayers) { val team = getPlayerTeam(player) ?: continue - val baseTickets = when { + val tickets = when { saradominScore > zamorakScore && team == CastleWarsTeam.SARADOMIN -> 2 zamorakScore > saradominScore && team == CastleWarsTeam.ZAMORAK -> 2 saradominScore == zamorakScore -> 1 else -> 0 } - val tickets = - if (baseTickets > 0 && teamsLargeEnough && player.hasPlayedMinimumTime(CastleWarsConstants.MIN_PLAY_TIME_MINUTES)) { - baseTickets - } else { - 0 - } if (tickets > 0) { addItem(player, Items.CASTLE_WARS_TICKET_4067, tickets) sendMessage(player, "You have received $tickets Castle Wars ticket${if (tickets > 1) "s" else ""}!") @@ -1511,6 +1501,9 @@ object CastleWarsGameState { */ private fun getAllGamePlayers(): Set { val players = mutableSetOf() + players.addAll((CastleWarsGameArea.saradominPlayers + CastleWarsGameArea.zamorakPlayers).filter { + it.isActive && it.session != null + }) for (border in CastleWarsGameArea.areaBorders) { val centerX = (border.southWestX + border.northEastX) / 2 val centerY = (border.southWestY + border.northEastY) / 2 @@ -1676,18 +1669,6 @@ object CastleWarsGameState { } } -/** - * Check if this player has played for the minimum required time in the current game. - * @param minMinutes Minimum minutes required - * @return true if the player has been in the game for at least minMinutes - */ -fun Player.hasPlayedMinimumTime(minMinutes: Int): Boolean { - val joinTick = getAttribute(this, CastleWarsConstants.ATTR_JOIN_TICK, 0) - if (joinTick == 0) return false - val elapsed = getWorldTicks() - joinTick - return elapsed >= minMinutes * 100 // ticksPerMinute = 100 -} - /** * Check if the player has a Castle Wars bracelet equipped. * The bracelet gives bonus damage against flag carriers and improved bandage healing. diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsListeners.kt b/Server/src/main/content/minigame/castlewars/CastleWarsListeners.kt index c1daeb0a0..573e9b9d2 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsListeners.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsListeners.kt @@ -373,15 +373,11 @@ 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.") + leaveGameThroughPortal(player, CastleWarsTeam.SARADOMIN) 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.") + leaveGameThroughPortal(player, CastleWarsTeam.ZAMORAK) return@on true } @@ -1018,14 +1014,52 @@ class CastleWarsListeners : InteractionListener { CastleWarsGameState.dropFlagAtLocation(player, barrierData.outsideTile) } - private fun hasNonCombatItems(container: Container): Boolean { + private fun leaveGameThroughPortal(player: Player, portalTeam: CastleWarsTeam) { + if (CastleWarsGameState.getPlayerTeam(player) != portalTeam) { + sendMessage(player, "Only the ${portalTeam.teamName} team may use this portal.") + return + } + CastleWarsGameArea.removePlayerFromGame(player) + player.properties.teleportLocation = CastleWars.lobbyBankArea.randomWalkableLoc + sendMessage(player, "You have left the Castle Wars game.") + } + + private fun hasDisallowedCastleWarsItems(container: Container): Boolean { for (item in container.toArray()) { - if (item?.id != null && (item.id == Items.COINS_995 || item.definition?.noteId == item.id)) + if (item != null && !isAllowedCastleWarsInventoryItem(item)) { return true + } } return false } + private fun isAllowedCastleWarsInventoryItem(item: Item): Boolean { + if (item.id == Items.COINS_995) return false + val definition = item.definition ?: return false + if (!definition.isUnnoted) return false + val equipmentSlot = equipSlot(item.id) + if (equipmentSlot != null) { + return equipmentSlot != EquipmentSlot.HEAD && equipmentSlot != EquipmentSlot.CAPE + } + val itemName = definition.name.lowercase() + return isRune(itemName) || + definition.hasAction("drink") || + itemName.contains("potion") || + isHunterButterfly(itemName) + } + + private fun isRune(itemName: String): Boolean { + return itemName.endsWith(" rune") || itemName.endsWith(" runes") + } + + private fun isHunterButterfly(itemName: String): Boolean { + return itemName.contains("butterfly") || + itemName.contains("ruby harvest") || + itemName.contains("sapphire glacialis") || + itemName.contains("snowy knight") || + itemName.contains("black warlock") + } + private fun capeOrHelmetError(player: Player): String? { val wornCape = getItemFromEquipment(player, EquipmentSlot.CAPE)?.id ?: -1 val wornHelmet = getItemFromEquipment(player, EquipmentSlot.HEAD)?.id ?: -1 @@ -1035,7 +1069,7 @@ class CastleWarsListeners : InteractionListener { } private fun nonCombatItemsCheck(player: Player): String? { - if (hasNonCombatItems(player.inventory)) return "You can't take non-combat items into the arena." + if (hasDisallowedCastleWarsItems(player.inventory)) return "You can't take non-combat items into the arena." return null } @@ -1043,7 +1077,7 @@ class CastleWarsListeners : InteractionListener { private fun familiarCheck(player: Player): String? { val familiar: BurdenBeast = player.familiarManager.familiar as? BurdenBeast ?: return null - if (hasNonCombatItems(familiar.container)) return "Your familiar can't take non-combat items into the arena." + if (hasDisallowedCastleWarsItems(familiar.container)) return "Your familiar can't take non-combat items into the arena." return null } diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsOverlay.kt b/Server/src/main/content/minigame/castlewars/CastleWarsOverlay.kt index 0b0c503c3..460a208b9 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsOverlay.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsOverlay.kt @@ -9,8 +9,7 @@ import core.game.node.entity.player.Player */ object CastleWarsOverlay { @JvmStatic - fun sendLobbyUpdate(player: Player, enoughPlayers: Boolean, gameStartMinutes: Int) { - // When not enough players, send -1 to hide the timer display - setVarp(player, 380, if (enoughPlayers) gameStartMinutes else -1) + fun sendLobbyUpdate(player: Player, gameStartMinutes: Int) { + setVarp(player, 380, gameStartMinutes) } } diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsSubstitutionDialogue.kt b/Server/src/main/content/minigame/castlewars/CastleWarsSubstitutionDialogue.kt new file mode 100644 index 000000000..cddbf6ea1 --- /dev/null +++ b/Server/src/main/content/minigame/castlewars/CastleWarsSubstitutionDialogue.kt @@ -0,0 +1,38 @@ +package content.minigame.castlewars + +import content.minigame.castlewars.areas.CastleWarsGameArea +import core.api.sendMessage +import core.game.dialogue.DialogueFile +import core.game.dialogue.Topic +import core.tools.END_DIALOGUE +import core.tools.START_DIALOGUE + +class CastleWarsSubstitutionDialogue(private val team: CastleWarsTeam) : DialogueFile() { + + override fun handle(componentID: Int, buttonID: Int) { + when (stage) { + START_DIALOGUE -> showTopics( + Topic("Yes.", ACCEPT, true), + Topic("No.", DECLINE, true), + title = "Do you wish to join the current game?" + ) + + ACCEPT -> { + if (!CastleWarsGameArea.claimVacancy(player!!, team)) { + sendMessage(player!!, "That Castle Wars vacancy is no longer available.") + } + stage = END_DIALOGUE + } + + DECLINE -> { + CastleWarsGameArea.declineVacancyOffer(player!!, team) + stage = END_DIALOGUE + } + } + } + + companion object { + private const val ACCEPT = 10 + private const val DECLINE = 20 + } +} diff --git a/Server/src/main/content/minigame/castlewars/Lanthus.kt b/Server/src/main/content/minigame/castlewars/Lanthus.kt index 701d04785..9e9fb54a7 100644 --- a/Server/src/main/content/minigame/castlewars/Lanthus.kt +++ b/Server/src/main/content/minigame/castlewars/Lanthus.kt @@ -13,7 +13,6 @@ import core.game.interaction.IntType import core.game.interaction.InteractionListener import core.game.node.item.Item import core.tools.END_DIALOGUE -import core.tools.ticksPerMinute import org.rs09.consts.Items import org.rs09.consts.NPCs @@ -142,7 +141,8 @@ class Lanthus: InteractionListener, TickListener { return } - val minutesLeft = (countdown + ticksPerMinute - 1) / ticksPerMinute + val minutesLeft = + (countdown + CastleWarsConstants.TICKS_PER_MINUTE - 1) / CastleWarsConstants.TICKS_PER_MINUTE // Announce at minute boundaries, only once per distinct minute value if (minutesLeft != lastAnnouncedMinute && minutesLeft in 1..5) { @@ -152,4 +152,4 @@ class Lanthus: InteractionListener, TickListener { sendChat(lanthus, "The next game will start in $minutesLeft $minuteWord!") } } -} \ No newline at end of file +} diff --git a/Server/src/main/content/minigame/castlewars/areas/CastleWarsGameArea.kt b/Server/src/main/content/minigame/castlewars/areas/CastleWarsGameArea.kt index 5114d4e29..a93b4c8c3 100644 --- a/Server/src/main/content/minigame/castlewars/areas/CastleWarsGameArea.kt +++ b/Server/src/main/content/minigame/castlewars/areas/CastleWarsGameArea.kt @@ -16,13 +16,12 @@ import core.game.world.map.Location import core.game.world.map.zone.ZoneBorders import core.game.world.map.zone.ZoneType import core.tools.Log -import core.tools.ticksPerMinute import org.rs09.consts.Components /** * Handles the Castle Wars game map */ -class CastleWarsGameArea : CastleWarsArea(), TickListener { +class CastleWarsGameArea : CastleWarsArea(), TickListener, LoginListener { companion object { private val saradominStandardFloor: ZoneBorders = @@ -64,8 +63,15 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener { val saradominPlayers = mutableSetOf() val zamorakPlayers = mutableSetOf() - /** Players who have logged out but are within the grace period. Maps Player -> logout tick. */ - val loggedOutPlayers = mutableMapOf() + data class LogoutRecord( + val player: Player, + val team: CastleWarsTeam, + val logoutTick: Int, + val logoutLocation: Location + ) + + /** Players who have logged out but are within the grace period. Maps normalized username -> logout record. */ + val loggedOutPlayers = mutableMapOf() var ticksLeftInGame = -1 @@ -73,6 +79,10 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener { private val declinedSubstitutionOffers = mutableMapOf>() fun startGame() { + if (!CastleWarsWaitingArea.hasMinimumPlayers()) { + CastleWarsWaitingArea.gameStartCountdown = CastleWarsConstants.WAITING_ROOM_COUNTDOWN_TICKS + return + } clearSubstitutionState() CastleWarsGameState.resetGame() CastleWarsItemHandler.clearAllBarricades() @@ -81,7 +91,7 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener { zamorakPlayers.addAll(CastleWarsWaitingArea.waitingZamorakPlayers) CastleWarsWaitingArea.waitingSaradominPlayers.clear() CastleWarsWaitingArea.waitingZamorakPlayers.clear() - ticksLeftInGame = CastleWars.gameTimeMinutes * ticksPerMinute + ticksLeftInGame = CastleWars.gameTimeMinutes * CastleWarsConstants.TICKS_PER_MINUTE saradominPlayers.forEach { player -> player.properties.teleportLocation = CastleWarsRespawnArea.saradominRespawnRoom.randomWalkableLoc setAttribute(player, CastleWarsConstants.ATTR_JOIN_TICK, getWorldTicks()) @@ -111,8 +121,6 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener { } fun selectGuthixTeam(): CastleWarsTeam { - combatLevelVacancyTeam()?.let { return it } - val saraTotal = CastleWarsWaitingArea.waitingSaradominPlayers.size + saradominPlayers.size val zamTotal = CastleWarsWaitingArea.waitingZamorakPlayers.size + zamorakPlayers.size return when { @@ -123,21 +131,6 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener { } } - 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 } - } - /** * 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. @@ -159,11 +152,6 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener { } } - 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 @@ -186,7 +174,6 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener { } player.properties.teleportLocation = respawnLoc sendMessage(player, "A spot has opened up! You have been moved into the Castle Wars game.") - offerCombatLevelVacancyIfNeeded() return true } @@ -209,18 +196,109 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener { CastleWarsGameState.handlePlayerLeave(player) player.unhook(SaradominInterfaceCloseHook) player.unhook(ZamorakInterfaceCloseHook) - loggedOutPlayers.remove(player) + clearLogoutState(player) saradominPlayers.remove(player) zamorakPlayers.remove(player) CastleWarsGameState.clearHintIconForPlayer(player) player.interaction.remove(Option._P_ATTACK) if (offerReplacement) { fillVacancy(leavingTeam) - offerCombatLevelVacancyIfNeeded() } return leavingTeam } + fun playerKey(player: Player): String { + return player.username.lowercase() + } + + private fun setTeam(player: Player, team: CastleWarsTeam) { + when (team) { + CastleWarsTeam.SARADOMIN -> { + zamorakPlayers.remove(player) + saradominPlayers.add(player) + } + + CastleWarsTeam.ZAMORAK -> { + saradominPlayers.remove(player) + zamorakPlayers.add(player) + } + } + } + + private fun removeFromTeam(player: Player) { + saradominPlayers.remove(player) + zamorakPlayers.remove(player) + } + + private fun clearLogoutAttributes(player: Player) { + removeAttributes( + player, + CastleWarsConstants.ATTR_LOGOUT_TICK, + CastleWarsConstants.ATTR_LOGOUT_TIME_MILLIS, + CastleWarsConstants.ATTR_LOGOUT_TEAM, + CastleWarsConstants.ATTR_LOGOUT_LOCATION + ) + } + + private fun clearLogoutState(player: Player) { + loggedOutPlayers.remove(playerKey(player)) + clearLogoutAttributes(player) + } + + private fun waitingRoomFor(team: CastleWarsTeam): ZoneBorders { + return when (team) { + CastleWarsTeam.SARADOMIN -> CastleWarsWaitingArea.saradominWaitingRoom + CastleWarsTeam.ZAMORAK -> CastleWarsWaitingArea.zamorakWaitingRoom + } + } + + private fun waitingSetFor(team: CastleWarsTeam): MutableSet { + return when (team) { + CastleWarsTeam.SARADOMIN -> CastleWarsWaitingArea.waitingSaradominPlayers + CastleWarsTeam.ZAMORAK -> CastleWarsWaitingArea.waitingZamorakPlayers + } + } + + private fun moveLoggedOutPlayerToWaitingRoom(player: Player, team: CastleWarsTeam) { + removeFromTeam(player) + CastleWarsGameState.clearHintIconForPlayer(player) + CastleWarsGameState.restoreAndCleanupPlayer(player) + player.interaction.remove(Option._P_ATTACK) + player.location = waitingRoomFor(team).randomWalkableLoc + player.equipment.replace(Item(team.cloakId), 1) + waitingSetFor(team).add(player) + clearLogoutAttributes(player) + } + + fun restoreActiveLogoutOnLogin(player: Player) { + val savedTeamName = getAttribute(player, CastleWarsConstants.ATTR_LOGOUT_TEAM, "") + val savedLogoutTime = getAttribute(player, CastleWarsConstants.ATTR_LOGOUT_TIME_MILLIS, 0L) + if (savedTeamName.isEmpty() || savedLogoutTime <= 0L) return + val team = CastleWarsTeam.values().firstOrNull { it.name == savedTeamName } ?: return + val key = playerKey(player) + val record = loggedOutPlayers.remove(key) + val previousPlayer = record?.player + if (previousPlayer != null && previousPlayer != player) { + removeFromTeam(previousPlayer) + } + val elapsedMillis = System.currentTimeMillis() - savedLogoutTime + val logoutLocation = getAttribute( + player, + CastleWarsConstants.ATTR_LOGOUT_LOCATION, + record?.logoutLocation ?: player.location + ) + if (ticksLeftInGame > 0 && elapsedMillis < CastleWarsConstants.LOGOUT_GRACE_PERIOD_MILLIS) { + player.location = Location.create(logoutLocation) + setTeam(player, team) + clearLogoutAttributes(player) + return + } + moveLoggedOutPlayerToWaitingRoom(player, team) + if (ticksLeftInGame > 0) { + fillVacancy(team) + } + } + object SaradominInterfaceCloseHook : EventHook { // TODO: this might be useless. override fun process(entity: Entity, event: InterfaceCloseEvent) { val player = entity as? Player ?: return @@ -249,7 +327,6 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener { } private fun endGame() { // TODO: ropes, rocks in tunnels, ground items, castle doors, catapults - CastleWarsWaitingArea.postGameCooldown = true CastleWarsGameState.endGame() CastleWarsItemHandler.clearAllBarricades() clearSubstitutionState() @@ -274,13 +351,6 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener { override fun areaEnter(entity: Entity) { val player = entity as? Player ?: return - // Handle rejoin from logout grace period - if (loggedOutPlayers.containsKey(player)) { - loggedOutPlayers.remove(player) - player.removeAttribute(CastleWarsConstants.ATTR_LOGOUT_TICK) - // join tick is preserved from the original game start -- no changes needed - } - super.areaEnter(player) zone.zoneType = ZoneType.CASTLE_WARS.id registerTimer(player, spawnTimer("teleblock", (CastleWars.gameTimeMinutes) * 60 * 2)) @@ -316,20 +386,27 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener { } override fun logout(player: Player) { - // Store logout tick for grace period instead of immediate removal - if (ticksLeftInGame > 0 && (saradominPlayers.contains(player) || zamorakPlayers.contains(player))) { - setAttribute(player, CastleWarsConstants.ATTR_LOGOUT_TICK, getWorldTicks()) - loggedOutPlayers[player] = getWorldTicks() + val team = when { + saradominPlayers.contains(player) -> CastleWarsTeam.SARADOMIN + zamorakPlayers.contains(player) -> CastleWarsTeam.ZAMORAK + else -> null + } + if (ticksLeftInGame > 0 && team != null) { + val logoutTick = getWorldTicks() + val logoutLocation = Location.create(player.location) + setAttribute(player, CastleWarsConstants.ATTR_LOGOUT_TICK, logoutTick) + setAttribute(player, CastleWarsConstants.ATTR_LOGOUT_TIME_MILLIS, System.currentTimeMillis()) + setAttribute(player, CastleWarsConstants.ATTR_LOGOUT_TEAM, team.name) + setAttribute(player, CastleWarsConstants.ATTR_LOGOUT_LOCATION, logoutLocation) + loggedOutPlayers[playerKey(player)] = LogoutRecord(player, team, logoutTick, logoutLocation) if (CastleWarsGameState.isCarryingFlag(player)) { CastleWarsGameState.dropFlag(player) } else if (CastleWarsGameState.isCarryingOwnFlag(player)) { CastleWarsGameState.dropOwnFlag(player) } CastleWarsGameState.clearHintIconForPlayer(player) - // Do NOT remove from team sets -- grace period keeps them on the team return } else { - // No active game or not on a team -- full cleanup CastleWarsGameState.handlePlayerLeave(player) CastleWarsGameState.clearHintIconForPlayer(player) saradominPlayers.remove(player) @@ -338,6 +415,10 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener { super.logout(player) } + override fun login(player: Player) { + restoreActiveLogoutOnLogin(player) + } + override fun tick() { if (ticksLeftInGame < 0) { return @@ -346,17 +427,11 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener { endGame() return } - val previousMinutes = (ticksLeftInGame + ticksPerMinute - 1) / ticksPerMinute + val previousMinutes = + (ticksLeftInGame + CastleWarsConstants.TICKS_PER_MINUTE - 1) / CastleWarsConstants.TICKS_PER_MINUTE ticksLeftInGame-- - val currentMinutes = (ticksLeftInGame + ticksPerMinute - 1) / ticksPerMinute - if (ticksLeftInGame > 0) { - val saraEmpty = saradominPlayers.isEmpty() - val zamEmpty = zamorakPlayers.isEmpty() - if (saraEmpty || zamEmpty) { - endGame() - return - } - } + val currentMinutes = + (ticksLeftInGame + CastleWarsConstants.TICKS_PER_MINUTE - 1) / CastleWarsConstants.TICKS_PER_MINUTE if (ticksLeftInGame == 0) { endGame() return @@ -365,7 +440,6 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener { } CastleWarsGameState.checkFlagCarriers() CastleWarsGameState.checkDroppedFlagTimeouts() - offerCombatLevelVacancyIfNeeded() for (player in saradominPlayers) { val currentOverlayId = player.interfaceManager.overlay?.id if (currentOverlayId == null || currentOverlayId != 58) { @@ -393,28 +467,23 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener { private fun cleanupInactivePlayers() { val currentTick = getWorldTicks() // Check logged-out players whose grace period has expired - val expiredLogouts = loggedOutPlayers.filter { (_, logoutTick) -> - currentTick - logoutTick >= CastleWarsConstants.LOGOUT_GRACE_PERIOD_TICKS + val expiredLogouts = loggedOutPlayers.filter { (_, record) -> + currentTick - record.logoutTick >= CastleWarsConstants.LOGOUT_GRACE_PERIOD_TICKS } - for ((player, _) in expiredLogouts) { - val team = when { - saradominPlayers.contains(player) -> CastleWarsTeam.SARADOMIN - zamorakPlayers.contains(player) -> CastleWarsTeam.ZAMORAK - else -> null - } - loggedOutPlayers.remove(player) - saradominPlayers.remove(player) - zamorakPlayers.remove(player) + for ((key, record) in expiredLogouts) { + val player = record.player + loggedOutPlayers.remove(key) + removeFromTeam(player) CastleWarsGameState.clearHintIconForPlayer(player) CastleWarsGameState.restoreAndCleanupPlayer(player) - if (team != null) fillVacancy(team) + fillVacancy(record.team) } // Remove truly inactive players (not in grace period) val inactiveSara = saradominPlayers.filter { player -> - (!player.isActive || player.session == null) && !loggedOutPlayers.containsKey(player) + (!player.isActive || player.session == null) && !loggedOutPlayers.containsKey(playerKey(player)) } val inactiveZam = zamorakPlayers.filter { player -> - (!player.isActive || player.session == null) && !loggedOutPlayers.containsKey(player) + (!player.isActive || player.session == null) && !loggedOutPlayers.containsKey(playerKey(player)) } for (player in inactiveSara) { saradominPlayers.remove(player) diff --git a/Server/src/main/content/minigame/castlewars/areas/CastleWarsRespawnArea.kt b/Server/src/main/content/minigame/castlewars/areas/CastleWarsRespawnArea.kt index 117e96cfb..1b3bdc60e 100644 --- a/Server/src/main/content/minigame/castlewars/areas/CastleWarsRespawnArea.kt +++ b/Server/src/main/content/minigame/castlewars/areas/CastleWarsRespawnArea.kt @@ -1,9 +1,9 @@ package content.minigame.castlewars.areas import content.minigame.castlewars.CastleWars +import content.minigame.castlewars.CastleWarsConstants import content.minigame.castlewars.CastleWarsGameState -import core.api.TickListener -import core.api.sendMessage +import core.api.* import core.game.node.entity.Entity import core.game.node.entity.player.Player import core.game.node.item.Item @@ -21,8 +21,8 @@ class CastleWarsRespawnArea : CastleWarsArea(), TickListener { val saradominRespawnRoom: ZoneBorders = ZoneBorders(Location.create(2423, 3080, 1), Location.create(2431, 3072, 1)) - val zamorakPlayersInRespawnRoom = mutableSetOf() - val saradominPlayersInRespawnRoom = mutableSetOf() + val zamorakPlayersInRespawnRoom = mutableMapOf() + val saradominPlayersInRespawnRoom = mutableMapOf() } override fun defineAreaBorders(): Array { @@ -42,12 +42,47 @@ class CastleWarsRespawnArea : CastleWarsArea(), TickListener { } if (saradominRespawnRoom.insideBorder(player.location)) { player.equipment.replace(Item(CastleWars.saradominTeamHoodedCloak), 1) + saradominPlayersInRespawnRoom[player] = getWorldTicks() } else if (zamorakRespawnRoom.insideBorder(player.location)) { player.equipment.replace(Item(CastleWars.zamorakTeamHoodedCloak), 1) + zamorakPlayersInRespawnRoom[player] = getWorldTicks() } } override fun tick() { + if (CastleWarsGameArea.ticksLeftInGame <= 0) { + saradominPlayersInRespawnRoom.clear() + zamorakPlayersInRespawnRoom.clear() + return + } + removePlayersOverSpawnLimit(saradominPlayersInRespawnRoom) + removePlayersOverSpawnLimit(zamorakPlayersInRespawnRoom) + } + + private fun removePlayersOverSpawnLimit(players: MutableMap) { + val currentTick = getWorldTicks() + val expiredPlayers = players.filter { (player, entryTick) -> + player.isActive && + player.session != null && + currentTick - entryTick >= CastleWarsConstants.LOGOUT_GRACE_PERIOD_TICKS + }.keys + for (player in expiredPlayers) { + players.remove(player) + CastleWarsGameArea.removePlayerFromGame(player) + player.properties.teleportLocation = CastleWars.lobbyBankArea.randomWalkableLoc + sendMessage(player, "You have been removed from Castle Wars for staying in the spawn room too long.") + } + } + + override fun logout(player: Player) { + if (!defineAreaBorders().any { it.insideBorder(player.location) }) { + return + } + if (CastleWarsGameArea.ticksLeftInGame > 0 && CastleWarsGameState.getPlayerTeam(player) != null) { + CastleWarsGameArea().logout(player) + return + } + super.logout(player) } override fun exitArea(player: Player) { diff --git a/Server/src/main/content/minigame/castlewars/areas/CastleWarsWaitingArea.kt b/Server/src/main/content/minigame/castlewars/areas/CastleWarsWaitingArea.kt index 3e1106ba1..1d954bb99 100644 --- a/Server/src/main/content/minigame/castlewars/areas/CastleWarsWaitingArea.kt +++ b/Server/src/main/content/minigame/castlewars/areas/CastleWarsWaitingArea.kt @@ -8,9 +8,7 @@ import core.game.component.Component import core.game.node.entity.Entity import core.game.node.entity.player.Player import core.game.node.item.Item -import core.game.world.GameWorld import core.game.world.map.zone.ZoneBorders -import core.tools.ticksPerMinute import org.rs09.consts.Components /** @@ -27,7 +25,11 @@ class CastleWarsWaitingArea : CastleWarsArea(), TickListener { val waitingSaradominPlayers = mutableSetOf() val waitingZamorakPlayers = mutableSetOf() internal var gameStartCountdown = -1 - var postGameCooldown = false + + fun hasMinimumPlayers(): Boolean { + return waitingSaradominPlayers.size >= CastleWarsConstants.MIN_PLAYERS_PER_TEAM && + waitingZamorakPlayers.size >= CastleWarsConstants.MIN_PLAYERS_PER_TEAM + } } override fun defineAreaBorders(): Array { @@ -105,7 +107,6 @@ 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) { @@ -116,44 +117,37 @@ class CastleWarsWaitingArea : CastleWarsArea(), TickListener { } override fun tick() { - var nextStart = Int.MAX_VALUE - // The game will start when there's enough players and CastleWarsGameArea.ticksLeftInGame = -5 minutes + val nextStart: Int if (CastleWarsGameArea.ticksLeftInGame > 0) { - // A game is going on, display 5 minutes + remaining game time - gameStartCountdown = -1 // Reset countdown while game is active - nextStart = CastleWarsGameArea.ticksLeftInGame + CastleWars.gameCooldownMinutes * ticksPerMinute - } else if (waitingSaradominPlayers.size < (if (GameWorld.settings?.isDevMode == true) 1 else CastleWarsConstants.MIN_PLAYERS_PER_TEAM) - || waitingZamorakPlayers.size < (if (GameWorld.settings?.isDevMode == true) 1 else CastleWarsConstants.MIN_PLAYERS_PER_TEAM) - ) { - // A game is not going and there aren't enough players - Reset countdown gameStartCountdown = -1 - CastleWarsGameArea.ticksLeftInGame = -1 + nextStart = CastleWarsGameArea.ticksLeftInGame + + CastleWars.gameCooldownMinutes * CastleWarsConstants.TICKS_PER_MINUTE } else { - // There are enough players - start or continue countdown if (gameStartCountdown < 0) { - // Start new countdown: dev mode uses short timer, post-game uses 2-min, otherwise standard 5-min - gameStartCountdown = when { - GameWorld.settings?.isDevMode == true -> CastleWarsConstants.DEV_COUNTDOWN_TICKS - postGameCooldown -> CastleWarsConstants.POST_GAME_COUNTDOWN_TICKS - else -> CastleWarsConstants.WAITING_ROOM_COUNTDOWN_TICKS - } - postGameCooldown = false // Reset after consuming + gameStartCountdown = CastleWarsConstants.WAITING_ROOM_COUNTDOWN_TICKS } nextStart = gameStartCountdown gameStartCountdown-- } - val minPlayers = if (GameWorld.settings?.isDevMode == true) 1 else CastleWarsConstants.MIN_PLAYERS_PER_TEAM for (player in waitingSaradominPlayers + waitingZamorakPlayers) { CastleWarsOverlay.sendLobbyUpdate( player, - (waitingSaradominPlayers.size >= minPlayers && waitingZamorakPlayers.size >= minPlayers) || CastleWarsGameArea.ticksLeftInGame > 0, - (nextStart - 1) / ticksPerMinute + 1 // Displays max 5 minutes, min 1 minute, seems authentic + ticksToDisplayMinutes(nextStart) ) } - if (nextStart <= 0) { - gameStartCountdown = -1 // Reset countdown + if (CastleWarsGameArea.ticksLeftInGame <= 0 && nextStart <= 0) { + gameStartCountdown = -1 + if (!hasMinimumPlayers()) { + gameStartCountdown = CastleWarsConstants.WAITING_ROOM_COUNTDOWN_TICKS + return + } CastleWarsGameArea.startGame() } } + private fun ticksToDisplayMinutes(ticks: Int): Int { + if (ticks <= 0) return 0 + return (ticks - 1) / CastleWarsConstants.TICKS_PER_MINUTE + 1 + } + } diff --git a/Server/src/test/kotlin/content/minigame/castlewars/CastleWarsTests.kt b/Server/src/test/kotlin/content/minigame/castlewars/CastleWarsTests.kt index 8c7206a7c..d999cc05a 100644 --- a/Server/src/test/kotlin/content/minigame/castlewars/CastleWarsTests.kt +++ b/Server/src/test/kotlin/content/minigame/castlewars/CastleWarsTests.kt @@ -5,7 +5,7 @@ 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.api.* import core.game.node.entity.skill.Skills import core.game.global.action.EquipHandler import core.game.interaction.IntType @@ -14,6 +14,7 @@ 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.GameWorld import core.game.world.map.Location import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals @@ -50,9 +51,10 @@ class CastleWarsTests { CastleWarsWaitingArea.waitingSaradominPlayers.clear() CastleWarsWaitingArea.waitingZamorakPlayers.clear() CastleWarsWaitingArea.gameStartCountdown = -1 - CastleWarsWaitingArea.postGameCooldown = false CastleWarsGameArea.ticksLeftInGame = -1 CastleWarsGameArea.clearSubstitutionState() + CastleWarsRespawnArea.saradominPlayersInRespawnRoom.clear() + CastleWarsRespawnArea.zamorakPlayersInRespawnRoom.clear() CastleWarsGameState.resetGame() } @@ -71,21 +73,6 @@ class CastleWarsTests { } } - @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 -> @@ -129,28 +116,84 @@ class CastleWarsTests { } @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) + fun activeMatchDoesNotOfferSubstitutionWithoutVacancy() { + TestUtils.getMockPlayer("cwsubnovacancy").use { waiting -> + CastleWarsGameArea.ticksLeftInGame = 100 + CastleWarsWaitingArea.waitingSaradominPlayers.add(waiting) - CastleWarsGameArea.offerCombatLevelVacancyIfNeeded() + CastleWarsWaitingArea().areaEnter(waiting) - assertSubstitutionDialogue(waiting) - assertTrue(CastleWarsGameArea.claimVacancy(waiting, CastleWarsTeam.SARADOMIN)) - assertTrue(CastleWarsGameArea.saradominPlayers.contains(waiting)) - assertFalse(CastleWarsWaitingArea.waitingSaradominPlayers.contains(waiting)) + assertFalse(waiting.dialogueInterpreter.dialogue?.file is CastleWarsSubstitutionDialogue) + assertFalse(CastleWarsGameArea.claimVacancy(waiting, CastleWarsTeam.SARADOMIN)) + } + } + + @Test + fun waitingRoomTimerShowsTwoMinuteMinimumWithoutEnoughPlayers() { + TestUtils.getMockPlayer("cwminwait").use { waiting -> + CastleWarsWaitingArea.waitingSaradominPlayers.add(waiting) + + CastleWarsWaitingArea().tick() + + assertEquals(2, getVarp(waiting, 380)) + assertEquals(CastleWarsConstants.WAITING_ROOM_COUNTDOWN_TICKS - 1, CastleWarsWaitingArea.gameStartCountdown) + } + } + + @Test + fun waitingRoomTimerCyclesWhenMinimumPlayersAreMissingAtExpiry() { + TestUtils.getMockPlayer("cwcycle").use { waiting -> + CastleWarsWaitingArea.waitingSaradominPlayers.add(waiting) + CastleWarsWaitingArea.gameStartCountdown = 0 + + CastleWarsWaitingArea().tick() + + assertEquals(-1, CastleWarsGameArea.ticksLeftInGame) + assertTrue(CastleWarsWaitingArea.waitingSaradominPlayers.contains(waiting)) + assertEquals(CastleWarsConstants.WAITING_ROOM_COUNTDOWN_TICKS, CastleWarsWaitingArea.gameStartCountdown) + } + } + + @Test + fun waitingRoomTimerStartsGameWhenBothTeamsHaveTwoPlayersAtExpiry() { + TestUtils.getMockPlayer("cwstarts1").use { sara1 -> + TestUtils.getMockPlayer("cwstarts2").use { sara2 -> + TestUtils.getMockPlayer("cwstartz1").use { zam1 -> + TestUtils.getMockPlayer("cwstartz2").use { zam2 -> + CastleWarsWaitingArea.waitingSaradominPlayers.addAll(listOf(sara1, sara2)) + CastleWarsWaitingArea.waitingZamorakPlayers.addAll(listOf(zam1, zam2)) + CastleWarsWaitingArea.gameStartCountdown = 0 + + CastleWarsWaitingArea().tick() + + assertEquals( + CastleWarsConstants.GAME_TIME_MINUTES * CastleWarsConstants.TICKS_PER_MINUTE, + CastleWarsGameArea.ticksLeftInGame + ) + assertTrue(CastleWarsGameArea.saradominPlayers.containsAll(listOf(sara1, sara2))) + assertTrue(CastleWarsGameArea.zamorakPlayers.containsAll(listOf(zam1, zam2))) + assertTrue(CastleWarsWaitingArea.waitingSaradominPlayers.isEmpty()) + assertTrue(CastleWarsWaitingArea.waitingZamorakPlayers.isEmpty()) + assertTrue(CastleWarsRespawnArea.saradominRespawnRoom.insideBorder(sara1.properties.teleportLocation)) + assertTrue(CastleWarsRespawnArea.zamorakRespawnRoom.insideBorder(zam1.properties.teleportLocation)) + } } } } } + @Test + fun activeGameWaitingTimerShowsRemainingGamePlusFiveMinutes() { + TestUtils.getMockPlayer("cwactivewait").use { waiting -> + CastleWarsGameArea.ticksLeftInGame = 350 + CastleWarsWaitingArea.waitingSaradominPlayers.add(waiting) + + CastleWarsWaitingArea().tick() + + assertEquals(9, getVarp(waiting, 380)) + } + } + @Test fun idleGameAreaTickDoesNotCountDownForever() { CastleWarsGameArea.ticksLeftInGame = -1 @@ -167,7 +210,19 @@ class CastleWarsTests { CastleWarsGameArea().tick() assertEquals(-1, CastleWarsGameArea.ticksLeftInGame) - assertTrue(CastleWarsWaitingArea.postGameCooldown) + assertEquals(-1, CastleWarsWaitingArea.gameStartCountdown) + } + + @Test + fun activeGameDoesNotEndEarlyWhenOneTeamIsTemporarilyEmpty() { + TestUtils.getMockPlayer("cwstillrunning").use { player -> + CastleWarsGameArea.ticksLeftInGame = 100 + CastleWarsGameArea.saradominPlayers.add(player) + + CastleWarsGameArea().tick() + + assertEquals(99, CastleWarsGameArea.ticksLeftInGame) + } } @Test @@ -181,11 +236,203 @@ class CastleWarsTests { CastleWarsGameArea().logout(player) assertTrue(CastleWarsGameArea.saradominPlayers.contains(player)) - assertTrue(CastleWarsGameArea.loggedOutPlayers.containsKey(player)) + assertTrue(CastleWarsGameArea.loggedOutPlayers.containsKey(CastleWarsGameArea.playerKey(player))) assertEquals(startLocation, player.location) } } + @Test + fun activeGameLogoutWithinTwoMinutesRejoinsAtExactLocation() { + TestUtils.getMockPlayer("cwquickrelog").use { player -> + val logoutLocation = Location.create(2401, 3101, 0) + player.location = logoutLocation + CastleWarsGameArea.ticksLeftInGame = 500 + CastleWarsGameArea.saradominPlayers.add(player) + + CastleWarsGameArea().logout(player) + CastleWarsGameArea().login(player) + + assertTrue(CastleWarsGameArea.saradominPlayers.contains(player)) + assertFalse(CastleWarsWaitingArea.waitingSaradominPlayers.contains(player)) + assertFalse(CastleWarsGameArea.loggedOutPlayers.containsKey(CastleWarsGameArea.playerKey(player))) + assertEquals(logoutLocation, player.location) + } + } + + @Test + fun activeGameLogoutAfterTwoMinutesReturnsPlayerToTeamWaitingRoom() { + TestUtils.getMockPlayer("cwslowrelog").use { player -> + player.location = Location.create(2402, 3102, 0) + CastleWarsGameArea.ticksLeftInGame = 500 + CastleWarsGameArea.saradominPlayers.add(player) + + CastleWarsGameArea().logout(player) + setAttribute( + player, + CastleWarsConstants.ATTR_LOGOUT_TIME_MILLIS, + System.currentTimeMillis() - CastleWarsConstants.LOGOUT_GRACE_PERIOD_MILLIS - 1 + ) + CastleWarsGameArea().login(player) + + assertFalse(CastleWarsGameArea.saradominPlayers.contains(player)) + assertTrue(CastleWarsWaitingArea.waitingSaradominPlayers.contains(player)) + assertTrue(CastleWarsWaitingArea.saradominWaitingRoom.insideBorder(player.location)) + } + } + + @Test + fun spawnRoomLogoutUsesActiveGameGraceInsteadOfLobbyRemoval() { + TestUtils.getMockPlayer("cwspawnlogout").use { player -> + val logoutLocation = CastleWarsRespawnArea.saradominRespawnRoom.randomWalkableLoc + player.location = logoutLocation + CastleWarsGameArea.ticksLeftInGame = 500 + CastleWarsGameArea.saradominPlayers.add(player) + CastleWarsRespawnArea().areaEnter(player) + + CastleWarsRespawnArea().logout(player) + CastleWarsGameArea().login(player) + + assertTrue(CastleWarsGameArea.saradominPlayers.contains(player)) + assertFalse(CastleWarsWaitingArea.waitingSaradominPlayers.contains(player)) + assertEquals(logoutLocation, player.location) + } + } + + @Test + fun waitingRoomLogoutReturnsPlayerToLobbyAndLosesQueuePosition() { + TestUtils.getMockPlayer("cwwaitlogout").use { player -> + player.location = CastleWarsWaitingArea.zamorakWaitingRoom.randomWalkableLoc + CastleWarsWaitingArea().areaEnter(player) + + CastleWarsWaitingArea().logout(player) + + assertFalse(CastleWarsWaitingArea.waitingZamorakPlayers.contains(player)) + assertTrue(CastleWars.lobbyBankArea.insideBorder(player.location)) + } + } + + @Test + fun spawnRoomTwoMinuteLimitRemovesPlayerAndOffersVacancy() { + TestUtils.getMockPlayer("cwspawnidle").use { player -> + TestUtils.getMockPlayer("cwspawnsub").use { waiting -> + player.location = CastleWarsRespawnArea.saradominRespawnRoom.randomWalkableLoc + CastleWarsGameArea.ticksLeftInGame = 500 + CastleWarsGameArea.saradominPlayers.add(player) + CastleWarsWaitingArea.waitingSaradominPlayers.add(waiting) + CastleWarsRespawnArea().areaEnter(player) + CastleWarsRespawnArea.saradominPlayersInRespawnRoom[player] = + getWorldTicks() - CastleWarsConstants.LOGOUT_GRACE_PERIOD_TICKS + + CastleWarsRespawnArea().tick() + + assertFalse(CastleWarsGameArea.saradominPlayers.contains(player)) + assertTrue(CastleWars.lobbyBankArea.insideBorder(player.properties.teleportLocation)) + assertSubstitutionDialogue(waiting) + } + } + } + + @Test + fun exitPortalCanOnlyBeUsedByMatchingTeamAndOffersVacancy() { + TestUtils.getMockPlayer("cwportalteam").use { player -> + TestUtils.getMockPlayer("cwportalwait").use { waiting -> + CastleWarsGameArea.ticksLeftInGame = 500 + CastleWarsGameArea.saradominPlayers.add(player) + CastleWarsWaitingArea.waitingSaradominPlayers.add(waiting) + + runSceneryInteraction( + CastleWars.zamorakExitPortal, + "Leave", + player, + Scenery(CastleWars.zamorakExitPortal, Location.create(2372, 3131, 1), 10, 0) + ) + assertTrue(CastleWarsGameArea.saradominPlayers.contains(player)) + + runSceneryInteraction( + CastleWars.saradominExitPortal, + "Leave", + player, + Scenery(CastleWars.saradominExitPortal, Location.create(2427, 3076, 1), 10, 0) + ) + + assertFalse(CastleWarsGameArea.saradominPlayers.contains(player)) + assertTrue(CastleWars.lobbyBankArea.insideBorder(player.properties.teleportLocation)) + assertSubstitutionDialogue(waiting) + } + } + } + + @Test + fun gameEndAwardsDrawTicketWithoutFivePerTeamOrTenMinuteGate() { + TestUtils.getMockPlayer("cwticket1").use { sara1 -> + TestUtils.getMockPlayer("cwticket2").use { sara2 -> + TestUtils.getMockPlayer("cwticket3").use { zam1 -> + TestUtils.getMockPlayer("cwticket4").use { zam2 -> + CastleWarsGameArea.saradominPlayers.addAll(listOf(sara1, sara2)) + CastleWarsGameArea.zamorakPlayers.addAll(listOf(zam1, zam2)) + + CastleWarsGameState.endGame() + + assertEquals(1, sara1.inventory.getAmount(CastleWars.cwTicketRewardCurrency)) + assertEquals(1, sara2.inventory.getAmount(CastleWars.cwTicketRewardCurrency)) + assertEquals(1, zam1.inventory.getAmount(CastleWars.cwTicketRewardCurrency)) + assertEquals(1, zam2.inventory.getAmount(CastleWars.cwTicketRewardCurrency)) + } + } + } + } + } + + @Test + fun joinPortalRejectsFoodAndInventoryHelmets() { + withCastleWarsEnabled { + TestUtils.getMockPlayer("cwfood").use { player -> + player.inventory.add(Item(Items.SHARK_385)) + + runSceneryInteraction( + CastleWars.joinSaradominTeamPortal, + "Enter", + player, + Scenery(CastleWars.joinSaradominTeamPortal, Location.create(2440, 3089, 0), 10, 0) + ) + + assertNull(player.properties.teleportLocation) + player.inventory.clear() + player.inventory.add(Item(Items.BRONZE_FULL_HELM_1155)) + + runSceneryInteraction( + CastleWars.joinSaradominTeamPortal, + "Enter", + player, + Scenery(CastleWars.joinSaradominTeamPortal, Location.create(2440, 3089, 0), 10, 0) + ) + + assertNull(player.properties.teleportLocation) + } + } + } + + @Test + fun joinPortalAllowsCombatEquipmentPotionsAndRunes() { + withCastleWarsEnabled { + TestUtils.getMockPlayer("cwallowed").use { player -> + player.inventory.add(Item(Items.BRONZE_SWORD_1277)) + player.inventory.add(Item(Items.AIR_RUNE_556, 25)) + player.inventory.add(Item(Items.ATTACK_POTION2_123)) + + runSceneryInteraction( + CastleWars.joinSaradominTeamPortal, + "Enter", + player, + Scenery(CastleWars.joinSaradominTeamPortal, Location.create(2440, 3089, 0), 10, 0) + ) + + assertTrue(CastleWarsWaitingArea.saradominWaitingRoom.insideBorder(player.properties.teleportLocation)) + } + } + } + + @Test fun castleWarsPlayersCannotEquipHelmsAfterEntry() { TestUtils.getMockPlayer("cwhelm").use { player -> @@ -377,8 +624,20 @@ class CastleWarsTests { assertTrue(player.dialogueInterpreter.dialogue.file is CastleWarsSubstitutionDialogue) } - @Suppress("DEPRECATION") - private fun setCombatLevel(player: Player, level: Int) { - player.properties.setCombatLevel(level) + private fun withCastleWarsEnabled(block: () -> Unit) { + val settings = GameWorld.settings!! + val original = settings.enable_castle_wars + settings.enable_castle_wars = true + try { + block() + } finally { + settings.enable_castle_wars = original + } + } + + private fun runSceneryInteraction(id: Int, option: String, player: Player, scenery: Scenery): Boolean { + val handler = InteractionListeners.get(id, IntType.SCENERY.ordinal, option) + assertNotNull(handler) + return handler!!.invoke(player, scenery) } }