From 89fdb871abd3e889dcdf5ffee57fa0725ebbabe3 Mon Sep 17 00:00:00 2001 From: dam <27978131-real_damighty@users.noreply.gitlab.com> Date: Tue, 17 Feb 2026 21:30:11 +0200 Subject: [PATCH 01/26] AIPlayer getNodeInRange() Z axis definition support --- Server/src/main/core/game/bots/AIPlayer.java | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/Server/src/main/core/game/bots/AIPlayer.java b/Server/src/main/core/game/bots/AIPlayer.java index e54fe732a..f8a01f5e3 100644 --- a/Server/src/main/core/game/bots/AIPlayer.java +++ b/Server/src/main/core/game/bots/AIPlayer.java @@ -317,6 +317,7 @@ public class AIPlayer extends Player { private ArrayList getNodeInRange(int range, int entry) { int meX = this.getLocation().getX(); int meY = this.getLocation().getY(); + int meZ = this.getLocation().getZ(); ArrayList nodes = new ArrayList(); for (NPC npc : RegionManager.getLocalNpcs(this, range)) { if (npc.getId() == entry) @@ -324,19 +325,19 @@ public class AIPlayer extends Player { } for (int x = 0; x < range; x++) { for (int y = 0; y < range - x; y++) { - Node node = RegionManager.getObject(0, meX + x, meY + y); + Node node = RegionManager.getObject(meZ, meX + x, meY + y); if (node != null) if (node.getId() == entry) nodes.add(node); - Node node2 = RegionManager.getObject(0, meX + x, meY - y); + Node node2 = RegionManager.getObject(meZ, meX + x, meY - y); if (node2 != null) if (node2.getId() == entry) nodes.add(node2); - Node node3 = RegionManager.getObject(0, meX - x, meY + y); + Node node3 = RegionManager.getObject(meZ, meX - x, meY + y); if (node3 != null) if (node3.getId() == entry) nodes.add(node3); - Node node4 = RegionManager.getObject(0, meX - x, meY - y); + Node node4 = RegionManager.getObject(meZ, meX - x, meY - y); if (node4 != null) if (node4.getId() == entry) nodes.add(node4); @@ -348,7 +349,7 @@ public class AIPlayer extends Player { private ArrayList getNodeInRange(int range, List entrys) { int meX = this.getLocation().getX(); int meY = this.getLocation().getY(); - //int meX2 = this.getLocation().getX(); + int meZ = this.getLocation().getZ(); ArrayList nodes = new ArrayList(); for (NPC npc : RegionManager.getLocalNpcs(this, range)) { @@ -357,19 +358,19 @@ public class AIPlayer extends Player { } for (int x = 0; x < range; x++) { for (int y = 0; y < range - x; y++) { - Node node = RegionManager.getObject(0, meX + x, meY + y); + Node node = RegionManager.getObject(meZ, meX + x, meY + y); if (node != null) if (entrys.contains(node.getId())) nodes.add(node); - Node node2 = RegionManager.getObject(0, meX + x, meY - y); + Node node2 = RegionManager.getObject(meZ, meX + x, meY - y); if (node2 != null) if (entrys.contains(node2.getId())) nodes.add(node2); - Node node3 = RegionManager.getObject(0, meX - x, meY + y); + Node node3 = RegionManager.getObject(meZ, meX - x, meY + y); if (node3 != null) if (entrys.contains(node3.getId())) nodes.add(node3); - Node node4 = RegionManager.getObject(0, meX - x, meY - y); + Node node4 = RegionManager.getObject(meZ, meX - x, meY - y); if (node4 != null) if (entrys.contains(node4.getId())) nodes.add(node4); From d427a9882f8eb348d02ef89ad6a3cb203cda3b81 Mon Sep 17 00:00:00 2001 From: dam <27978131-real_damighty@users.noreply.gitlab.com> Date: Wed, 18 Feb 2026 22:39:08 +0200 Subject: [PATCH 02/26] CW areas --- .../castlewars/areas/CastleWarsArea.kt | 29 +- .../castlewars/areas/CastleWarsGameArea.kt | 351 ++++++++++++++++-- .../castlewars/areas/CastleWarsRespawnArea.kt | 27 +- .../castlewars/areas/CastleWarsWaitingArea.kt | 73 ++-- 4 files changed, 409 insertions(+), 71 deletions(-) diff --git a/Server/src/main/content/minigame/castlewars/areas/CastleWarsArea.kt b/Server/src/main/content/minigame/castlewars/areas/CastleWarsArea.kt index c6b336c55..0d1ed3d76 100644 --- a/Server/src/main/content/minigame/castlewars/areas/CastleWarsArea.kt +++ b/Server/src/main/content/minigame/castlewars/areas/CastleWarsArea.kt @@ -1,11 +1,14 @@ -package rs09.game.content.activity.castlewars.areas +package content.minigame.castlewars.areas import content.global.skill.summoning.familiar.BurdenBeast -import core.api.* +import content.minigame.castlewars.CastleWars +import core.api.LogoutListener +import core.api.MapArea +import core.api.removeTimer +import core.api.sendMessage import core.game.interaction.InteractionListener import core.game.node.entity.Entity import core.game.node.entity.player.Player -import rs09.game.content.activity.castlewars.CastleWars abstract class CastleWarsArea : MapArea, LogoutListener, InteractionListener { @@ -48,21 +51,21 @@ abstract class CastleWarsArea : MapArea, LogoutListener, InteractionListener { // Remove teleblock removeTimer(player, "teleblock") - // Remove any Castle Wars items - // Todo Remove any tinderboxes or other castle wars items - See Jan 2018 update: https://oldschool.runescape.wiki/w/Castle_Wars - val cwarsItems = intArrayOf(CastleWars.saradominTeamHoodedCloak, - CastleWars.zamorakTeamHoodedCloak, - CastleWars.saradominFlag, - CastleWars.zamorakFlag) + // TODO: PvPStateAudit blows, has gotta go and actual safe handling of PvP generalized to cover current inauthentic deep wildy, + // future authentic minigames and that wont result in 7000 exploits that ruin the server (yes. thats a good idea. who said that) + // PvPStateAudit.cleanupPvPState(player) - player.equipment.removeAll(cwarsItems) - player.inventory.removeAll(cwarsItems) - (player.familiarManager.familiar as? BurdenBeast)?.container?.removeAll(cwarsItems) + player.equipment.removeAll(CastleWars.CW_ITEMS_TO_REMOVE) + player.inventory.removeAll(CastleWars.CW_ITEMS_TO_REMOVE) + (player.familiarManager.familiar as? BurdenBeast)?.container?.removeAll(CastleWars.CW_ITEMS_TO_REMOVE) } override fun defineListeners() { onUnequip(intArrayOf(CastleWars.saradominTeamHoodedCloak, CastleWars.zamorakTeamHoodedCloak)) { player, _ -> - defineAreaBorders().forEach { border -> + val allCastleWarsBorders = CastleWarsGameArea.areaBorders.toList() + + CastleWarsWaitingArea.areaBorders.toList() + + listOf(CastleWarsRespawnArea.saradominRespawnRoom, CastleWarsRespawnArea.zamorakRespawnRoom) + for (border in allCastleWarsBorders) { if (border.insideBorder(player)) { sendMessage(player, "You can't remove your team's colours") return@onUnequip false diff --git a/Server/src/main/content/minigame/castlewars/areas/CastleWarsGameArea.kt b/Server/src/main/content/minigame/castlewars/areas/CastleWarsGameArea.kt index fb44ea7d9..3db8b03f2 100644 --- a/Server/src/main/content/minigame/castlewars/areas/CastleWarsGameArea.kt +++ b/Server/src/main/content/minigame/castlewars/areas/CastleWarsGameArea.kt @@ -1,7 +1,12 @@ -package rs09.game.content.activity.castlewars.areas +package content.minigame.castlewars.areas +import content.minigame.castlewars.* +import content.minigame.castlewars.CastleWarsConstants.PLAYER_CLEANUP_INTERVAL_TICKS import core.api.* import core.game.component.Component +import core.game.event.EventHook +import core.game.event.InterfaceCloseEvent +import core.game.interaction.Option import core.game.node.entity.Entity import core.game.node.entity.player.Player import core.game.node.item.Item @@ -11,57 +16,159 @@ import core.game.world.map.zone.ZoneType import core.tools.Log import core.tools.ticksPerMinute import org.rs09.consts.Components -import rs09.game.content.activity.castlewars.CastleWars /** * Handles the Castle Wars game map */ -class CastleWarsGameArea : CastleWarsArea(), TickListener { +class CastleWarsGameArea : CastleWarsArea(), TickListener, PvPZone { companion object { - private val saradominStandardFloor: ZoneBorders = ZoneBorders(Location.create(2426, 3073, 3), Location.create(2430, 3077, 3)) - private val zamorakStandardFloor: ZoneBorders = ZoneBorders(Location.create(2373, 3134, 3), Location.create(2369, 3130, 3)) - private val saradominUpperFloor: ZoneBorders = ZoneBorders(Location.create(2431, 3080, 2), Location.create(2423, 3072, 2)) - private val zamorakUpperFloor: ZoneBorders = ZoneBorders(Location.create(2368, 3127, 2), Location.create(2376, 3135, 2)) - private val saradominFloor: ZoneBorders = ZoneBorders(Location.create(2420, 3072, 1), Location.create(2431, 3083, 1)) - private val zamorakFloor: ZoneBorders = ZoneBorders(Location.create(2379, 3135, 1), Location.create(2368, 3124, 1)) - private val battleField: ZoneBorders = ZoneBorders(Location.create(2368, 3135, 0), Location.create(2431, 3072, 0)) - private val saradominTunnels: ZoneBorders = ZoneBorders(Location.create(2430, 9481, 0), Location.create(2400, 9504, 0)) - private val zamorakTunnels: ZoneBorders = ZoneBorders(Location.create(2401, 9503, 0), Location.create(2366, 9529, 0)) + private val saradominStandardFloor: ZoneBorders = + ZoneBorders(Location.create(2426, 3073, 3), Location.create(2430, 3077, 3)) + private val zamorakStandardFloor: ZoneBorders = + ZoneBorders(Location.create(2373, 3134, 3), Location.create(2369, 3130, 3)) + private val saradominUpperFloor: ZoneBorders = + ZoneBorders(Location.create(2431, 3080, 2), Location.create(2423, 3072, 2)) + private val zamorakUpperFloor: ZoneBorders = + ZoneBorders(Location.create(2368, 3127, 2), Location.create(2376, 3135, 2)) + private val saradominFloor: ZoneBorders = + ZoneBorders(Location.create(2420, 3072, 1), Location.create(2431, 3083, 1)) + private val zamorakFloor: ZoneBorders = + ZoneBorders(Location.create(2379, 3135, 1), Location.create(2368, 3124, 1)) + private val battleField: ZoneBorders = + ZoneBorders(Location.create(2368, 3135, 0), Location.create(2431, 3072, 0)) + private val saradominTunnels: ZoneBorders = + ZoneBorders(Location.create(2430, 9481, 0), Location.create(2385, 9504, 0)) + private val zamorakTunnels: ZoneBorders = + ZoneBorders(Location.create(2401, 9503, 0), Location.create(2366, 9529, 0)) - val areaBorders = arrayOf(saradominStandardFloor, zamorakStandardFloor, saradominUpperFloor, zamorakUpperFloor, saradominFloor, zamorakFloor, battleField, saradominTunnels, zamorakTunnels) + // Center tunnel zone to cover the gap where ladders are located (2399, 9499) and (2400, 9508) + private val centerTunnels: ZoneBorders = + ZoneBorders(Location.create(2396, 9493, 0), Location.create(2405, 9515, 0)) + + val areaBorders = arrayOf( + saradominStandardFloor, + zamorakStandardFloor, + saradominUpperFloor, + zamorakUpperFloor, + saradominFloor, + zamorakFloor, + battleField, + saradominTunnels, + zamorakTunnels, + centerTunnels + ) val saradominPlayers = mutableSetOf() val zamorakPlayers = mutableSetOf() + /** Players who have logged out but are within the grace period. Maps Player -> logout tick. */ + val loggedOutPlayers = mutableMapOf() + var ticksLeftInGame = 0 fun startGame() { + CastleWarsGameState.resetGame() + CastleWarsItemHandler.clearAllBarricades() saradominPlayers.addAll(CastleWarsWaitingArea.waitingSaradominPlayers) zamorakPlayers.addAll(CastleWarsWaitingArea.waitingZamorakPlayers) CastleWarsWaitingArea.waitingSaradominPlayers.clear() CastleWarsWaitingArea.waitingZamorakPlayers.clear() ticksLeftInGame = CastleWars.gameTimeMinutes * ticksPerMinute - - // Put all the players in their respawn area saradominPlayers.forEach { player -> player.properties.teleportLocation = CastleWarsRespawnArea.saradominRespawnRoom.randomWalkableLoc + setAttribute(player, CastleWarsConstants.ATTR_JOIN_TICK, getWorldTicks()) } zamorakPlayers.forEach { player -> player.properties.teleportLocation = CastleWarsRespawnArea.zamorakRespawnRoom.randomWalkableLoc + setAttribute(player, CastleWarsConstants.ATTR_JOIN_TICK, getWorldTicks()) + } + val allPlayers = saradominPlayers + zamorakPlayers + allPlayers.forEach { player -> + sendMessage(player, "The Castle Wars game has begun!") + CastleWarsGameState.updateOverlay(player) + } + } + + /** + * Handle player death in Castle Wars - delegates to CastleWarsGameZone. + * This ensures consistent death behavior including poison preservation. + */ + fun handleDeath(player: Player, killer: Entity?): Boolean { + return CastleWarsGameZone.handleCastleWarsDeath(player) + } + + /** + * Try to fill a vacancy on the given team by pulling a player from the 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 + val waitingSet = when (team) { + CastleWarsTeam.SARADOMIN -> CastleWarsWaitingArea.waitingSaradominPlayers + CastleWarsTeam.ZAMORAK -> CastleWarsWaitingArea.waitingZamorakPlayers + } + val gameSet = when (team) { + CastleWarsTeam.SARADOMIN -> saradominPlayers + CastleWarsTeam.ZAMORAK -> zamorakPlayers + } + if (waitingSet.isEmpty()) return + if (gameSet.size >= CastleWarsConstants.MAX_PLAYERS_PER_TEAM) return + val replacement = waitingSet.first() + waitingSet.remove(replacement) + gameSet.add(replacement) + setAttribute(replacement, 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.") + } + + object SaradominInterfaceCloseHook : EventHook { // TODO: this might be useless. + override fun process(entity: Entity, event: InterfaceCloseEvent) { + val player = entity as? Player ?: return + // Only restore if player is still in the game + if (!saradominPlayers.contains(player)) return + + val currentOverlayId = player.interfaceManager.overlay?.id + if (currentOverlayId == null || currentOverlayId != 58) { + player.interfaceManager.openOverlay(Component(Components.CASTLEWARS_STATUS_OVERLAY_SARADOMIN_58)) + CastleWarsGameState.updateOverlay(player) + } + } + } + + object ZamorakInterfaceCloseHook : EventHook { // TODO: this might be useless. + override fun process(entity: Entity, event: InterfaceCloseEvent) { + val player = entity as? Player ?: return + if (!zamorakPlayers.contains(player)) return + val currentOverlayId = player.interfaceManager.overlay?.id + if (currentOverlayId == null || currentOverlayId != 59) { + player.interfaceManager.openOverlay(Component(Components.CASTLEWARS_STATUS_OVERLAY_ZAMORAK_59)) + CastleWarsGameState.updateOverlay(player) + } } } } - private fun endGame() { + private fun endGame() { // TODO: ropes, rocks in tunnels, ground items, castle doors, catapults + CastleWarsWaitingArea.postGameCooldown = true + CastleWarsGameState.endGame() + CastleWarsItemHandler.clearAllBarricades() saradominPlayers.forEach { player -> + player.unhook(SaradominInterfaceCloseHook) player.properties.teleportLocation = CastleWars.lobbyBankArea.randomWalkableLoc } zamorakPlayers.forEach { player -> + player.unhook(ZamorakInterfaceCloseHook) player.properties.teleportLocation = CastleWars.lobbyBankArea.randomWalkableLoc } saradominPlayers.clear() zamorakPlayers.clear() + loggedOutPlayers.clear() } override fun defineAreaBorders(): Array { @@ -70,31 +177,211 @@ 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)) + registerTimer(player, spawnTimer("teleblock", (CastleWars.gameTimeMinutes) * 60 * 2)) + val team = CastleWarsGameState.getPlayerTeam(player) + when (team) { + CastleWarsTeam.SARADOMIN -> { + player.interfaceManager.openOverlay(Component(Components.CASTLEWARS_STATUS_OVERLAY_SARADOMIN_58)) + player.equipment.replace(Item(CastleWars.saradominTeamHoodedCloak), 1) + player.hook(Event.InterfaceClosed, SaradominInterfaceCloseHook) + } - if (saradominPlayers.contains(player)) { - player.interfaceManager.openOverlay(Component(Components.CASTLEWARS_STATUS_OVERLAY_SARADOMIN_58)) - player.equipment.replace(Item(CastleWars.saradominTeamHoodedCloak), 1) - } else if (zamorakPlayers.contains(player)) { - player.interfaceManager.openOverlay(Component(Components.CASTLEWARS_STATUS_OVERLAY_ZAMORAK_59)) - player.equipment.replace(Item(CastleWars.zamorakTeamHoodedCloak), 1) + CastleWarsTeam.ZAMORAK -> { + player.interfaceManager.openOverlay(Component(Components.CASTLEWARS_STATUS_OVERLAY_ZAMORAK_59)) + player.equipment.replace(Item(CastleWars.zamorakTeamHoodedCloak), 1) + player.hook(Event.InterfaceClosed, ZamorakInterfaceCloseHook) + } + + null -> { + log(this::class.java, Log.WARN, "${player.name} entered Castle Wars but is NOT on a team?!") + } } + player.interaction.set(Option._P_ATTACK) } override fun exitArea(player: Player) { super.exitArea(player) - // Remove player from the players set (whichever one that is) - saradominPlayers.remove(player) - zamorakPlayers.remove(player) - } - - override fun tick() { - ticksLeftInGame-- - if (ticksLeftInGame == 0) { - endGame() + val stillInCastleWars = areaBorders.any { it.insideBorder(player.location) } || + 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) + } } } + 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() + 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 + } else { + // No active game or not on a team -- full cleanup + CastleWarsGameState.handlePlayerLeave(player) + CastleWarsGameState.clearHintIconForPlayer(player) + saradominPlayers.remove(player) + zamorakPlayers.remove(player) + } + super.logout(player) + } + + override fun tick() { + val previousMinutes = (ticksLeftInGame + ticksPerMinute - 1) / ticksPerMinute + ticksLeftInGame-- + val currentMinutes = (ticksLeftInGame + ticksPerMinute - 1) / ticksPerMinute + if (ticksLeftInGame > 0) { + val saraEmpty = saradominPlayers.isEmpty() + val zamEmpty = zamorakPlayers.isEmpty() + if (saraEmpty || zamEmpty) { + endGame() + return + } + } + if (ticksLeftInGame == 0) { + endGame() + } else if (currentMinutes != previousMinutes) { + CastleWarsGameState.updateAllOverlays() + } + CastleWarsGameState.checkFlagCarriers() + CastleWarsGameState.checkDroppedFlagTimeouts() + for (player in saradominPlayers) { + val currentOverlayId = player.interfaceManager.overlay?.id + if (currentOverlayId == null || currentOverlayId != 58) { + player.interfaceManager.openOverlay(Component(Components.CASTLEWARS_STATUS_OVERLAY_SARADOMIN_58)) + } + // TODO: this... should not be done lmfao CRITICAL TODO: PROPER CW MATCH UI UPDATING THATS NOT SHIT + CastleWarsGameState.updateOverlay(player) + } + for (player in zamorakPlayers) { + val currentOverlayId = player.interfaceManager.overlay?.id + if (currentOverlayId == null || currentOverlayId != 59) { + player.interfaceManager.openOverlay(Component(Components.CASTLEWARS_STATUS_OVERLAY_ZAMORAK_59)) + } + // TODO: this... should not be done lmfao CRITICAL TODO: PROPER CW MATCH UI UPDATING THATS NOT SHIT + CastleWarsGameState.updateOverlay(player) + } + if (ticksLeftInGame % PLAYER_CLEANUP_INTERVAL_TICKS == 0) { + cleanupInactivePlayers() + } + } + + /** + * Remove inactive players from team tracking sets. + */ + 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 + } + 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) + CastleWarsGameState.clearHintIconForPlayer(player) + CastleWarsGameState.restoreAndCleanupPlayer(player) + if (team != null) fillVacancy(team) + } + // Remove truly inactive players (not in grace period) + val inactiveSara = saradominPlayers.filter { player -> + (!player.isActive || player.session == null) && !loggedOutPlayers.containsKey(player) + } + val inactiveZam = zamorakPlayers.filter { player -> + (!player.isActive || player.session == null) && !loggedOutPlayers.containsKey(player) + } + for (player in inactiveSara) { + saradominPlayers.remove(player) + CastleWarsGameState.clearHintIconForPlayer(player) + fillVacancy(CastleWarsTeam.SARADOMIN) + } + for (player in inactiveZam) { + zamorakPlayers.remove(player) + CastleWarsGameState.clearHintIconForPlayer(player) + fillVacancy(CastleWarsTeam.ZAMORAK) + } + // Also cleanup hint icons for any other inactive players + CastleWarsGameState.cleanupInactiveHintIcons() + } + + /** + * Handle death in Castle Wars - respawn at team base instead of normal death + */ + fun death(entity: Entity, killer: Entity?): Boolean { + if (entity !is Player) return false + return handleDeath(entity, killer) + } + + // ==================== PvPZone Implementation ==================== TODO: PvPZone general review... it might be good but need to triple-check + + /** + * Check if attacker can attack victim in this zone. + * Only allows attacks between players on opposite teams. + * Uses self-healing team detection based on equipped cloak to ensure + * players are properly tracked even if they weren't added to sets correctly. + * @param attacker The player initiating the attack + * @param victim The player being attacked + * @return true if players are on opposite teams, false otherwise + */ + override fun canAttackPlayer(attacker: Player, victim: Player): Boolean { + if (ticksLeftInGame <= 0) return false + val attackerTeam = CastleWarsGameState.getPlayerTeam(attacker) ?: return false + val victimTeam = CastleWarsGameState.getPlayerTeam(victim) ?: return false + return attackerTeam != victimTeam + } + + /** + * Check combat level restrictions. + * Castle Wars has no combat level restrictions. + */ + override fun checkCombatLevel(attacker: Player, victim: Player): Boolean { + return true + } + + /** + * Get the message to display when attack is blocked. + * @return The message string + */ + override fun getBlockedAttackMessage(): String { + return "You can only attack players on the opposite team!" // TODO: pretty sure this is inauthentic. seen the real one in vids, needs correction here (also does this even get called huh) + } + + /** + * Get the message to display when attack is blocked by combat level check. + * @return The message string + */ + override fun getCombatLevelBlockedMessage(): String { + return PvPZone.DEFAULT_COMBAT_LEVEL_MESSAGE + } + } diff --git a/Server/src/main/content/minigame/castlewars/areas/CastleWarsRespawnArea.kt b/Server/src/main/content/minigame/castlewars/areas/CastleWarsRespawnArea.kt index 671564342..f0a306e50 100644 --- a/Server/src/main/content/minigame/castlewars/areas/CastleWarsRespawnArea.kt +++ b/Server/src/main/content/minigame/castlewars/areas/CastleWarsRespawnArea.kt @@ -1,12 +1,14 @@ -package rs09.game.content.activity.castlewars.areas +package content.minigame.castlewars.areas +import content.minigame.castlewars.CastleWars +import content.minigame.castlewars.CastleWarsGameState import core.api.TickListener +import core.api.sendMessage import core.game.node.entity.Entity import core.game.node.entity.player.Player import core.game.node.item.Item import core.game.world.map.Location import core.game.world.map.zone.ZoneBorders -import rs09.game.content.activity.castlewars.CastleWars /** * Handles the Castle Wars respawn rooms @@ -14,8 +16,10 @@ import rs09.game.content.activity.castlewars.CastleWars class CastleWarsRespawnArea : CastleWarsArea(), TickListener { companion object { - val zamorakRespawnRoom: ZoneBorders = ZoneBorders(Location.create(2376, 3127, 1), Location.create(2368, 3135, 1)) - val saradominRespawnRoom: ZoneBorders = ZoneBorders(Location.create(2423, 3080, 1), Location.create(2431, 3072, 1)) + val zamorakRespawnRoom: ZoneBorders = + ZoneBorders(Location.create(2376, 3127, 1), Location.create(2368, 3135, 1)) + val saradominRespawnRoom: ZoneBorders = + ZoneBorders(Location.create(2423, 3080, 1), Location.create(2431, 3072, 1)) val zamorakPlayersInRespawnRoom = mutableSetOf() val saradominPlayersInRespawnRoom = mutableSetOf() @@ -27,6 +31,14 @@ class CastleWarsRespawnArea : CastleWarsArea(), TickListener { override fun areaEnter(entity: Entity) { val player = entity as? Player ?: return + if (CastleWarsGameState.isCarryingAnyFlag(player)) { + if (CastleWarsGameState.isCarryingFlag(player)) { + CastleWarsGameState.dropFlag(player) + } else if (CastleWarsGameState.isCarryingOwnFlag(player)) { + CastleWarsGameState.dropOwnFlag(player) + } + sendMessage(player, "You cannot take the flag into your respawn room!") + } if (saradominRespawnRoom.insideBorder(player.location)) { player.equipment.replace(Item(CastleWars.saradominTeamHoodedCloak), 1) } else if (zamorakRespawnRoom.insideBorder(player.location)) { @@ -37,4 +49,11 @@ class CastleWarsRespawnArea : CastleWarsArea(), TickListener { override fun tick() { } + override fun exitArea(player: Player) { + super.exitArea(player) + // Remove player from respawn room tracking + saradominPlayersInRespawnRoom.remove(player) + zamorakPlayersInRespawnRoom.remove(player) + } + } diff --git a/Server/src/main/content/minigame/castlewars/areas/CastleWarsWaitingArea.kt b/Server/src/main/content/minigame/castlewars/areas/CastleWarsWaitingArea.kt index 3bc53ddd2..eb8f2a93e 100644 --- a/Server/src/main/content/minigame/castlewars/areas/CastleWarsWaitingArea.kt +++ b/Server/src/main/content/minigame/castlewars/areas/CastleWarsWaitingArea.kt @@ -1,15 +1,17 @@ -package rs09.game.content.activity.castlewars.areas +package content.minigame.castlewars.areas -import CastleWarsOverlay +import content.minigame.castlewars.CastleWars +import content.minigame.castlewars.CastleWarsConstants +import content.minigame.castlewars.CastleWarsOverlay import core.api.* 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 -import rs09.game.content.activity.castlewars.CastleWars /** * Handles the Castle Wars "waiting room" @@ -24,6 +26,8 @@ class CastleWarsWaitingArea : CastleWarsArea(), TickListener { val waitingSaradominPlayers = mutableSetOf() val waitingZamorakPlayers = mutableSetOf() + internal var gameStartCountdown = -1 + var postGameCooldown = false } override fun defineAreaBorders(): Array { @@ -33,7 +37,10 @@ class CastleWarsWaitingArea : CastleWarsArea(), TickListener { override fun areaEnter(entity: Entity) { val player = entity as? Player ?: return super.areaEnter(player) - registerTimer(player, spawnTimer("teleblock", (CastleWars.gameCooldownMinutes + CastleWars.gameTimeMinutes)*60*2)) + registerTimer( + player, + spawnTimer("teleblock", (CastleWars.gameCooldownMinutes + CastleWars.gameTimeMinutes) * 60 * 2) + ) // Set team attribute and equip the hooded cloak on the entity based on which waiting room they're in if (zamorakWaitingRoom.insideBorder(player.location)) { @@ -46,32 +53,42 @@ class CastleWarsWaitingArea : CastleWarsArea(), TickListener { var transformed = false if (player.attributes[CastleWars.portalAttribute] == CastleWars.guthixName - && (hasGodItem(player, God.ZAMORAK) || hasGodItem(player, God.SARADOMIN))) { + && (hasGodItem(player, God.ZAMORAK) || hasGodItem(player, God.SARADOMIN)) + ) { // https://www.youtube.com/watch?v=Vg3Om-jZNIY // Turn the player into a sheep player.appearance.transformNPC(CastleWars.sheep) // Send the player a chat dialog - sendDialogue(player, "I pity your faith in my brothers. I shall bless you with some time in the most holy of forms; maybe its wisdom will rub off on you and you'll see the error of your ways.") + sendDialogue( + player, + "I pity your faith in my brothers. I shall bless you with some time in the most holy of forms; maybe its wisdom will rub off on you and you'll see the error of your ways." + ) transformed = true } if (player.attributes[CastleWars.portalAttribute] == CastleWars.zamorakName - && hasGodItem(player, God.SARADOMIN) || hasGodItem(player, God.GUTHIX)) - { + && (hasGodItem(player, God.SARADOMIN) || hasGodItem(player, God.GUTHIX)) + ) { // https://youtu.be/hmF-CU9OEY4?t=63 // Turn the player into an imp! player.appearance.transformNPC(CastleWars.imp) // Send the player a chat dialog - sendDialogue(player, "You're wearing objects of my ignorant brothers and you come to me? Such treachery must be rewarded! Enjoy some time in the most mischievous of forms.") + sendDialogue( + player, + "You're wearing objects of my ignorant brothers and you come to me? Such treachery must be rewarded! Enjoy some time in the most mischievous of forms." + ) transformed = true } if (player.attributes[CastleWars.portalAttribute] == CastleWars.saradominName - && hasGodItem(player, God.ZAMORAK) || hasGodItem(player, God.GUTHIX)) - { + && (hasGodItem(player, God.ZAMORAK) || hasGodItem(player, God.GUTHIX)) + ) { // https://www.youtube.com/watch?v=Vg3Om-jZNIY // Turn the player into a rabbit! player.appearance.transformNPC(CastleWars.rabbit) // Send the player a chat dialog - sendDialogue(player, "You wear objects of my foolish brothers? Perhaps some time spent as the lowliest of forms will help you appreciate the gifts that I can bestow upon my followers.") + sendDialogue( + player, + "You wear objects of my foolish brothers? Perhaps some time spent as the lowliest of forms will help you appreciate the gifts that I can bestow upon my followers." + ) transformed = true } @@ -101,26 +118,38 @@ class CastleWarsWaitingArea : CastleWarsArea(), TickListener { // The game will start when there's enough players and CastleWarsGameArea.ticksLeftInGame = -5 minutes 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.isEmpty() || waitingZamorakPlayers.isEmpty()) { - // A game is not going and there aren't enough players - Keep the game over time to the current value + } 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 + } 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 + } + nextStart = gameStartCountdown + gameStartCountdown-- } - else { - // There are enough players - Set nextStart to gameCooldownMinutes + ticksLeftInGame - nextStart = CastleWars.gameCooldownMinutes * ticksPerMinute + CastleWarsGameArea.ticksLeftInGame - } - + val minPlayers = if (GameWorld.settings?.isDevMode == true) 1 else CastleWarsConstants.MIN_PLAYERS_PER_TEAM for (player in waitingSaradominPlayers + waitingZamorakPlayers) { CastleWarsOverlay.sendLobbyUpdate( player, - waitingSaradominPlayers.isNotEmpty() && waitingZamorakPlayers.isNotEmpty() || CastleWarsGameArea.ticksLeftInGame >= 0, + (waitingSaradominPlayers.size >= minPlayers && waitingZamorakPlayers.size >= minPlayers) || CastleWarsGameArea.ticksLeftInGame >= 0, (nextStart - 1) / ticksPerMinute + 1 // Displays max 5 minutes, min 1 minute, seems authentic ) } - if (nextStart <= 0) { + gameStartCountdown = -1 // Reset countdown CastleWarsGameArea.startGame() } } From 1f505efa26442841a97ef5a80373f8d4acb9667a Mon Sep 17 00:00:00 2001 From: dam <27978131-real_damighty@users.noreply.gitlab.com> Date: Wed, 18 Feb 2026 22:50:10 +0200 Subject: [PATCH 03/26] Experimental PvP scaffolding --- .../global/handlers/PvPLogoutHandler.kt | 33 +++ Server/src/main/core/api/PvPCombatUtils.kt | 76 ++++++ Server/src/main/core/api/PvPZone.kt | 51 ++++ .../game/node/entity/combat/DeathContext.kt | 33 +++ .../core/game/node/entity/player/Player.java | 125 +++++++++- .../node/entity/player/link/PvPStateAudit.kt | 67 +++++ .../core/game/world/map/zone/ZoneMonitor.java | 236 ++++++++++++------ .../game/world/map/zone/ZoneRestriction.java | 14 ++ 8 files changed, 547 insertions(+), 88 deletions(-) create mode 100644 Server/src/main/content/global/handlers/PvPLogoutHandler.kt create mode 100644 Server/src/main/core/api/PvPCombatUtils.kt create mode 100644 Server/src/main/core/api/PvPZone.kt create mode 100644 Server/src/main/core/game/node/entity/combat/DeathContext.kt create mode 100644 Server/src/main/core/game/node/entity/player/link/PvPStateAudit.kt diff --git a/Server/src/main/content/global/handlers/PvPLogoutHandler.kt b/Server/src/main/content/global/handlers/PvPLogoutHandler.kt new file mode 100644 index 000000000..4e0d26f5c --- /dev/null +++ b/Server/src/main/content/global/handlers/PvPLogoutHandler.kt @@ -0,0 +1,33 @@ +package content.global.handlers + +import core.api.LogoutListener +import core.game.node.entity.player.Player +import core.game.node.entity.player.link.PvPStateAudit + +/** + * Global logout handler that ensures PvP state is always cleaned up on logout. + * This is a safety net to prevent PvP state leakage (Falador Massacre-style bugs). + * + * Even if a minigame's leave() or logout() method fails to clean up properly, + * this handler ensures the player's PvP flags are reset before they rejoin. + * + * This works alongside PvPStateAudit (which runs periodically) to provide + * comprehensive protection against PvP state leaking out of designated areas. + * + * @see core.game.node.entity.player.link.PvPStateAudit + * @see core.api.PvPZone + */ +class PvPLogoutHandler : LogoutListener { + + /** + * Called when a player logs out. + * Always cleans up PvP state as a final safety measure. + */ + override fun logout(player: Player) { + // Always clean up PvP state on logout as final safety net. + // This ensures that even if a minigame's leave() method fails, + // the player won't have PvP flags enabled when they log back in. + PvPStateAudit.cleanupPvPState(player) + } +} + diff --git a/Server/src/main/core/api/PvPCombatUtils.kt b/Server/src/main/core/api/PvPCombatUtils.kt new file mode 100644 index 000000000..ea904442f --- /dev/null +++ b/Server/src/main/core/api/PvPCombatUtils.kt @@ -0,0 +1,76 @@ +package core.api + +import core.game.node.entity.player.Player +import kotlin.math.abs + +/** + * Utility object providing combat level range checking for PvP zones. + * Used by zones that implement the PvPZone interface to determine + * if two players are within the allowed combat level range for an area. + * + * @see PvPZone + */ +object PvPCombatUtils { + /** + * Check if two players are within a combat level range. + * Used for wilderness-style level restrictions where the allowed + * combat level difference is determined by the area's level. + * + * @param attacker The player initiating the attack + * @param victim The player being attacked + * @param range The maximum allowed combat level difference + * @return true if within allowed range, false otherwise + */ + @JvmStatic + fun withinCombatRange(attacker: Player, victim: Player, range: Int): Boolean { + val attackerLevel = attacker.properties.currentCombatLevel + val victimLevel = victim.properties.currentCombatLevel + return abs(attackerLevel - victimLevel) <= range + } + + /** + * Calculate wilderness-style combat range based on wilderness level. + * In the wilderness, the combat level range equals the wilderness level. + * For example, in level 5 wilderness, you can attack players within + * 5 combat levels of your own level. + * + * @param wildernessLevel The wilderness level of the area + * @return The combat level range (same as wilderness level) + */ + @JvmStatic + fun getWildernessStyleRange(wildernessLevel: Int): Int { + return wildernessLevel + } + + /** + * Check if combat between two players is allowed based on wilderness-style rules. + * Combines the wilderness level lookup with combat range checking. + * + * @param attacker The player initiating the attack + * @param victim The player being attacked + * @param wildernessLevel The wilderness level of the area + * @return true if combat is allowed, false otherwise + */ + @JvmStatic + fun checkWildernessStyleCombat(attacker: Player, victim: Player, wildernessLevel: Int): Boolean { + val range = getWildernessStyleRange(wildernessLevel) + return withinCombatRange(attacker, victim, range) + } + + /** + * Get a formatted message explaining why combat was blocked due to level difference. + * + * @param attacker The attacking player + * @param victim The victim player + * @param allowedRange The maximum allowed combat level difference + * @return A descriptive message explaining the level restriction + */ + @JvmStatic + fun getCombatLevelBlockedMessage(attacker: Player, victim: Player, allowedRange: Int): String { + val attackerLevel = attacker.properties.currentCombatLevel + val victimLevel = victim.properties.currentCombatLevel + val actualDifference = abs(attackerLevel - victimLevel) + return "The combat level difference ($actualDifference) exceeds the allowed range ($allowedRange)." + } +} + diff --git a/Server/src/main/core/api/PvPZone.kt b/Server/src/main/core/api/PvPZone.kt new file mode 100644 index 000000000..628e1b5d2 --- /dev/null +++ b/Server/src/main/core/api/PvPZone.kt @@ -0,0 +1,51 @@ +package core.api + +import core.game.node.entity.player.Player + +/** + * Interface for zones that allow player-vs-player combat. + * Zones implementing this interface must explicitly define PvP rules. + * This provides a centralized, explicit mechanism for PvP permission checking + * to prevent combat state leakage (e.g., Falador Massacre-style bugs). + */ +interface PvPZone { + /** + * Check if attacker can attack victim in this zone. + * This is the primary permission check for PvP combat. + * @param attacker The player initiating the attack + * @param victim The player being attacked + * @return true if attack is permitted, false otherwise + */ + fun canAttackPlayer(attacker: Player, victim: Player): Boolean + + /** + * Check if combat level restrictions apply between two players. + * Override this for zones with level-based restrictions (e.g., wilderness level range). + * @param attacker The player initiating the attack + * @param victim The player being attacked + * @return true if within allowed combat level range, false otherwise + */ + fun checkCombatLevel(attacker: Player, victim: Player): Boolean + + /** + * Get the message to display when attack is blocked by canAttackPlayer(). + * @return The message string to send to the attacking player + */ + fun getBlockedAttackMessage(): String + + /** + * Get the message to display when attack is blocked by combat level check. + * @return The message string to send to the attacking player + */ + fun getCombatLevelBlockedMessage(): String + + /** + * Companion object providing default values for the interface methods. + * Use these in Java implementations to get default behavior. + */ + companion object { + const val DEFAULT_BLOCKED_MESSAGE = "You cannot attack that player here." // TODO: correct + const val DEFAULT_COMBAT_LEVEL_MESSAGE = "The level difference between you and your opponent is too great." + } +} + diff --git a/Server/src/main/core/game/node/entity/combat/DeathContext.kt b/Server/src/main/core/game/node/entity/combat/DeathContext.kt new file mode 100644 index 000000000..f81f6887b --- /dev/null +++ b/Server/src/main/core/game/node/entity/combat/DeathContext.kt @@ -0,0 +1,33 @@ +package core.game.node.entity.combat + +import core.game.node.entity.Entity +import core.game.world.map.Location + +/** + * Stores the state at the moment death commenced. + * Used to prevent zone-transition exploits during death animation. + * + * When a player dies, there's a delay between death commencement (when HP reaches 0) + * and death finalization (after the death animation plays). During this time, a player + * could theoretically transition between zones. By capturing the state at death start, + * we ensure that item loss/keep decisions are made based on where the player actually died, + * not where they ended up after the animation. + * + * @param deathLocation The location where death commenced + * @param zoneType The zone type ID at death time (from ZoneMonitor.getType()) + * @param isSafeZone Whether the player was in a safe zone at death time + * @param wasInWilderness Whether the player was in wilderness at death time + * @param wasSkulled Whether the player was skulled at death time + * @param killer The entity that killed the player (may be null for environmental deaths) + * @param deathTick The game tick when death commenced + */ +data class DeathContext( + val deathLocation: Location, + val zoneType: Int, + val isSafeZone: Boolean, + val wasInWilderness: Boolean, + val wasSkulled: Boolean, + val killer: Entity?, + val deathTick: Int +) + diff --git a/Server/src/main/core/game/node/entity/player/Player.java b/Server/src/main/core/game/node/entity/player/Player.java index d21c591fc..3381df361 100644 --- a/Server/src/main/core/game/node/entity/player/Player.java +++ b/Server/src/main/core/game/node/entity/player/Player.java @@ -47,8 +47,12 @@ import core.game.system.task.Pulse; import core.game.world.map.*; import core.game.world.map.build.DynamicRegion; import core.game.world.map.path.Pathfinder; +import core.game.world.map.zone.RegionZone; import core.game.world.map.zone.ZoneRestriction; import core.game.world.map.zone.ZoneType; +import core.api.MapArea; +import core.api.PvPZone; +import core.game.node.entity.combat.DeathContext; import core.game.world.update.flag.PlayerFlags; import core.game.world.update.flag.*; import core.net.IoSession; @@ -610,6 +614,17 @@ public class Player extends Entity { @Override public void commenceDeath(Entity killer) { if (!isPlaying()) return; + // TODO: review DeathContext, PvPStateAudit, PvPZone and related components. theyre all cool but also... cant this break a lot of shit? 100%. does it? uhhh... lemme check (later + DeathContext context = new DeathContext( + getLocation(), + getZoneMonitor().getType(), + getProperties().isSafeZone(), + getSkullManager().isWilderness(), + getSkullManager().isSkulled(), + killer, + GameWorld.getTicks() + ); + setAttribute("death-context", context); super.commenceDeath(killer); if (prayer.get(PrayerType.RETRIBUTION)) { prayer.startRetribution(killer); @@ -619,6 +634,11 @@ public class Player extends Entity { @Override public void finalizeDeath(Entity killer) { if (!isPlaying()) return; //if the player has already been full cleared, it has already disconnected. This code is probably getting called because something is maintaining a stale reference. + DeathContext deathContext = getAttribute("death-context", null); + removeAttribute("death-context"); + boolean wasSafeZone = deathContext != null ? deathContext.isSafeZone() : getProperties().isSafeZone(); + int deathZoneType = deathContext != null ? deathContext.getZoneType() : getZoneMonitor().getType(); + Location deathLocation = deathContext != null ? deathContext.getDeathLocation() : getLocation(); GlobalStats.incrementDeathCount(); settings.setSpecialEnergy(100); settings.updateRunEnergy(settings.getRunEnergy() - 100); @@ -639,7 +659,8 @@ public class Player extends Entity { incrementAttribute("/save:"+STATS_BASE+":"+STATS_DEATHS); packetDispatch.sendTempMusic(90); - if (!getZoneMonitor().handleDeath(killer) && (!getProperties().isSafeZone() && getZoneMonitor().getType() != ZoneType.SAFE.getId()) && getDetails().getRights() != Rights.ADMINISTRATOR) { + boolean zoneHandledDeath = getZoneMonitor().handleDeath(killer); + if (!zoneHandledDeath && (!wasSafeZone && deathZoneType != ZoneType.SAFE.getId()) && getDetails().getRights() != Rights.ADMINISTRATOR) { //If player was a Hardcore Ironman, announce that they died if (this.getIronmanManager().getMode().equals(IronmanMode.HARDCORE)) { //if this was checkRestriction, ultimate irons would be moved to HARDCORE_DEAD as well String gender = this.isMale() ? "man " : "woman "; @@ -649,7 +670,7 @@ public class Player extends Entity { return; } } - GroundItemManager.create(new Item(BONES_526), this.getAttribute("/save:original-loc",location), k); + GroundItemManager.create(new Item(BONES_526), this.getAttribute("/save:original-loc", deathLocation), k); final Container[] c = DeathTask.getContainers(this); for (Item i : getEquipment().toArray()) { @@ -662,7 +683,7 @@ public class Player extends Entity { boolean canCreateGrave = GraveController.allowGenerate(this); if (canCreateGrave) { Grave g = GraveController.produceGrave(GraveController.getGraveType(this)); - g.initialize(this, location, Arrays.stream(c[1].toArray()).filter(Objects::nonNull).toArray(Item[]::new)); //note: the amount of code required to filter nulls from an array in Java is atrocious. + g.initialize(this, deathLocation, Arrays.stream(c[1].toArray()).filter(Objects::nonNull).toArray(Item[]::new)); //note: the amount of code required to filter nulls from an array in Java is atrocious. } else { StringBuilder itemsLost = new StringBuilder(); int coins = 0; @@ -693,11 +714,11 @@ public class Player extends Entity { } else { item = GraveController.checkTransform(item); } - GroundItem gi = GroundItemManager.create(item, location, killer instanceof Player ? (Player) killer : this); + GroundItem gi = GroundItemManager.create(item, deathLocation, killer instanceof Player ? (Player) killer : this); gi.setRemainPrivate(stayPrivate); } if (coins > 0) { - GroundItemManager.create(new Item(Items.COINS_995, coins), location, (Player) killer); + GroundItemManager.create(new Item(Items.COINS_995, coins), deathLocation, (Player) killer); } if (killer instanceof Player) PlayerMonitor.log((Player) killer, LogType.PK, "Killed " + name + ", who dropped: " + itemsLost); @@ -719,7 +740,8 @@ public class Player extends Entity { setComponentVisibility(this, 746, 12, false); //reenable the logout button (HD) super.finalizeDeath(killer); appearance.sync(); - if (!getSavedData().getGlobalData().isDeathScreenDisabled()) { + // Don't show death tutorial in safe minigames (zone handled death) or if player disabled it + if (!zoneHandledDeath && !getSavedData().getGlobalData().isDeathScreenDisabled()) { getInterfaceManager().open(new Component(153)); } } @@ -771,11 +793,31 @@ public class Player extends Entity { return false; } if (entity instanceof Player) { - Player p = (Player) entity; - if (p.getSkullManager().isWilderness() && skullManager.isWilderness()) { + Player attacker = (Player) entity; + + // NEW: Check for PvPZone-based permission (minigames, etc.) + PvPZone pvpZone = findCommonPvPZone(attacker, this); + if (pvpZone != null) { + if (!pvpZone.canAttackPlayer(attacker, this)) { + if (message) { + attacker.getPacketDispatch().sendMessage(pvpZone.getBlockedAttackMessage()); + } + return false; + } + if (!pvpZone.checkCombatLevel(attacker, this)) { + if (message) { + attacker.getPacketDispatch().sendMessage(pvpZone.getCombatLevelBlockedMessage()); + } + return false; + } + return true; + } + + // LEGACY: Wilderness fallback (kept for backward compatibility) + if (attacker.getSkullManager().isWilderness() && skullManager.isWilderness()) { if (!GameWorld.getSettings().getWild_pvp_enabled()) return false; - if (p.getSkullManager().hasWildernessProtection()) + if (attacker.getSkullManager().hasWildernessProtection()) return false; if (skullManager.hasWildernessProtection()) return false; @@ -785,6 +827,71 @@ public class Player extends Entity { return super.isAttackable(entity, style, message); } + /** + * Find a PvPZone that both the attacker and this player (victim) are in. + * @param attacker The attacking player + * @param victim The player being attacked (this) + * @return The common PvPZone, or null if no common zone exists + */ + private PvPZone findCommonPvPZone(Player attacker, Player victim) { + // First check: Direct PvPZone implementations (e.g., ActivityPlugin subclasses) + for (RegionZone zone : attacker.getZoneMonitor().getZones()) { + if (zone.getZone() instanceof PvPZone) { + for (RegionZone victimZone : victim.getZoneMonitor().getZones()) { + if (zone.getZone() == victimZone.getZone()) { + return (PvPZone) zone.getZone(); + } + } + } + } + + // Second check: MapArea implementations that implement PvPZone + // ClassScanner wraps MapAreas in anonymous MapZone classes, so we need to + // look up the original MapArea from MapArea.zoneMaps + for (RegionZone zone : attacker.getZoneMonitor().getZones()) { + String zoneName = zone.getZone().getName(); + // MapArea zones are named with "MapArea" suffix by ClassScanner + if (zoneName != null && zoneName.endsWith("MapArea")) { + // Look up the original MapArea class that implements PvPZone + for (java.util.Map.Entry entry : MapArea.Companion.getZoneMaps().entrySet()) { + if (entry.getKey().equals(zoneName) && entry.getValue() == zone.getZone()) { + // Find the MapArea instance - we need to check all loaded ContentInterfaces + // The MapArea interface stores zones but not the instances themselves + // We need to iterate GameWorld's content to find the matching MapArea + PvPZone pvpZone = findMapAreaPvPZone(zoneName, attacker, victim); + if (pvpZone != null) { + return pvpZone; + } + } + } + } + } + + return null; + } + + /** + * Find a MapArea that implements PvPZone and matches the zone name. + * This is needed because ClassScanner wraps MapAreas in anonymous classes. + */ + private PvPZone findMapAreaPvPZone(String zoneName, Player attacker, Player victim) { + // Check TickListeners - MapAreas often implement TickListener + for (Object listener : GameWorld.getTickListeners()) { + if (listener instanceof MapArea && listener instanceof PvPZone) { + String mapAreaZoneName = listener.getClass().getSimpleName() + "MapArea"; + if (mapAreaZoneName.equals(zoneName)) { + // Verify victim is also in this zone + for (RegionZone victimZone : victim.getZoneMonitor().getZones()) { + if (victimZone.getZone().getName().equals(zoneName)) { + return (PvPZone) listener; + } + } + } + } + } + return null; + } + @Override public boolean continueAttack(Entity target, CombatStyle style, boolean message) { if (target instanceof NPC) { diff --git a/Server/src/main/core/game/node/entity/player/link/PvPStateAudit.kt b/Server/src/main/core/game/node/entity/player/link/PvPStateAudit.kt new file mode 100644 index 000000000..ffc711add --- /dev/null +++ b/Server/src/main/core/game/node/entity/player/link/PvPStateAudit.kt @@ -0,0 +1,67 @@ +package core.game.node.entity.player.link + +import core.api.PvPZone +import core.api.TickListener +import core.api.log +import core.game.interaction.Option +import core.game.node.entity.player.Player +import core.game.world.map.zone.ZoneRestriction +import core.game.world.map.zone.impl.WildernessZone +import core.game.world.repository.Repository +import core.tools.Log + +/** + * Periodic audit that detects and fixes PvP state leakage. + * Runs every 10 ticks (6 seconds) to catch players who have + * PvP flags enabled outside valid PvP zones. + */ +class PvPStateAudit : TickListener { + private var tickCounter = 0 + + override fun tick() { + tickCounter++ + if (tickCounter < 10) return + tickCounter = 0 + + for (player in Repository.players) { + if (player == null || !player.isActive || player.isArtificial) continue + auditPlayer(player) + } + } + + private fun auditPlayer(player: Player) { + val hasPvPEnabled = player.skullManager.isWilderness + val inPvPZone = isInValidPvPZone(player) + if (hasPvPEnabled && !inPvPZone) { + log(this::class.java, Log.WARN, + "Player ${player.username} has PvP enabled but is not in a PvP zone! " + + "Location: ${player.location}, Cleaning up state.") + cleanupPvPState(player) + } + } + + private fun isInValidPvPZone(player: Player): Boolean { + for (zone in player.zoneMonitor.zones) { + if (zone.zone is PvPZone) return true + } + if (player.zoneMonitor.isRestricted(ZoneRestriction.PVP_ZONE)) return true + return WildernessZone.isInZone(player) + } + + companion object { + /** + * Clean up all PvP-related state from a player. + * Called when a player is found to have PvP flags enabled outside a PvP zone. + */ + @JvmStatic + fun cleanupPvPState(player: Player) { + player.skullManager.isWilderness = false + player.skullManager.isSkullCheckDisabled = false + player.skullManager.level = 0 + player.properties.isSafeZone = false + player.properties.isMultiZone = false + player.interaction.remove(Option._P_ATTACK) + } + } +} + diff --git a/Server/src/main/core/game/world/map/zone/ZoneMonitor.java b/Server/src/main/core/game/world/map/zone/ZoneMonitor.java index 65b4a0322..d3e7de92e 100644 --- a/Server/src/main/core/game/world/map/zone/ZoneMonitor.java +++ b/Server/src/main/core/game/world/map/zone/ZoneMonitor.java @@ -1,85 +1,90 @@ package core.game.world.map.zone; +import core.api.MapArea; +import core.api.PvPZone; import core.game.interaction.Option; import core.game.node.Node; import core.game.node.entity.Entity; import core.game.node.entity.combat.CombatStyle; import core.game.node.entity.player.Player; -import core.game.node.entity.player.link.music.MusicEntry; import core.game.node.entity.player.link.music.MusicZone; import core.game.node.entity.player.link.request.RequestType; import core.game.node.item.Item; +import core.game.world.GameWorld; import core.game.world.map.Location; +import core.game.world.map.Region; +import org.rs09.consts.Items; import java.util.ArrayList; import java.util.Iterator; import java.util.List; import java.util.Set; -import core.game.world.map.Region; -import org.rs09.consts.Items; - /** * Handles the zones for an entity. + * * @author Emperor */ public final class ZoneMonitor { - + /** * The set of jewellery which allow teleporting from up to and including level 30 wildy. * Used to check if a player can teleport from 20 < level <= 30 wildy, see canTeleportByJewellery. * Note: the check is based on the nextJewellery (see EnchantedJewellery.kt), so this list should not contain the (4) items, and should contain the empty ones. + * * @author Player Name */ static final Set MID_WILDY_TELEPORT_JEWELLERY = Set.of( - Items.AMULET_OF_GLORY_1704, - Items.AMULET_OF_GLORY1_1706, - Items.AMULET_OF_GLORY2_1708, - Items.AMULET_OF_GLORY3_1710, - Items.AMULET_OF_GLORYT_10362, - Items.AMULET_OF_GLORYT1_10360, - Items.AMULET_OF_GLORYT2_10358, - Items.AMULET_OF_GLORYT3_10356, - Items.SKILLS_NECKLACE_11113, - Items.SKILLS_NECKLACE1_11111, - Items.SKILLS_NECKLACE2_11109, - Items.SKILLS_NECKLACE3_11107, - Items.COMBAT_BRACELET_11126, - Items.COMBAT_BRACELET1_11124, - Items.COMBAT_BRACELET2_11122, - Items.COMBAT_BRACELET3_11120, - Items.RING_OF_WEALTH_14638, - Items.RING_OF_WEALTH1_14640, - Items.RING_OF_WEALTH2_14642, - Items.RING_OF_WEALTH3_14644, - Items.RING_OF_LIFE_2570 - ); - + Items.AMULET_OF_GLORY_1704, + Items.AMULET_OF_GLORY1_1706, + Items.AMULET_OF_GLORY2_1708, + Items.AMULET_OF_GLORY3_1710, + Items.AMULET_OF_GLORYT_10362, + Items.AMULET_OF_GLORYT1_10360, + Items.AMULET_OF_GLORYT2_10358, + Items.AMULET_OF_GLORYT3_10356, + Items.SKILLS_NECKLACE_11113, + Items.SKILLS_NECKLACE1_11111, + Items.SKILLS_NECKLACE2_11109, + Items.SKILLS_NECKLACE3_11107, + Items.COMBAT_BRACELET_11126, + Items.COMBAT_BRACELET1_11124, + Items.COMBAT_BRACELET2_11122, + Items.COMBAT_BRACELET3_11120, + Items.RING_OF_WEALTH_14638, + Items.RING_OF_WEALTH1_14640, + Items.RING_OF_WEALTH2_14642, + Items.RING_OF_WEALTH3_14644, + Items.RING_OF_LIFE_2570 + ); + /** * The entity. */ private final Entity entity; - + /** * The currently entered zones. */ private final List zones = new ArrayList<>(20); - + /** * The currently entered music zones. */ private final List musicZones = new ArrayList<>(20); - + /** * Constructs a new {@code ZoneMonitor} {@code Object}. + * * @param entity The entity. */ public ZoneMonitor(Entity entity) { this.entity = entity; } - + /** * Gets the zone type. + * * @return The zone type. */ public int getType() { @@ -90,9 +95,10 @@ public final class ZoneMonitor { } return 0; } - + /** * Checks if the player can logout. + * * @return {@code True} if so. */ public boolean canLogout() { @@ -103,18 +109,20 @@ public final class ZoneMonitor { } return true; } - + /** * Checks if the restriction was flagged. + * * @param restriction The restriction flag. * @return {@code True} if so. */ public boolean isRestricted(ZoneRestriction restriction) { return isRestricted(restriction.getFlag()); } - + /** * Checks if the restriction was flagged. + * * @param flag The restriction flag. * @return {@code True} if so. */ @@ -126,9 +134,10 @@ public final class ZoneMonitor { } return false; } - + /** * Handles a death. + * * @param killer The killer. * @return {@code True} if the death got handled. */ @@ -140,11 +149,12 @@ public final class ZoneMonitor { } return false; } - + /** * Checks if the entity is able to continue attacking the target. + * * @param target The target. - * @param style The combat style used. + * @param style The combat style used. * @return {@code True} if so. */ public boolean continueAttack(Node target, CombatStyle style, boolean message) { @@ -159,11 +169,17 @@ public final class ZoneMonitor { } } if (entity instanceof Player && target instanceof Player) { - if (!((Player) entity).getSkullManager().isWilderness() || !((Player) target).getSkullManager().isWilderness()) { - if(message) { - ((Player) entity).getPacketDispatch().sendMessage("You can only attack other players in the wilderness."); - } - return false; + Player attacker = (Player) entity; + Player victim = (Player) target; + // Skip wilderness check if both players are in a PvPZone (e.g., Castle Wars, Duel Arena) + // PvPZone handles its own attack permissions via canAttackPlayer() + if (!isInCommonPvPZone(attacker, victim)) { + if (!attacker.getSkullManager().isWilderness() || !victim.getSkullManager().isWilderness()) { + if (message) { + attacker.getPacketDispatch().sendMessage("You can only attack other players in the wilderness."); + } + return false; + } } } if (target instanceof Entity && !MapZone.checkMulti(entity, (Entity) target, message)) { @@ -171,9 +187,56 @@ public final class ZoneMonitor { } return true; } - + + /** + * Check if both players are in a common PvPZone. + * Used to bypass the wilderness check for minigames like Castle Wars and Duel Arena. + * + * @param attacker The attacking player + * @param victim The player being attacked + * @return true if both players share a PvPZone + */ + private boolean isInCommonPvPZone(Player attacker, Player victim) { + // Check for direct PvPZone implementations (e.g., ActivityPlugin subclasses) + for (RegionZone zone : attacker.getZoneMonitor().getZones()) { + if (zone.getZone() instanceof PvPZone) { + for (RegionZone victimZone : victim.getZoneMonitor().getZones()) { + if (zone.getZone() == victimZone.getZone()) { + return true; + } + } + } + } + // Check for MapArea-based PvPZones (ClassScanner wraps MapAreas in anonymous MapZone classes) + for (RegionZone zone : attacker.getZoneMonitor().getZones()) { + String zoneName = zone.getZone().getName(); + if (zoneName != null && zoneName.endsWith("MapArea")) { + // Look up the original MapArea instance from TickListeners + for (Object listener : GameWorld.getTickListeners()) { + if (listener instanceof MapArea && listener instanceof PvPZone) { + String mapAreaZoneName = listener.getClass().getSimpleName() + "MapArea"; + if (mapAreaZoneName.equals(zoneName)) { + for (RegionZone victimZone : victim.getZoneMonitor().getZones()) { + if (victimZone.getZone().getName().equals(zoneName)) { + return true; + } + } + } + } + } + } + } + // Check for PVP_ZONE restriction flag + if (attacker.getZoneMonitor().isRestricted(ZoneRestriction.PVP_ZONE) && + victim.getZoneMonitor().isRestricted(ZoneRestriction.PVP_ZONE)) { + return true; + } + return false; + } + /** * Checks if the entity can interact with the target. + * * @param target The target to interact with. * @param option The option. * @return {@code True} if the option got handled. @@ -186,26 +249,27 @@ public final class ZoneMonitor { } return false; } - + /** * Checks if a zone handles a useWith interaction */ - public boolean useWith(Item used, Node with){ + public boolean useWith(Item used, Node with) { for (RegionZone z : zones) { - if (z.getZone().handleUseWith(entity.asPlayer(), used,with)) { + if (z.getZone().handleUseWith(entity.asPlayer(), used, with)) { return true; } } return false; } - + /** * Checks if the player handled the reward button using a map zone. + * * @param interfaceId The interface id. - * @param buttonId The button id. - * @param slot The slot. - * @param itemId The item id. - * @param opcode The packet opcode. + * @param buttonId The button id. + * @param slot The slot. + * @param itemId The item id. + * @param opcode The packet opcode. * @return {@code True} if the button got handled. */ public boolean clickButton(int interfaceId, int buttonId, int slot, int itemId, int opcode) { @@ -216,9 +280,10 @@ public final class ZoneMonitor { } return false; } - + /** * Checks if multiway combat zone rules should be ignored. + * * @param victim The victim. * @return {@code True} if this entity can attack regardless of multiway * combat zone. @@ -231,9 +296,10 @@ public final class ZoneMonitor { } return false; } - + /** * Checks if the entity can teleport. + * * @param type The teleport type (0=spell, 1=item, 2=object, 3=npc -1= force) * @return {@code True} if so. */ @@ -251,9 +317,10 @@ public final class ZoneMonitor { } return true; } - + /** * Checks if a player can teleport with a jewellery piece in >= 1 <= 30 wilderness level + * * @return {@code True} if so. */ private boolean canTeleportByJewellery(int type, Node node) { @@ -262,11 +329,11 @@ public final class ZoneMonitor { } if (entity.timers.getTimer("teleblock") != null) return false; - + if (entity.getZoneMonitor().isRestricted(ZoneRestriction.TELEPORT)) { return false; } - + if (entity.getLocks().isTeleportLocked()) { if (entity.isPlayer()) { Player p = entity.asPlayer(); @@ -275,12 +342,13 @@ public final class ZoneMonitor { } } } - + return false; } - + /** * Checks if the death should start for an entity. + * * @param entity the entity. * @param killer the killer. * @return {@code True} if so. @@ -293,9 +361,10 @@ public final class ZoneMonitor { } return true; } - + /** * Checks if the entity can fire a random event. + * * @return {@code True} if so. */ public boolean canFireRandomEvent() { @@ -306,9 +375,10 @@ public final class ZoneMonitor { } return true; } - + /** * Clears the zones. + * * @return {@code True} if the entity successfully left all regions. */ public boolean clear() { @@ -324,10 +394,11 @@ public final class ZoneMonitor { musicZones.clear(); return true; } - + /** * Checks if the entity can move. - * @param location The destination location. + * + * @param location The destination location. * @param destination The destination location. * @return {@code True} if so. */ @@ -339,18 +410,19 @@ public final class ZoneMonitor { } return true; } - + /** * Handles a location update. + * * @param last The last location of the entity. * @return {@code false} If the entity could not enter/leave a region. */ public boolean updateLocation(Location last) { - if(entity instanceof Player && !entity.asPlayer().isArtificial()) { + if (entity instanceof Player && !entity.asPlayer().isArtificial()) { checkMusicZones(); } entity.updateLocation(last); - for (Iterator it = zones.iterator(); it.hasNext();) { + for (Iterator it = zones.iterator(); it.hasNext(); ) { RegionZone zone = it.next(); if (!zone.getBorders().insideBorder(entity)) { if (zone.getZone().isDynamicZone()) { @@ -385,7 +457,7 @@ public final class ZoneMonitor { } return true; } - + /** * Checks the music zones. */ @@ -395,7 +467,7 @@ public final class ZoneMonitor { } Player player = (Player) entity; Location l = player.getLocation(); - for (Iterator it = musicZones.iterator(); it.hasNext();) { + for (Iterator it = musicZones.iterator(); it.hasNext(); ) { MusicZone zone = it.next(); if (!zone.getBorders().insideBorder(l.getX(), l.getY())) { if (zone.leave(player, false)) { @@ -419,11 +491,12 @@ public final class ZoneMonitor { player.getMusicPlayer().unlock(music, true); } } - + /** * Parses commands in a certain zone. - * @param player the player. - * @param name the name. + * + * @param player the player. + * @param name the name. * @param arguments the arguments. * @return {@code True} if parsed. */ @@ -435,10 +508,11 @@ public final class ZoneMonitor { } return false; } - + /** * Checks if a request can be made in this zone. - * @param type the type. + * + * @param type the type. * @param target the target. * @return {@code True} if so. */ @@ -450,9 +524,10 @@ public final class ZoneMonitor { } return true; } - + /** * Checks if the entity is in a zone. + * * @param name The name of the zone. * @return {@code True} if so. */ @@ -465,34 +540,37 @@ public final class ZoneMonitor { } return false; } - + /** * Removes the proper region zone for the given map zone. + * * @param zone The map zone. */ public void remove(MapZone zone) { - for (Iterator it = zones.iterator(); it.hasNext();) { + for (Iterator it = zones.iterator(); it.hasNext(); ) { if (it.next().getZone() == zone) { it.remove(); break; } } } - + /** * Gets the zones list. + * * @return The list of region zones the entity is in. */ public List getZones() { return zones; } - + /** * Gets the musicZones. + * * @return The musicZones. */ public List getMusicZones() { return musicZones; } - + } diff --git a/Server/src/main/core/game/world/map/zone/ZoneRestriction.java b/Server/src/main/core/game/world/map/zone/ZoneRestriction.java index 64dd971db..0d34313f1 100644 --- a/Server/src/main/core/game/world/map/zone/ZoneRestriction.java +++ b/Server/src/main/core/game/world/map/zone/ZoneRestriction.java @@ -47,6 +47,20 @@ public enum ZoneRestriction { * Dynamic regions are implicitly off-map and do not require this attribute. */ OFF_MAP, + + /** + * This zone allows player-vs-player combat. + * Used to mark zones where PvP is explicitly permitted. + * Zones with this restriction should implement the PvPZone interface + * to define specific combat rules (who can attack whom, combat level ranges, etc.). + * + * This flag is used by PvPStateAudit as an additional validation mechanism + * to ensure PvP combat state doesn't leak out of designated areas. + * + * @see core.api.PvPZone + * @see core.game.node.entity.player.link.PvPStateAudit + */ + PVP_ZONE, ; /** From 4650568aa984e116d01911f244427538dbec4056 Mon Sep 17 00:00:00 2001 From: dam <27978131-real_damighty@users.noreply.gitlab.com> Date: Wed, 18 Feb 2026 22:50:30 +0200 Subject: [PATCH 04/26] CW spell restrictions --- .../content/global/skill/magic/lunar/LunarListeners.kt | 8 ++++++++ .../content/global/skill/magic/modern/ModernListeners.kt | 4 ++++ 2 files changed, 12 insertions(+) diff --git a/Server/src/main/content/global/skill/magic/lunar/LunarListeners.kt b/Server/src/main/content/global/skill/magic/lunar/LunarListeners.kt index 8d70f99cb..4efb4ba1a 100644 --- a/Server/src/main/content/global/skill/magic/lunar/LunarListeners.kt +++ b/Server/src/main/content/global/skill/magic/lunar/LunarListeners.kt @@ -74,6 +74,10 @@ class LunarListeners : SpellListener("lunar"), Commands { // Level 68 onCast(Lunar.CURE_OTHER, PLAYER) { player, node -> requires(player, 68, arrayOf(Item(Items.ASTRAL_RUNE_9075, 1), Item(Items.LAW_RUNE_563), Item(Items.EARTH_RUNE_557, 10))) + if (player.zoneMonitor.isInZone("CastleWarsGame")) { + player.sendMessage("You can't use Cure Other in Castle Wars!") + return@onCast + } node?.let { cureOther(player, node) } } @@ -134,6 +138,10 @@ class LunarListeners : SpellListener("lunar"), Commands { // Level 74 onCast(Lunar.CURE_GROUP, NONE) { player, _ -> requires(player, 74, arrayOf(Item(Items.ASTRAL_RUNE_9075, 2), Item(Items.LAW_RUNE_563, 2), Item(Items.COSMIC_RUNE_564, 2))) + if (player.zoneMonitor.isInZone("CastleWarsGame")) { + player.sendMessage("You can't use Cure Group in Castle Wars!") + return@onCast + } cureGroup(player) } diff --git a/Server/src/main/content/global/skill/magic/modern/ModernListeners.kt b/Server/src/main/content/global/skill/magic/modern/ModernListeners.kt index 777beee18..6dcabf5bc 100644 --- a/Server/src/main/content/global/skill/magic/modern/ModernListeners.kt +++ b/Server/src/main/content/global/skill/magic/modern/ModernListeners.kt @@ -234,6 +234,10 @@ class ModernListeners : SpellListener("modern"){ player.sendMessage("You can only alch items from the cupboards!") return false } + if (player.zoneMonitor.isInZone("CastleWarsGame")) { + player.sendMessage("You can't use alchemy spells in Castle Wars!") + return false + } val coins = Item(995, item.definition.getAlchemyValue(high)) if (item.amount > 1 && coins.amount > 0 && !player.inventory.hasSpaceFor(coins)) { From 357160befe153ff9a49ca3b80bf5e87915d6a9b1 Mon Sep 17 00:00:00 2001 From: dam <27978131-real_damighty@users.noreply.gitlab.com> Date: Wed, 18 Feb 2026 22:55:22 +0200 Subject: [PATCH 05/26] JSON and SpecialLadders --- Server/data/configs/door_configs.json | 24 +++++++++++++++++++ Server/data/configs/item_configs.json | 12 +++++++--- .../game/global/action/SpecialLadders.java | 1 + 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/Server/data/configs/door_configs.json b/Server/data/configs/door_configs.json index f342b1856..b776dd312 100644 --- a/Server/data/configs/door_configs.json +++ b/Server/data/configs/door_configs.json @@ -3080,5 +3080,29 @@ "replaceId": "38142", "fence": "false", "metal": "false" + }, + { + "id": "4465", + "replaceId": "4466", + "fence": "false", + "metal": "false" + }, + { + "id": "4466", + "replaceId": "4465", + "fence": "false", + "metal": "false" + }, + { + "id": "4467", + "replaceId": "4468", + "fence": "false", + "metal": "false" + }, + { + "id": "4468", + "replaceId": "4467", + "fence": "false", + "metal": "false" } ] diff --git a/Server/data/configs/item_configs.json b/Server/data/configs/item_configs.json index b928857e7..bfdd22535 100644 --- a/Server/data/configs/item_configs.json +++ b/Server/data/configs/item_configs.json @@ -34815,6 +34815,9 @@ "name": "Saradomin banner", "examine": "The Holy Team Standard", "archery_ticket_price": "0", + "attack_anims": "419,419,419,419", + "attack_speed": "4", + "defence_anim": "420", "durability": null, "equipment_slot": "3", "render_anim": "131", @@ -34827,7 +34830,7 @@ "turn90cw_anim": "1424", "two_handed": "true", "walk_anim": "1422", - "weapon_interface": "14", + "weapon_interface": "1", "weight": "2" }, { @@ -34842,6 +34845,9 @@ "name": "Zamorak banner", "examine": "The Zamorak team's flag.", "archery_ticket_price": "0", + "attack_anims": "419,419,419,419", + "attack_speed": "4", + "defence_anim": "420", "durability": null, "equipment_slot": "3", "render_anim": "131", @@ -34854,7 +34860,7 @@ "turn90cw_anim": "1424", "two_handed": "true", "walk_anim": "1422", - "weapon_interface": "14" + "weapon_interface": "1" }, { "id": "4040", @@ -125297,4 +125303,4 @@ "tradeable": "false", "weight": "0.2" } -] \ No newline at end of file +] diff --git a/Server/src/main/core/game/global/action/SpecialLadders.java b/Server/src/main/core/game/global/action/SpecialLadders.java index 532bd896a..2174665d7 100644 --- a/Server/src/main/core/game/global/action/SpecialLadders.java +++ b/Server/src/main/core/game/global/action/SpecialLadders.java @@ -65,6 +65,7 @@ public enum SpecialLadders implements LadderAchievementCheck { CASTLEWARS_ZAMORAK_TOP_FLOOR_DOWN(Location.create(2374, 3133, 3), Location.create(2374, 3130, 2)), CASTLEWARS_ZAMORAK_MAIN_FLOOR_STAIRS_UP(Location.create(2380, 3129, 0), Location.create(2379, 3127, 1)), + CASTLEWARS_ZAMORAK_MAIN_FLOOR_STAIRS_DOWN(Location.create(2379, 3127, 1), Location.create(2380, 3129, 0)), CASTLEWARS_ZAMORAK_OUTERWALL_STAIRS_UP(Location.create(2382, 3130, 0), Location.create(2383, 3132, 0)), CASTLEWARS_ZAMOUTER_WALL_STAIRS_DOWN(Location.create(2382, 3132, 0), Location.create(2382, 3129, 0)), From 5f34e94c96ea0619b9778007c4b1de1518d13d69 Mon Sep 17 00:00:00 2001 From: dam <27978131-real_damighty@users.noreply.gitlab.com> Date: Wed, 18 Feb 2026 23:00:30 +0200 Subject: [PATCH 06/26] And then you commit the rest of the Castle War --- .../content/minigame/castlewars/CastleWars.kt | 123 +- .../castlewars/CastleWarsBarricadeBehavior.kt | 22 + .../castlewars/CastleWarsBarricadeNPC.kt | 85 + .../castlewars/CastleWarsCatapultInterface.kt | 108 ++ .../minigame/castlewars/CastleWarsCommands.kt | 106 ++ .../castlewars/CastleWarsConstants.kt | 153 ++ .../castlewars/CastleWarsDoorAttackPulse.kt | 78 + .../castlewars/CastleWarsFlagHandler.kt | 195 ++ .../castlewars/CastleWarsGameState.kt | 1694 +++++++++++++++++ .../minigame/castlewars/CastleWarsGameZone.kt | 141 ++ .../castlewars/CastleWarsItemHandler.kt | 351 ++++ .../castlewars/CastleWarsListeners.kt | 971 +++++++++- .../minigame/castlewars/CastleWarsOverlay.kt | 16 +- .../castlewars/CastleWarsShutdownHandler.kt | 78 + .../castlewars/CastleWarsTunnelMiningPulse.kt | 200 ++ .../minigame/castlewars/CastlewarsBook.kt | 137 +- .../minigame/castlewars/CatapultConfig.kt | 126 ++ .../castlewars/CatapultFiringHandler.kt | 116 ++ .../castlewars/CatapultInterfaceRenderer.kt | 97 + .../content/minigame/castlewars/Lanthus.kt | 38 +- 20 files changed, 4717 insertions(+), 118 deletions(-) create mode 100644 Server/src/main/content/minigame/castlewars/CastleWarsBarricadeBehavior.kt create mode 100644 Server/src/main/content/minigame/castlewars/CastleWarsBarricadeNPC.kt create mode 100644 Server/src/main/content/minigame/castlewars/CastleWarsCatapultInterface.kt create mode 100644 Server/src/main/content/minigame/castlewars/CastleWarsCommands.kt create mode 100644 Server/src/main/content/minigame/castlewars/CastleWarsConstants.kt create mode 100644 Server/src/main/content/minigame/castlewars/CastleWarsDoorAttackPulse.kt create mode 100644 Server/src/main/content/minigame/castlewars/CastleWarsFlagHandler.kt create mode 100644 Server/src/main/content/minigame/castlewars/CastleWarsGameState.kt create mode 100644 Server/src/main/content/minigame/castlewars/CastleWarsGameZone.kt create mode 100644 Server/src/main/content/minigame/castlewars/CastleWarsItemHandler.kt create mode 100644 Server/src/main/content/minigame/castlewars/CastleWarsShutdownHandler.kt create mode 100644 Server/src/main/content/minigame/castlewars/CastleWarsTunnelMiningPulse.kt create mode 100644 Server/src/main/content/minigame/castlewars/CatapultConfig.kt create mode 100644 Server/src/main/content/minigame/castlewars/CatapultFiringHandler.kt create mode 100644 Server/src/main/content/minigame/castlewars/CatapultInterfaceRenderer.kt diff --git a/Server/src/main/content/minigame/castlewars/CastleWars.kt b/Server/src/main/content/minigame/castlewars/CastleWars.kt index 670ea453c..bb0f4784f 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWars.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWars.kt @@ -1,11 +1,16 @@ -package rs09.game.content.activity.castlewars +package content.minigame.castlewars +import content.minigame.castlewars.CastleWarsConstants.GAME_COOLDOWN_MINUTES +import content.minigame.castlewars.CastleWarsConstants.GAME_TIME_MINUTES +import content.minigame.castlewars.CastleWarsConstants.ROPE_DURATION_TICKS +import core.game.world.map.Location import core.game.world.map.zone.ZoneBorders import org.rs09.consts.Items import org.rs09.consts.NPCs import org.rs09.consts.Scenery object CastleWars { + // Scenery IDs const val joinSaradominTeamPortal: Int = Scenery.SARADOMIN_PORTAL_4387 const val joinZamorakTeamPortal: Int = Scenery.ZAMORAK_PORTAL_4388 @@ -16,6 +21,85 @@ object CastleWars { const val castleWaterTap: Int = Scenery.TAP_4482 const val cwSteppingStones: Int = Scenery.STEPPING_STONE_4411 + // Stepping stone tile locations for flag auto-return check. + // If a flag is dropped on any of these tiles, it immediately returns to base. + fun isSteppingStoneTile(location: Location): Boolean { + val obj = core.game.world.map.RegionManager.getObject(location) + return obj != null && obj.id == cwSteppingStones + } + + // Energy barriers + const val saradominEnergyBarrier: Int = 4469 // "Only the Saradomin team may pass." + const val zamorakEnergyBarrier: Int = 4470 // "Only the Zamorak team may pass." + + // Exit portals - leave the game (in respawn room) + const val saradominExitPortal: Int = 4406 // "A portal to leave the Saradomin team." + const val zamorakExitPortal: Int = 4407 // "A portal to leave the Zamorak team." + + // Flag stand scenery IDs + const val saradominFlagStand: Int = 4902 // Saradomin Standard with flag (Capture option) + const val zamorakFlagStand: Int = 4903 // Zamorak Standard with flag (Capture option) + const val emptyFlagStand1: Int = 4377 // Empty stand (flag taken) - Saradomin side + const val emptyFlagStand2: Int = 4378 // Empty stand (flag taken) - Zamorak side + const val emptyFlagStand3: Int = 4379 // Empty stand variant (unused) + + // Dropped flag scenery IDs (flags dropped on ground as interactable scenery) + // 4900/4901 are standalone flag objects with "Take" option - used when flag is dropped + const val saradominDroppedFlagScenery: Int = 4900 // Saradomin flag dropped on ground (Take option) + const val zamorakDroppedFlagScenery: Int = 4901 // Zamorak flag dropped on ground (Take option) + val droppedFlagSceneryIds: IntArray = intArrayOf(saradominDroppedFlagScenery, zamorakDroppedFlagScenery) + + // All flag stand IDs for interaction handlers (stands at castle top) + val allFlagStandIds: IntArray = intArrayOf(4902, 4903, 4377, 4378, 4379) + + // Large doors - castle wall doors that can be attacked/repaired + val largeDoorIds: IntArray = intArrayOf(4423, 4424, 4427, 4428) // Closed door variants + val largeDoorOpenIds: IntArray = intArrayOf(4425, 4426, 4429, 4430) // Open door variants + + // Small doors / Backdoors - can be locked/unlocked + const val saradominBackdoor: Int = 4465 + const val zamorakBackdoor: Int = 4467 + val backdoorIds: IntArray = intArrayOf(saradominBackdoor, zamorakBackdoor) + + // Backdoor open variants (for autowalk door handling) + const val saradominBackdoorOpen: Int = 4466 + const val zamorakBackdoorOpen: Int = 4468 + val backdoorOpenIds: IntArray = intArrayOf(saradominBackdoorOpen, zamorakBackdoorOpen) + + // Tunnel rocks/boulders - can be mined or collapsed + const val tunnelRocks: Int = 4437 // Fully collapsed rocks (blocking) + const val tunnelRocksWeakened: Int = 4438 // Weakened rocks (1 explosive hit, still blocking) + const val caveWall: Int = 4448 // Cave wall for causing collapse + + // Catapults - can be operated, destroyed, and repaired + // Both 4381 and 4382 are operational catapults (different orientations in the map) + // Both 4385 and 4386 are damaged/destroyed catapults (have "Repair" option instead of "Operate") + const val catapult: Int = 4381 // Operational catapult variant 1 + const val catapultAlt: Int = 4382 // Operational catapult variant 2 (different orientation) + const val catapultDamaged1: Int = 4385 // Damaged catapult variant 1 + const val catapultDamaged2: Int = 4386 // Damaged catapult variant 2 + val damagedCatapultIds: IntArray = intArrayOf(catapultDamaged1, catapultDamaged2) + val allCatapultIds: IntArray = intArrayOf(catapult, catapultAlt, catapultDamaged1, catapultDamaged2) + + // Trapdoors - allow going down from 2nd floor to respawn room + const val saradominTrapdoor: Int = 4471 // Saradomin castle trapdoor + const val zamorakTrapdoor: Int = 4472 // Zamorak castle trapdoor + + // Ladders and Staircases for vertical navigation + // Ladders - climb down from respawn floor to ground floor (plane 1 -> plane 0) + val ladderClimbDownIds: IntArray = intArrayOf(36349, 36365, 4911, 4912) // All Castle Wars ladders + + // Staircases - climb down from respawn floor to ground floor (plane 1 -> plane 0) + val staircaseClimbDownIds: IntArray = intArrayOf(4415, 4416, 4417, 4418, 4419, 4420) + + // Steps - climb up from respawn floor to upper floors and flag floor + // These go from plane 1 -> plane 2 -> plane 3 (flag floor) + val stepsClimbUpIds: IntArray = intArrayOf(36480, 36481, 36484, 36495, 36521, 36523, 36532, 36540) + + // All vertical navigation scenery IDs combined for easy searching + val allClimbDownIds: IntArray = ladderClimbDownIds + staircaseClimbDownIds + val allClimbUpIds: IntArray = stepsClimbUpIds + // Map of the item table scenery id to the item id they give val cwTableItemRewardMap = mapOf( Scenery.TABLE_36573 to Items.TOOLKIT_4051, // Saradomin Toolkit Table @@ -33,7 +117,6 @@ object CastleWars { Scenery.TABLE_36579 to Items.BANDAGES_4049, // Saradomin Bandages Table Scenery.TABLE_36586 to Items.BANDAGES_4049 // Zamorak Bandages Table ) - val cwCastleBattlementsMap = mapOf( Scenery.BATTLEMENTS_4446 to Scenery.BATTLEMENTS_36313, Scenery.BATTLEMENTS_4447 to Scenery.BATTLEMENTS_36314 @@ -51,17 +134,36 @@ object CastleWars { const val cwBandages: Int = Items.BANDAGES_4049 const val cwToolkit: Int = Items.TOOLKIT_4051 const val cwBarricade: Int = Items.BARRICADE_4053 - const val cwManualBook: Int = Items.CASTLEWARS_MANUAL_4055 + const val cwManualBook: Int = Items.CASTLEWARS_MANUAL_4055 // TODO: unused? what happened const val cwTicketRewardCurrency: Int = Items.CASTLE_WARS_TICKET_4067 + // Authoritative list of items to remove from players on game end or area exit. + // Includes all CW-specific equipment, supplies, and items obtained from CW supply sources. + // Does NOT include tinderbox. I think you can just steal them =) + val CW_ITEMS_TO_REMOVE = intArrayOf( + saradominFlag, // 4037 - Saradomin banner + zamorakFlag, // 4039 - Zamorak banner + saradominTeamHoodedCloak, // 4041 - Saradomin hooded cloak + zamorakTeamHoodedCloak, // 4042 - Zamorak hooded cloak + cwRock, // 4043 - Rock + cwExplosivePotion, // 4045 - Explosive potion + cwClimbingRope, // 4047 - Climbing rope + cwBandages, // 4049 - Bandages + cwToolkit, // 4051 - Toolkit + cwBarricade, // 4053 - Barricade + cwPickaxe, // 1265 - Bronze pickaxe + Items.BUCKET_1925, // Bucket (obtained from CW water tap area) + Items.BUCKET_OF_WATER_1929 // Bucket of water (filled at CW water tap) + ) + // NPC IDs const val sheep = NPCs.SHEEP_1529 const val imp = NPCs.IMP_1531 const val rabbit = NPCs.RABBIT_1530 - const val unknownCwarsBarricade1 = NPCs.BARRICADE_1532 - const val unknownCwarsBarricadeOnFire = NPCs.BARRICADE_1533 - const val unknownCwarsBarricade2 = NPCs.BARRICADE_1534 - const val unknownCwarsBarricade3 = NPCs.BARRICADE_1535 + const val saradominBarricadeNpc = NPCs.BARRICADE_1532 // Saradomin/Blue barricade + const val burningBarricadeNpc = NPCs.BARRICADE_1533 // Burning barricade (either team) + const val zamorakBarricadeNpc = NPCs.BARRICADE_1534 // Zamorak/Red barricade + const val barricadeVariantNpc = NPCs.BARRICADE_1535 // Additional barricade variant // Locations val lobbyBankArea: ZoneBorders = ZoneBorders(2440, 3092, 2444, 3086, 0) @@ -74,8 +176,7 @@ object CastleWars { const val invFullMessage = "Your inventory is too full to hold any more " // Ints - const val gameTimeMinutes = 20 - const val gameCooldownMinutes = 5 - const val ropeAliveTicks = -1 // TODO: Find amount of time until wall reverts to non rope state - + const val gameTimeMinutes = GAME_TIME_MINUTES + const val gameCooldownMinutes = GAME_COOLDOWN_MINUTES + val ropeAliveTicks = ROPE_DURATION_TICKS } \ No newline at end of file diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsBarricadeBehavior.kt b/Server/src/main/content/minigame/castlewars/CastleWarsBarricadeBehavior.kt new file mode 100644 index 000000000..76301afc6 --- /dev/null +++ b/Server/src/main/content/minigame/castlewars/CastleWarsBarricadeBehavior.kt @@ -0,0 +1,22 @@ +package content.minigame.castlewars + +import core.game.node.entity.Entity +import core.game.node.entity.npc.NPC +import core.game.node.entity.npc.NPCBehavior +import org.rs09.consts.NPCs + +/** + * NPCBehavior for Castle Wars barricades that prevents combat XP gain. + * Authentic RS behavior: "manual combat, no XP gained" when attacking barricades. + * This is registered for all barricade NPC IDs and returns 0.0 XP multiplier. + */ +class CastleWarsBarricadeBehavior : NPCBehavior( + NPCs.BARRICADE_1532, // Saradomin/Blue barricade + NPCs.BARRICADE_1533, // Burning barricade + NPCs.BARRICADE_1534, // Zamorak/Red barricade + NPCs.BARRICADE_1535 // Additional variant +) { + override fun getXpMultiplier(self: NPC, attacker: Entity): Double { + return 0.0 + } +} diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsBarricadeNPC.kt b/Server/src/main/content/minigame/castlewars/CastleWarsBarricadeNPC.kt new file mode 100644 index 000000000..f974e6fe7 --- /dev/null +++ b/Server/src/main/content/minigame/castlewars/CastleWarsBarricadeNPC.kt @@ -0,0 +1,85 @@ +package content.minigame.castlewars + +import core.game.node.Node +import core.game.node.entity.Entity +import core.game.node.entity.npc.AbstractNPC +import core.game.node.entity.npc.NPC +import core.game.world.map.Location +import org.rs09.consts.NPCs + +/** + * Custom NPC class for Castle Wars barricades. + * + * - Block all entity movement through them (like Pest Control Brawlers) TODO: so... it sucks? shit + * - Never retaliate when attacked (this was scary man they squared up) + * - Never turn to face attackers or any direction + * - Cannot attack or move + */ +class CastleWarsBarricadeNPC : AbstractNPC { + + constructor() : super(NPCs.BARRICADE_1532, null) + + constructor(id: Int, location: Location) : super(id, location) + + override fun init() { + super.init() + isWalks = false + isNeverWalks = true + isRespawn = false + properties.isRetaliating = false + isAggressive = false + } + + override fun shouldPreventStacking(mover: Entity): Boolean { + return true + } + + override fun faceLocation(location: Location?): Boolean { + return false + } + + override fun face(entity: Entity?): Boolean { + return false + } + + override fun faceTemporary(entity: Entity?, ticks: Int): Boolean { + return false + } + + override fun attack(node: Node?) { + return + } + + override fun construct(id: Int, location: Location, vararg objects: Any?): AbstractNPC { + return CastleWarsBarricadeNPC(id, location) + } + + override fun getIds(): IntArray { + return intArrayOf( + NPCs.BARRICADE_1532, // Saradomin/Blue barricade + NPCs.BARRICADE_1533, // Burning barricade + NPCs.BARRICADE_1534, // Zamorak/Red barricade + NPCs.BARRICADE_1535 // Additional variant + ) + } + + companion object { + + /** + * Spawns a Castle Wars barricade at the given location for the specified team. + * + * @param location The location to spawn the barricade + * @param team The team the barricade belongs to + * @return The spawned barricade NPC + */ + fun spawn(location: Location, team: CastleWarsTeam): NPC { + val barricadeId = when (team) { + CastleWarsTeam.SARADOMIN -> NPCs.BARRICADE_1532 + CastleWarsTeam.ZAMORAK -> NPCs.BARRICADE_1534 + } + val barricade = CastleWarsBarricadeNPC(barricadeId, location) + barricade.init() + return barricade + } + } +} diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsCatapultInterface.kt b/Server/src/main/content/minigame/castlewars/CastleWarsCatapultInterface.kt new file mode 100644 index 000000000..b33899d56 --- /dev/null +++ b/Server/src/main/content/minigame/castlewars/CastleWarsCatapultInterface.kt @@ -0,0 +1,108 @@ +package content.minigame.castlewars + +import content.minigame.castlewars.CastleWarsGameState.VARBIT_CATAPULT_X +import content.minigame.castlewars.CastleWarsGameState.VARBIT_CATAPULT_Y +import content.minigame.castlewars.CatapultConfig.ATTR_LOCATION +import content.minigame.castlewars.CatapultConfig.INTERFACE_ID +import core.api.* +import core.game.interaction.InterfaceListener +import core.game.node.entity.player.Player +import core.game.world.map.Location + +class CastleWarsCatapultInterface : InterfaceListener { + + private val renderer = CatapultInterfaceRenderer() + private val firingHandler = CatapultFiringHandler() + + override fun defineInterfaceListeners() { + on(INTERFACE_ID) { player, _, _, buttonID, _, _ -> + handleButtonClick(player, buttonID) + } + + onOpen(INTERFACE_ID) { player, _ -> + renderer.resetToDefaults(player) + true + } + + onClose(INTERFACE_ID) { player, _ -> + player.removeAttribute(ATTR_LOCATION) + true + } + } + + private fun handleButtonClick(player: Player, buttonId: Int): Boolean { + val team = getPlayerTeam(player) ?: return closeAndReturn(player, false) + val currentCoord = getCurrentCoordinate(player) + + return when (val button = CatapultButton.fromId(buttonId)) { + CatapultButton.FIRE -> handleFire(player, currentCoord, team) + CatapultButton.CLOSE -> closeAndReturn(player, true) + CatapultButton.LEFT, CatapultButton.RIGHT, CatapultButton.UP, CatapultButton.DOWN -> { + handleCoordinateChange(player, currentCoord, button) + } + + CatapultButton.UNKNOWN -> true // Ignore unknown buttons + } + } + + private fun handleFire(player: Player, coord: Coordinate, team: CastleWarsTeam): Boolean { + if (!hasRocks(player)) { + sendMessage(player, "You don't have any rocks to fire!") + return true + } + + val catapultLocation = getCatapultLocation(player) ?: run { + sendMessage(player, "Error: Catapult location not found.") + return true + } + + if (!removeItem(player, CastleWars.cwRock)) { + sendMessage(player, "You don't have any rocks to fire!") + return true + } + + val catapultTeam = CatapultTeam.fromCastleWarsTeam(team) + firingHandler.fire(player, coord, catapultTeam, catapultLocation) + closeInterface(player) + return true + } + + private fun handleCoordinateChange( + player: Player, + currentCoord: Coordinate, + button: CatapultButton + ): Boolean { + val newCoord = currentCoord.withDelta(button.deltaX, button.deltaY).clamp() + + setVarbit(player, VARBIT_CATAPULT_X, newCoord.x) + setVarbit(player, VARBIT_CATAPULT_Y, newCoord.y) + + renderer.updateMarkerPosition(player, newCoord) + renderer.updateDigitDisplay(player, newCoord) + + return true + } + + private fun getCurrentCoordinate(player: Player): Coordinate { + val x = getVarbit(player, VARBIT_CATAPULT_X) + val y = getVarbit(player, VARBIT_CATAPULT_Y) + return Coordinate(x, y) + } + + private fun getPlayerTeam(player: Player): CastleWarsTeam? { + return CastleWarsGameState.getPlayerTeam(player) + } + + private fun getCatapultLocation(player: Player): Location? { + return getAttribute(player, ATTR_LOCATION, null) + } + + private fun hasRocks(player: Player): Boolean { + return player.inventory.contains(CastleWars.cwRock, 1) + } + + private fun closeAndReturn(player: Player, result: Boolean): Boolean { + closeInterface(player) + return result + } +} diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsCommands.kt b/Server/src/main/content/minigame/castlewars/CastleWarsCommands.kt new file mode 100644 index 000000000..31733218c --- /dev/null +++ b/Server/src/main/content/minigame/castlewars/CastleWarsCommands.kt @@ -0,0 +1,106 @@ +package content.minigame.castlewars + +import content.minigame.castlewars.bots.CastleWarsBotSpawner +import core.api.Commands +import core.api.sendMessage +import core.game.interaction.Option +import core.game.system.command.Privilege +import content.minigame.castlewars.areas.CastleWarsGameArea +import content.minigame.castlewars.areas.CastleWarsGameArea.Companion.saradominPlayers +import content.minigame.castlewars.areas.CastleWarsGameArea.Companion.zamorakPlayers +import content.minigame.castlewars.areas.CastleWarsWaitingArea + +/** + * Admin commands for Castle Wars development and testing. + * + * Commands: + * - ::cw start - Force starts a game if both teams have players + * - ::cw end - Ends the current game + * - ::cw addbot [amount] - Spawns bots for the specified team + */ +class CastleWarsCommands : Commands { + + override fun defineCommands() { + define("cw", Privilege.ADMIN, "::cw ", "Castle Wars admin commands") { player, args -> + if (args.size < 2) { + sendMessage(player, "Usage: ::cw ") + return@define + } + when (args[1].lowercase()) { + "start" -> { + if (CastleWarsGameArea.ticksLeftInGame > 0) { + sendMessage(player, "A Castle Wars game is already in progress!") + return@define + } + val saraCount = CastleWarsWaitingArea.waitingSaradominPlayers.size + val zamCount = CastleWarsWaitingArea.waitingZamorakPlayers.size + if (saraCount == 0 || zamCount == 0) { + sendMessage(player, "Both teams need at least 1 player. Saradomin: $saraCount, Zamorak: $zamCount") + return@define + } + CastleWarsGameArea.startGame() + sendMessage(player, "Castle Wars game force started! Saradomin: $saraCount, Zamorak: $zamCount") + } + + "end" -> { + if (CastleWarsGameArea.ticksLeftInGame <= 0) { + sendMessage(player, "No Castle Wars game is currently running.") + return@define + } + // Snapshot the sets before endGame() modifies them via teleport callbacks + val saraSnapshot = saradominPlayers.toSet() + val zamSnapshot = zamorakPlayers.toSet() + CastleWarsGameState.endGame() + CastleWarsItemHandler.clearAllBarricades() + for (p in saraSnapshot) { + p.interaction.remove(Option._P_ATTACK) + p.properties.teleportLocation = CastleWars.lobbyBankArea.randomWalkableLoc + } + for (p in zamSnapshot) { + p.interaction.remove(Option._P_ATTACK) + p.properties.teleportLocation = CastleWars.lobbyBankArea.randomWalkableLoc + } + saradominPlayers.clear() + zamorakPlayers.clear() + sendMessage(player, "Castle Wars game ended by admin command.") + } + + "addbot" -> { + if (args.size < 3) { + sendMessage(player, "Usage: ::cw addbot [amount]") + return@define + } + val teamArg = args[2].lowercase() + val team = when { + teamArg.startsWith("sara") -> CastleWarsTeam.SARADOMIN + teamArg.startsWith("zam") -> CastleWarsTeam.ZAMORAK + else -> { + sendMessage(player, "Invalid team. Use 'sara' or 'zammy'.") + return@define + } + } + val amount = if (args.size >= 4) { + args[3].toIntOrNull() ?: run { + sendMessage(player, "Invalid amount. Must be a number.") + return@define + } + } else { + 1 + } + if (amount !in 1..20) { + sendMessage(player, "Amount must be between 1 and 20.") + return@define + } + for (i in 0 until amount) { + CastleWarsBotSpawner.spawnBot(team) + } + sendMessage(player, "Spawned $amount ${team.teamName} bot(s) at the Castle Wars lobby.") + } + + else -> { + sendMessage(player, "Unknown subcommand. Use: start, end, or addbot") + } + } + } + } +} diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsConstants.kt b/Server/src/main/content/minigame/castlewars/CastleWarsConstants.kt new file mode 100644 index 000000000..45ebde308 --- /dev/null +++ b/Server/src/main/content/minigame/castlewars/CastleWarsConstants.kt @@ -0,0 +1,153 @@ +package content.minigame.castlewars + +/** + * Centralized constants for Castle Wars minigame. + * All magic numbers should be defined here with descriptive names. + */ +object CastleWarsConstants { + + // ==================== Timing (in ticks) ==================== + /** Grace period after picking up own flag before castle entry check (prevents instant return) */ + const val FLAG_RETURN_GRACE_PERIOD_TICKS = 3 + + /** Ticks before a dropped flag automatically returns to its stand (~90 seconds) */ + const val FLAG_AUTO_RETURN_TICKS = 150 + + /** Duration of climbing ropes before despawning (45 seconds at 600ms/tick) */ + const val ROPE_DURATION_TICKS = 75 + + /** Burning barricade pulse interval (3 ticks = 1.8 seconds) */ + const val BURNING_BARRICADE_PULSE_INTERVAL = 3 + + // ==================== Damage Values ==================== + /** Self-damage when dropping explosive potion */ + const val EXPLOSIVE_POTION_SELF_DAMAGE = 15 + + /** Door damage from explosive potion */ + const val EXPLOSIVE_POTION_DOOR_DAMAGE = 25 + + /** Door repair amount per toolkit use */ + const val TOOLKIT_DOOR_REPAIR = 10 + + /** Fire damage per tick to burning barricades */ + const val BURNING_BARRICADE_DAMAGE_PER_TICK = 4 + + /** Maximum ticks a barricade can burn before destruction */ + const val BURNING_BARRICADE_MAX_TICKS = 10 + + // ==================== Gameplay Limits ==================== + /** Maximum barricades per team */ + const val MAX_BARRICADES_PER_TEAM = 10 + + /** Maximum players per team */ + const val MAX_PLAYERS_PER_TEAM = 25 // TODO: I dont believe this, where? Says who + + /** Minimum players per team required to start a game */ + const val MIN_PLAYERS_PER_TEAM = 2 + + /** Catapult targeting inaccuracy radius */ + const val CATAPULT_INACCURACY_RADIUS = 2 // TODO: says who + + /** Catapult impact damage radius */ + const val CATAPULT_IMPACT_RADIUS = 3 // TODO: says who + + /** Catapult minimum damage */ + const val CATAPULT_MIN_DAMAGE = 10 // TODO: says who + + /** Catapult maximum damage */ + const val CATAPULT_MAX_DAMAGE = 20 // TODO: says who + + // ==================== Coordinate Defaults ==================== + /** Default X/Y coordinate for catapult targeting (center of 0-30 range) */ + const val CATAPULT_DEFAULT_COORDINATE = 15 // TODO: says who + + /** Maximum catapult coordinate value */ + const val CATAPULT_MAX_COORDINATE = 30 + + // ==================== Bandage Healing ==================== + /** Bandage heal percentage of max HP (10%) */ + const val BANDAGE_HEAL_PERCENTAGE = 0.10 + + /** Bandage run energy restore percentage (30%) */ + const val BANDAGE_RUN_ENERGY_RESTORE = 30.0 + + /** Bandage cooldown in ticks (1 tick = faster than food's 2 ticks) */ + const val BANDAGE_COOLDOWN_TICKS = 1 // TODO: says who + + // ==================== Castle Wars Bracelet ==================== + /** Bracelet damage bonus multiplier against flag carriers (20% extra) */ + const val BRACELET_FLAG_CARRIER_DAMAGE_BONUS = 0.20 + + /** Bracelet bandage heal percentage (15% instead of base 10%) */ + const val BRACELET_BANDAGE_HEAL_PERCENTAGE = 0.15 + + // ==================== Energy Barrier ==================== + /** Run energy drained when passing through an energy barrier */ + const val ENERGY_BARRIER_DRAIN = 4.0 + + // ==================== Backdoor Pick-Lock ==================== // TODO: says who + /** Thieving level required for pick-lock (1 = no real requirement, all players can attempt) */ + const val BACKDOOR_PICKLOCK_LEVEL = 1 + + /** Base pick-lock success chance (out of 100) at minimum level */ + const val BACKDOOR_PICKLOCK_BASE_CHANCE = 50 + + /** Maximum pick-lock success chance (out of 100) */ + const val BACKDOOR_PICKLOCK_MAX_CHANCE = 95 + + /** Ticks to lock player during pick-lock attempt */ + const val BACKDOOR_PICKLOCK_TICKS = 3 + + // ==================== Cleanup Intervals ==================== + /** How often to run inactive player cleanup (every 10 ticks = 6 seconds) */ + const val PLAYER_CLEANUP_INTERVAL_TICKS = 10 + + // ==================== Door Health ==================== + /** Initial door health percentage */ + const val DOOR_INITIAL_HEALTH = 100 + + /** Door destroyed threshold (0 or below) */ + const val DOOR_DESTROYED_THRESHOLD = 0 + + // ==================== Game Timing ==================== + /** Game duration in minutes */ + const val GAME_TIME_MINUTES = 20 + + /** Game cooldown in minutes */ + 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 + + // ==================== Player Attribute Keys ==================== + /** Attribute key: world tick when player joined the current game */ + const val ATTR_JOIN_TICK = "cw:join_tick" + + /** Attribute key: world tick when player logged out (for grace period) */ + const val ATTR_LOGOUT_TICK = "cw:logout_tick" + + // ==================== Logout Grace Period ==================== + /** Grace period after logout before removing player from team (2 minutes = 200 ticks) */ + const val LOGOUT_GRACE_PERIOD_TICKS = 200 + + // ==================== 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 + + /** 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/CastleWarsDoorAttackPulse.kt b/Server/src/main/content/minigame/castlewars/CastleWarsDoorAttackPulse.kt new file mode 100644 index 000000000..932e621db --- /dev/null +++ b/Server/src/main/content/minigame/castlewars/CastleWarsDoorAttackPulse.kt @@ -0,0 +1,78 @@ +package content.minigame.castlewars + +import core.api.animate +import core.api.playAudio +import core.game.container.impl.EquipmentContainer +import core.game.node.entity.player.Player +import core.game.node.entity.skill.Skills +import core.game.node.scenery.Scenery +import core.game.system.task.Pulse +import org.rs09.consts.Sounds + +/** + * Handles attacking the Castle Wars front doors. + * - Uses player's attack speed based on weapon + * - Calculates damage based on combat stats + * - Damages door health until destroyed or player stops + * TODO: 100% wild guesses and needs a fat refactoring + */ +class CastleWarsDoorAttackPulse( + private val player: Player, + private val door: Scenery, + private val doorTeam: CastleWarsTeam +) : Pulse() { + + private var ticksUntilNextHit = 0 + + override fun pulse(): Boolean { + if (!player.isActive || CastleWarsGameState.isDoorDestroyed(doorTeam) || CastleWarsGameState.getPlayerTeam( + player + ) == null + ) { + return true + } + if (player.location.getDistance(door.location) > 2.0) { + return true + } + player.faceLocation(door.location) + if (ticksUntilNextHit <= 0) { + performAttack() + ticksUntilNextHit = getAttackSpeed() + } + ticksUntilNextHit-- + return false + } + + private fun performAttack() { + val animation = getAttackAnimation() + animate(player, animation) + val damage = calculateDamage() + CastleWarsGameState.damageDoor(doorTeam, damage) + playAudio(player, Sounds.WOODCUTTING_HIT_3038) + } + + private fun getAttackSpeed(): Int { + val weapon = player.equipment.get(EquipmentContainer.SLOT_WEAPON) + return weapon?.definition?.getConfiguration("attack_speed", 4) ?: 4 + } + + private fun calculateDamage(): Int { + // Doors are weak to crush > slash > stab + val strengthLevel = player.skills.getLevel(Skills.STRENGTH) + val bonusType = player.properties.attackStyle.bonusType + val styleBonus = when (bonusType) { + 2 -> 10 // Crush + 1 -> 4 // Slash + 0 -> 1 // Stab + else -> 3 // Default + } // Calculate damage: base from strength + style bonus + val damage = + (strengthLevel / 12) + styleBonus // Clamp to 1-25 range (max 25% of door health per hit) TODO: 100% inauthentic + return damage.coerceIn(1, 25) + } + + private fun getAttackAnimation(): Int { + val attackAnim = player.properties.attackAnimation + return if (attackAnim != null && attackAnim.id > 0) attackAnim.id else 422 + } +} diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsFlagHandler.kt b/Server/src/main/content/minigame/castlewars/CastleWarsFlagHandler.kt new file mode 100644 index 000000000..ebf9ecbfe --- /dev/null +++ b/Server/src/main/content/minigame/castlewars/CastleWarsFlagHandler.kt @@ -0,0 +1,195 @@ +package content.minigame.castlewars + +import core.api.sendMessage +import core.game.interaction.IntType +import core.game.interaction.InteractionListener +import core.game.node.item.GroundItem +import core.game.world.map.Location + +/** + * Handles flag interactions in Castle Wars: + * - Taking the enemy flag from their stand + * - Capturing a flag at your own stand + * - Picking up dropped flags + * - Returning your own dropped flag + */ +class CastleWarsFlagHandler : InteractionListener { + + companion object { + + // Zone borders for flag stand areas (top floor of each castle) + private val saradominFlagArea = + core.game.world.map.zone.ZoneBorders(Location.create(2426, 3071, 3), Location.create(2431, 3078, 3)) + private val zamorakFlagArea = + core.game.world.map.zone.ZoneBorders(Location.create(2368, 3129, 3), Location.create(2373, 3136, 3)) + + /** + * Check if a location is in the Saradomin flag area + */ + fun isInSaradominFlagArea(location: Location): Boolean { + return saradominFlagArea.insideBorder(location) + } + + /** + * Check if a location is in the Zamorak flag area + */ + fun isInZamorakFlagArea(location: Location): Boolean { + return zamorakFlagArea.insideBorder(location) + } + } + + override fun defineListeners() { // Handle clicking on flag stands (both with and without flags) + on(CastleWars.allFlagStandIds, IntType.SCENERY, "capture") { player, node -> + if (!CastleWarsGameState.isGameActive) { + sendMessage(player, "There is no game in progress.") + return@on true + } + val playerTeam = CastleWarsGameState.getPlayerTeam(player) + if (playerTeam == null) { + sendMessage(player, "You are not in a Castle Wars game.") + return@on true + } + val sceneryId = node.id + val location = node.location + // 4902 = Saradomin flag stand with flag, 4377 = empty Saradomin stand + // 4903 = Zamorak flag stand with flag, 4378 = empty Zamorak stand + val isSaradominStand = + sceneryId == CastleWars.saradominFlagStand || sceneryId == CastleWars.emptyFlagStand1 || isInSaradominFlagArea( + location + ) + val isZamorakStand = + sceneryId == CastleWars.zamorakFlagStand || sceneryId == CastleWars.emptyFlagStand2 || isInZamorakFlagArea( + location + ) + val standTeam = when { + isSaradominStand -> CastleWarsTeam.SARADOMIN + isZamorakStand -> CastleWarsTeam.ZAMORAK + else -> { + sendMessage(player, "You can't interact with this.") + return@on true + } + } + val isEmptyStand = sceneryId in intArrayOf( + CastleWars.emptyFlagStand1, CastleWars.emptyFlagStand2, CastleWars.emptyFlagStand3 + ) + if (CastleWarsGameState.isCarryingFlag(player)) { + if (standTeam == playerTeam) { + val ownFlagState = if (playerTeam == CastleWarsTeam.SARADOMIN) { + CastleWarsGameState.saradominFlagState + } else { + CastleWarsGameState.zamorakFlagState + } + + if (ownFlagState != FlagState.SAFE) { + sendMessage(player, "Your team's flag must be at its stand to capture!") + return@on true + } + CastleWarsGameState.captureFlag(player) + } else { + sendMessage(player, "You need to take this flag to your own flag stand!") + } + return@on true + } + if (isEmptyStand) { + sendMessage(player, "The flag isn't here!") + return@on true + } + if (standTeam != playerTeam) { + val flagState = if (standTeam == CastleWarsTeam.SARADOMIN) { + CastleWarsGameState.saradominFlagState + } else { + CastleWarsGameState.zamorakFlagState + } + + if (flagState != FlagState.SAFE) { + sendMessage(player, "The flag isn't here!") + return@on true + } + CastleWarsGameState.pickUpFlag(player, standTeam) + } else { + sendMessage(player, "This is your team's flag stand.") + } + + return@on true + } + // Intercept flag unequip - drop on ground instead of adding to inventory + // When player clicks "Remove" on the flag in equipment screen, or tries to equip another weapon, + // the flag should drop at their feet instead of going to their inventory. + onUnequip(intArrayOf(CastleWars.saradominFlag, CastleWars.zamorakFlag)) { player, _ -> + if (CastleWarsGameState.isCarryingFlag(player)) { + CastleWarsGameState.dropFlag(player) + } else if (CastleWarsGameState.isCarryingOwnFlag(player)) { + CastleWarsGameState.dropOwnFlag(player) + } + return@onUnequip false + } + on(CastleWars.droppedFlagSceneryIds, IntType.SCENERY, "take") { player, node -> + if (!CastleWarsGameState.isGameActive) { + sendMessage(player, "There is no game in progress.") + return@on true + } + val playerTeam = CastleWarsGameState.getPlayerTeam(player) + if (playerTeam == null) { + sendMessage(player, "You are not in a Castle Wars game.") + return@on true + } + val scenery = node as? core.game.node.scenery.Scenery ?: return@on true + val flagTeam = when (scenery.id) { + CastleWars.saradominDroppedFlagScenery -> CastleWarsTeam.SARADOMIN + CastleWars.zamorakDroppedFlagScenery -> CastleWarsTeam.ZAMORAK + else -> return@on true + } + val isValidDroppedFlag = when (flagTeam) { + CastleWarsTeam.SARADOMIN -> CastleWarsGameState.saradominDroppedFlagScenery?.location == scenery.location + CastleWarsTeam.ZAMORAK -> CastleWarsGameState.zamorakDroppedFlagScenery?.location == scenery.location + } + if (!isValidDroppedFlag) { + sendMessage(player, "The flag isn't here!") + return@on true + } + if (flagTeam == playerTeam) { + if (CastleWarsGameState.pickUpOwnFlag(player, flagTeam)) { // Message already sent by pickUpOwnFlag + } + } else { + if (CastleWarsGameState.pickUpFlag(player, flagTeam)) { // Message already sent by pickUpFlag + } + } + + return@on true + } + // Legacy: Handle picking up dropped flags from the ground (ground items) + // This is kept for backwards compatibility but scenery is the primary method now + // TODO: cut + on(intArrayOf(CastleWars.saradominFlag, CastleWars.zamorakFlag), IntType.GROUNDITEM, "take") { player, node -> + if (!CastleWarsGameState.isGameActive) { + return@on false + } + val playerTeam = CastleWarsGameState.getPlayerTeam(player) ?: return@on false + val groundItem = node as? GroundItem ?: return@on false + val flagTeam = when (groundItem.id) { + CastleWars.saradominFlag -> CastleWarsTeam.SARADOMIN + CastleWars.zamorakFlag -> CastleWarsTeam.ZAMORAK + else -> return@on false + } + val isDroppedFlag = when (flagTeam) { + CastleWarsTeam.SARADOMIN -> CastleWarsGameState.saradominFlagState == FlagState.DROPPED + CastleWarsTeam.ZAMORAK -> CastleWarsGameState.zamorakFlagState == FlagState.DROPPED + } + if (!isDroppedFlag) { + return@on false + } + if (flagTeam == playerTeam) { + if (CastleWarsGameState.pickUpOwnFlag(player, flagTeam)) { + return@on true + } + } else { + if (CastleWarsGameState.pickUpFlag(player, flagTeam)) { + return@on true + } + } + + return@on true + } + } +} + diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsGameState.kt b/Server/src/main/content/minigame/castlewars/CastleWarsGameState.kt new file mode 100644 index 000000000..c1fa9cfda --- /dev/null +++ b/Server/src/main/content/minigame/castlewars/CastleWarsGameState.kt @@ -0,0 +1,1694 @@ +package content.minigame.castlewars + +import content.global.skill.summoning.familiar.BurdenBeast +import content.minigame.castlewars.CastleWarsConstants.FLAG_RETURN_GRACE_PERIOD_TICKS +import content.minigame.castlewars.areas.CastleWarsGameArea +import content.minigame.castlewars.areas.CastleWarsRespawnArea +import content.minigame.castlewars.areas.CastleWarsWaitingArea +import core.api.* +import core.game.container.impl.EquipmentContainer +import core.game.node.entity.combat.ImpactHandler +import core.game.node.entity.player.Player +import core.game.node.entity.player.link.HintIconManager +import core.game.node.item.GroundItemManager +import core.game.node.item.Item +import core.game.node.scenery.Scenery +import core.game.node.scenery.SceneryBuilder +import core.game.world.map.Location +import core.game.world.map.RegionManager +import core.game.world.map.zone.ZoneBorders +import core.tools.ticksPerMinute +import org.rs09.consts.Items + +/** + * Represents the state of a flag in Castle Wars + */ +enum class FlagState { + + SAFE, TAKEN, DROPPED +} + +/** + * Represents a team in Castle Wars + */ +enum class CastleWarsTeam( + val teamName: String, val cloakId: Int, val flagItemId: Int, val flagStandId: Int, val flagStandLocation: Location +) { + + SARADOMIN( + "Saradomin", + CastleWars.saradominTeamHoodedCloak, + CastleWars.saradominFlag, + CastleWars.saradominFlagStand, + CastleWarsGameState.saradominFlagStandLocation + ), + ZAMORAK( + "Zamorak", + CastleWars.zamorakTeamHoodedCloak, + CastleWars.zamorakFlag, + CastleWars.zamorakFlagStand, + CastleWarsGameState.zamorakFlagStandLocation + ); + + fun opposite(): CastleWarsTeam = if (this == SARADOMIN) ZAMORAK else SARADOMIN +} + +/** + * Represents the state of a boulder/rock passage in Castle Wars tunnels + */ +enum class BoulderState { + + COLLAPSED, WEAKENED, CLEARED +} + +/** + * Manages the state of a Castle Wars game including scores, flags, and game lifecycle. + * This is a singleton that tracks the current game state. + */ +object CastleWarsGameState { + + // Scores + var saradominScore: Int = 0 + private set + var zamorakScore: Int = 0 + private set + + // Flag states + var saradominFlagState: FlagState = FlagState.SAFE + private set + var zamorakFlagState: FlagState = FlagState.SAFE + private set + + // Who is carrying the flags (null if not carried) + var saradominFlagCarrier: Player? = null + private set + var zamorakFlagCarrier: Player? = null + private set + + // Scenery objects for dropped flags (flags drop as scenery, not ground items) + var saradominDroppedFlagScenery: Scenery? = null + private set + var zamorakDroppedFlagScenery: Scenery? = null + private set + + // Tick when each flag was dropped (for auto-return timer) + private var saradominFlagDroppedTick: Int = 0 + private var zamorakFlagDroppedTick: Int = 0 + + // Players carrying their OWN team's flag (for return mechanic) + var saradominOwnFlagCarrier: Player? = null + private set + var zamorakOwnFlagCarrier: Player? = null + private set + private var saradominOwnFlagPickupTick: Int = 0 + private var zamorakOwnFlagPickupTick: Int = 0 + + // Hint icon slots for each player (tracking enemy flag location on minimap) + private val playerHintIconSlots = mutableMapOf() + + // Castle interior zones (for detecting when own flag carrier returns to castle) + // These cover all floors of each castle (z=0, z=1, z=2, z=3) + val saradominCastleInterior = arrayOf( + ZoneBorders( + Location.create(2414, 3072, 0), Location.create(2431, 3084, 0) + ), // Ground floor - expanded to include door perimeters + ZoneBorders(Location.create(2420, 3072, 1), Location.create(2431, 3083, 1)), // Floor 1 + ZoneBorders(Location.create(2423, 3072, 2), Location.create(2431, 3080, 2)), // Floor 2 + ZoneBorders(Location.create(2426, 3073, 3), Location.create(2430, 3077, 3)) // Floor 3 (flag) + ) + val zamorakCastleInterior = arrayOf( + ZoneBorders( + Location.create(2368, 3124, 0), Location.create(2386, 3136, 0) + ), // Ground floor - expanded to include door perimeters + ZoneBorders(Location.create(2368, 3124, 1), Location.create(2379, 3135, 1)), // Floor 1 + ZoneBorders(Location.create(2368, 3127, 2), Location.create(2376, 3135, 2)), // Floor 2 + ZoneBorders(Location.create(2369, 3130, 3), Location.create(2373, 3134, 3)) // Floor 3 (flag) + ) + + // ==================== Castle Structure State ==================== + + // Large door health (0-100, 100 = full health) + var saradominDoorHealth: Int = 100 + private set + var zamorakDoorHealth: Int = 100 + private set + var saradominBackdoorLocked: Boolean = true + private set + var zamorakBackdoorLocked: Boolean = true + private set + + // Boulder status - tracks the state of tunnel rock passages + // Boulder locations: Saradomin West [2391,9501], Saradomin North [2400,9512] + // Zamorak East [2409,9503], Zamorak South [2401,9494] + var saradominBoulder1State: BoulderState = BoulderState.COLLAPSED // West tunnel + private set + var saradominBoulder2State: BoulderState = BoulderState.COLLAPSED // North tunnel + private set + var zamorakBoulder1State: BoulderState = BoulderState.COLLAPSED // East tunnel + private set + var zamorakBoulder2State: BoulderState = BoulderState.COLLAPSED // South tunnel + private set + + // Catapult status (true = operational, false = destroyed) + var saradominCatapultOperational: Boolean = true + private set + var zamorakCatapultOperational: Boolean = true + private set + var saradominCatapultOnFire: Boolean = false + private set + var zamorakCatapultOnFire: Boolean = false + private set + + // Boulder locations for lookup + val boulderLocations = mapOf( + "saradomin1" to Location.create(2391, 9501, 0), + "saradomin2" to Location.create(2400, 9512, 0), + "zamorak1" to Location.create(2409, 9503, 0), + "zamorak2" to Location.create(2401, 9494, 0) + ) + + // Flag stand locations + val saradominFlagStandLocation: Location = Location.create(2429, 3074, 3) + val zamorakFlagStandLocation: Location = Location.create(2370, 3133, 3) + + // Catapult locations (on standard floor, z=0) + val saradominCatapultLocation: Location = Location.create(2426, 3073, 0) + val zamorakCatapultLocation: Location = Location.create(2373, 3134, 0) + + // Respawn locations + val saradominRespawnLocation: Location get() = CastleWarsRespawnArea.saradominRespawnRoom.randomWalkableLoc + val zamorakRespawnLocation: Location get() = CastleWarsRespawnArea.zamorakRespawnRoom.randomWalkableLoc + + // Varbits for overlay updates + private const val SARADOMIN_FLAG_VARBIT = 143 + private const val SARADOMIN_SCORE_VARBIT = 145 + private const val ZAMORAK_FLAG_VARBIT = 153 + private const val ZAMORAK_SCORE_VARBIT = 155 + + // Saradomin overlay varbits (varplayer 378) + // These control the 5 UI elements: door health, backdoor, 2 boulders, catapult + private const val SARA_DOOR_HEALTH_VARBIT = 136 // bits 0-6, door health 0-100 + private const val SARA_BACKDOOR_VARBIT = 137 // bit 7, 0 = locked (green), 1 = unlocked (red) + private const val SARA_BOULDER1_VARBIT = 138 // bit 8, 0 = collapsed (green), 1 = clear (red) + private const val SARA_BOULDER2_VARBIT = 139 // bit 9, 0 = collapsed (green), 1 = clear (red) + private const val SARA_CATAPULT_VARBIT = 140 // bit 10, 0 = operational (green), 1 = destroyed (red) + + // Zamorak overlay varbits (varplayer 377) + private const val ZAM_DOOR_HEALTH_VARBIT = 146 // bits 0-6, door health 0-100 + private const val ZAM_BACKDOOR_VARBIT = 147 // bit 7, 0 = locked (green), 1 = unlocked (red) + private const val ZAM_BOULDER1_VARBIT = 148 // bit 8, 0 = collapsed (green), 1 = clear (red) + private const val ZAM_BOULDER2_VARBIT = 149 // bit 9, 0 = collapsed (green), 1 = clear (red) + private const val ZAM_CATAPULT_VARBIT = 150 // bit 10, 0 = operational (green), 1 = destroyed (red) + + // Timer uses varplayer 380 directly (CASTLE_WARS_COUNTDOWN) + private const val CASTLE_WARS_TIMER_VARP = 380 + + // Catapult targeting varbits (stored in Varplayer 379) + // Used for the 30x30 coordinate targeting interface + const val VARBIT_CATAPULT_X = 156 // X-axis offset (0-30), bits 0-4 of varp 379 + const val VARBIT_CATAPULT_Y = 157 // Y-axis offset (0-30), bits 5-9 of varp 379 + + /** + * Check if a game is currently in progress + */ + val isGameActive: Boolean + get() = CastleWarsGameArea.ticksLeftInGame > 0 + + /** + * Get the remaining game time in minutes + */ + val remainingMinutes: Int + get() = (CastleWarsGameArea.ticksLeftInGame + ticksPerMinute - 1) / ticksPerMinute + + /** + * Reset the game state for a new game + */ + fun resetGame() { + saradominScore = 0 + zamorakScore = 0 + saradominFlagState = FlagState.SAFE + zamorakFlagState = FlagState.SAFE + saradominFlagCarrier = null + zamorakFlagCarrier = null + saradominOwnFlagCarrier = null + zamorakOwnFlagCarrier = null + saradominOwnFlagPickupTick = 0 + zamorakOwnFlagPickupTick = 0 + saradominFlagDroppedTick = 0 + zamorakFlagDroppedTick = 0 + saradominDroppedFlagScenery?.let { SceneryBuilder.remove(it) } + zamorakDroppedFlagScenery?.let { SceneryBuilder.remove(it) } + saradominDroppedFlagScenery = null + zamorakDroppedFlagScenery = null + clearAllHintIcons() + updateFlagStandObject(CastleWarsTeam.SARADOMIN, FlagState.SAFE) + updateFlagStandObject(CastleWarsTeam.ZAMORAK, FlagState.SAFE) + saradominDoorHealth = 100 + zamorakDoorHealth = 100 + saradominBackdoorLocked = true + zamorakBackdoorLocked = true + saradominBoulder1State = BoulderState.COLLAPSED + saradominBoulder2State = BoulderState.COLLAPSED + zamorakBoulder1State = BoulderState.COLLAPSED + zamorakBoulder2State = BoulderState.COLLAPSED + saradominCatapultOperational = true + zamorakCatapultOperational = true + saradominCatapultOnFire = false + zamorakCatapultOnFire = false + for (key in boulderLocations.keys) { + updateBoulderScenery(key, BoulderState.COLLAPSED) + } + replaceCatapultScenery(saradominCatapultLocation, CastleWars.catapult) + replaceCatapultScenery(zamorakCatapultLocation, CastleWars.catapult) + resetBattlefield() + } + + /** + * Reset the battlefield by removing climbing ropes, restoring modified battlements, + * and closing any open doors. Called at the end of each match. + */ + private fun resetBattlefield() { + for (x in 2368..2431) { + for (y in 3072..3135) { + for (z in 0..3) { + val loc = Location.create(x, y, z) + val obj = RegionManager.getObject(loc) + if (obj != null && obj.id == CastleWars.cwCastleClimbingRope) { + SceneryBuilder.remove(obj) + } + } + } + } + // Reset modified battlements back to original state + // 36313 -> 4446, 36314 -> 4447 + for (x in 2368..2431) { + for (y in 3072..3135) { + for (z in 0..3) { + val loc = Location.create(x, y, z) + val obj = RegionManager.getObject(loc) + if (obj != null) { + when (obj.id) { + 36313 -> SceneryBuilder.replace(obj, Scenery(4446, loc, obj.type, obj.rotation)) + 36314 -> SceneryBuilder.replace(obj, Scenery(4447, loc, obj.type, obj.rotation)) + } + } + } + } + } + val doorResetAreas = listOf( + 2418..2432 to 3070..3085, // Saradomin castle area + 2368..2382 to 3125..3140 // Zamorak castle area + ) + for ((xRange, yRange) in doorResetAreas) { + for (x in xRange) { + for (y in yRange) { + val loc = Location.create(x, y, 0) + val obj = RegionManager.getObject(loc) + if (obj != null && obj.id in CastleWars.largeDoorOpenIds) { + val closedId = when (obj.id) { + 4425 -> 4423 + 4426 -> 4424 + 4429 -> 4427 + 4430 -> 4428 + else -> null + } + if (closedId != null) { + SceneryBuilder.replace(obj, Scenery(closedId, loc, obj.type, obj.rotation)) + } + } + } + } + } + } + + /** + * Get the team a player is on. + * Includes a self-healing mechanism if the player is in the area but not in the sets. + */ + fun getPlayerTeam(player: Player): CastleWarsTeam? { + if (CastleWarsGameArea.saradominPlayers.contains(player)) return CastleWarsTeam.SARADOMIN + if (CastleWarsGameArea.zamorakPlayers.contains(player)) return CastleWarsTeam.ZAMORAK + if (CastleWarsWaitingArea.waitingSaradominPlayers.contains(player)) return CastleWarsTeam.SARADOMIN + if (CastleWarsWaitingArea.waitingZamorakPlayers.contains(player)) return CastleWarsTeam.ZAMORAK + if (isGameActive) { + val inCWArea = + CastleWarsGameArea.areaBorders.any { it.insideBorder(player.location) } || CastleWarsRespawnArea.saradominRespawnRoom.insideBorder( + player.location + ) || CastleWarsRespawnArea.zamorakRespawnRoom.insideBorder( + player.location + ) + if (inCWArea) { + val capeId = player.equipment.get(EquipmentContainer.SLOT_CAPE)?.id ?: -1 + if (capeId == CastleWars.saradominTeamHoodedCloak) { + CastleWarsGameArea.saradominPlayers.add(player) + return CastleWarsTeam.SARADOMIN + } else if (capeId == CastleWars.zamorakTeamHoodedCloak) { + CastleWarsGameArea.zamorakPlayers.add(player) + return CastleWarsTeam.ZAMORAK + } + } + } + return null + } + + /** + * Check if carriers still have the flag equipped. + * Called every tick by CastleWarsGameArea. + * Also checks own flag carriers and castle entry for flag return. + */ + fun checkFlagCarriers() { + saradominFlagCarrier?.let { player -> + if (!player.isActive || getPlayerTeam(player) == null) { + dropFlag(player) + } else { + val weapon = player.equipment.get(EquipmentContainer.SLOT_WEAPON) + if (weapon == null || weapon.id != CastleWarsTeam.SARADOMIN.flagItemId) { + dropFlag(player) + } + } + } + zamorakFlagCarrier?.let { player -> + if (!player.isActive || getPlayerTeam(player) == null) { + dropFlag(player) + } else { + val weapon = player.equipment.get(EquipmentContainer.SLOT_WEAPON) + if (weapon == null || weapon.id != CastleWarsTeam.ZAMORAK.flagItemId) { + dropFlag(player) + } + } + } + saradominOwnFlagCarrier?.let { player -> + if (!player.isActive || getPlayerTeam(player) == null) { + dropOwnFlag(player) + } else { + val weapon = player.equipment.get(EquipmentContainer.SLOT_WEAPON) + if (weapon == null || weapon.id != CastleWarsTeam.SARADOMIN.flagItemId) { + dropOwnFlag(player) + } else { + checkOwnFlagReturn(player) + } + } + } + zamorakOwnFlagCarrier?.let { player -> + if (!player.isActive || getPlayerTeam(player) == null) { + dropOwnFlag(player) + } else { + val weapon = player.equipment.get(EquipmentContainer.SLOT_WEAPON) + if (weapon == null || weapon.id != CastleWarsTeam.ZAMORAK.flagItemId) { + dropOwnFlag(player) + } else { + checkOwnFlagReturn(player) + } + } + } + updateAllHintIcons() + } + + /** + * Check if any dropped flags should be auto-returned to their stands. + * Called every tick by CastleWarsGameArea.tick(). + * Flags auto-return after FLAG_AUTO_RETURN_TICKS (~90 seconds). + */ + fun checkDroppedFlagTimeouts() { + val currentTick = getWorldTicks() + + if (saradominFlagState == FlagState.DROPPED && saradominDroppedFlagScenery != null) { + if (currentTick - saradominFlagDroppedTick >= CastleWarsConstants.FLAG_AUTO_RETURN_TICKS) { + saradominDroppedFlagScenery?.let { SceneryBuilder.remove(it) } + saradominDroppedFlagScenery = null + saradominFlagState = FlagState.SAFE + saradominOwnFlagCarrier = null + updateFlagStandObject(CastleWarsTeam.SARADOMIN, FlagState.SAFE) + announceToAllPlayers("The Saradomin flag has been returned to its stand!") + updateAllOverlays() + updateAllHintIcons() + } + } + + if (zamorakFlagState == FlagState.DROPPED && zamorakDroppedFlagScenery != null) { + if (currentTick - zamorakFlagDroppedTick >= CastleWarsConstants.FLAG_AUTO_RETURN_TICKS) { + zamorakDroppedFlagScenery?.let { SceneryBuilder.remove(it) } + zamorakDroppedFlagScenery = null + zamorakFlagState = FlagState.SAFE + zamorakOwnFlagCarrier = null + updateFlagStandObject(CastleWarsTeam.ZAMORAK, FlagState.SAFE) + announceToAllPlayers("The Zamorak flag has been returned to its stand!") + updateAllOverlays() + updateAllHintIcons() + } + } + } + + /** + * Update the flag stand object in the world based on state + */ + private fun updateFlagStandObject(team: CastleWarsTeam, state: FlagState) { + val standLoc = team.flagStandLocation + val currentObj = RegionManager.getObject(standLoc) + val newId = if (state == FlagState.SAFE) { + team.flagStandId // 4902 for Saradomin, 4903 for Zamorak (with flag) + } else { + when (team) { + CastleWarsTeam.SARADOMIN -> CastleWars.emptyFlagStand1 // 4377 + CastleWarsTeam.ZAMORAK -> CastleWars.emptyFlagStand2 // 4378 + } + } + if (currentObj != null) { + if (currentObj.id != newId) { + SceneryBuilder.replace(currentObj, Scenery(newId, standLoc, currentObj.type, currentObj.rotation)) + } + } else { // Force add if missing (should exist) + SceneryBuilder.add( + Scenery( + newId, standLoc, 10, if (team == CastleWarsTeam.SARADOMIN) 0 else 2 + ) + ) // Guessing rotation + } + } + + /** + * Pick up an enemy flag (from stand or ground). + * Weapon and shield are auto-unequipped when picking up a flag. + * If inventory is full, unequipped items drop to the ground. + * @param player The player picking up the flag + * @param flagTeam The team whose flag is being picked up + * @return true if the flag was successfully picked up + */ + fun pickUpFlag(player: Player, flagTeam: CastleWarsTeam): Boolean { + val playerTeam = getPlayerTeam(player) ?: return false + if (playerTeam == flagTeam) { + sendMessage(player, "You can't pick up your own team's flag!") + return false + } + if (isCarryingFlag(player)) { + sendMessage(player, "You're already carrying a flag!") + return false + } + when (flagTeam) { + CastleWarsTeam.SARADOMIN -> { + saradominFlagCarrier = player + saradominFlagState = FlagState.TAKEN + saradominDroppedFlagScenery?.let { SceneryBuilder.remove(it) } + saradominDroppedFlagScenery = null + updateFlagStandObject(CastleWarsTeam.SARADOMIN, FlagState.TAKEN) + } + + CastleWarsTeam.ZAMORAK -> { + zamorakFlagCarrier = player + zamorakFlagState = FlagState.TAKEN + zamorakDroppedFlagScenery?.let { SceneryBuilder.remove(it) } + zamorakDroppedFlagScenery = null + updateFlagStandObject(CastleWarsTeam.ZAMORAK, FlagState.TAKEN) + } + } + val currentWeapon = player.equipment.get(EquipmentContainer.SLOT_WEAPON) + player.equipment.replace( + Item(flagTeam.flagItemId), EquipmentContainer.SLOT_WEAPON + ) + if (currentWeapon != null) { + if (!player.inventory.add(currentWeapon)) { + GroundItemManager.create(currentWeapon, player.location, player) + sendMessage(player, "Your weapon was dropped because your inventory is full.") // TODO: nah? + } else { + sendMessage(player, "Your weapon has been unequipped.") + } + } + val shield = player.equipment.get(EquipmentContainer.SLOT_SHIELD) + if (shield != null) { + player.equipment.remove(shield) + if (!player.inventory.add(shield)) { + GroundItemManager.create(shield, player.location, player) + sendMessage(player, "Your shield was dropped because your inventory is full.") // TODO: nah? + } else { + sendMessage(player, "Your shield has been unequipped.") + } + } + setAttribute(player, CastleWarsConstants.ATTR_FLAG_PICKUP_TICK, getWorldTicks()) + announceToTeam(playerTeam, "${player.username} has taken the ${flagTeam.teamName} flag!") + announceToTeam(flagTeam, "Your flag has been taken by ${player.username}!") + updateAllOverlays() + return true + } + + /** + * Drop a flag (when a player dies or leaves) - creates scenery object + * @param player The player dropping the flag + */ + fun dropFlag(player: Player) { + val carriedFlag = getCarriedFlag(player) ?: return + when (carriedFlag) { + CastleWarsTeam.SARADOMIN -> { + saradominFlagCarrier = null + saradominFlagState = FlagState.DROPPED + val droppedFlag = Scenery(CastleWars.saradominDroppedFlagScenery, player.location, 10, 0) + SceneryBuilder.add(droppedFlag) + saradominDroppedFlagScenery = droppedFlag + saradominFlagDroppedTick = getWorldTicks() + } + + CastleWarsTeam.ZAMORAK -> { + zamorakFlagCarrier = null + zamorakFlagState = FlagState.DROPPED + val droppedFlag = Scenery(CastleWars.zamorakDroppedFlagScenery, player.location, 10, 0) + SceneryBuilder.add(droppedFlag) + zamorakDroppedFlagScenery = droppedFlag + zamorakFlagDroppedTick = getWorldTicks() + } + } + if (player.equipment.get(EquipmentContainer.SLOT_WEAPON)?.id == carriedFlag.flagItemId) { + player.equipment.remove(Item(carriedFlag.flagItemId), EquipmentContainer.SLOT_WEAPON, true) + } + if (player.inventory.contains(carriedFlag.flagItemId, 1)) { + player.inventory.remove(Item(carriedFlag.flagItemId)) + } + removeAttribute(player, CastleWarsConstants.ATTR_FLAG_PICKUP_TICK) + announceToAllPlayers("${carriedFlag.teamName} flag has been dropped!") + updateAllOverlays() + updateAllHintIcons() + // Stepping stone auto-return: if the flag was dropped on a stepping stone tile, + // immediately return it to the base stand (authentic RS behavior) + if (CastleWars.isSteppingStoneTile(player.location)) { + returnFlagToStand(carriedFlag) + } + } + + /** + * Drop own flag (when a player carrying their own team's flag dies or leaves) + * @param player The player dropping the flag + */ + fun dropOwnFlag(player: Player) { + val carriedFlag = getOwnCarriedFlag(player) ?: return + when (carriedFlag) { + CastleWarsTeam.SARADOMIN -> { + saradominOwnFlagCarrier = null + saradominFlagState = FlagState.DROPPED + val droppedFlag = Scenery(CastleWars.saradominDroppedFlagScenery, player.location, 10, 0) + SceneryBuilder.add(droppedFlag) + saradominDroppedFlagScenery = droppedFlag + saradominFlagDroppedTick = getWorldTicks() + } + + CastleWarsTeam.ZAMORAK -> { + zamorakOwnFlagCarrier = null + zamorakFlagState = FlagState.DROPPED + val droppedFlag = Scenery(CastleWars.zamorakDroppedFlagScenery, player.location, 10, 0) + SceneryBuilder.add(droppedFlag) + zamorakDroppedFlagScenery = droppedFlag + zamorakFlagDroppedTick = getWorldTicks() + } + } + if (player.equipment.get(EquipmentContainer.SLOT_WEAPON)?.id == carriedFlag.flagItemId) { + player.equipment.remove(Item(carriedFlag.flagItemId), EquipmentContainer.SLOT_WEAPON, true) + } + if (player.inventory.contains(carriedFlag.flagItemId, 1)) { + player.inventory.remove(Item(carriedFlag.flagItemId)) + } + announceToAllPlayers("${carriedFlag.teamName} flag has been dropped!") + updateAllOverlays() + updateAllHintIcons() + // Stepping stone auto-return: if the flag was dropped on a stepping stone tile, + // immediately return it to the base stand (authentic RS behavior) + if (CastleWars.isSteppingStoneTile(player.location)) { + returnFlagToStand(carriedFlag) + } + } + + /** + * Drop a flag at a specific location (when a player is forced to drop, e.g., at barrier). + * This differs from dropFlag() which drops at player's current location. + * Handles BOTH enemy flags and own-team flags. + * @param player The player dropping the flag + * @param dropLocation The location where the flag should be dropped + */ + fun dropFlagAtLocation(player: Player, dropLocation: Location) { + val carriedEnemyFlag = getCarriedFlag(player) + if (carriedEnemyFlag != null) { + when (carriedEnemyFlag) { + CastleWarsTeam.SARADOMIN -> { + saradominFlagCarrier = null + saradominFlagState = FlagState.DROPPED + val droppedFlag = Scenery(CastleWars.saradominDroppedFlagScenery, dropLocation, 10, 0) + SceneryBuilder.add(droppedFlag) + saradominDroppedFlagScenery = droppedFlag + saradominFlagDroppedTick = getWorldTicks() + } + + CastleWarsTeam.ZAMORAK -> { + zamorakFlagCarrier = null + zamorakFlagState = FlagState.DROPPED + val droppedFlag = Scenery(CastleWars.zamorakDroppedFlagScenery, dropLocation, 10, 0) + SceneryBuilder.add(droppedFlag) + zamorakDroppedFlagScenery = droppedFlag + zamorakFlagDroppedTick = getWorldTicks() + } + } + if (player.equipment.get(EquipmentContainer.SLOT_WEAPON)?.id == carriedEnemyFlag.flagItemId) { + player.equipment.remove(Item(carriedEnemyFlag.flagItemId), EquipmentContainer.SLOT_WEAPON, true) + } + if (player.inventory.contains(carriedEnemyFlag.flagItemId, 1)) { + player.inventory.remove(Item(carriedEnemyFlag.flagItemId)) + } + announceToAllPlayers("${carriedEnemyFlag.teamName} flag has been dropped!") + updateAllOverlays() + updateAllHintIcons() + // Stepping stone auto-return check + if (CastleWars.isSteppingStoneTile(dropLocation)) { + returnFlagToStand(carriedEnemyFlag) + } + return + } + val carriedOwnFlag = getOwnCarriedFlag(player) + if (carriedOwnFlag != null) { + when (carriedOwnFlag) { + CastleWarsTeam.SARADOMIN -> { + saradominOwnFlagCarrier = null + saradominFlagState = FlagState.DROPPED + val droppedFlag = Scenery(CastleWars.saradominDroppedFlagScenery, dropLocation, 10, 0) + SceneryBuilder.add(droppedFlag) + saradominDroppedFlagScenery = droppedFlag + saradominFlagDroppedTick = getWorldTicks() + } + + CastleWarsTeam.ZAMORAK -> { + zamorakOwnFlagCarrier = null + zamorakFlagState = FlagState.DROPPED + val droppedFlag = Scenery(CastleWars.zamorakDroppedFlagScenery, dropLocation, 10, 0) + SceneryBuilder.add(droppedFlag) + zamorakDroppedFlagScenery = droppedFlag + zamorakFlagDroppedTick = getWorldTicks() + } + } + if (player.equipment.get(EquipmentContainer.SLOT_WEAPON)?.id == carriedOwnFlag.flagItemId) { + player.equipment.remove(Item(carriedOwnFlag.flagItemId), EquipmentContainer.SLOT_WEAPON, true) + } + if (player.inventory.contains(carriedOwnFlag.flagItemId, 1)) { + player.inventory.remove(Item(carriedOwnFlag.flagItemId)) + } + announceToAllPlayers("${carriedOwnFlag.teamName} flag has been dropped!") + updateAllOverlays() + updateAllHintIcons() + + // Stepping stone auto-return check + if (CastleWars.isSteppingStoneTile(dropLocation)) { + returnFlagToStand(carriedOwnFlag) + } + } + } + + /** + * Pick up your own team's dropped flag (for return mechanic). + * Player must carry it back to their castle to return it. + * Weapon and shield are auto-unequipped when picking up a flag. + * If inventory is full, unequipped items drop to the ground. + * @param player The player picking up their own flag + * @param flagTeam The team whose flag is being picked up + * @return true if the flag was successfully picked up + */ + fun pickUpOwnFlag(player: Player, flagTeam: CastleWarsTeam): Boolean { + val playerTeam = getPlayerTeam(player) ?: return false // Must be your own team's flag + if (playerTeam != flagTeam) { + sendMessage(player, "That's not your team's flag!") + return false + } + val flagState = if (flagTeam == CastleWarsTeam.SARADOMIN) saradominFlagState else zamorakFlagState + if (flagState != FlagState.DROPPED) { + sendMessage(player, "The flag isn't here!") + return false + } + if (isCarryingFlag(player) || isCarryingOwnFlag(player)) { + sendMessage(player, "You're already carrying a flag!") + return false + } + when (flagTeam) { + CastleWarsTeam.SARADOMIN -> { + saradominOwnFlagCarrier = player + saradominFlagState = FlagState.TAKEN + saradominOwnFlagPickupTick = getWorldTicks() + saradominDroppedFlagScenery?.let { SceneryBuilder.remove(it) } + saradominDroppedFlagScenery = null + } + + CastleWarsTeam.ZAMORAK -> { + zamorakOwnFlagCarrier = player + zamorakFlagState = FlagState.TAKEN + zamorakOwnFlagPickupTick = getWorldTicks() + zamorakDroppedFlagScenery?.let { SceneryBuilder.remove(it) } + zamorakDroppedFlagScenery = null + } + } + val currentWeapon = player.equipment.get(EquipmentContainer.SLOT_WEAPON) + player.equipment.replace( + Item(flagTeam.flagItemId), EquipmentContainer.SLOT_WEAPON + ) + if (currentWeapon != null) { + if (!player.inventory.add(currentWeapon)) { + GroundItemManager.create(currentWeapon, player.location, player) + sendMessage(player, "Your weapon was dropped because your inventory is full.") + } else { + sendMessage(player, "Your weapon has been unequipped.") + } + } + val shield = player.equipment.get(EquipmentContainer.SLOT_SHIELD) + if (shield != null) { + player.equipment.remove(shield) + if (!player.inventory.add(shield)) { + GroundItemManager.create(shield, player.location, player) + sendMessage(player, "Your shield was dropped because your inventory is full.") + } else { + sendMessage(player, "Your shield has been unequipped.") + } + } + sendMessage(player, "You pick up your team's flag. Return it to your castle!") + announceToTeam(flagTeam, "${player.username} has picked up the ${flagTeam.teamName} flag!") + updateAllOverlays() + updateAllHintIcons() + return true + } + + /** + * Check if a player carrying their own flag has entered their castle to return it. + * Carrying own dropped flag into castle interior returns it to its stand. + * A grace period prevents instant return if the flag is picked up while already inside the castle. + */ + private fun checkOwnFlagReturn(player: Player) { + val playerTeam = getPlayerTeam(player) ?: return + val carriedFlag = getOwnCarriedFlag(player) ?: return + if (carriedFlag != playerTeam) return + val pickupTick = when (playerTeam) { + CastleWarsTeam.SARADOMIN -> saradominOwnFlagPickupTick + CastleWarsTeam.ZAMORAK -> zamorakOwnFlagPickupTick + } + if (getWorldTicks() - pickupTick <= FLAG_RETURN_GRACE_PERIOD_TICKS) return + val castleZones = when (playerTeam) { + CastleWarsTeam.SARADOMIN -> saradominCastleInterior + CastleWarsTeam.ZAMORAK -> zamorakCastleInterior + } + val inOwnCastle = castleZones.any { it.insideBorder(player.location) } + if (inOwnCastle) { + returnFlagViaCastle(player) + } + } + + /** + * Return a flag when player carrying their own flag enters their castle + */ + private fun returnFlagViaCastle(player: Player) { + val carriedFlag = getOwnCarriedFlag(player) ?: return + when (carriedFlag) { + CastleWarsTeam.SARADOMIN -> { + saradominOwnFlagCarrier = null + saradominFlagState = FlagState.SAFE + updateFlagStandObject(CastleWarsTeam.SARADOMIN, FlagState.SAFE) + } + + CastleWarsTeam.ZAMORAK -> { + zamorakOwnFlagCarrier = null + zamorakFlagState = FlagState.SAFE + updateFlagStandObject(CastleWarsTeam.ZAMORAK, FlagState.SAFE) + } + } + if (player.equipment.get(EquipmentContainer.SLOT_WEAPON)?.id == carriedFlag.flagItemId) { + player.equipment.remove(Item(carriedFlag.flagItemId), EquipmentContainer.SLOT_WEAPON, true) + } + sendMessage(player, "You return your team's flag to its stand!") + announceToTeam(carriedFlag, "${player.username} has returned the ${carriedFlag.teamName} flag!") + updateAllOverlays() + updateAllHintIcons() + } + + /** + * Return a dropped flag to its stand (legacy) + * TODO: This is now only used for ground item pickup which is being cut coz its fake. Not real + * The new behavior uses pickUpOwnFlag() and castle entry detection. + * @param player The player returning the flag + * @param flagTeam The team whose flag is being returned + * @return true if the flag was successfully returned + */ + fun returnFlag(player: Player, flagTeam: CastleWarsTeam): Boolean { + val playerTeam = getPlayerTeam(player) ?: return false + if (playerTeam != flagTeam) { + return false + } + val flagState = + if (flagTeam == CastleWarsTeam.SARADOMIN) saradominFlagState else zamorakFlagState + if (flagState != FlagState.DROPPED) { + return false + } + when (flagTeam) { + CastleWarsTeam.SARADOMIN -> { + saradominFlagState = FlagState.SAFE + saradominDroppedFlagScenery?.let { SceneryBuilder.remove(it) } + saradominDroppedFlagScenery = null + updateFlagStandObject(CastleWarsTeam.SARADOMIN, FlagState.SAFE) + } + + CastleWarsTeam.ZAMORAK -> { + zamorakFlagState = FlagState.SAFE + zamorakDroppedFlagScenery?.let { SceneryBuilder.remove(it) } + zamorakDroppedFlagScenery = null + updateFlagStandObject(CastleWarsTeam.ZAMORAK, FlagState.SAFE) + } + } + announceToTeam(flagTeam, "${player.username} has returned the ${flagTeam.teamName} flag!") + updateAllOverlays() + updateAllHintIcons() + return true + } + + /** + * Return a dropped flag directly to its stand (instant, no player involved). + * Used for auto-return (timeout, stepping stone) scenarios. + */ + fun returnFlagToStand(flagTeam: CastleWarsTeam) { + when (flagTeam) { + CastleWarsTeam.SARADOMIN -> { + saradominDroppedFlagScenery?.let { SceneryBuilder.remove(it) } + saradominDroppedFlagScenery = null + saradominFlagState = FlagState.SAFE + saradominFlagCarrier = null + saradominOwnFlagCarrier = null + updateFlagStandObject(CastleWarsTeam.SARADOMIN, FlagState.SAFE) + } + + CastleWarsTeam.ZAMORAK -> { + zamorakDroppedFlagScenery?.let { SceneryBuilder.remove(it) } + zamorakDroppedFlagScenery = null + zamorakFlagState = FlagState.SAFE + zamorakFlagCarrier = null + zamorakOwnFlagCarrier = null + updateFlagStandObject(CastleWarsTeam.ZAMORAK, FlagState.SAFE) + } + } + announceToAllPlayers("The ${flagTeam.teamName} flag has been returned to its stand!") + updateAllOverlays() + updateAllHintIcons() + } + + /** + * Capture a flag (score a point) + * @param player The player capturing the flag + * @return true if the flag was successfully captured + */ + fun captureFlag(player: Player): Boolean { + val playerTeam = getPlayerTeam(player) ?: return false + val carriedFlag = getCarriedFlag(player) ?: return false + if (carriedFlag == playerTeam) { + return false + } + val ownFlagState = + if (playerTeam == CastleWarsTeam.SARADOMIN) saradominFlagState else zamorakFlagState + if (ownFlagState != FlagState.SAFE) { + sendMessage(player, "You can't capture while your own flag is not at its stand!") + return false + } + when (playerTeam) { + CastleWarsTeam.SARADOMIN -> saradominScore++ + CastleWarsTeam.ZAMORAK -> zamorakScore++ + } + when (carriedFlag) { + CastleWarsTeam.SARADOMIN -> { + saradominFlagCarrier = null + saradominFlagState = FlagState.SAFE + updateFlagStandObject(CastleWarsTeam.SARADOMIN, FlagState.SAFE) + } + + CastleWarsTeam.ZAMORAK -> { + zamorakFlagCarrier = null + zamorakFlagState = FlagState.SAFE + updateFlagStandObject(CastleWarsTeam.ZAMORAK, FlagState.SAFE) + } + } + if (player.equipment.get(EquipmentContainer.SLOT_WEAPON)?.id == carriedFlag.flagItemId) { + player.equipment.remove(Item(carriedFlag.flagItemId), EquipmentContainer.SLOT_WEAPON, true) + } + announceToAllPlayers("${playerTeam.teamName} has captured the ${carriedFlag.teamName} flag!") + announceToAllPlayers("Score: Saradomin $saradominScore - $zamorakScore Zamorak") + updateAllOverlays() + return true + } + + /** + * Check if a player is carrying an enemy flag + */ + fun isCarryingFlag(player: Player): Boolean { + return saradominFlagCarrier == player || zamorakFlagCarrier == player + } + + /** + * Check if a player is carrying their own team's flag (for return) + */ + fun isCarryingOwnFlag(player: Player): Boolean { + return saradominOwnFlagCarrier == player || zamorakOwnFlagCarrier == player + } + + /** + * Check if a player is carrying any flag (enemy or own) + */ + fun isCarryingAnyFlag(player: Player): Boolean { + return isCarryingFlag(player) || isCarryingOwnFlag(player) + } + + /** + * Calculate bonus damage from Castle Wars bracelet against flag carriers. + * Returns the bonus damage amount (20% of base hit) if attacker has bracelet + * and target is carrying a flag, otherwise returns 0. + */ + fun getBraceletBonusDamage(attacker: Player, target: Player, baseHit: Int): Int { + if (!attacker.hasCastleWarsBracelet()) return 0 + if (!isCarryingAnyFlag(target)) return 0 + return (baseHit * CastleWarsConstants.BRACELET_FLAG_CARRIER_DAMAGE_BONUS).toInt() + } + + /** + * Get which enemy flag a player is carrying + */ + fun getCarriedFlag(player: Player): CastleWarsTeam? { + return when (player) { + saradominFlagCarrier -> CastleWarsTeam.SARADOMIN + zamorakFlagCarrier -> CastleWarsTeam.ZAMORAK + else -> null + } + } + + /** + * Get which own team flag a player is carrying (for return) + */ + fun getOwnCarriedFlag(player: Player): CastleWarsTeam? { + return when (player) { + saradominOwnFlagCarrier -> CastleWarsTeam.SARADOMIN + zamorakOwnFlagCarrier -> CastleWarsTeam.ZAMORAK + else -> null + } + } + + // ==================== Hint Icon (Minimap Arrow) Functions ==================== + + /** + * Update hint icons for all players in the game. + * Shows a blinking yellow arrow pointing to the enemy flag/carrier. + */ + fun updateAllHintIcons() { + val allPlayers = getAllGamePlayers() + for (player in allPlayers) { + updateHintIconForPlayer(player) + } + } + + /** + * Update the hint icon for a single player + */ + private fun updateHintIconForPlayer(player: Player) { + val playerTeam = getPlayerTeam(player) ?: return + val enemyTeam = playerTeam.opposite() + playerHintIconSlots[player]?.let { slot -> + HintIconManager.removeHintIcon(player, slot) + } + playerHintIconSlots.remove(player) + val enemyFlagState = if (enemyTeam == CastleWarsTeam.SARADOMIN) saradominFlagState else zamorakFlagState + when (enemyFlagState) { + FlagState.TAKEN -> { + val carrier = if (enemyTeam == CastleWarsTeam.SARADOMIN) saradominFlagCarrier ?: saradominOwnFlagCarrier + else zamorakFlagCarrier ?: zamorakOwnFlagCarrier + carrier?.let { + if (it == player) { + } else { + val slot = HintIconManager.registerHintIcon(player, it) + if (slot >= 0) { + playerHintIconSlots[player] = slot + } + } + } + } + + FlagState.DROPPED -> { + val droppedFlag = + if (enemyTeam == CastleWarsTeam.SARADOMIN) saradominDroppedFlagScenery else zamorakDroppedFlagScenery + + droppedFlag?.let { + val slot = HintIconManager.registerHintIcon(player, it.location) + if (slot >= 0) { + playerHintIconSlots[player] = slot + } + } + } + + FlagState.SAFE -> { + } + } + } + + /** + * Clear hint icon for a specific player + */ + fun clearHintIconForPlayer(player: Player) { + playerHintIconSlots[player]?.let { slot -> + HintIconManager.removeHintIcon(player, slot) + } + playerHintIconSlots.remove(player) + } + + /** + * Clear all hint icons + */ + private fun clearAllHintIcons() { + for ((player, slot) in playerHintIconSlots) { + HintIconManager.removeHintIcon(player, slot) + } + playerHintIconSlots.clear() + } + + /** + * Fully restore a player and remove all Castle Wars items (except tinderbox). + * Called when a player leaves the game or the match ends. + * Restores: HP, prayer, run energy, special attack, summoning points, all stats. + * Removes: All CW items from inventory, equipment, and beast of burden. + */ + fun restoreAndCleanupPlayer(player: Player) { + player.fullRestore() + player.settings.runEnergy = 100.0 + player.settings.specialEnergy = 100 + player.familiarManager.familiar?.let { familiar -> + // Restore to max (60) by calculating the difference needed + val diff = familiar.specialPoints - 60 + familiar.updateSpecialPoints(diff) + } + player.inventory.removeAll(CastleWars.CW_ITEMS_TO_REMOVE) + player.equipment.removeAll(CastleWars.CW_ITEMS_TO_REMOVE) + (player.familiarManager.familiar as? BurdenBeast)?.container?.removeAll(CastleWars.CW_ITEMS_TO_REMOVE) + } + + /** + * End the game and distribute rewards + */ + 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 { + 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 ""}!") + } + if (isCarryingFlag(player)) { + dropFlag(player) + } else if (isCarryingOwnFlag(player)) { + dropOwnFlag(player) + } + player.depleteCastleWarsBracelet() + restoreAndCleanupPlayer(player) + } + val resultMessage = when { + saradominScore > zamorakScore -> "Saradomin wins the game!" + zamorakScore > saradominScore -> "Zamorak wins the game!" + else -> "The game ends in a draw!" + } + announceToAllPlayers(resultMessage) + resetGame() + } + + /** + * Handle a player leaving the game + */ + fun handlePlayerLeave(player: Player) { + if (isCarryingFlag(player)) { + dropFlag(player) + } else if (isCarryingOwnFlag(player)) { + dropOwnFlag(player) + } + clearHintIconForPlayer(player) + restoreAndCleanupPlayer(player) + } + + // ==================== Castle Structure Methods ==================== + + /** + * Damage a team's large door + * @param team The team whose door to damage + * @param damage Amount of damage (0-100) + */ + fun damageDoor(team: CastleWarsTeam, damage: Int) { + val previousHealth = getDoorHealth(team) + when (team) { + CastleWarsTeam.SARADOMIN -> { + saradominDoorHealth = (saradominDoorHealth - damage).coerceAtLeast(0) + } + + CastleWarsTeam.ZAMORAK -> { + zamorakDoorHealth = (zamorakDoorHealth - damage).coerceAtLeast(0) + } + } + if (previousHealth > 0 && getDoorHealth(team) <= 0) { + forceDoorsOpen(team) + announceToAllPlayers("The ${team.teamName} castle doors have been destroyed!") + } + updateAllOverlays() + } + + /** + * Check if a team's door is destroyed (health = 0) + */ + fun isDoorDestroyed(team: CastleWarsTeam): Boolean { + return getDoorHealth(team) <= 0 + } + + /** + * Force all doors of a team to open state (when destroyed). + * Searches the Castle Wars castle area for doors with matching IDs and replaces them. + */ + private fun forceDoorsOpen(team: CastleWarsTeam) { + val (minX, maxX, minY, maxY) = when (team) { + CastleWarsTeam.SARADOMIN -> arrayOf(2418, 2432, 3070, 3085) + CastleWarsTeam.ZAMORAK -> arrayOf(2368, 2382, 3125, 3140) + } + for (x in minX..maxX) { + for (y in minY..maxY) { + val loc = Location.create(x, y, 0) + val obj = RegionManager.getObject(loc) ?: continue + val openId = getOpenDoorId(obj.id) + if (openId != null) { + SceneryBuilder.replace(obj, Scenery(openId, loc, obj.type, obj.rotation)) + } + } + } + } + + /** + * Get the open door variant ID for a closed door + */ + private fun getOpenDoorId(closedId: Int): Int? = when (closedId) { + 4423 -> 4425 + 4424 -> 4426 + 4427 -> 4429 + 4428 -> 4430 + else -> null + } + + /** + * Repair a team's large door + * @param team The team whose door to repair + * @param repair Amount to repair (0-100) + */ + fun repairDoor(team: CastleWarsTeam, repair: Int) { + when (team) { + CastleWarsTeam.SARADOMIN -> { + saradominDoorHealth = (saradominDoorHealth + repair).coerceAtMost(100) + } + + CastleWarsTeam.ZAMORAK -> { + zamorakDoorHealth = (zamorakDoorHealth + repair).coerceAtMost(100) + } + } + updateAllOverlays() + } + + /** + * Get a team's door health + */ + fun getDoorHealth(team: CastleWarsTeam): Int { + return when (team) { + CastleWarsTeam.SARADOMIN -> saradominDoorHealth + CastleWarsTeam.ZAMORAK -> zamorakDoorHealth + } + } + + /** + * Set a team's backdoor lock status + */ + fun setBackdoorLocked(team: CastleWarsTeam, locked: Boolean) { + when (team) { + CastleWarsTeam.SARADOMIN -> saradominBackdoorLocked = locked + CastleWarsTeam.ZAMORAK -> zamorakBackdoorLocked = locked + } + updateAllOverlays() + } + + /** + * Check if a team's backdoor is locked + */ + fun isBackdoorLocked(team: CastleWarsTeam): Boolean { + return when (team) { + CastleWarsTeam.SARADOMIN -> saradominBackdoorLocked + CastleWarsTeam.ZAMORAK -> zamorakBackdoorLocked + } + } + + /** + * Get the boulder key based on location + */ + fun getBoulderKey(location: Location): String? { + for ((key, loc) in boulderLocations) { + if (loc.equals(location)) return key + } + return null + } + + /** + * Get the state of a boulder + */ + fun getBoulderState(boulderKey: String): BoulderState { + return when (boulderKey) { + "saradomin1" -> saradominBoulder1State + "saradomin2" -> saradominBoulder2State + "zamorak1" -> zamorakBoulder1State + "zamorak2" -> zamorakBoulder2State + else -> BoulderState.COLLAPSED + } + } + + /** + * Set the state of a boulder + */ + private fun setBoulderState(boulderKey: String, state: BoulderState) { + when (boulderKey) { + "saradomin1" -> saradominBoulder1State = state + "saradomin2" -> saradominBoulder2State = state + "zamorak1" -> zamorakBoulder1State = state + "zamorak2" -> zamorakBoulder2State = state + } + } + + /** + * Check if a boulder is blocking the passage (collapsed or weakened) + */ + fun isBoulderCollapsed(boulderKey: String): Boolean { + val state = getBoulderState(boulderKey) + return state == BoulderState.COLLAPSED || state == BoulderState.WEAKENED + } + + /** + * Check if a boulder passage is clear (no rocks) + */ + fun isBoulderCleared(boulderKey: String): Boolean { + return getBoulderState(boulderKey) == BoulderState.CLEARED + } + + /** + * Weaken a boulder (transform from COLLAPSED to WEAKENED). + * Used when first explosive potion hits fully collapsed rocks. + */ + fun weakenBoulder(boulderKey: String) { + setBoulderState(boulderKey, BoulderState.WEAKENED) + updateBoulderScenery(boulderKey, BoulderState.WEAKENED) + updateAllOverlays() + } + + /** + * Get the nearest boulder key to a given location. + * Used for finding which boulder to collapse when using explosive potion on cave wall. + */ + fun getNearestBoulderKey(location: Location): String? { + var nearestKey: String? = null + var nearestDistance = Int.MAX_VALUE + for ((key, loc) in boulderLocations) { + val distance = location.getDistance(loc).toInt() + if (distance < nearestDistance && distance <= 5) { // Within 5 tiles + nearestDistance = distance + nearestKey = key + } + } + return nearestKey + } + + /** + * Update the boulder scenery object in the world + * @param boulderKey the key identifying which boulder + * @param state the boulder state to set + */ + private fun updateBoulderScenery(boulderKey: String, state: BoulderState) { + val loc = boulderLocations[boulderKey] ?: return + val currentObj = RegionManager.getObject(loc) + when (state) { + BoulderState.COLLAPSED -> { + if (currentObj == null) { + SceneryBuilder.add(Scenery(CastleWars.tunnelRocks, loc, 10, 0)) + } else if (currentObj.id != CastleWars.tunnelRocks) { + SceneryBuilder.replace( + currentObj, Scenery(CastleWars.tunnelRocks, loc, currentObj.type, currentObj.rotation) + ) + } + } + + BoulderState.WEAKENED -> { + if (currentObj != null && currentObj.id != CastleWars.tunnelRocksWeakened) { + SceneryBuilder.replace( + currentObj, Scenery(CastleWars.tunnelRocksWeakened, loc, currentObj.type, currentObj.rotation) + ) + } + } + + BoulderState.CLEARED -> { + if (currentObj != null) { + SceneryBuilder.remove(currentObj) + } + } + } + } + + /** + * Clear a boulder (make passage walkable by removing rocks) + */ + fun clearBoulder(boulderKey: String) { + setBoulderState(boulderKey, BoulderState.CLEARED) + updateBoulderScenery(boulderKey, BoulderState.CLEARED) + updateAllOverlays() + } + + /** + * Collapse a boulder (block passage) + * This will kill any players standing on the tile instantly! + */ + fun collapseBoulder(boulderKey: String) { + setBoulderState(boulderKey, BoulderState.COLLAPSED) + updateBoulderScenery( + boulderKey, BoulderState.COLLAPSED + ) + val loc = boulderLocations[boulderKey] + if (loc != null) { + RegionManager.getLocalPlayers(loc, 0).filter { it.location == loc }.forEach { player -> + player.impactHandler.manualHit(player, player.skills.lifepoints, ImpactHandler.HitsplatType.NORMAL) + } + } + updateAllOverlays() + } + + /** + * Destroy a team's catapult + */ + fun destroyCatapult(team: CastleWarsTeam) { + val location = when (team) { + CastleWarsTeam.SARADOMIN -> saradominCatapultLocation + CastleWarsTeam.ZAMORAK -> zamorakCatapultLocation + } + when (team) { + CastleWarsTeam.SARADOMIN -> saradominCatapultOperational = false + CastleWarsTeam.ZAMORAK -> zamorakCatapultOperational = false + } // Replace with damaged catapult scenery (4385 or 4386 have "Repair" option) + replaceCatapultScenery(location, CastleWars.catapultDamaged1) + updateAllOverlays() + } + + /** + * Set a team's catapult on fire (degrades over time, not instantly destroyed). + * Tinderbox sets fire, catapult degrades until extinguished or destroyed. + */ + fun setCatapultOnFire(team: CastleWarsTeam) { + when (team) { + CastleWarsTeam.SARADOMIN -> saradominCatapultOnFire = true + CastleWarsTeam.ZAMORAK -> zamorakCatapultOnFire = true + } + updateAllOverlays() + } + + /** + * Extinguish a catapult fire (restores to usable state if not yet destroyed) + */ + fun extinguishCatapult(team: CastleWarsTeam) { + when (team) { + CastleWarsTeam.SARADOMIN -> saradominCatapultOnFire = false + CastleWarsTeam.ZAMORAK -> zamorakCatapultOnFire = false + } + updateAllOverlays() + } + + /** + * Check if a team's catapult is on fire + */ + fun isCatapultOnFire(team: CastleWarsTeam): Boolean { + return when (team) { + CastleWarsTeam.SARADOMIN -> saradominCatapultOnFire + CastleWarsTeam.ZAMORAK -> zamorakCatapultOnFire + } + } + + /** + * Repair a team's catapult + */ + fun repairCatapult(team: CastleWarsTeam) { + val location = when (team) { + CastleWarsTeam.SARADOMIN -> saradominCatapultLocation + CastleWarsTeam.ZAMORAK -> zamorakCatapultLocation + } + when (team) { + CastleWarsTeam.SARADOMIN -> saradominCatapultOperational = true + CastleWarsTeam.ZAMORAK -> zamorakCatapultOperational = true + } + replaceCatapultScenery(location, CastleWars.catapult) + updateAllOverlays() + } + + /** + * Check if a team's catapult is operational + */ + fun isCatapultOperational(team: CastleWarsTeam): Boolean { + return when (team) { + CastleWarsTeam.SARADOMIN -> saradominCatapultOperational + CastleWarsTeam.ZAMORAK -> zamorakCatapultOperational + } + } + + /** + * Update the overlay for a single player + * Each team sees THEIR OWN castle's status for structure elements + */ + fun updateOverlay(player: Player) { // Flag status and scores are shown for both teams + setVarbit(player, SARADOMIN_FLAG_VARBIT, saradominFlagState.ordinal) + setVarbit(player, SARADOMIN_SCORE_VARBIT, saradominScore) + setVarbit(player, ZAMORAK_FLAG_VARBIT, zamorakFlagState.ordinal) + setVarbit(player, ZAMORAK_SCORE_VARBIT, zamorakScore) // Timer is shared (same varplayer 380 for all players) + setVarp( + player, CASTLE_WARS_TIMER_VARP, remainingMinutes + ) + // Boulder varbits: 0 = collapsed/blocked (green/secure), 1 = cleared (red/open) + val team = getPlayerTeam(player) ?: return + when (team) { + CastleWarsTeam.SARADOMIN -> { + setVarbit(player, SARA_DOOR_HEALTH_VARBIT, saradominDoorHealth) + setVarbit(player, SARA_BACKDOOR_VARBIT, if (saradominBackdoorLocked) 0 else 1) + setVarbit(player, SARA_BOULDER1_VARBIT, if (isBoulderCleared("saradomin1")) 1 else 0) + setVarbit(player, SARA_BOULDER2_VARBIT, if (isBoulderCleared("saradomin2")) 1 else 0) + setVarbit(player, SARA_CATAPULT_VARBIT, if (saradominCatapultOperational) 0 else 1) + } + + CastleWarsTeam.ZAMORAK -> { + setVarbit(player, ZAM_DOOR_HEALTH_VARBIT, zamorakDoorHealth) + setVarbit(player, ZAM_BACKDOOR_VARBIT, if (zamorakBackdoorLocked) 0 else 1) + setVarbit(player, ZAM_BOULDER1_VARBIT, if (isBoulderCleared("zamorak1")) 1 else 0) + setVarbit(player, ZAM_BOULDER2_VARBIT, if (isBoulderCleared("zamorak2")) 1 else 0) + setVarbit(player, ZAM_CATAPULT_VARBIT, if (zamorakCatapultOperational) 0 else 1) + } + } + } + + /** + * Get all players currently in the Castle Wars game area zones. + * Uses RegionManager to find all players in the zone borders, + * ensuring we reach players even if they weren't properly added to the tracked sets. + * @return Set of all players in the game area who have a valid team + */ + private fun getAllGamePlayers(): Set { + val players = mutableSetOf() + for (border in CastleWarsGameArea.areaBorders) { + val centerX = (border.southWestX + border.northEastX) / 2 + val centerY = (border.southWestY + border.northEastY) / 2 + val plane = border.plane + val radius = maxOf( + kotlin.math.abs(border.northEastX - border.southWestX), + kotlin.math.abs(border.northEastY - border.southWestY) + ) / 2 + 10 // Add buffer + val centerLocation = Location.create(centerX, centerY, plane) + for (player in RegionManager.getLocalPlayers( + centerLocation, radius + )) { + if (border.insideBorder(player.location) && getPlayerTeam(player) != null) { + players.add(player) + } + } + } + val saraRespawn = CastleWarsRespawnArea.saradominRespawnRoom + val zamRespawn = CastleWarsRespawnArea.zamorakRespawnRoom + for (respawnBorder in arrayOf(saraRespawn, zamRespawn)) { + val centerX = (respawnBorder.southWestX + respawnBorder.northEastX) / 2 + val centerY = (respawnBorder.southWestY + respawnBorder.northEastY) / 2 + val plane = respawnBorder.plane + val centerLocation = Location.create(centerX, centerY, plane) + for (player in RegionManager.getLocalPlayers(centerLocation, 20)) { + if (respawnBorder.insideBorder(player.location) && getPlayerTeam(player) != null) { + players.add(player) + } + } + } + return players + } + + /** + * Get all players on a specific team in the game area. + * @param team The team to get players for + * @return Set of all players on the specified team + */ + private fun getTeamPlayers(team: CastleWarsTeam): Set { + return getAllGamePlayers().filter { getPlayerTeam(it) == team }.toSet() + } + + /** + * Update the overlay for all players in the game + */ + fun updateAllOverlays() { + for (player in getAllGamePlayers()) { + updateOverlay(player) + } + } + + /** + * Announce a message to all players in the game + */ + private fun announceToAllPlayers(message: String) { + for (player in getAllGamePlayers()) { + sendMessage(player, message) + } + } + + /** + * Announce a message to a specific team + */ + private fun announceToTeam(team: CastleWarsTeam, message: String) { + for (player in getTeamPlayers(team)) { + sendMessage(player, message) + } + } + + /** + * Replace catapult scenery at a location with a new catapult ID. + * Searches for any catapult object at the location and replaces it. + */ + private fun replaceCatapultScenery(location: Location, newId: Int) { // Get the object at the catapult location + val existingObj = RegionManager.getObject(location) + if (existingObj != null && existingObj.id in CastleWars.allCatapultIds) { + SceneryBuilder.replace(existingObj, Scenery(newId, location, existingObj.rotation)) + return + } + for (dx in -1..1) { + for (dy in -1..1) { + val searchLoc = Location.create(location.x + dx, location.y + dy, location.z) + val obj = RegionManager.getObject(searchLoc) + if (obj != null && obj.id in CastleWars.allCatapultIds) { + SceneryBuilder.replace(obj, Scenery(newId, obj.location, obj.rotation)) + return + } + } + } + } + + /** + * Remove inactive players from hint icon tracking. + * Called periodically by CastleWarsGameArea.tick() + */ + fun cleanupInactiveHintIcons() { + val inactivePlayers = playerHintIconSlots.keys.filter { !it.isActive || it.session == null } + for (player in inactivePlayers) { + playerHintIconSlots.remove(player) + } + } + + /** + * Check if a teammate can steal the flag from a flag carrier. + * Returns true if the carrier has held the flag for longer than FLAG_STEAL_TIMER_TICKS (3 minutes). + * Use bandages on a teammate carrying a flag to take it from them. + */ + fun canTeammateStealFlag(carrier: Player): Boolean { + val pickupTick = getAttribute(carrier, CastleWarsConstants.ATTR_FLAG_PICKUP_TICK, 0) + if (pickupTick == 0) return false + return getWorldTicks() - pickupTick >= CastleWarsConstants.FLAG_STEAL_TIMER_TICKS + } + + /** + * Transfer the enemy flag from one player to another teammate. + * Used when bandages are used on a flag carrier after the steal timer expires. + */ + fun transferFlag(from: Player, to: Player) { + val carriedFlag = getCarriedFlag(from) ?: return + if (from.equipment.get(EquipmentContainer.SLOT_WEAPON)?.id == carriedFlag.flagItemId) { + from.equipment.remove(Item(carriedFlag.flagItemId), EquipmentContainer.SLOT_WEAPON, true) + } + removeAttribute(from, CastleWarsConstants.ATTR_FLAG_PICKUP_TICK) + val currentWeapon = to.equipment.get(EquipmentContainer.SLOT_WEAPON) + to.equipment.replace(Item(carriedFlag.flagItemId), EquipmentContainer.SLOT_WEAPON) + if (currentWeapon != null) { + if (!to.inventory.add(currentWeapon)) { + GroundItemManager.create(currentWeapon, to.location, to) + } + } + val shield = to.equipment.get(EquipmentContainer.SLOT_SHIELD) + if (shield != null) { + to.equipment.remove(shield) + if (!to.inventory.add(shield)) { + GroundItemManager.create(shield, to.location, to) + } + } + when (carriedFlag) { + CastleWarsTeam.SARADOMIN -> saradominFlagCarrier = to + CastleWarsTeam.ZAMORAK -> zamorakFlagCarrier = to + } + setAttribute(to, CastleWarsConstants.ATTR_FLAG_PICKUP_TICK, getWorldTicks()) + sendMessage(from, "${to.username} takes the flag from you!") + sendMessage(to, "You take the ${carriedFlag.teamName} flag from ${from.username}!") + updateAllHintIcons() + } +} + +/** + * 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. + */ +fun Player.hasCastleWarsBracelet(): Boolean { + val bracelet = equipment.get(EquipmentContainer.SLOT_HANDS) ?: return false + return bracelet.id == Items.CASTLEWAR_BRACE3_11079 || bracelet.id == Items.CASTLEWAR_BRACE2_11081 || bracelet.id == Items.CASTLEWAR_BRACE1_11083 +} + +/** + * Deplete one charge from the Castle Wars bracelet. + * Bracelet(3) -> Bracelet(2) -> Bracelet(1) -> removed. + * Called at the end of each game for players wearing the bracelet. + */ +fun Player.depleteCastleWarsBracelet() { + val bracelet = equipment.get(EquipmentContainer.SLOT_HANDS) ?: return + val nextId = when (bracelet.id) { + Items.CASTLEWAR_BRACE3_11079 -> Items.CASTLEWAR_BRACE2_11081 + Items.CASTLEWAR_BRACE2_11081 -> Items.CASTLEWAR_BRACE1_11083 + Items.CASTLEWAR_BRACE1_11083 -> -1 // Remove + else -> return + } + if (nextId == -1) { + equipment.remove(bracelet) + sendMessage(this, "Your Castle Wars bracelet crumbles to dust.") + } else { + equipment.replace(Item(nextId), EquipmentContainer.SLOT_HANDS) + } +} + diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsGameZone.kt b/Server/src/main/content/minigame/castlewars/CastleWarsGameZone.kt new file mode 100644 index 000000000..8831cf08c --- /dev/null +++ b/Server/src/main/content/minigame/castlewars/CastleWarsGameZone.kt @@ -0,0 +1,141 @@ +package content.minigame.castlewars + +import content.minigame.castlewars.areas.CastleWarsGameArea +import content.minigame.castlewars.areas.CastleWarsRespawnArea +import core.api.* +import core.game.node.entity.Entity +import core.game.node.entity.impl.PulseManager +import core.game.node.entity.player.Player +import core.game.system.timer.impl.Poison +import core.game.world.map.Location +import core.game.world.map.RegionManager.getLocalNpcs +import core.game.world.map.path.Pathfinder +import core.game.world.map.zone.* + +/** + * MapZone for Castle Wars game area that handles death and respawn. + * This is registered in addition to the MapArea to provide death handling, + * since MapArea doesn't support overriding the death method. + */ +class CastleWarsGameZone : MapZone("CastleWarsGame", true, ZoneRestriction.RANDOM_EVENTS), StartupListener { + + companion object { + + private val saradominStandardFloor = ZoneBorders(Location.create(2426, 3073, 3), Location.create(2430, 3077, 3)) + private val zamorakStandardFloor = ZoneBorders(Location.create(2373, 3134, 3), Location.create(2369, 3130, 3)) + private val saradominUpperFloor = ZoneBorders(Location.create(2431, 3080, 2), Location.create(2423, 3072, 2)) + private val zamorakUpperFloor = ZoneBorders(Location.create(2368, 3127, 2), Location.create(2376, 3135, 2)) + private val saradominFloor = ZoneBorders(Location.create(2420, 3072, 1), Location.create(2431, 3083, 1)) + private val zamorakFloor = ZoneBorders(Location.create(2379, 3135, 1), Location.create(2368, 3124, 1)) + private val battleField = ZoneBorders(Location.create(2368, 3135, 0), Location.create(2431, 3072, 0)) + private val saradominTunnels = ZoneBorders(Location.create(2430, 9481, 0), Location.create(2385, 9504, 0)) + private val zamorakTunnels = ZoneBorders(Location.create(2401, 9503, 0), Location.create(2366, 9529, 0)) + private val centerTunnels = ZoneBorders(Location.create(2396, 9493, 0), Location.create(2405, 9515, 0)) + val allBorders = arrayOf( + saradominStandardFloor, + zamorakStandardFloor, + saradominUpperFloor, + zamorakUpperFloor, + saradominFloor, + zamorakFloor, + battleField, + saradominTunnels, + zamorakTunnels, + centerTunnels + ) + + /** + * Handle Castle Wars death with poison preservation. + * Shared between CastleWarsGameZone.death() and CastleWarsGameArea.handleDeath(). + * In Castle Wars, poison persists through death unlike normal safe deaths. + */ + fun handleCastleWarsDeath(player: Player): Boolean { + if (CastleWarsGameArea.ticksLeftInGame <= 0) return false + val team = CastleWarsGameState.getPlayerTeam(player) ?: return false + + if (CastleWarsGameState.isCarryingFlag(player)) { + CastleWarsGameState.dropFlag(player) + } else if (CastleWarsGameState.isCarryingOwnFlag(player)) { + CastleWarsGameState.dropOwnFlag(player) + } + + PulseManager.cancelDeathTask(player) + + val poisonTimer = getTimer(player) + val poisonSeverity: Int? = poisonTimer?.severity + val poisonSource: Entity? = poisonTimer?.damageSource + + player.fullRestore() + + if (poisonSeverity != null && poisonSeverity > 0 && poisonSource != null) { + registerTimer(player, spawnTimer(poisonSource, poisonSeverity)) + } + + val respawnLoc = when (team) { + CastleWarsTeam.SARADOMIN -> CastleWarsRespawnArea.saradominRespawnRoom.randomWalkableLoc + CastleWarsTeam.ZAMORAK -> CastleWarsRespawnArea.zamorakRespawnRoom.randomWalkableLoc + } + player.properties.teleportLocation = respawnLoc + sendMessage(player, "You have been defeated and respawn at your team's base.") + return true + } + } + + override fun startup() { + ZoneBuilder.configure(this) + for (border in allBorders) { + register(border) + } + } + + override fun configure() { + } + + override fun death(e: Entity, killer: Entity?): Boolean { + if (e !is Player) return false + return handleCastleWarsDeath(e) + } + + override fun move(e: Entity, from: Location, to: Location): Boolean { + val dx = to.x - from.x + val dy = to.y - from.y + + val isMovingUp = to.z > from.z + val isChangingZLevel = to.z != from.z + + for (n in getLocalNpcs(e, 5)) { + if (n.isInvisible || n === e) { + continue + } + if (n.shouldPreventStacking(e)) { + val s1 = e.size() + val s2 = n.size() + val l = n.location + + if (Pathfinder.isStandingIn(to.x, to.y, s1, s1, l.x, l.y, s2, s2)) { + if (isChangingZLevel && !isMovingUp) { + continue + } + return false + } + if (!isChangingZLevel && dx != 0 && dy != 0) { + val intermediateXOverlap = Pathfinder.isStandingIn(from.x + dx, from.y, s1, s1, l.x, l.y, s2, s2) + val intermediateYOverlap = Pathfinder.isStandingIn( + from.x, from.y + dy, s1, s1, l.x, l.y, s2, s2 + ) + if (intermediateXOverlap || intermediateYOverlap) { + return false + } + } + } + } + + if (e !is Player) return true + return true + } + + override fun getZoneType(): Int { + return ZoneType.CASTLE_WARS.id + } +} + diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsItemHandler.kt b/Server/src/main/content/minigame/castlewars/CastleWarsItemHandler.kt new file mode 100644 index 000000000..54dea8aac --- /dev/null +++ b/Server/src/main/content/minigame/castlewars/CastleWarsItemHandler.kt @@ -0,0 +1,351 @@ +package content.minigame.castlewars + +import content.minigame.castlewars.CastleWarsConstants.BANDAGE_COOLDOWN_TICKS +import content.minigame.castlewars.CastleWarsConstants.BANDAGE_HEAL_PERCENTAGE +import content.minigame.castlewars.CastleWarsConstants.BANDAGE_RUN_ENERGY_RESTORE +import content.minigame.castlewars.CastleWarsConstants.BURNING_BARRICADE_DAMAGE_PER_TICK +import content.minigame.castlewars.CastleWarsConstants.BURNING_BARRICADE_MAX_TICKS +import content.minigame.castlewars.CastleWarsConstants.BURNING_BARRICADE_PULSE_INTERVAL +import content.minigame.castlewars.CastleWarsConstants.EXPLOSIVE_POTION_SELF_DAMAGE +import content.minigame.castlewars.CastleWarsConstants.MAX_BARRICADES_PER_TEAM +import core.api.* +import core.game.interaction.Clocks +import core.game.interaction.IntType +import core.game.interaction.InteractionListener +import core.game.node.entity.combat.ImpactHandler +import core.game.node.entity.npc.NPC +import core.game.node.entity.player.Player +import core.game.node.entity.skill.Skills +import core.game.node.item.Item +import core.game.world.map.Location +import core.game.world.map.RegionManager +import org.rs09.consts.Animations +import org.rs09.consts.Items +import org.rs09.consts.NPCs + + +/** + * Handles Castle Wars item mechanics: + * - Bandages: Heal 10% of max HP, restore 30% run energy, can heal teammates + * - Barricades: Place barricade NPC with 5000 HP, max 10 per team TODO: 5000 HP BARRICADE HAHHAH FAT CHANCE. keeping that in for now + * - Explosive Potions: Instant-destroy barricades, 15% max HP self-damage on drop + */ +class CastleWarsItemHandler : InteractionListener { + + companion object { + + val saradominBarricades = mutableListOf() + val zamorakBarricades = mutableListOf() + + val burningBarricadePulses = mutableMapOf() + + fun getBarricadeCount(team: CastleWarsTeam): Int { + return when (team) { + CastleWarsTeam.SARADOMIN -> saradominBarricades.count { it.isActive } + CastleWarsTeam.ZAMORAK -> zamorakBarricades.count { it.isActive } + } + } + + fun clearAllBarricades() { + burningBarricadePulses.values.forEach { it.stop() } + burningBarricadePulses.clear() + + saradominBarricades.forEach { if (it.isActive) it.clear() } + zamorakBarricades.forEach { if (it.isActive) it.clear() } + saradominBarricades.clear() + zamorakBarricades.clear() + } + + fun cleanupDeadBarricades() { + saradominBarricades.removeAll { !it.isActive } + zamorakBarricades.removeAll { !it.isActive } + } + + fun dealExplosiveAoeDamage(attacker: Player, location: Location, radius: Int = 1) { + val attackerTeam = CastleWarsGameState.getPlayerTeam(attacker) ?: return + RegionManager.getLocalPlayers(location, radius) + .filter { target -> + target != attacker && + target.isActive && + CastleWarsGameState.getPlayerTeam(target) != null && + CastleWarsGameState.getPlayerTeam(target) != attackerTeam + } + .forEach { target -> + val damage = CastleWarsConstants.EXPLOSIVE_POTION_SELF_DAMAGE + .coerceAtMost(target.skills.lifepoints) + target.impactHandler.manualHit( + attacker, damage, ImpactHandler.HitsplatType.NORMAL + ) + sendMessage(target, "You are caught in an explosion!") + } + } + } + + override fun defineListeners() { + on(CastleWars.cwBandages, IntType.ITEM, "Heal") { player, node -> + if (useBandage(player, player)) { + removeItem(player, node.asItem()) + } + return@on true + } + + onUseWith(IntType.PLAYER, CastleWars.cwBandages, -1) { player, used, with -> + val target = with as? Player ?: return@onUseWith false + + val playerTeam = CastleWarsGameState.getPlayerTeam(player) + val targetTeam = CastleWarsGameState.getPlayerTeam(target) + + if (playerTeam == null || targetTeam == null) { + sendMessage(player, "You can only use bandages in Castle Wars.") + return@onUseWith false + } + + if (playerTeam != targetTeam) { + sendMessage(player, "You can only heal your teammates!") + return@onUseWith false + } + + if (CastleWarsGameState.isCarryingFlag(target) && CastleWarsGameState.canTeammateStealFlag(target)) { + CastleWarsGameState.transferFlag(target, player) + return@onUseWith true + } + + if (useBandage(player, target)) { + removeItem(player, used.asItem()) + } + return@onUseWith true + } + + on(CastleWars.cwBarricade, IntType.ITEM, "set-up") { player, node -> + if (!CastleWarsGameState.isGameActive) { + sendMessage(player, "You can only set up barricades during a game.") + return@on false + } + + val team = CastleWarsGameState.getPlayerTeam(player) + if (team == null) { + sendMessage(player, "You're not in a Castle Wars game.") + return@on false + } + + cleanupDeadBarricades() + if (getBarricadeCount(team) >= MAX_BARRICADES_PER_TEAM) { + sendMessage( + player, + "Your team already has the maximum number of barricades ($MAX_BARRICADES_PER_TEAM)." + ) + return@on false + } + + val nearbyBarricades = getLocalBarricades(player.location, 0) + if (nearbyBarricades.isNotEmpty()) { + sendMessage(player, "There's already a barricade here.") + return@on false + } + + val barricade = CastleWarsBarricadeNPC.spawn(player.location, team) + barricade.skills.setStaticLevel(Skills.HITPOINTS, 50) + barricade.skills.lifepoints = 50 + + when (team) { + CastleWarsTeam.SARADOMIN -> saradominBarricades.add(barricade) + CastleWarsTeam.ZAMORAK -> zamorakBarricades.add(barricade) + } + + removeItem(player, node.asItem()) + animate(player, 827) // Place animation + sendMessage(player, "You set up a barricade.") + + return@on true + } + + onUseWith( + IntType.NPC, CastleWars.cwExplosivePotion, *intArrayOf( + NPCs.BARRICADE_1532, NPCs.BARRICADE_1533, + NPCs.BARRICADE_1534, NPCs.BARRICADE_1535 + ) + ) { player, used, with -> + val barricade = with as? NPC ?: return@onUseWith false + + if (!CastleWarsGameState.isGameActive) { + return@onUseWith false + } + + if (barricade.id == NPCs.BARRICADE_1533) { + sendMessage(player, "You cannot blow up a burning barricade.") + return@onUseWith false + } + + animate(player, Animations.HUMAN_BURYING_BONES_827) + playAudio(player, 2599) + + sendGraphics(157, barricade.location) + + barricade.skills.lifepoints = 0 + barricade.clear() + cleanupDeadBarricades() + + removeItem(player, used.asItem()) + sendMessage(player, "The explosive potion destroys the barricade!") + + dealExplosiveAoeDamage(player, barricade.location) + + return@onUseWith true + } + + on(CastleWars.cwExplosivePotion, IntType.ITEM, "drop") { player, node -> + if (!CastleWarsGameState.isGameActive) { + return@on false + } + + player.impactHandler.manualHit( + player, + EXPLOSIVE_POTION_SELF_DAMAGE.coerceAtMost(player.skills.lifepoints), + ImpactHandler.HitsplatType.NORMAL + ) + animate(player, Animations.HUMAN_BURYING_BONES_827) + playAudio(player, 2599) // Fire/explosion sound + + removeItem(player, node.asItem()) + sendMessage(player, "The potion explodes! You take damage.") + + val nearbyBarricades = getLocalBarricades(player.location, 1) + for (barricade in nearbyBarricades) { + if (barricade.id == NPCs.BARRICADE_1533) continue + + sendGraphics(157, barricade.location) + barricade.skills.lifepoints = 0 + barricade.clear() + } + cleanupDeadBarricades() + + dealExplosiveAoeDamage(player, player.location) + + return@on true + } + + onUseWith( + IntType.NPC, Items.TINDERBOX_590, *intArrayOf( + NPCs.BARRICADE_1532, NPCs.BARRICADE_1533, + NPCs.BARRICADE_1534, NPCs.BARRICADE_1535 + ) + ) { player, _, with -> + val barricade = with as? NPC ?: return@onUseWith false + + if (!CastleWarsGameState.isGameActive) { + return@onUseWith false + } + + if (barricade.id == NPCs.BARRICADE_1533) { + sendMessage(player, "The barricade is already on fire!") + return@onUseWith false + } + + val burningId = NPCs.BARRICADE_1533 + + playAudio(player, 2599) + + barricade.transform(burningId) + sendMessage(player, "You set the barricade on fire!") + + val firePulse = object : core.game.system.task.Pulse(BURNING_BARRICADE_PULSE_INTERVAL) { + var tickCount = 0 + override fun pulse(): Boolean { + if (!barricade.isActive || barricade.id != NPCs.BARRICADE_1533 || tickCount >= BURNING_BARRICADE_MAX_TICKS) { + burningBarricadePulses.remove(barricade) + if (barricade.isActive && barricade.id == NPCs.BARRICADE_1533) { + barricade.skills.lifepoints = 0 + barricade.clear() + cleanupDeadBarricades() + } + return true + } + barricade.skills.lifepoints = + (barricade.skills.lifepoints - BURNING_BARRICADE_DAMAGE_PER_TICK).coerceAtLeast(0) + tickCount++ + return false + } + } + burningBarricadePulses[barricade] = firePulse + submitWorldPulse(firePulse) + + return@onUseWith true + } + + onUseWith(IntType.NPC, Items.BUCKET_OF_WATER_1929, NPCs.BARRICADE_1533) { player, used, with -> + val barricade = with as? NPC ?: return@onUseWith false + + if (!CastleWarsGameState.isGameActive) { + return@onUseWith false + } + + val originalId = if (saradominBarricades.contains(barricade)) { + NPCs.BARRICADE_1532 + } else { + NPCs.BARRICADE_1534 + } + + barricade.transform(originalId) + + replaceSlot(player, used.asItem().slot, Item(Items.BUCKET_1925)) + + playAudio(player, 2401) + sendMessage(player, "You extinguish the fire on the barricade.") + + return@onUseWith true + } + } + + private fun useBandage(user: Player, target: Player): Boolean { + if (CastleWarsGameState.isCarryingOwnFlag(target)) { + if (user == target) { + sendMessage(user, "You can't heal while carrying your team's flag!") + } else { + sendMessage(user, "You can't heal a player carrying their team's flag!") + } + return false + } + + if (target.clocks[Clocks.NEXT_EAT] >= getWorldTicks()) { + return false + } + target.clocks[Clocks.NEXT_EAT] = getWorldTicks() + BANDAGE_COOLDOWN_TICKS + + val maxHp = target.skills.getStaticLevel(Skills.HITPOINTS) + val healPercent = if (user.hasCastleWarsBracelet()) { + CastleWarsConstants.BRACELET_BANDAGE_HEAL_PERCENTAGE + } else { + BANDAGE_HEAL_PERCENTAGE + } + val healAmount = (maxHp * healPercent).toInt().coerceAtLeast(1) + + val currentHp = target.skills.lifepoints + val newHp = (currentHp + healAmount).coerceAtMost(maxHp) + target.skills.lifepoints = newHp + + // Restore run energy + val currentEnergy = target.settings.runEnergy + target.settings.runEnergy = (currentEnergy + BANDAGE_RUN_ENERGY_RESTORE).coerceAtMost(100.0) + + curePoison(target) + + if (user == target) { + sendMessage(user, "You apply the bandage and feel better.") + } else { + sendMessage(user, "You apply the bandage to ${target.username}.") + sendMessage(target, "${user.username} applies a bandage to you.") + } + return true + } + + private fun getLocalBarricades(location: Location, radius: Int): List { + val barricadeIds = intArrayOf( + NPCs.BARRICADE_1532, NPCs.BARRICADE_1533, + NPCs.BARRICADE_1534, NPCs.BARRICADE_1535 + ) + + return RegionManager.getLocalNpcs(location, radius) + .filter { it.id in barricadeIds && it.isActive } + } +} + diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsListeners.kt b/Server/src/main/content/minigame/castlewars/CastleWarsListeners.kt index 24fdb55cc..e989ab751 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsListeners.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsListeners.kt @@ -1,10 +1,17 @@ -package rs09.game.content.activity.castlewars +package content.minigame.castlewars -import core.api.* import content.global.skill.agility.AgilityHandler import content.global.skill.summoning.familiar.BurdenBeast +import content.minigame.castlewars.areas.CastleWarsGameArea +import content.minigame.castlewars.areas.CastleWarsRespawnArea +import content.minigame.castlewars.areas.CastleWarsWaitingArea +import content.minigame.castlewars.bots.CastleWarsBotSpawner +import core.api.* import core.cache.def.impl.ItemDefinition +import core.game.component.Component import core.game.container.Container +import core.game.global.action.ClimbActionHandler +import core.game.global.action.DoorActionHandler import core.game.interaction.IntType import core.game.interaction.InteractionListener import core.game.node.entity.player.Player @@ -12,81 +19,190 @@ import core.game.node.entity.player.link.TeleportManager import core.game.node.item.Item import core.game.node.scenery.Scenery import core.game.node.scenery.SceneryBuilder +import core.game.system.task.Pulse import core.game.world.GameWorld +import core.game.world.map.Direction import core.game.world.map.Location +import core.game.world.map.RegionManager +import core.game.world.update.flag.chunk.ObjectUpdateFlag import core.game.world.update.flag.context.Animation +import core.tools.Log +import org.rs09.consts.Components import org.rs09.consts.Items import org.rs09.consts.Sounds -import rs09.game.content.activity.castlewars.areas.CastleWarsWaitingArea -@Suppress("unused") +data class BarrierData( + val location: Location, + val direction: Direction, + val outsideTile: Location, + val insideTile: Location +) + +private val barrierCoordinates: Map> = mapOf( + CastleWars.zamorakEnergyBarrier to listOf( + BarrierData( + Location.create(2373, 3127, 1), Direction.WEST, + Location.create(2373, 3126, 1), + Location.create(2373, 3128, 1) + ), + BarrierData( + Location.create(2376, 3131, 1), Direction.SOUTH, + Location.create(2377, 3131, 1), + Location.create(2375, 3131, 1) + ) + ), + CastleWars.saradominEnergyBarrier to listOf( + BarrierData( + Location.create(2426, 3080, 1), + Direction.EAST, Location.create(2426, 3081, 1), + Location.create(2426, 3079, 1) + ), + BarrierData( + Location.create(2423, 3076, 1), Direction.NORTH, + Location.create(2422, 3076, 1), + Location.create(2424, 3076, 1) + ) + ) +) + class CastleWarsListeners : InteractionListener { - override fun defineListeners() { + private val pickaxeIds = intArrayOf(1265, 1267, 1269, 1271, 1273, 1275) - // Scenery Interactions - PORTALS + override fun defineDestinationOverrides() { + setDest(IntType.SCENERY, intArrayOf(CastleWars.cwCastleClimbingRope), "climb") { entity, node -> + val rope = node.asScenery() + val playerLoc = entity.location + val ropeLoc = rope.location + if (playerLoc.getDistance(ropeLoc) <= 2.0) { + return@setDest playerLoc + } + return@setDest ropeLoc + } + + setDest( + IntType.SCENERY, + intArrayOf(CastleWars.saradominEnergyBarrier, CastleWars.zamorakEnergyBarrier), + "Pass" + ) { entity, node -> + val barrier = node.asScenery() + val playerLoc = entity.location + val barrierDataList = barrierCoordinates[barrier.id] ?: return@setDest barrier.location + val barrierData = + barrierDataList.find { it.location == barrier.location } ?: return@setDest barrier.location + if (playerLoc == barrierData.location) { + return@setDest barrierData.outsideTile + } + if (playerLoc == barrierData.outsideTile || playerLoc == barrierData.insideTile) { + return@setDest barrierData.location + } + val distToOutside = playerLoc.getDistance(barrierData.outsideTile) + val distToInside = playerLoc.getDistance(barrierData.insideTile) + if (distToOutside <= distToInside) { + return@setDest barrierData.outsideTile + } else { + return@setDest barrierData.insideTile + } + } + } + + override fun defineListeners() { on(CastleWars.joinSaradominTeamPortal, IntType.SCENERY, "Enter") { player, _ -> if (GameWorld.settings?.enable_castle_wars != true) return@on false - if (joinError(player)) return@on true - // MapArea handles joining properties + if (!player.isArtificial && joinError(player)) return@on true + if (CastleWarsWaitingArea.waitingSaradominPlayers.size + CastleWarsGameArea.saradominPlayers.size >= CastleWarsConstants.MAX_PLAYERS_PER_TEAM) { + sendMessage(player, "The team is currently full.") + return@on true + } + if (!player.isArtificial) { + CastleWarsBotSpawner.spawnBotsIfNeeded() + } player.properties.teleportLocation = CastleWarsWaitingArea.saradominWaitingRoom.randomWalkableLoc player.setAttribute(CastleWars.portalAttribute, CastleWars.saradominName) return@on true } on(CastleWars.joinZamorakTeamPortal, IntType.SCENERY, "Enter") { player, _ -> if (GameWorld.settings?.enable_castle_wars != true) return@on false - if (joinError(player)) return@on true - // MapArea handles joining properties + if (!player.isArtificial && joinError(player)) return@on true + if (CastleWarsWaitingArea.waitingZamorakPlayers.size + CastleWarsGameArea.zamorakPlayers.size >= CastleWarsConstants.MAX_PLAYERS_PER_TEAM) { + sendMessage(player, "The team is currently full.") + return@on true + } + if (!player.isArtificial) { + CastleWarsBotSpawner.spawnBotsIfNeeded() + } player.properties.teleportLocation = CastleWarsWaitingArea.zamorakWaitingRoom.randomWalkableLoc player.setAttribute(CastleWars.portalAttribute, CastleWars.zamorakName) return@on true } on(CastleWars.saradominLeaveLobbyPortal, IntType.SCENERY, "Exit") { player, _ -> player.properties.teleportLocation = CastleWars.lobbyBankArea.randomWalkableLoc - // MapArea handles leaving properties return@on true } on(CastleWars.zamorakLeaveLobbyPortal, IntType.SCENERY, "Exit") { player, _ -> player.properties.teleportLocation = CastleWars.lobbyBankArea.randomWalkableLoc - // MapArea handles leaving properties return@on true } on(CastleWars.joinGuthixTeamPortal, IntType.SCENERY, "Enter") { player, _ -> if (GameWorld.settings?.enable_castle_wars != true) return@on false - if (joinError(player)) return@on true - - // Join the team with fewer players, if they're equal, join randomly - if (CastleWarsWaitingArea.waitingSaradominPlayers.size < CastleWarsWaitingArea.waitingZamorakPlayers.size) { - player.properties.teleportLocation = CastleWarsWaitingArea.saradominWaitingRoom.randomWalkableLoc - } else if (CastleWarsWaitingArea.waitingSaradominPlayers.size > CastleWarsWaitingArea.waitingZamorakPlayers.size) { - player.properties.teleportLocation = CastleWarsWaitingArea.zamorakWaitingRoom.randomWalkableLoc - } else { - if (Math.random() < 0.5) { - player.properties.teleportLocation = CastleWarsWaitingArea.saradominWaitingRoom.randomWalkableLoc - } else { + if (!player.isArtificial && joinError(player)) return@on true + if (!player.isArtificial) { + CastleWarsBotSpawner.spawnBotsIfNeeded() + } + val saraTotal = + CastleWarsWaitingArea.waitingSaradominPlayers.size + CastleWarsGameArea.saradominPlayers.size + val zamTotal = CastleWarsWaitingArea.waitingZamorakPlayers.size + CastleWarsGameArea.zamorakPlayers.size + val saraFull = saraTotal >= CastleWarsConstants.MAX_PLAYERS_PER_TEAM + val zamFull = zamTotal >= CastleWarsConstants.MAX_PLAYERS_PER_TEAM + if (saraFull && zamFull) { + sendMessage(player, "The team is currently full.") + return@on true + } + when { + saraFull -> { player.properties.teleportLocation = CastleWarsWaitingArea.zamorakWaitingRoom.randomWalkableLoc } + + zamFull -> { + player.properties.teleportLocation = CastleWarsWaitingArea.saradominWaitingRoom.randomWalkableLoc + } + + 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.setAttribute(CastleWars.portalAttribute, CastleWars.guthixName) return@on true } - // Scenery Interactions - Item Tables on(CastleWars.cwTableItemRewardMap.keys.toIntArray(), SCENERY, "take-from") { player, node -> - // Retrieve the item id from the map (null safe) val rewardItem: Int = CastleWars.cwTableItemRewardMap.getValue(node.id) - // If item is added to inventory, play pickup sound if (addItem(player, rewardItem)) { playAudio(player, Sounds.PICK2_2582) - // Warn player inventory full using custom dialogue box } else { - val formattedItemName = (ItemDefinition.forId(rewardItem).name.lowercase() + "s.") // Get the formatted item name string - .replace("pes.", "pe.") // Replacement for ropes. -> rope. - .replace("bronze ", "") // Replacement for bronze pickaxes. -> pickaxes. + val formattedItemName = + (ItemDefinition.forId(rewardItem).name.lowercase() + "s.") + .replace("pes.", "pe.") + .replace( + "bronze ", + "" + ) - // Toolkit gets a more grammatically correct sentence if (rewardItem == Items.TOOLKIT_4051) { sendDialogue(player, "Your inventory is too full to hold a toolkit.") } else { @@ -96,54 +212,793 @@ class CastleWarsListeners : InteractionListener { return@on true } - // Item/Scenery Interaction - Outside Battlement (Wall) Rope Climb Setup - onUseWith(SCENERY, CastleWars.cwClimbingRope, *CastleWars.cwCastleBattlementsMap.keys.toIntArray()) { player, rope, wall -> - // Remove rope item from inventory + onUseWith( + SCENERY, + CastleWars.cwClimbingRope, + *CastleWars.cwCastleBattlementsMap.keys.toIntArray() + ) { player, rope, wall -> removeItem(player, rope) - // First, replace the scenery with the rope on top of the wall - replaceScenery(wall.asScenery(), CastleWars.cwCastleBattlementsMap.getValue(wall.id), CastleWars.ropeAliveTicks) - // Second, create a new scenery with the rope falling down - val toAdd = Scenery(CastleWars.cwCastleClimbingRope, wall.location, 4, wall.direction.toInteger()) - SceneryBuilder.add(toAdd, CastleWars.ropeAliveTicks) - toAdd.isActive = true + val wallScenery = wall.asScenery() + val ropeWall = wallScenery.transform(CastleWars.cwCastleBattlementsMap.getValue(wall.id)) + SceneryBuilder.replace(wallScenery, ropeWall, CastleWars.ropeAliveTicks, false) + val hangingRope = Scenery(CastleWars.cwCastleClimbingRope, wall.location, 4, wall.direction.toInteger()) + RegionManager.getRegionChunk(hangingRope.location).flag(ObjectUpdateFlag(hangingRope, false)) + hangingRope.isActive = true + GameWorld.Pulser.submit(object : Pulse(CastleWars.ropeAliveTicks) { + override fun pulse(): Boolean { + RegionManager.getRegionChunk(hangingRope.location).flag(ObjectUpdateFlag(hangingRope, true)) + hangingRope.isActive = false + return true + } + }) return@onUseWith true } - // Scenery Interactions - Outside Battlement (Wall) Player Climbs Rope on(CastleWars.cwCastleClimbingRope, SCENERY, "climb") { player, rope -> - // Get the direction of the scenery + val ropeLoc = rope.asScenery().location val dir = rope.asScenery().direction.opposite - // Move player on top of the wall from that specific rope location - teleport(player, Location(dir.stepY + player.location.x, -dir.stepX + player.location.y), TeleportManager.TeleportType.INSTANT) + + val topOfWallLoc = Location(dir.stepY + ropeLoc.x, -dir.stepX + ropeLoc.y, ropeLoc.z) + + val baseOfWallLoc = Location(-dir.stepY + ropeLoc.x, dir.stepX + ropeLoc.y, ropeLoc.z) + + val distToTop = player.location.getDistance(topOfWallLoc) + val distToBase = player.location.getDistance(baseOfWallLoc) + + val onRopeTile = + player.location.x == ropeLoc.x && player.location.y == ropeLoc.y && player.location.z == ropeLoc.z + + if (onRopeTile || distToBase <= distToTop) { + teleport(player, topOfWallLoc, TeleportManager.TeleportType.INSTANT) + } else { + sendMessage(player, "You can't climb down the rope.") + } return@on true } - // Scenery Interactions - Water Tap - No pulse onUseWith(SCENERY, Items.BUCKET_1925, CastleWars.castleWaterTap) { player, used, _ -> - // Lock player lock(player, 1) - // Bucket fill animation animate(player, 832) - // Bucket fill sound playAudio(player, Sounds.TAP_FILL_2609) - // Replace empty -> full water bucket replaceSlot(player, used.asItem().slot, Item(Items.BUCKET_OF_WATER_1929)) return@onUseWith true } - // Scenery Interactions - Stepping stones on(CastleWars.cwSteppingStones, SCENERY, "jump-to") { player, stone -> - // Lock the player + if (player.locks.isMovementLocked) { + sendMessage(player, "You can't jump while you're bound!") + return@on true + } + + if (CastleWarsGameState.isCarryingAnyFlag(player)) { + CastleWarsGameState.dropFlagAtLocation(player, player.location) + sendMessage(player, "You drop the flag as you jump across!") + } + lock(player, 3) - // Make the player "Jump" to the next stepping stone AgilityHandler.forceWalk(player, -1, player.location, stone.location, Animation(741), 10, 0.0, null, 1) - // Delay sound to line up with "jump" movement runTask(player, 1) { - // Play jumping sound playAudio(player, Sounds.JUMP_2461) } return@on true } + + on(CastleWars.saradominEnergyBarrier, IntType.SCENERY, "Pass") { player, barrier -> + val playerTeam = CastleWarsGameState.getPlayerTeam(player) + if (playerTeam != CastleWarsTeam.SARADOMIN) { + sendMessage(player, "Only the Saradomin team may pass through this barrier.") + return@on true + } + + val barrierScenery = barrier.asScenery() + if (barrierScenery == null) { + sendMessage(player, "The barrier is not working properly. Please try again.") + return@on true + } + + val currentBarrier = RegionManager.getObject( + barrierScenery.location.z, + barrierScenery.location.x, + barrierScenery.location.y, + barrierScenery.id + ) + if (currentBarrier == null || !currentBarrier.isActive) { + sendMessage(player, "The barrier is not working properly. Please try again.") + return@on true + } + + if (isEnteringRespawnRoom(player, barrierScenery) && CastleWarsGameState.isCarryingAnyFlag(player)) { + dropFlagOutsideBarrier(player, barrierScenery) + sendMessage(player, "You cannot take the flag into your respawn room!") + } + + passEnergyBarrier(player, barrierScenery) + return@on true + } + on(CastleWars.zamorakEnergyBarrier, IntType.SCENERY, "Pass") { player, barrier -> + val playerTeam = CastleWarsGameState.getPlayerTeam(player) + if (playerTeam != CastleWarsTeam.ZAMORAK) { + sendMessage(player, "Only the Zamorak team may pass through this barrier.") + return@on true + } + + val barrierScenery = barrier.asScenery() + if (barrierScenery == null) { + sendMessage(player, "The barrier is not working properly. Please try again.") + return@on true + } + + val currentBarrier = RegionManager.getObject( + barrierScenery.location.z, + barrierScenery.location.x, + barrierScenery.location.y, + barrierScenery.id + ) + if (currentBarrier == null || !currentBarrier.isActive) { + sendMessage(player, "The barrier is not working properly. Please try again.") + return@on true + } + + if (isEnteringRespawnRoom(player, barrierScenery) && CastleWarsGameState.isCarryingAnyFlag(player)) { + dropFlagOutsideBarrier(player, barrierScenery) + sendMessage(player, "You cannot take the flag into your respawn room!") + } + + passEnergyBarrier(player, barrierScenery) + return@on true + } + + on(CastleWars.saradominExitPortal, IntType.SCENERY, "Leave") { 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, _ -> + player.properties.teleportLocation = CastleWars.lobbyBankArea.randomWalkableLoc + sendMessage(player, "You have left the Castle Wars game.") + return@on true + } + + on(CastleWars.largeDoorIds, IntType.SCENERY, "Open") { player, node -> + val door = node.asScenery() + val doorTeam = getDoorTeam(door.location) + val playerTeam = CastleWarsGameState.getPlayerTeam(player) + + if (playerTeam == null) { + // Not in Castle Wars game - shouldn't happen in game area + return@on false + } + + val doorHealth = CastleWarsGameState.getDoorHealth(doorTeam) + + if (doorHealth <= 0) { + DoorActionHandler.handleAutowalkDoor(player, door) + return@on true + } + + if (playerTeam != doorTeam) { + val isOutside = isPlayerOutsideFrontDoor(player, door, doorTeam) + if (isOutside) { + sendMessage(player, "The door is barred from the inside!") + return@on true + } + } + + val secondDoor = DoorActionHandler.getSecondDoor(door, player) + val openDoorId = getOpenDoorVariant(door.id) ?: door.id + val secondOpenId = if (secondDoor != null) getOpenDoorVariant(secondDoor.id) ?: secondDoor.id else -1 + + DoorActionHandler.open(door, secondDoor, openDoorId, secondOpenId, true, -1, false) + playAudio(player, Sounds.DOOR_OPEN_62) + return@on true + } + + on(CastleWars.largeDoorOpenIds, IntType.SCENERY, "Close") { player, node -> + val door = node.asScenery() + val doorTeam = getDoorTeam(door.location) + val playerTeam = CastleWarsGameState.getPlayerTeam(player) + + if (playerTeam == null) { + return@on false + } + + val doorHealth = CastleWarsGameState.getDoorHealth(doorTeam) + + if (doorHealth <= 0) { + sendMessage(player, "The door is too damaged to close!") + return@on true + } + + if (playerTeam != doorTeam) { + sendMessage(player, "You can't close the enemy's door!") + return@on true + } + + DoorActionHandler.handleDoor(player, door) + return@on true + } + + on(CastleWars.largeDoorIds, IntType.SCENERY, "Attack") { player, node -> + val door = node.asScenery() + val doorTeam = getDoorTeam(door.location) + val playerTeam = CastleWarsGameState.getPlayerTeam(player) + + if (playerTeam == null) { + return@on false + } + + if (playerTeam == doorTeam) { + sendMessage(player, "You can't attack your own team's door!") + return@on true + } + + if (CastleWarsGameState.isDoorDestroyed(doorTeam)) { + sendMessage(player, "The door is already destroyed!") + return@on true + } + + player.pulseManager.run(CastleWarsDoorAttackPulse(player, door, doorTeam)) + return@on true + } + + onUseWith( + SCENERY, + CastleWars.cwExplosivePotion, + *(CastleWars.largeDoorIds + CastleWars.largeDoorOpenIds) + ) { player, used, with -> + val door = with.asScenery() + val doorTeam = getDoorTeam(door.location) + val playerTeam = CastleWarsGameState.getPlayerTeam(player) + + if (playerTeam == null) { + sendMessage(player, "You can only use this in Castle Wars.") + return@onUseWith false + } + + if (playerTeam == doorTeam) { + sendMessage(player, "You can't damage your own team's door!") + return@onUseWith false + } + + if (CastleWarsGameState.isDoorDestroyed(doorTeam)) { + sendMessage(player, "The door is already destroyed!") + return@onUseWith false + } + + val previousHealth = CastleWarsGameState.getDoorHealth(doorTeam) + CastleWarsGameState.damageDoor(doorTeam, 25) + removeItem(player, used.asItem()) + playAudio(player, 2599) + animate(player, 827) + + if (CastleWarsGameState.isDoorDestroyed(doorTeam) && previousHealth > 0) { + sendMessage(player, "The explosion destroys the ${doorTeam.teamName} door!") + } else { + sendMessage(player, "The explosive damages the ${doorTeam.teamName} door!") + } + return@onUseWith true + } + + onUseWith( + SCENERY, + CastleWars.cwToolkit, + *(CastleWars.largeDoorIds + CastleWars.largeDoorOpenIds) + ) { player, used, with -> + val door = with.asScenery() + val doorTeam = getDoorTeam(door.location) + val playerTeam = CastleWarsGameState.getPlayerTeam(player) + + if (playerTeam == null) { + sendMessage(player, "You can only use this in Castle Wars.") + return@onUseWith false + } + + if (playerTeam != doorTeam) { + sendMessage(player, "You can't repair the enemy's door!") + return@onUseWith false + } + + val currentHealth = CastleWarsGameState.getDoorHealth(doorTeam) + if (currentHealth >= 100) { + sendMessage(player, "The door is already fully repaired.") + return@onUseWith false + } + CastleWarsGameState.repairDoor(doorTeam, 10) + animate(player, 898) + sendMessage(player, "You repair some of the door's damage.") + return@onUseWith true + } + on(CastleWars.backdoorIds, IntType.SCENERY, "Unlock") { player, node -> + val door = node.asScenery() + val doorTeam = + if (door.id == CastleWars.saradominBackdoor) CastleWarsTeam.SARADOMIN else CastleWarsTeam.ZAMORAK + val playerTeam = CastleWarsGameState.getPlayerTeam(player) ?: return@on false + if (playerTeam == doorTeam) { + openBackdoor(player, door) + } else { + val isOutside = isPlayerOutsideCastle(player, door, doorTeam) + if (isOutside) { + sendMessage(player, "You can't open the enemy's door from outside!") + } else { + openBackdoor(player, door) + } + } + return@on true + } + + on(CastleWars.backdoorIds, IntType.SCENERY, "Pick-lock") { player, node -> + val door = node.asScenery() + val doorTeam = + if (door.id == CastleWars.saradominBackdoor) CastleWarsTeam.SARADOMIN else CastleWarsTeam.ZAMORAK + val playerTeam = CastleWarsGameState.getPlayerTeam(player) ?: return@on false + if (playerTeam == doorTeam) { + sendMessage(player, "You don't need to pick the lock on your own door.") + return@on true + } + if (!isPlayerOutsideCastle(player, door, doorTeam)) { + sendMessage(player, "You can't pick the lock from this side.") + return@on true + } + lock(player, CastleWarsConstants.BACKDOOR_PICKLOCK_TICKS) + animate(player, 2246) + sendMessage(player, "You attempt to pick the lock...") + val thievingLevel = player.skills.getLevel(core.game.node.entity.skill.Skills.THIEVING) + val successChance = (CastleWarsConstants.BACKDOOR_PICKLOCK_BASE_CHANCE + + (thievingLevel - CastleWarsConstants.BACKDOOR_PICKLOCK_LEVEL)) + .coerceIn( + CastleWarsConstants.BACKDOOR_PICKLOCK_BASE_CHANCE, + CastleWarsConstants.BACKDOOR_PICKLOCK_MAX_CHANCE + ) + + runTask(player, CastleWarsConstants.BACKDOOR_PICKLOCK_TICKS) { + if ((1..100).random() <= successChance) { + openBackdoor(player, door) + sendMessage(player, "You successfully pick the lock!") + } else { + sendMessage(player, "You fail to pick the lock.") + } + } + return@on true + } + + on(CastleWars.backdoorOpenIds, IntType.SCENERY, "Close") { player, node -> + val door = node.asScenery() + val doorTeam = if (door.id == CastleWars.saradominBackdoorOpen) CastleWarsTeam.SARADOMIN + else CastleWarsTeam.ZAMORAK + val playerTeam = CastleWarsGameState.getPlayerTeam(player) ?: return@on false + if (playerTeam != doorTeam) { + sendMessage(player, "You can't close the enemy's door!") + return@on true + } + CastleWarsGameState.setBackdoorLocked(doorTeam, true) + DoorActionHandler.handleDoor(player, door) + return@on true + } + + on(CastleWars.tunnelRocks, IntType.SCENERY, "Mine") { player, node -> + val rocks = node.asScenery() + val boulderKey = CastleWarsGameState.getBoulderKey(rocks.location) + if (boulderKey == null) { + sendMessage(player, "You can't mine these rocks.") + return@on false + } + player.pulseManager.run(CastleWarsTunnelMiningPulse(player, rocks, boulderKey, isWeakened = false)) + return@on true + } + + on(CastleWars.tunnelRocksWeakened, IntType.SCENERY, "Mine") { player, node -> + val rocks = node.asScenery() + val boulderKey = CastleWarsGameState.getBoulderKey(rocks.location) + if (boulderKey == null) { + sendMessage(player, "You can't mine these rocks.") + return@on false + } + player.pulseManager.run(CastleWarsTunnelMiningPulse(player, rocks, boulderKey, isWeakened = true)) + return@on true + } + + onUseWith(SCENERY, CastleWars.cwExplosivePotion, CastleWars.tunnelRocks) { player, used, with -> + val rocks = with.asScenery() + val boulderKey = CastleWarsGameState.getBoulderKey(rocks.location) + if (boulderKey == null) { + sendMessage(player, "You can't use the potion here.") + return@onUseWith false + } + removeItem(player, used.asItem()) + playAudio(player, 2599) + animate(player, 827) + CastleWarsGameState.weakenBoulder(boulderKey) + sendMessage(player, "The explosion weakens the rocks!") + return@onUseWith true + } + + onUseWith(SCENERY, CastleWars.cwExplosivePotion, CastleWars.tunnelRocksWeakened) { player, used, with -> + val rocks = with.asScenery() + val boulderKey = CastleWarsGameState.getBoulderKey(rocks.location) + if (boulderKey == null) { + sendMessage(player, "You can't use the potion here.") + return@onUseWith false + } + removeItem(player, used.asItem()) + playAudio(player, 2599) + animate(player, 827) + CastleWarsGameState.clearBoulder(boulderKey) + sendMessage(player, "The explosion clears the rocks!") + return@onUseWith true + } + + onUseWith(SCENERY, CastleWars.cwExplosivePotion, CastleWars.caveWall) { player, used, with -> + val wall = with.asScenery() + val boulderKey = CastleWarsGameState.getNearestBoulderKey(wall.location) + if (boulderKey == null) { + sendMessage(player, "You can't use the potion here.") + return@onUseWith false + } + if (!CastleWarsGameState.isBoulderCleared(boulderKey)) { + sendMessage(player, "There are already rocks blocking the passage.") + return@onUseWith false + } + removeItem(player, used.asItem()) + playAudio(player, 2599) + animate(player, 827) + CastleWarsGameState.collapseBoulder(boulderKey) + sendMessage(player, "You collapse the tunnel!") + return@onUseWith true + } + + onUseWith(SCENERY, pickaxeIds, CastleWars.caveWall) { player, used, with -> + val wall = with.asScenery() + val boulderKey = CastleWarsGameState.getNearestBoulderKey(wall.location) + if (boulderKey == null) { + sendMessage(player, "You can't mine here.") + return@onUseWith false + } + if (!CastleWarsGameState.isBoulderCleared(boulderKey)) { + sendMessage(player, "There are already rocks blocking the passage.") + return@onUseWith true + } + player.pulseManager.run(CastleWarsCaveWallMiningPulse(player, wall, boulderKey)) + return@onUseWith true + } + + on(CastleWars.caveWall, IntType.SCENERY, "Collapse") { player, node -> + val wall = node.asScenery() + val boulderKey = CastleWarsGameState.getNearestBoulderKey(wall.location) + if (boulderKey == null) { + sendMessage(player, "You can't collapse anything here.") + return@on true + } + if (!CastleWarsGameState.isBoulderCleared(boulderKey)) { + sendMessage(player, "There are already rocks blocking the passage.") + return@on true + } + player.pulseManager.run(CastleWarsCaveWallMiningPulse(player, wall, boulderKey)) + return@on true + } + + on(CastleWars.allCatapultIds, IntType.SCENERY, "Operate") { player, node -> + val catapult = node.asScenery() + val catapultTeam = getCatapultTeam(catapult.location) + val playerTeam = CastleWarsGameState.getPlayerTeam(player) + if (playerTeam == null) { + sendMessage(player, "You need to be in a Castle Wars game to use this!") + return@on true + } + if (playerTeam != catapultTeam) { + sendMessage(player, "You can't use the enemy's catapult!") + return@on true + } + if (catapult.id in CastleWars.damagedCatapultIds) { + sendMessage(player, "The catapult is destroyed! Use a toolkit to repair it.") + return@on true + } + if (!CastleWarsGameState.isCatapultOperational(catapultTeam)) { + sendMessage(player, "The catapult is destroyed! Use a toolkit to repair it.") + return@on true + } + if (CastleWarsGameState.isCatapultOnFire(catapultTeam)) { + sendMessage(player, "The catapult is on fire! Use a bucket of water to extinguish it.") + return@on true + } + if (!inInventory(player, CastleWars.cwRock)) { + sendMessage(player, "You need rocks to fire the catapult!") + return@on true + } + setVarbit(player, CastleWarsGameState.VARBIT_CATAPULT_X, 15) + setVarbit(player, CastleWarsGameState.VARBIT_CATAPULT_Y, 15) + player.setAttribute("catapult:location", catapult.location) + player.interfaceManager.open(Component(Components.CASTLEWARS_CATAPULT_54)) + return@on true + } + + onUseWith(SCENERY, org.rs09.consts.Items.TINDERBOX_590, *CastleWars.allCatapultIds) { player, _, with -> + val catapult = with.asScenery() + val catapultTeam = getCatapultTeam(catapult.location) + val playerTeam = CastleWarsGameState.getPlayerTeam(player) ?: return@onUseWith false + if (playerTeam == catapultTeam) { + sendMessage(player, "You can't destroy your own catapult!") + return@onUseWith false + } + if (!CastleWarsGameState.isCatapultOperational(catapultTeam)) { + sendMessage(player, "The catapult is already destroyed.") + return@onUseWith false + } + if (CastleWarsGameState.isCatapultOnFire(catapultTeam)) { + sendMessage(player, "The catapult is already on fire!") + return@onUseWith false + } + CastleWarsGameState.setCatapultOnFire(catapultTeam) + playAudio(player, 2599) + sendMessage(player, "You set the catapult on fire!") + GameWorld.Pulser.submit(object : Pulse(50) { + override fun pulse(): Boolean { + if (CastleWarsGameState.isCatapultOnFire(catapultTeam) && CastleWarsGameState.isCatapultOperational( + catapultTeam + ) + ) { + CastleWarsGameState.extinguishCatapult(catapultTeam) + CastleWarsGameState.destroyCatapult(catapultTeam) + } + return true + } + }) + return@onUseWith true + } + + onUseWith(SCENERY, CastleWars.cwExplosivePotion, *CastleWars.allCatapultIds) { player, used, with -> + val catapult = with.asScenery() + val catapultTeam = getCatapultTeam(catapult.location) + val playerTeam = CastleWarsGameState.getPlayerTeam(player) ?: return@onUseWith false + if (playerTeam == catapultTeam) { + sendMessage(player, "You can't destroy your own catapult!") + return@onUseWith false + } + if (!CastleWarsGameState.isCatapultOperational(catapultTeam)) { + sendMessage(player, "The catapult is already destroyed.") + return@onUseWith false + } + CastleWarsGameState.destroyCatapult(catapultTeam) + removeItem(player, used.asItem()) + playAudio(player, 2599) + sendMessage(player, "The explosion destroys the catapult!") + return@onUseWith true + } + + onUseWith(SCENERY, Items.BUCKET_OF_WATER_1929, *CastleWars.allCatapultIds) { player, used, with -> + val catapult = with.asScenery() + val catapultTeam = getCatapultTeam(catapult.location) + val playerTeam = CastleWarsGameState.getPlayerTeam(player) ?: return@onUseWith false + if (playerTeam != catapultTeam) { + sendMessage(player, "You can't help the enemy's catapult!") + return@onUseWith false + } + if (!CastleWarsGameState.isCatapultOnFire(catapultTeam)) { + sendMessage(player, "The catapult is not on fire.") + return@onUseWith false + } + CastleWarsGameState.extinguishCatapult(catapultTeam) + replaceSlot(player, used.asItem().slot, Item(Items.BUCKET_1925)) + playAudio(player, 2401) + sendMessage(player, "You extinguish the fire on the catapult.") + return@onUseWith true + } + + onUseWith(SCENERY, CastleWars.cwToolkit, *CastleWars.allCatapultIds) { player, used, with -> + val catapult = with.asScenery() + val catapultTeam = getCatapultTeam(catapult.location) + val playerTeam = CastleWarsGameState.getPlayerTeam(player) ?: return@onUseWith false + if (playerTeam != catapultTeam) { + sendMessage(player, "You can't repair the enemy's catapult!") + return@onUseWith false + } + if (CastleWarsGameState.isCatapultOperational(catapultTeam)) { + sendMessage(player, "The catapult is already operational.") + return@onUseWith false + } + CastleWarsGameState.repairCatapult(catapultTeam) + sendMessage(player, "You repair the catapult.") + return@onUseWith true + } + + on(CastleWars.saradominTrapdoor, IntType.SCENERY, "Go-down") { player, node -> + val playerTeam = CastleWarsGameState.getPlayerTeam(player) + if (playerTeam != CastleWarsTeam.SARADOMIN) { + sendMessage(player, "Only the Saradomin team may use this trapdoor.") + return@on true + } + if (CastleWarsGameState.isCarryingAnyFlag(player)) { + sendMessage(player, "You cannot take the flag into your respawn room!") + return@on true + } + val destination = CastleWarsRespawnArea.saradominRespawnRoom.randomWalkableLoc + teleport(player, destination, TeleportManager.TeleportType.INSTANT) + return@on true + } + on(CastleWars.zamorakTrapdoor, IntType.SCENERY, "Go-down") { player, node -> + val playerTeam = CastleWarsGameState.getPlayerTeam(player) + if (playerTeam != CastleWarsTeam.ZAMORAK) { + sendMessage(player, "Only the Zamorak team may use this trapdoor.") + return@on true + } + if (CastleWarsGameState.isCarryingAnyFlag(player)) { + sendMessage(player, "You cannot take the flag into your respawn room!") + return@on true + } + val destination = CastleWarsRespawnArea.zamorakRespawnRoom.randomWalkableLoc + teleport(player, destination, TeleportManager.TeleportType.INSTANT) + return@on true + } + + on(CastleWars.stepsClimbUpIds, IntType.SCENERY, "Climb-up") { player, node -> + val scenery = node.asScenery() + val destination: Location = getStaircaseDestination(scenery, 1) + if (!CastleWarsGameState.isCarryingOwnFlag(player)) { + player.properties.teleportLocation = destination + return@on true + } + val playerTeam = CastleWarsGameState.getPlayerTeam(player) + if (playerTeam == null) { + player.properties.teleportLocation = destination + return@on true + } + val stairLoc = node.location + val inSaradominCastle = CastleWarsGameState.saradominCastleInterior.any { + it.insideBorder(stairLoc) + } + val inZamorakCastle = CastleWarsGameState.zamorakCastleInterior.any { + it.insideBorder(stairLoc) + } + val inEnemyCastle = when (playerTeam) { + CastleWarsTeam.SARADOMIN -> inZamorakCastle + CastleWarsTeam.ZAMORAK -> inSaradominCastle + } + if (inEnemyCastle) { + sendMessage(player, "You can't climb up while carrying the flag!") + return@on true + } + player.properties.teleportLocation = destination + return@on true + } + + on(CastleWars.staircaseClimbDownIds, IntType.SCENERY, "Climb-down") { player, node -> + val scenery = node.asScenery() + player.properties.teleportLocation = getStaircaseDestination(scenery, -1) + return@on true + } + } + + private fun getStaircaseDestination(scenery: Scenery, zDelta: Int): Location { + val targetPlaneLocation = scenery.location.transform(0, 0, zDelta) + val endScenery = RegionManager.getObject(targetPlaneLocation) + if (endScenery != null) { + val properDestination: Location? = ClimbActionHandler.getDestination(endScenery) + if (properDestination != null) { + return properDestination + } + } + return targetPlaneLocation + } + + private fun passEnergyBarrier( + player: Player, + barrier: Scenery + ) { // TODO: this is so weird that it might just as well be rewritten. doesnt work when occupying tiles with barriers. works otherwise + val currentBarrier = + RegionManager.getObject(barrier.location.z, barrier.location.x, barrier.location.y, barrier.id) + if (currentBarrier == null || !currentBarrier.isActive) { + sendMessage(player, "The barrier is not working properly.") + return + } + val barrierDataList = barrierCoordinates[barrier.id] + if (barrierDataList == null) { + log(this::class.java, Log.WARN, "Unknown barrier ID ${barrier.id} at ${barrier.location}") + sendMessage(player, "The barrier is not working properly.") + return + } + val barrierData = barrierDataList.find { it.location == barrier.location } + if (barrierData == null) { + log(this::class.java, Log.WARN, "Barrier ${barrier.id} at unknown location ${barrier.location}") + sendMessage(player, "The barrier is not working properly.") + return + } + val playerLoc = player.location + val onBarrierTile = playerLoc == barrierData.location + val (startTile, endTile) = if (onBarrierTile) { + Pair(barrierData.location, barrierData.outsideTile) + } else { + val distToOutside = playerLoc.getDistance(barrierData.outsideTile) + val distToInside = playerLoc.getDistance(barrierData.insideTile) + + if (distToOutside <= distToInside) { + Pair(barrierData.outsideTile, barrierData.insideTile) + } else { + Pair(barrierData.insideTile, barrierData.outsideTile) + } + } + if (playerLoc != startTile) { + player.walkingQueue.reset() + player.properties.teleportLocation = startTile + } + player.settings.runEnergy = + (player.settings.runEnergy - CastleWarsConstants.ENERGY_BARRIER_DRAIN).coerceAtLeast(0.0) + lock(player, 2) + AgilityHandler.forceWalk(player, -1, startTile, endTile, Animation.create(819), 12, 0.0, null) + } + + private fun getDoorTeam(location: Location): CastleWarsTeam { + return if (location.y < 3100) CastleWarsTeam.SARADOMIN else CastleWarsTeam.ZAMORAK + } + + private fun getCatapultTeam(location: Location): CastleWarsTeam { + return if (location.y < 3100) CastleWarsTeam.SARADOMIN else CastleWarsTeam.ZAMORAK + } + + private fun getOpenDoorVariant(closedId: Int): Int? = when (closedId) { + 4423 -> 4425 + 4424 -> 4426 + 4427 -> 4429 + 4428 -> 4430 + else -> null + } + + private fun getClosedDoorVariant(openId: Int): Int? = when (openId) { + 4425 -> 4423 + 4426 -> 4424 + 4429 -> 4427 + 4430 -> 4428 + else -> null + } + + private fun isPlayerOutsideFrontDoor(player: Player, door: Scenery, doorTeam: CastleWarsTeam): Boolean { + val playerY = player.location.y + val doorY = door.location.y + + return when (doorTeam) { + CastleWarsTeam.SARADOMIN -> playerY > doorY // Outside is north of door + CastleWarsTeam.ZAMORAK -> playerY < doorY // Outside is south of door + } + } + + private fun isPlayerOutsideCastle(player: Player, door: Scenery, castleTeam: CastleWarsTeam): Boolean { + val playerX = player.location.x + val doorX = door.location.x + return when (castleTeam) { + CastleWarsTeam.SARADOMIN -> playerX < doorX + CastleWarsTeam.ZAMORAK -> playerX >= doorX + } + } + + private fun openBackdoor(player: Player, door: Scenery) { + val openId = if (door.id == CastleWars.saradominBackdoor) CastleWars.saradominBackdoorOpen + else CastleWars.zamorakBackdoorOpen + val doorTeam = if (door.id == CastleWars.saradominBackdoor) CastleWarsTeam.SARADOMIN + else CastleWarsTeam.ZAMORAK + + CastleWarsGameState.setBackdoorLocked(doorTeam, false) + + DoorActionHandler.open(door, null, openId, -1, true, -1, false) + playAudio(player, Sounds.DOOR_OPEN_62) + } + + private fun isEnteringRespawnRoom(player: Player, barrier: Scenery): Boolean { + val barrierDataList = barrierCoordinates[barrier.id] ?: return false + val barrierData = barrierDataList.find { it.location == barrier.location } ?: return false + + if (player.location == barrierData.location) { + return false + } + + val distToOutside = player.location.getDistance(barrierData.outsideTile) + val distToInside = player.location.getDistance(barrierData.insideTile) + + return distToOutside <= distToInside + } + + private fun dropFlagOutsideBarrier(player: Player, barrier: Scenery) { + val barrierDataList = barrierCoordinates[barrier.id] ?: return + val barrierData = barrierDataList.find { it.location == barrier.location } ?: return + + CastleWarsGameState.dropFlagAtLocation(player, barrierData.outsideTile) } private fun hasNonCombatItems(container: Container): Boolean { @@ -163,17 +1018,12 @@ class CastleWarsListeners : InteractionListener { } private fun nonCombatItemsCheck(player: Player): String? { - // Coins & Noted items, possibly others? if (hasNonCombatItems(player.inventory)) return "You can't take non-combat items into the arena." return null } private fun familiarCheck(player: Player): String? { - // https://forum.tip.it/topic/250066-castle-wars-familiar-msg/ - // Your familiar can't hold any non-combat items - - // Check the player's familiar container for Coins or Noted items 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." @@ -182,7 +1032,8 @@ class CastleWarsListeners : InteractionListener { } private fun joinError(player: Player): Boolean { - val errorMessage = capeOrHelmetError(player) ?: nonCombatItemsCheck(player) ?: familiarCheck(player) ?: return false + val errorMessage = + capeOrHelmetError(player) ?: nonCombatItemsCheck(player) ?: familiarCheck(player) ?: return false player.sendMessage(errorMessage).also { return true } } } \ No newline at end of file diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsOverlay.kt b/Server/src/main/content/minigame/castlewars/CastleWarsOverlay.kt index 76a4a8a35..0b0c503c3 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsOverlay.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsOverlay.kt @@ -1,3 +1,5 @@ +package content.minigame.castlewars + import core.api.* import core.game.node.entity.player.Player @@ -7,16 +9,8 @@ import core.game.node.entity.player.Player */ object CastleWarsOverlay { @JvmStatic - fun sendLobbyUpdate(player: Player, bothTeamsHavePlayers: Boolean, gameStartMinutes: Int) { - setVarp(player, 380, if (bothTeamsHavePlayers) gameStartMinutes else 0) - } - - @JvmStatic - fun sendGameUpdate(player: Player) { - // Todo - Figure out underground mine/etc - setVarbit(player, 143, 0) // Flag status - safe = 0, taken = 1, dropped = 2 - setVarbit(player, 145, 5) // Saradomin's score - setVarbit(player, 153, 0) // Flag status - safe = 0, taken = 1, dropped = 2 - setVarbit(player, 155, 7) // Zamorak's score + 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) } } diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsShutdownHandler.kt b/Server/src/main/content/minigame/castlewars/CastleWarsShutdownHandler.kt new file mode 100644 index 000000000..50093ed48 --- /dev/null +++ b/Server/src/main/content/minigame/castlewars/CastleWarsShutdownHandler.kt @@ -0,0 +1,78 @@ +package content.minigame.castlewars + +import core.api.ShutdownListener +import core.game.node.entity.player.Player +import core.game.world.map.Location +import core.game.world.map.RegionManager +import content.minigame.castlewars.areas.CastleWarsGameArea +import content.minigame.castlewars.areas.CastleWarsRespawnArea +import content.minigame.castlewars.areas.CastleWarsWaitingArea + +/** + * Handles Castle Wars cleanup when the server shuts down. + * Ensures all players in any Castle Wars area are properly removed + * so they don't end up in a zombie match state when they log back in. + */ +class CastleWarsShutdownHandler : ShutdownListener { + + override fun shutdown() { + val allTrackedPlayers = mutableSetOf() + allTrackedPlayers += CastleWarsGameArea.saradominPlayers + allTrackedPlayers += CastleWarsGameArea.zamorakPlayers + allTrackedPlayers += CastleWarsWaitingArea.waitingSaradominPlayers + allTrackedPlayers += CastleWarsWaitingArea.waitingZamorakPlayers + + val respawnBorders = arrayOf( + CastleWarsRespawnArea.saradominRespawnRoom, + CastleWarsRespawnArea.zamorakRespawnRoom + ) + for (border in respawnBorders) { + val centerX = (border.southWestX + border.northEastX) / 2 + val centerY = (border.southWestY + border.northEastY) / 2 + val centerLocation = Location.create(centerX, centerY, border.plane) + for (player in RegionManager.getLocalPlayers(centerLocation, 20)) { + if (border.insideBorder(player.location)) { + allTrackedPlayers.add(player) + } + } + } + + for (border in CastleWarsGameArea.areaBorders) { + val centerX = (border.southWestX + border.northEastX) / 2 + val centerY = (border.southWestY + border.northEastY) / 2 + val plane = border.plane + val radius = maxOf( + kotlin.math.abs(border.northEastX - border.southWestX), + kotlin.math.abs(border.northEastY - border.southWestY) + ) / 2 + 10 + val centerLocation = Location.create(centerX, centerY, plane) + for (player in RegionManager.getLocalPlayers(centerLocation, radius)) { + if (border.insideBorder(player.location)) { + allTrackedPlayers.add(player) + } + } + } + + for (player in allTrackedPlayers) { + if (CastleWarsGameState.isCarryingFlag(player)) { + CastleWarsGameState.dropFlag(player) + } else if (CastleWarsGameState.isCarryingOwnFlag(player)) { + CastleWarsGameState.dropOwnFlag(player) + } + CastleWarsGameState.restoreAndCleanupPlayer(player) + player.location = CastleWars.lobbyBankArea.randomWalkableLoc + } + + CastleWarsItemHandler.clearAllBarricades() + + CastleWarsGameState.resetGame() + + CastleWarsGameArea.saradominPlayers.clear() + CastleWarsGameArea.zamorakPlayers.clear() + CastleWarsGameArea.loggedOutPlayers.clear() + CastleWarsWaitingArea.waitingSaradominPlayers.clear() + CastleWarsWaitingArea.waitingZamorakPlayers.clear() + + CastleWarsGameArea.ticksLeftInGame = 0 + } +} diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsTunnelMiningPulse.kt b/Server/src/main/content/minigame/castlewars/CastleWarsTunnelMiningPulse.kt new file mode 100644 index 000000000..09a63b19a --- /dev/null +++ b/Server/src/main/content/minigame/castlewars/CastleWarsTunnelMiningPulse.kt @@ -0,0 +1,200 @@ +package content.minigame.castlewars + +import content.data.skill.SkillingTool +import core.api.* +import core.game.node.entity.player.Player +import core.game.node.entity.skill.Skills +import core.game.node.scenery.Scenery +import core.game.system.task.Pulse +import core.game.world.update.flag.context.Animation + +/** + * Mining pulse for Castle Wars tunnel rocks. + * + * Uses coal ore difficulty parameters: + * - Level requirement: None (authentic Castle Wars behavior) + * - Rate: 0.40 (coal ore rate - difficulty factor for success chance) + * + * This pulse replaces the instant 3-tick delay with proper mining mechanics + * where players must successfully "hit" the rock multiple times to clear it. + */ +class CastleWarsTunnelMiningPulse( // TODO: tunnels in general are working great. but this... needs a thorough check from start to finish + private val player: Player, + private val rocks: Scenery, + private val boulderKey: String, + private val isWeakened: Boolean +) : Pulse(1, player, rocks) { + + companion object { + + private const val REQUIRED_LEVEL = 1 + private const val RATE = 0.40 + private const val MINING_ANIMATION = 625 + + private const val EXPERIENCE = 0.0 + } + + private var ticks = 0 + private var started = false + + override fun start() { + if (!checkRequirements()) { + return + } + super.start() + started = true + sendMessage(player, "You swing your pickaxe at the rocks...") + } + + override fun pulse(): Boolean { + if (!started) return true + + if (!checkRequirements()) { + return true + } + + animate() + return checkReward() + } + + override fun stop() { + if (started) { + animate(player, Animation(-1)) + } + super.stop() + } + + private fun checkRequirements(): Boolean { + val pickaxe = SkillingTool.getPickaxe(player) + if (pickaxe == null) { + sendMessage(player, "You need a pickaxe to mine these rocks.") + return false + } + if (!rocks.isActive) { + return false + } + + return true + } + + private fun animate() { + val pickaxe = SkillingTool.getPickaxe(player) ?: return + animate(player, pickaxe.animation) + } + + private fun checkReward(): Boolean { + ticks++ + if (ticks % 4 != 0) { + return false + } + val pickaxe = SkillingTool.getPickaxe(player) ?: return true + val level = 1 + getDynLevel(player, Skills.MINING) + getFamiliarBoost(player, Skills.MINING) + val hostRatio = Math.random() * (100.0 * RATE) + val toolRatio = pickaxe.ratio + val clientRatio = Math.random() * ((level - REQUIRED_LEVEL) * (1.0 + toolRatio)) + + if (hostRatio < clientRatio) { + clearRocks() + return true + } + return false + } + + private fun clearRocks() { + rewardXP(player, Skills.MINING, EXPERIENCE) + + if (isWeakened) { + CastleWarsGameState.clearBoulder(boulderKey) + sendMessage(player, "You manage to clear the rocks.") + } else { + CastleWarsGameState.weakenBoulder(boulderKey) + sendMessage(player, "You manage to weaken the rocks.") + } + } +} + +class CastleWarsCaveWallMiningPulse( + private val player: Player, private val caveWall: Scenery, private val boulderKey: String +) : Pulse(1, player, caveWall) { + + companion object { + + private const val REQUIRED_LEVEL = 1 + private const val RATE = 0.40 + private const val EXPERIENCE = 0.0 + } + + private var ticks = 0 + private var started = false + + override fun start() { + if (!checkRequirements()) { + return + } + super.start() + started = true + sendMessage(player, "You swing your pickaxe at the cave wall...") + } + + override fun pulse(): Boolean { + if (!started) return true + + if (!checkRequirements()) { + return true + } + if (!CastleWarsGameState.isBoulderCleared(boulderKey)) { + sendMessage(player, "The passage has already collapsed.") + return true + } + + animate() + return checkReward() + } + + override fun stop() { + if (started) { + animate(player, Animation(-1)) + } + super.stop() + } + + private fun checkRequirements(): Boolean { + val pickaxe = SkillingTool.getPickaxe(player) + if (pickaxe == null) { + sendMessage(player, "You need a pickaxe to mine the cave wall.") + return false + } + + return true + } + + private fun animate() { + val pickaxe = SkillingTool.getPickaxe(player) ?: return + animate(player, pickaxe.animation) + } + + private fun checkReward(): Boolean { + ticks++ + if (ticks % 4 != 0) { + return false + } + val pickaxe = SkillingTool.getPickaxe(player) ?: return true + val level = 1 + getDynLevel(player, Skills.MINING) + getFamiliarBoost(player, Skills.MINING) + val hostRatio = Math.random() * (100.0 * RATE) + val toolRatio = pickaxe.ratio + val clientRatio = Math.random() * ((level - REQUIRED_LEVEL) * (1.0 + toolRatio)) + + if (hostRatio < clientRatio) { + collapsePassage() + return true + } + return false + } + + private fun collapsePassage() { + rewardXP(player, Skills.MINING, EXPERIENCE) + CastleWarsGameState.collapseBoulder(boulderKey) + + sendMessage(player, "You collapse the tunnel!") + } +} diff --git a/Server/src/main/content/minigame/castlewars/CastlewarsBook.kt b/Server/src/main/content/minigame/castlewars/CastlewarsBook.kt index 8395ece6a..139e05db1 100644 --- a/Server/src/main/content/minigame/castlewars/CastlewarsBook.kt +++ b/Server/src/main/content/minigame/castlewars/CastlewarsBook.kt @@ -1,25 +1,18 @@ package content.minigame.castlewars -import content.data.Quests import content.global.handlers.iface.BookInterface import content.global.handlers.iface.BookLine import content.global.handlers.iface.Page import content.global.handlers.iface.PageSet -import core.api.getAttribute import core.game.interaction.IntType import core.game.interaction.InteractionListener import core.game.node.entity.player.Player -import core.game.system.command.sets.ModelViewerCommandSet.Companion.ATTRIBUTE_MODEL_NUMBER -import core.game.system.command.sets.ModelViewerCommandSet.Companion.ATTRIBUTE_PITCH -import core.game.system.command.sets.ModelViewerCommandSet.Companion.ATTRIBUTE_YAW -import core.game.system.command.sets.ModelViewerCommandSet.Companion.ATTRIBUTE_ZOOM -import core.game.system.command.sets.ModelViewerCommandSet.Companion.DEF_BOOK -import core.game.world.GameWorld import org.rs09.consts.Items class CastlewarsBook : InteractionListener { companion object { + private val TITLE = "Castle Wars Manual" private val CONTENTS = arrayOf( PageSet( @@ -30,8 +23,7 @@ class CastlewarsBook : InteractionListener { BookLine("their team standard. Then", 41), BookLine("bring that back and capture", 42), BookLine("it on your teams standard. ", 43), - ), - Page( + ), Page( BookLine("Toolkit:", 58), BookLine("This useful item allows you", 59), BookLine("to repair broken doors and", 60), @@ -50,8 +42,7 @@ class CastlewarsBook : InteractionListener { BookLine("of your running energy.", 46), BookLine("You can also use them to", 47), BookLine("heal fellow players.", 48), - ), - Page( + ), Page( BookLine("Explosive Potion:", 58), BookLine("A simple but effective item,", 59), BookLine("use it to blow up your", 60), @@ -72,8 +63,7 @@ class CastlewarsBook : InteractionListener { BookLine("your castle. Each team can", 47), BookLine("only have 10 built at any one", 48), BookLine("time.", 49), - ), - Page( + ), Page( BookLine("Bucket:", 58), BookLine("Fill a bucket with water and", 59), BookLine("you can use it to put out a", 60), @@ -90,8 +80,7 @@ class CastlewarsBook : InteractionListener { BookLine("set light to your opponents", 46), BookLine("You can also use them to", 47), BookLine("catapult and barricades.", 48), - ), - Page( + ), Page( BookLine("Pickaxe:", 58), BookLine("Use a pickaxe to mine your", 59), BookLine("way through the tunnels", 60), @@ -111,8 +100,7 @@ class CastlewarsBook : InteractionListener { BookLine("coordinates and let the rock", 47), BookLine("fly, just be careful not to hit", 48), BookLine("your team with it!", 49), - ), - Page( + ), Page( BookLine("Rock:", 58), BookLine("Used as ammo for the", 59), BookLine("catapult, and not much else.", 60), @@ -121,18 +109,109 @@ class CastlewarsBook : InteractionListener { ) ), ) - private fun display(player: Player, pageNum: Int, buttonID: Int) : Boolean { + + private fun display(player: Player, pageNum: Int, buttonID: Int): Boolean { BookInterface.pageSetup(player, BookInterface.FANCY_BOOK_2_27, TITLE, CONTENTS) - BookInterface.clearModelsOnPage(player, BookInterface.FANCY_BOOK_2_27); - BookInterface.setItemOnPage(player, 0, Items.TOOLKIT_4051, BookInterface.FANCY_BOOK_2_27, BookInterface.FANCY_BOOK_2_27_IMAGE_ENABLE_DRAW_IDS[17], BookInterface.FANCY_BOOK_2_27_IMAGE_DRAW_IDS[17], 768, 192, 1792) - BookInterface.setItemOnPage(player, 1, Items.BANDAGES_4049, BookInterface.FANCY_BOOK_2_27, BookInterface.FANCY_BOOK_2_27_IMAGE_ENABLE_DRAW_IDS[2], BookInterface.FANCY_BOOK_2_27_IMAGE_DRAW_IDS[2], 768, 192, 1792) - BookInterface.setItemOnPage(player, 1, Items.EXPLOSIVE_POTION_4045, BookInterface.FANCY_BOOK_2_27, BookInterface.FANCY_BOOK_2_27_IMAGE_ENABLE_DRAW_IDS[17], BookInterface.FANCY_BOOK_2_27_IMAGE_DRAW_IDS[17], 768, 192, 1792) - BookInterface.setItemOnPage(player, 2, Items.BARRICADE_4053, BookInterface.FANCY_BOOK_2_27, BookInterface.FANCY_BOOK_2_27_IMAGE_ENABLE_DRAW_IDS[2], BookInterface.FANCY_BOOK_2_27_IMAGE_DRAW_IDS[2], 768, 192, 1792) - BookInterface.setItemOnPage(player, 2, Items.BUCKET_OF_WATER_1929, BookInterface.FANCY_BOOK_2_27, BookInterface.FANCY_BOOK_2_27_IMAGE_ENABLE_DRAW_IDS[17], BookInterface.FANCY_BOOK_2_27_IMAGE_DRAW_IDS[17], 768, 192, 1792) - BookInterface.setItemOnPage(player, 3, Items.TINDERBOX_590, BookInterface.FANCY_BOOK_2_27, BookInterface.FANCY_BOOK_2_27_IMAGE_ENABLE_DRAW_IDS[2], BookInterface.FANCY_BOOK_2_27_IMAGE_DRAW_IDS[2], 768, 192, 1792) - BookInterface.setItemOnPage(player, 3, Items.BRONZE_PICKAXE_1265, BookInterface.FANCY_BOOK_2_27, BookInterface.FANCY_BOOK_2_27_IMAGE_ENABLE_DRAW_IDS[17], BookInterface.FANCY_BOOK_2_27_IMAGE_DRAW_IDS[17], 768, 192, 1792) - BookInterface.setModelOnPage(player, 4, 38202, BookInterface.FANCY_BOOK_2_27, BookInterface.FANCY_BOOK_2_27_IMAGE_ENABLE_DRAW_IDS[4], BookInterface.FANCY_BOOK_2_27_IMAGE_DRAW_IDS[4], 4048, 192, 768) - BookInterface.setItemOnPage(player, 4, Items.ROCK_4043, BookInterface.FANCY_BOOK_2_27, BookInterface.FANCY_BOOK_2_27_IMAGE_ENABLE_DRAW_IDS[17], BookInterface.FANCY_BOOK_2_27_IMAGE_DRAW_IDS[17], 768, 192, 1792) + BookInterface.clearModelsOnPage(player, BookInterface.FANCY_BOOK_2_27) + BookInterface.setItemOnPage( + player, + 0, + Items.TOOLKIT_4051, + BookInterface.FANCY_BOOK_2_27, + BookInterface.FANCY_BOOK_2_27_IMAGE_ENABLE_DRAW_IDS[17], + BookInterface.FANCY_BOOK_2_27_IMAGE_DRAW_IDS[17], + 768, + 192, + 1792 + ) + BookInterface.setItemOnPage( + player, + 1, + Items.BANDAGES_4049, + BookInterface.FANCY_BOOK_2_27, + BookInterface.FANCY_BOOK_2_27_IMAGE_ENABLE_DRAW_IDS[2], + BookInterface.FANCY_BOOK_2_27_IMAGE_DRAW_IDS[2], + 768, + 192, + 1792 + ) + BookInterface.setItemOnPage( + player, + 1, + Items.EXPLOSIVE_POTION_4045, + BookInterface.FANCY_BOOK_2_27, + BookInterface.FANCY_BOOK_2_27_IMAGE_ENABLE_DRAW_IDS[17], + BookInterface.FANCY_BOOK_2_27_IMAGE_DRAW_IDS[17], + 768, + 192, + 1792 + ) + BookInterface.setItemOnPage( + player, + 2, + Items.BARRICADE_4053, + BookInterface.FANCY_BOOK_2_27, + BookInterface.FANCY_BOOK_2_27_IMAGE_ENABLE_DRAW_IDS[2], + BookInterface.FANCY_BOOK_2_27_IMAGE_DRAW_IDS[2], + 768, + 192, + 1792 + ) + BookInterface.setItemOnPage( + player, + 2, + Items.BUCKET_OF_WATER_1929, + BookInterface.FANCY_BOOK_2_27, + BookInterface.FANCY_BOOK_2_27_IMAGE_ENABLE_DRAW_IDS[17], + BookInterface.FANCY_BOOK_2_27_IMAGE_DRAW_IDS[17], + 768, + 192, + 1792 + ) + BookInterface.setItemOnPage( + player, + 3, + Items.TINDERBOX_590, + BookInterface.FANCY_BOOK_2_27, + BookInterface.FANCY_BOOK_2_27_IMAGE_ENABLE_DRAW_IDS[2], + BookInterface.FANCY_BOOK_2_27_IMAGE_DRAW_IDS[2], + 768, + 192, + 1792 + ) + BookInterface.setItemOnPage( + player, + 3, + Items.BRONZE_PICKAXE_1265, + BookInterface.FANCY_BOOK_2_27, + BookInterface.FANCY_BOOK_2_27_IMAGE_ENABLE_DRAW_IDS[17], + BookInterface.FANCY_BOOK_2_27_IMAGE_DRAW_IDS[17], + 768, + 192, + 1792 + ) + BookInterface.setModelOnPage( + player, + 4, + 38202, + BookInterface.FANCY_BOOK_2_27, + BookInterface.FANCY_BOOK_2_27_IMAGE_ENABLE_DRAW_IDS[4], + BookInterface.FANCY_BOOK_2_27_IMAGE_DRAW_IDS[4], + 4048, + 192, + 768 + ) + BookInterface.setItemOnPage( + player, + 4, + Items.ROCK_4043, + BookInterface.FANCY_BOOK_2_27, + BookInterface.FANCY_BOOK_2_27_IMAGE_ENABLE_DRAW_IDS[17], + BookInterface.FANCY_BOOK_2_27_IMAGE_DRAW_IDS[17], + 768, + 192, + 1792 + ) return true } } diff --git a/Server/src/main/content/minigame/castlewars/CatapultConfig.kt b/Server/src/main/content/minigame/castlewars/CatapultConfig.kt new file mode 100644 index 000000000..79e39cb85 --- /dev/null +++ b/Server/src/main/content/minigame/castlewars/CatapultConfig.kt @@ -0,0 +1,126 @@ +package content.minigame.castlewars + +import core.game.world.map.Location +import org.rs09.consts.Components + +/** + * Configuration constants for the Castle Wars catapult interface. + * All magic numbers and hardcoded values are centralized here. + */ +object CatapultConfig { // TODO: this is definitely fucked somehow + + /** Interface ID for the catapult targeting interface */ + const val INTERFACE_ID = Components.CASTLEWARS_CATAPULT_54 + + /** Child ID for the red X marker on the map */ + const val MARKER_CHILD_ID = 55 + + /** Enum ID for digit models (maps 0-9 to model IDs) */ + const val DIGIT_MODEL_ENUM = 1595 + + const val BUTTON_FIRE = 51 + const val BUTTON_CLOSE = 56 + const val BUTTON_UP = 65 + const val BUTTON_DOWN = 66 + const val BUTTON_RIGHT = 67 + const val BUTTON_LEFT = 68 + + val DIGIT_CHILD_IDS = intArrayOf(69, 70, 71, 72) + + const val MAP_BASE_X = 12 + const val MAP_BASE_Y = 20 + const val MAP_WIDTH = 485 + const val MAP_HEIGHT = 297 + + const val MAX_COORDINATE = CastleWarsConstants.CATAPULT_MAX_COORDINATE + const val DEFAULT_COORDINATE = CastleWarsConstants.CATAPULT_DEFAULT_COORDINATE + const val CENTER_OFFSET = 15 + + val SARADOMIN_BASE = Location.create(2426, 3073, 0) + val ZAMORAK_BASE = Location.create(2373, 3134, 0) + + const val SOUND_FIRE = 1440 + const val SOUND_IMPACT = 851 + + const val GFX_BOULDER_PROJECTILE = 857 + const val GFX_IMPACT = 406 + + const val PROJECTILE_START_HEIGHT = 80 + const val PROJECTILE_END_HEIGHT = 0 + const val PROJECTILE_DELAY = 0 + const val PROJECTILE_BASE_SPEED = 60 + const val PROJECTILE_SPEED_PER_TILE = 5 + const val PROJECTILE_ANGLE = 25 + + const val IMPACT_RADIUS = CastleWarsConstants.CATAPULT_IMPACT_RADIUS + const val MIN_DAMAGE = CastleWarsConstants.CATAPULT_MIN_DAMAGE + const val MAX_DAMAGE = CastleWarsConstants.CATAPULT_MAX_DAMAGE + const val INACCURACY_RADIUS = CastleWarsConstants.CATAPULT_INACCURACY_RADIUS + + const val ATTR_LOCATION = "catapult:location" +} + +/** + * Enum representing the buttons on the catapult interface. + */ +enum class CatapultButton(val buttonId: Int, val deltaX: Int, val deltaY: Int) { + LEFT(CatapultConfig.BUTTON_LEFT, -1, 0), + RIGHT(CatapultConfig.BUTTON_RIGHT, 1, 0), + UP(CatapultConfig.BUTTON_UP, 0, 1), + DOWN(CatapultConfig.BUTTON_DOWN, 0, -1), + FIRE(CatapultConfig.BUTTON_FIRE, 0, 0), + CLOSE(CatapultConfig.BUTTON_CLOSE, 0, 0), + UNKNOWN(-1, 0, 0); + + companion object { + fun fromId(id: Int): CatapultButton = values().find { it.buttonId == id } ?: UNKNOWN + } +} + +/** + * Enum representing team-specific catapult configurations. + * Handles coordinate transformations based on team orientation. + */ +enum class CatapultTeam(val baseLocation: Location) { + SARADOMIN(CatapultConfig.SARADOMIN_BASE) { + override fun calculateTargetX(catapultX: Int, interfaceX: Int): Int { + return catapultX + (interfaceX - CatapultConfig.CENTER_OFFSET) + } + + override fun calculateTargetY(catapultY: Int, interfaceY: Int): Int { + return catapultY + interfaceY + } + }, + + ZAMORAK(CatapultConfig.ZAMORAK_BASE) { + override fun calculateTargetX(catapultX: Int, interfaceX: Int): Int { + return catapultX - (interfaceX - CatapultConfig.CENTER_OFFSET) + } + + override fun calculateTargetY(catapultY: Int, interfaceY: Int): Int { + return catapultY - interfaceY + } + }; + + abstract fun calculateTargetX(catapultX: Int, interfaceX: Int): Int + abstract fun calculateTargetY(catapultY: Int, interfaceY: Int): Int + + companion object { + fun fromCastleWarsTeam(team: CastleWarsTeam): CatapultTeam = when (team) { + CastleWarsTeam.SARADOMIN -> SARADOMIN + CastleWarsTeam.ZAMORAK -> ZAMORAK + } + } +} + +/** + * Data class representing X/Y coordinates on the catapult interface. + */ +data class Coordinate(val x: Int, val y: Int) { + fun clamp(): Coordinate = Coordinate( + x.coerceIn(0, CatapultConfig.MAX_COORDINATE), + y.coerceIn(0, CatapultConfig.MAX_COORDINATE) + ) + + fun withDelta(deltaX: Int, deltaY: Int): Coordinate = Coordinate(x + deltaX, y + deltaY) +} diff --git a/Server/src/main/content/minigame/castlewars/CatapultFiringHandler.kt b/Server/src/main/content/minigame/castlewars/CatapultFiringHandler.kt new file mode 100644 index 000000000..5b09d65e7 --- /dev/null +++ b/Server/src/main/content/minigame/castlewars/CatapultFiringHandler.kt @@ -0,0 +1,116 @@ +package content.minigame.castlewars + +import content.minigame.castlewars.CatapultConfig.GFX_BOULDER_PROJECTILE +import content.minigame.castlewars.CatapultConfig.GFX_IMPACT +import content.minigame.castlewars.CatapultConfig.IMPACT_RADIUS +import content.minigame.castlewars.CatapultConfig.INACCURACY_RADIUS +import content.minigame.castlewars.CatapultConfig.MAX_DAMAGE +import content.minigame.castlewars.CatapultConfig.MIN_DAMAGE +import content.minigame.castlewars.CatapultConfig.PROJECTILE_ANGLE +import content.minigame.castlewars.CatapultConfig.PROJECTILE_BASE_SPEED +import content.minigame.castlewars.CatapultConfig.PROJECTILE_DELAY +import content.minigame.castlewars.CatapultConfig.PROJECTILE_END_HEIGHT +import content.minigame.castlewars.CatapultConfig.PROJECTILE_SPEED_PER_TILE +import content.minigame.castlewars.CatapultConfig.PROJECTILE_START_HEIGHT +import content.minigame.castlewars.CatapultConfig.SOUND_FIRE +import content.minigame.castlewars.CatapultConfig.SOUND_IMPACT +import core.api.* +import core.game.node.entity.combat.ImpactHandler +import core.game.node.entity.impl.Projectile +import core.game.node.entity.player.Player +import core.game.system.task.Pulse +import core.game.world.map.Location +import core.game.world.map.RegionManager +import core.tools.RandomFunction + +/** + * Handles firing the catapult and dealing impact damage. + */ +class CatapultFiringHandler { // TODO: i dont think this even works.. at all even + + /** + * Fires the catapult at the specified coordinates. + * @param player The player firing the catapult + * @param coord The interface coordinates to fire at + * @param team The catapult team configuration + * @param catapultLocation The actual world location of the catapult + */ + fun fire(player: Player, coord: Coordinate, team: CatapultTeam, catapultLocation: Location) { + val targetLocation = calculateTargetLocation(team, catapultLocation, coord) + val finalLocation = applyInaccuracy(targetLocation) + + val distance = catapultLocation.getDistance(finalLocation).toInt() + val speed = PROJECTILE_BASE_SPEED + (distance * PROJECTILE_SPEED_PER_TILE) + val impactDelayTicks = (speed / 30).coerceAtLeast(1) + + sendProjectile(catapultLocation, finalLocation) + playFireEffects(player, finalLocation, impactDelayTicks) + scheduleImpact(player, finalLocation, impactDelayTicks) + + sendMessage(player, "You fire the catapult!") + } + + private fun calculateTargetLocation(team: CatapultTeam, catapultLocation: Location, coord: Coordinate): Location { + val targetX = team.calculateTargetX(catapultLocation.x, coord.x) + val targetY = team.calculateTargetY(catapultLocation.y, coord.y) + return Location.create(targetX, targetY, 0) + } + + private fun applyInaccuracy(target: Location): Location { + val finalX = target.x + RandomFunction.random(-INACCURACY_RADIUS, INACCURACY_RADIUS) + val finalY = target.y + RandomFunction.random(-INACCURACY_RADIUS, INACCURACY_RADIUS) + return Location.create(finalX, finalY, 0) + } + + private fun sendProjectile(from: Location, to: Location) { + val distance = from.getDistance(to).toInt() + val speed = PROJECTILE_BASE_SPEED + (distance * PROJECTILE_SPEED_PER_TILE) + + Projectile.create( + from, + to, + GFX_BOULDER_PROJECTILE, + PROJECTILE_START_HEIGHT, + PROJECTILE_END_HEIGHT, + PROJECTILE_DELAY, + speed, + PROJECTILE_ANGLE, + distance + ).send() + } + + private fun playFireEffects(player: Player, targetLocation: Location, impactDelayTicks: Int) { + playAudio(player, SOUND_FIRE) + + submitWorldPulse(object : Pulse(impactDelayTicks) { + override fun pulse(): Boolean { + sendGraphics(GFX_IMPACT, targetLocation) + playGlobalAudio(targetLocation, SOUND_IMPACT) + return true + } + }) + } + + private fun scheduleImpact(player: Player, targetLocation: Location, impactDelayTicks: Int) { + submitWorldPulse(object : Pulse(impactDelayTicks) { + override fun pulse(): Boolean { + dealImpactDamage(player, targetLocation) + return true + } + }) + } + + private fun dealImpactDamage(attacker: Player, targetLocation: Location) { + RegionManager.getLocalPlayers(targetLocation, IMPACT_RADIUS) + .filter { isValidTarget(it, attacker) } + .forEach { target -> + val damage = RandomFunction.random(MIN_DAMAGE, MAX_DAMAGE) + target.impactHandler.manualHit(attacker, damage, ImpactHandler.HitsplatType.NORMAL) + sendMessage(target, "You are hit by a catapult rock!") + } + } + + private fun isValidTarget(target: Player, attacker: Player): Boolean { + return CastleWarsGameState.getPlayerTeam(target) != null + } +} diff --git a/Server/src/main/content/minigame/castlewars/CatapultInterfaceRenderer.kt b/Server/src/main/content/minigame/castlewars/CatapultInterfaceRenderer.kt new file mode 100644 index 000000000..d2647acfa --- /dev/null +++ b/Server/src/main/content/minigame/castlewars/CatapultInterfaceRenderer.kt @@ -0,0 +1,97 @@ +package content.minigame.castlewars + +import content.minigame.castlewars.CatapultConfig.DEFAULT_COORDINATE +import content.minigame.castlewars.CatapultConfig.DIGIT_CHILD_IDS +import content.minigame.castlewars.CatapultConfig.DIGIT_MODEL_ENUM +import content.minigame.castlewars.CatapultConfig.INTERFACE_ID +import content.minigame.castlewars.CatapultConfig.MAP_BASE_X +import content.minigame.castlewars.CatapultConfig.MAP_BASE_Y +import content.minigame.castlewars.CatapultConfig.MAP_HEIGHT +import content.minigame.castlewars.CatapultConfig.MAP_WIDTH +import content.minigame.castlewars.CatapultConfig.MARKER_CHILD_ID +import content.minigame.castlewars.CatapultConfig.MAX_COORDINATE +import core.cache.def.impl.DataMap +import core.game.node.entity.player.Player +import core.net.packet.PacketRepository +import core.net.packet.context.ChildPositionContext +import core.net.packet.context.DisplayModelContext +import core.net.packet.out.DisplayModel +import core.net.packet.out.RepositionChild + +/** + * Handles rendering the catapult interface including marker position and digit displays. + */ +class CatapultInterfaceRenderer { // TODO: this is getting very close to 100%. still fucked but on the right track for sure + + /** + * Updates the position of the red X marker on the interface map. + * @param player The player whose interface to update + * @param coord The current X/Y coordinates + */ + fun updateMarkerPosition(player: Player, coord: Coordinate) { + val screenX = calculateScreenX(coord.x) + val screenY = calculateScreenY(coord.y) + + PacketRepository.send( + RepositionChild::class.java, + ChildPositionContext(player, INTERFACE_ID, MARKER_CHILD_ID, screenX, screenY) + ) + } + + /** + * Updates the digit displays showing the current X and Y coordinates. + * @param player The player whose interface to update + * @param coord The current X/Y coordinates + */ + fun updateDigitDisplay(player: Player, coord: Coordinate) { + val xTens = coord.x / 10 + val xOnes = coord.x % 10 + val yTens = coord.y / 10 + val yOnes = coord.y % 10 + + sendDigitModel(player, DIGIT_CHILD_IDS[0], xTens) + sendDigitModel(player, DIGIT_CHILD_IDS[1], xOnes) + sendDigitModel(player, DIGIT_CHILD_IDS[2], yTens) + sendDigitModel(player, DIGIT_CHILD_IDS[3], yOnes) + } + + /** + * Resets the interface to default coordinates. + * @param player The player whose interface to reset + */ + fun resetToDefaults(player: Player) { + val defaultCoord = Coordinate(DEFAULT_COORDINATE, DEFAULT_COORDINATE) + updateMarkerPosition(player, defaultCoord) + updateDigitDisplay(player, defaultCoord) + } + + private fun calculateScreenX(coordX: Int): Int { + val pixelsPerUnit = MAP_WIDTH / MAX_COORDINATE + return MAP_BASE_X + (coordX * pixelsPerUnit) + } + + private fun calculateScreenY(coordY: Int): Int { + val pixelsPerUnit = MAP_HEIGHT / MAX_COORDINATE + // Y is inverted - 0 is at the bottom, MAX is at the top + return MAP_BASE_Y + ((MAX_COORDINATE - coordY) * pixelsPerUnit) + } + + private fun sendDigitModel(player: Player, childId: Int, digit: Int) { + val modelId = getDigitModelId(digit) + PacketRepository.send( + DisplayModel::class.java, + DisplayModelContext( + player, + DisplayModelContext.ModelType.MODEL, + modelId, + 0, + INTERFACE_ID, + childId + ) + ) + } + + private fun getDigitModelId(digit: Int): Int { + return DataMap.get(DIGIT_MODEL_ENUM).getInt(digit) + } +} diff --git a/Server/src/main/content/minigame/castlewars/Lanthus.kt b/Server/src/main/content/minigame/castlewars/Lanthus.kt index 301e178ad..701d04785 100644 --- a/Server/src/main/content/minigame/castlewars/Lanthus.kt +++ b/Server/src/main/content/minigame/castlewars/Lanthus.kt @@ -1,15 +1,19 @@ -package rs09.game.content.activity.castlewars +package content.minigame.castlewars +import content.minigame.castlewars.areas.CastleWarsGameArea +import content.minigame.castlewars.areas.CastleWarsWaitingArea import core.api.TickListener +import core.api.findNPC import core.api.openDialogue +import core.api.sendChat import core.api.sendMessage import core.game.dialogue.DialogueFile import core.game.dialogue.FacialExpression import core.game.interaction.IntType import core.game.interaction.InteractionListener -import core.game.node.entity.npc.NPC 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 @@ -96,7 +100,7 @@ class Lanthus: InteractionListener, TickListener { 90 -> npcl( FacialExpression.FRIENDLY, - "Players on the winning team will receive 2 Castle Wars Tickets which you can trade back to me for other items. In the event of a draw every player will get 1 ticket." + "Players on the winning team will receive 2 Castle Wars Tickets which you can trade back to me for other items. In the event of a draw every player will get 1 ticket. Losers receive no tickets." ).also { stage ++ } 91 -> options( "Are there any rules?", @@ -122,10 +126,30 @@ class Lanthus: InteractionListener, TickListener { } } - override fun tick() { - "The next game will start in 4 minutes" - "The next game will start in 1 minute" - // He also talks to Postie Pete sometimes? + // Track the last announced minute to avoid spamming the same announcement every tick + private var lastAnnouncedMinute = -1 + override fun tick() { + // Only announce when no game is active and a countdown is running + if (CastleWarsGameArea.ticksLeftInGame > 0) { + lastAnnouncedMinute = -1 // Reset for next countdown cycle + return + } + + val countdown = CastleWarsWaitingArea.gameStartCountdown + if (countdown < 0) { + lastAnnouncedMinute = -1 // No countdown active + return + } + + val minutesLeft = (countdown + ticksPerMinute - 1) / ticksPerMinute + + // Announce at minute boundaries, only once per distinct minute value + if (minutesLeft != lastAnnouncedMinute && minutesLeft in 1..5) { + lastAnnouncedMinute = minutesLeft + val lanthus = findNPC(NPCs.LANTHUS_1526) ?: return + val minuteWord = if (minutesLeft == 1) "minute" else "minutes" + sendChat(lanthus, "The next game will start in $minutesLeft $minuteWord!") + } } } \ No newline at end of file From 0d7d12a331f1c74f77c26501671c7725b2b7e3f9 Mon Sep 17 00:00:00 2001 From: dam <27978131-real_damighty@users.noreply.gitlab.com> Date: Wed, 1 Apr 2026 16:48:58 +0300 Subject: [PATCH 07/26] Stale bot references removed, energy barrier fixes, catapult interface fixes --- .../castlewars/CastleWarsCatapultInterface.kt | 16 +- .../minigame/castlewars/CastleWarsCommands.kt | 149 +++++++----------- .../castlewars/CastleWarsConstants.kt | 7 +- .../castlewars/CastleWarsListeners.kt | 90 ++++++----- .../minigame/castlewars/CatapultConfig.kt | 11 +- .../castlewars/CatapultInterfaceRenderer.kt | 61 ++++--- Server/src/main/core/ServerConstants.kt | 2 +- 7 files changed, 173 insertions(+), 163 deletions(-) diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsCatapultInterface.kt b/Server/src/main/content/minigame/castlewars/CastleWarsCatapultInterface.kt index b33899d56..8eb134c47 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsCatapultInterface.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsCatapultInterface.kt @@ -7,6 +7,7 @@ import content.minigame.castlewars.CatapultConfig.INTERFACE_ID import core.api.* import core.game.interaction.InterfaceListener import core.game.node.entity.player.Player +import core.game.system.task.Pulse import core.game.world.map.Location class CastleWarsCatapultInterface : InterfaceListener { @@ -20,7 +21,13 @@ class CastleWarsCatapultInterface : InterfaceListener { } onOpen(INTERFACE_ID) { player, _ -> - renderer.resetToDefaults(player) + renderCurrentCoordinate(player) + submitWorldPulse(object : Pulse(1) { + override fun pulse(): Boolean { + renderCurrentCoordinate(player) + return true + } + }) true } @@ -77,8 +84,7 @@ class CastleWarsCatapultInterface : InterfaceListener { setVarbit(player, VARBIT_CATAPULT_X, newCoord.x) setVarbit(player, VARBIT_CATAPULT_Y, newCoord.y) - renderer.updateMarkerPosition(player, newCoord) - renderer.updateDigitDisplay(player, newCoord) + renderer.renderCoordinate(player, newCoord) return true } @@ -89,6 +95,10 @@ class CastleWarsCatapultInterface : InterfaceListener { return Coordinate(x, y) } + private fun renderCurrentCoordinate(player: Player) { + renderer.renderCoordinate(player, getCurrentCoordinate(player)) + } + private fun getPlayerTeam(player: Player): CastleWarsTeam? { return CastleWarsGameState.getPlayerTeam(player) } diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsCommands.kt b/Server/src/main/content/minigame/castlewars/CastleWarsCommands.kt index 31733218c..af2efca53 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsCommands.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsCommands.kt @@ -1,14 +1,13 @@ package content.minigame.castlewars -import content.minigame.castlewars.bots.CastleWarsBotSpawner -import core.api.Commands -import core.api.sendMessage -import core.game.interaction.Option -import core.game.system.command.Privilege import content.minigame.castlewars.areas.CastleWarsGameArea import content.minigame.castlewars.areas.CastleWarsGameArea.Companion.saradominPlayers import content.minigame.castlewars.areas.CastleWarsGameArea.Companion.zamorakPlayers import content.minigame.castlewars.areas.CastleWarsWaitingArea +import core.api.Commands +import core.api.sendMessage +import core.game.interaction.Option +import core.game.system.command.Privilege /** * Admin commands for Castle Wars development and testing. @@ -16,91 +15,61 @@ import content.minigame.castlewars.areas.CastleWarsWaitingArea * Commands: * - ::cw start - Force starts a game if both teams have players * - ::cw end - Ends the current game - * - ::cw addbot [amount] - Spawns bots for the specified team */ class CastleWarsCommands : Commands { - - override fun defineCommands() { - define("cw", Privilege.ADMIN, "::cw ", "Castle Wars admin commands") { player, args -> - if (args.size < 2) { - sendMessage(player, "Usage: ::cw ") - return@define - } - when (args[1].lowercase()) { - "start" -> { - if (CastleWarsGameArea.ticksLeftInGame > 0) { - sendMessage(player, "A Castle Wars game is already in progress!") - return@define - } - val saraCount = CastleWarsWaitingArea.waitingSaradominPlayers.size - val zamCount = CastleWarsWaitingArea.waitingZamorakPlayers.size - if (saraCount == 0 || zamCount == 0) { - sendMessage(player, "Both teams need at least 1 player. Saradomin: $saraCount, Zamorak: $zamCount") - return@define - } - CastleWarsGameArea.startGame() - sendMessage(player, "Castle Wars game force started! Saradomin: $saraCount, Zamorak: $zamCount") - } - - "end" -> { - if (CastleWarsGameArea.ticksLeftInGame <= 0) { - sendMessage(player, "No Castle Wars game is currently running.") - return@define - } - // Snapshot the sets before endGame() modifies them via teleport callbacks - val saraSnapshot = saradominPlayers.toSet() - val zamSnapshot = zamorakPlayers.toSet() - CastleWarsGameState.endGame() - CastleWarsItemHandler.clearAllBarricades() - for (p in saraSnapshot) { - p.interaction.remove(Option._P_ATTACK) - p.properties.teleportLocation = CastleWars.lobbyBankArea.randomWalkableLoc - } - for (p in zamSnapshot) { - p.interaction.remove(Option._P_ATTACK) - p.properties.teleportLocation = CastleWars.lobbyBankArea.randomWalkableLoc - } - saradominPlayers.clear() - zamorakPlayers.clear() - sendMessage(player, "Castle Wars game ended by admin command.") - } - - "addbot" -> { - if (args.size < 3) { - sendMessage(player, "Usage: ::cw addbot [amount]") - return@define - } - val teamArg = args[2].lowercase() - val team = when { - teamArg.startsWith("sara") -> CastleWarsTeam.SARADOMIN - teamArg.startsWith("zam") -> CastleWarsTeam.ZAMORAK - else -> { - sendMessage(player, "Invalid team. Use 'sara' or 'zammy'.") - return@define - } - } - val amount = if (args.size >= 4) { - args[3].toIntOrNull() ?: run { - sendMessage(player, "Invalid amount. Must be a number.") - return@define - } - } else { - 1 - } - if (amount !in 1..20) { - sendMessage(player, "Amount must be between 1 and 20.") - return@define - } - for (i in 0 until amount) { - CastleWarsBotSpawner.spawnBot(team) - } - sendMessage(player, "Spawned $amount ${team.teamName} bot(s) at the Castle Wars lobby.") - } - - else -> { - sendMessage(player, "Unknown subcommand. Use: start, end, or addbot") - } - } - } - } + + override fun defineCommands() { + define("cw", Privilege.ADMIN, "::cw ", "Castle Wars admin commands") { player, args -> + if (args.size < 2) { + sendMessage(player, "Usage: ::cw ") + return@define + } + when (args[1].lowercase()) { + "start" -> { + if (CastleWarsGameArea.ticksLeftInGame > 0) { + sendMessage(player, "A Castle Wars game is already in progress!") + return@define + } + val saraCount = CastleWarsWaitingArea.waitingSaradominPlayers.size + val zamCount = CastleWarsWaitingArea.waitingZamorakPlayers.size + if (saraCount == 0 || zamCount == 0) { + sendMessage( + player, + "Both teams need at least 1 player. Saradomin: $saraCount, Zamorak: $zamCount" + ) + return@define + } + CastleWarsGameArea.startGame() + sendMessage(player, "Castle Wars game force started! Saradomin: $saraCount, Zamorak: $zamCount") + } + + "end" -> { + if (CastleWarsGameArea.ticksLeftInGame <= 0) { + sendMessage(player, "No Castle Wars game is currently running.") + return@define + } + // Snapshot the sets before endGame() modifies them via teleport callbacks + val saraSnapshot = saradominPlayers.toSet() + val zamSnapshot = zamorakPlayers.toSet() + CastleWarsGameState.endGame() + CastleWarsItemHandler.clearAllBarricades() + for (p in saraSnapshot) { + p.interaction.remove(Option._P_ATTACK) + p.properties.teleportLocation = CastleWars.lobbyBankArea.randomWalkableLoc + } + for (p in zamSnapshot) { + p.interaction.remove(Option._P_ATTACK) + p.properties.teleportLocation = CastleWars.lobbyBankArea.randomWalkableLoc + } + saradominPlayers.clear() + zamorakPlayers.clear() + sendMessage(player, "Castle Wars game ended by admin command.") + } + + else -> { + sendMessage(player, "Unknown subcommand. Use: start or end") + } + } + } + } } diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsConstants.kt b/Server/src/main/content/minigame/castlewars/CastleWarsConstants.kt index 45ebde308..a973d0579 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsConstants.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsConstants.kt @@ -58,8 +58,11 @@ object CastleWarsConstants { const val CATAPULT_MAX_DAMAGE = 20 // TODO: says who // ==================== Coordinate Defaults ==================== - /** Default X/Y coordinate for catapult targeting (center of 0-30 range) */ - const val CATAPULT_DEFAULT_COORDINATE = 15 // TODO: says who + /** Default X coordinate for catapult targeting */ + const val CATAPULT_DEFAULT_X = 8 + + /** Default Y coordinate for catapult targeting */ + const val CATAPULT_DEFAULT_Y = 13 /** Maximum catapult coordinate value */ const val CATAPULT_MAX_COORDINATE = 30 diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsListeners.kt b/Server/src/main/content/minigame/castlewars/CastleWarsListeners.kt index e989ab751..72375d44e 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsListeners.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsListeners.kt @@ -5,13 +5,13 @@ import content.global.skill.summoning.familiar.BurdenBeast import content.minigame.castlewars.areas.CastleWarsGameArea import content.minigame.castlewars.areas.CastleWarsRespawnArea import content.minigame.castlewars.areas.CastleWarsWaitingArea -import content.minigame.castlewars.bots.CastleWarsBotSpawner import core.api.* import core.cache.def.impl.ItemDefinition import core.game.component.Component import core.game.container.Container import core.game.global.action.ClimbActionHandler import core.game.global.action.DoorActionHandler +import core.game.global.action.SpecialLadders import core.game.interaction.IntType import core.game.interaction.InteractionListener import core.game.node.entity.player.Player @@ -65,6 +65,48 @@ private val barrierCoordinates: Map> = mapOf( ) ) +private val castleWarsStairDestinationOverrides: Map = mapOf( + Location.create(2425, 3076, 2) to Location.create(2426, 3074, 3), + Location.create(2425, 3074, 3) to Location.create(2425, 3077, 2), + Location.create(2430, 3081, 2) to Location.create(2427, 3081, 1) +) + +internal fun getCastleWarsStairDestination(start: Location): Location? { + return SpecialLadders.getDestination(start) ?: castleWarsStairDestinationOverrides[start] +} + +internal fun getBarrierInteractionDestination(playerLoc: Location, barrierData: BarrierData): Location { + return when (playerLoc) { + barrierData.location, barrierData.outsideTile, barrierData.insideTile -> playerLoc + else -> { + val distToOutside = playerLoc.getDistance(barrierData.outsideTile) + val distToInside = playerLoc.getDistance(barrierData.insideTile) + if (distToOutside <= distToInside) { + barrierData.outsideTile + } else { + barrierData.insideTile + } + } + } +} + +internal fun getBarrierPassTiles(playerLoc: Location, barrierData: BarrierData): Pair { + return when (playerLoc) { + barrierData.location -> barrierData.location to barrierData.outsideTile + barrierData.outsideTile -> barrierData.outsideTile to barrierData.insideTile + barrierData.insideTile -> barrierData.insideTile to barrierData.outsideTile + else -> { + val distToOutside = playerLoc.getDistance(barrierData.outsideTile) + val distToInside = playerLoc.getDistance(barrierData.insideTile) + if (distToOutside <= distToInside) { + barrierData.outsideTile to barrierData.insideTile + } else { + barrierData.insideTile to barrierData.outsideTile + } + } + } +} + class CastleWarsListeners : InteractionListener { private val pickaxeIds = intArrayOf(1265, 1267, 1269, 1271, 1273, 1275) @@ -90,19 +132,7 @@ class CastleWarsListeners : InteractionListener { val barrierDataList = barrierCoordinates[barrier.id] ?: return@setDest barrier.location val barrierData = barrierDataList.find { it.location == barrier.location } ?: return@setDest barrier.location - if (playerLoc == barrierData.location) { - return@setDest barrierData.outsideTile - } - if (playerLoc == barrierData.outsideTile || playerLoc == barrierData.insideTile) { - return@setDest barrierData.location - } - val distToOutside = playerLoc.getDistance(barrierData.outsideTile) - val distToInside = playerLoc.getDistance(barrierData.insideTile) - if (distToOutside <= distToInside) { - return@setDest barrierData.outsideTile - } else { - return@setDest barrierData.insideTile - } + return@setDest getBarrierInteractionDestination(playerLoc, barrierData) } } @@ -114,9 +144,6 @@ class CastleWarsListeners : InteractionListener { sendMessage(player, "The team is currently full.") return@on true } - if (!player.isArtificial) { - CastleWarsBotSpawner.spawnBotsIfNeeded() - } player.properties.teleportLocation = CastleWarsWaitingArea.saradominWaitingRoom.randomWalkableLoc player.setAttribute(CastleWars.portalAttribute, CastleWars.saradominName) return@on true @@ -128,9 +155,6 @@ class CastleWarsListeners : InteractionListener { sendMessage(player, "The team is currently full.") return@on true } - if (!player.isArtificial) { - CastleWarsBotSpawner.spawnBotsIfNeeded() - } player.properties.teleportLocation = CastleWarsWaitingArea.zamorakWaitingRoom.randomWalkableLoc player.setAttribute(CastleWars.portalAttribute, CastleWars.zamorakName) return@on true @@ -146,9 +170,6 @@ 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 - if (!player.isArtificial) { - CastleWarsBotSpawner.spawnBotsIfNeeded() - } val saraTotal = CastleWarsWaitingArea.waitingSaradominPlayers.size + CastleWarsGameArea.saradominPlayers.size val zamTotal = CastleWarsWaitingArea.waitingZamorakPlayers.size + CastleWarsGameArea.zamorakPlayers.size @@ -704,8 +725,8 @@ class CastleWarsListeners : InteractionListener { sendMessage(player, "You need rocks to fire the catapult!") return@on true } - setVarbit(player, CastleWarsGameState.VARBIT_CATAPULT_X, 15) - setVarbit(player, CastleWarsGameState.VARBIT_CATAPULT_Y, 15) + setVarbit(player, CastleWarsGameState.VARBIT_CATAPULT_X, CatapultConfig.DEFAULT_X) + setVarbit(player, CastleWarsGameState.VARBIT_CATAPULT_Y, CatapultConfig.DEFAULT_Y) player.setAttribute("catapult:location", catapult.location) player.interfaceManager.open(Component(Components.CASTLEWARS_CATAPULT_54)) return@on true @@ -868,6 +889,7 @@ class CastleWarsListeners : InteractionListener { } private fun getStaircaseDestination(scenery: Scenery, zDelta: Int): Location { + getCastleWarsStairDestination(scenery.location)?.let { return it } val targetPlaneLocation = scenery.location.transform(0, 0, zDelta) val endScenery = RegionManager.getObject(targetPlaneLocation) if (endScenery != null) { @@ -882,7 +904,7 @@ class CastleWarsListeners : InteractionListener { private fun passEnergyBarrier( player: Player, barrier: Scenery - ) { // TODO: this is so weird that it might just as well be rewritten. doesnt work when occupying tiles with barriers. works otherwise + ) { val currentBarrier = RegionManager.getObject(barrier.location.z, barrier.location.x, barrier.location.y, barrier.id) if (currentBarrier == null || !currentBarrier.isActive) { @@ -902,19 +924,7 @@ class CastleWarsListeners : InteractionListener { return } val playerLoc = player.location - val onBarrierTile = playerLoc == barrierData.location - val (startTile, endTile) = if (onBarrierTile) { - Pair(barrierData.location, barrierData.outsideTile) - } else { - val distToOutside = playerLoc.getDistance(barrierData.outsideTile) - val distToInside = playerLoc.getDistance(barrierData.insideTile) - - if (distToOutside <= distToInside) { - Pair(barrierData.outsideTile, barrierData.insideTile) - } else { - Pair(barrierData.insideTile, barrierData.outsideTile) - } - } + val (startTile, endTile) = getBarrierPassTiles(playerLoc, barrierData) if (playerLoc != startTile) { player.walkingQueue.reset() player.properties.teleportLocation = startTile @@ -1036,4 +1046,4 @@ class CastleWarsListeners : InteractionListener { capeOrHelmetError(player) ?: nonCombatItemsCheck(player) ?: familiarCheck(player) ?: return false player.sendMessage(errorMessage).also { return true } } -} \ No newline at end of file +} diff --git a/Server/src/main/content/minigame/castlewars/CatapultConfig.kt b/Server/src/main/content/minigame/castlewars/CatapultConfig.kt index 79e39cb85..8b3e574eb 100644 --- a/Server/src/main/content/minigame/castlewars/CatapultConfig.kt +++ b/Server/src/main/content/minigame/castlewars/CatapultConfig.kt @@ -7,7 +7,7 @@ import org.rs09.consts.Components * Configuration constants for the Castle Wars catapult interface. * All magic numbers and hardcoded values are centralized here. */ -object CatapultConfig { // TODO: this is definitely fucked somehow +object CatapultConfig { /** Interface ID for the catapult targeting interface */ const val INTERFACE_ID = Components.CASTLEWARS_CATAPULT_54 @@ -15,8 +15,8 @@ object CatapultConfig { // TODO: this is definitely fucked somehow /** Child ID for the red X marker on the map */ const val MARKER_CHILD_ID = 55 - /** Enum ID for digit models (maps 0-9 to model IDs) */ - const val DIGIT_MODEL_ENUM = 1595 + /** Native Castle Wars dummy items used to render digits on interface 54 */ + val DIGIT_ITEM_IDS = intArrayOf(4056, 4057, 4058, 4059, 4060, 4061, 4062, 4063, 4064, 4065) const val BUTTON_FIRE = 51 const val BUTTON_CLOSE = 56 @@ -25,7 +25,7 @@ object CatapultConfig { // TODO: this is definitely fucked somehow const val BUTTON_RIGHT = 67 const val BUTTON_LEFT = 68 - val DIGIT_CHILD_IDS = intArrayOf(69, 70, 71, 72) + val DIGIT_CHILD_IDS = intArrayOf(61, 62, 63, 64) const val MAP_BASE_X = 12 const val MAP_BASE_Y = 20 @@ -33,7 +33,8 @@ object CatapultConfig { // TODO: this is definitely fucked somehow const val MAP_HEIGHT = 297 const val MAX_COORDINATE = CastleWarsConstants.CATAPULT_MAX_COORDINATE - const val DEFAULT_COORDINATE = CastleWarsConstants.CATAPULT_DEFAULT_COORDINATE + const val DEFAULT_X = CastleWarsConstants.CATAPULT_DEFAULT_X + const val DEFAULT_Y = CastleWarsConstants.CATAPULT_DEFAULT_Y const val CENTER_OFFSET = 15 val SARADOMIN_BASE = Location.create(2426, 3073, 0) diff --git a/Server/src/main/content/minigame/castlewars/CatapultInterfaceRenderer.kt b/Server/src/main/content/minigame/castlewars/CatapultInterfaceRenderer.kt index d2647acfa..bf4914e11 100644 --- a/Server/src/main/content/minigame/castlewars/CatapultInterfaceRenderer.kt +++ b/Server/src/main/content/minigame/castlewars/CatapultInterfaceRenderer.kt @@ -1,8 +1,9 @@ package content.minigame.castlewars -import content.minigame.castlewars.CatapultConfig.DEFAULT_COORDINATE +import content.minigame.castlewars.CatapultConfig.DEFAULT_X +import content.minigame.castlewars.CatapultConfig.DEFAULT_Y import content.minigame.castlewars.CatapultConfig.DIGIT_CHILD_IDS -import content.minigame.castlewars.CatapultConfig.DIGIT_MODEL_ENUM +import content.minigame.castlewars.CatapultConfig.DIGIT_ITEM_IDS import content.minigame.castlewars.CatapultConfig.INTERFACE_ID import content.minigame.castlewars.CatapultConfig.MAP_BASE_X import content.minigame.castlewars.CatapultConfig.MAP_BASE_Y @@ -10,18 +11,24 @@ import content.minigame.castlewars.CatapultConfig.MAP_HEIGHT import content.minigame.castlewars.CatapultConfig.MAP_WIDTH import content.minigame.castlewars.CatapultConfig.MARKER_CHILD_ID import content.minigame.castlewars.CatapultConfig.MAX_COORDINATE -import core.cache.def.impl.DataMap +import core.cache.def.impl.ComponentType +import core.cache.def.impl.IfaceDefinition import core.game.node.entity.player.Player import core.net.packet.PacketRepository import core.net.packet.context.ChildPositionContext -import core.net.packet.context.DisplayModelContext -import core.net.packet.out.DisplayModel import core.net.packet.out.RepositionChild /** * Handles rendering the catapult interface including marker position and digit displays. */ -class CatapultInterfaceRenderer { // TODO: this is getting very close to 100%. still fucked but on the right track for sure +class CatapultInterfaceRenderer { + + private val interfaceDefinition: IfaceDefinition by lazy { IfaceDefinition.forId(INTERFACE_ID) } + + fun renderCoordinate(player: Player, coord: Coordinate) { + updateMarkerPosition(player, coord) + updateDigitDisplay(player, coord) + } /** * Updates the position of the red X marker on the interface map. @@ -60,9 +67,8 @@ class CatapultInterfaceRenderer { // TODO: this is getting very close to 100%. s * @param player The player whose interface to reset */ fun resetToDefaults(player: Player) { - val defaultCoord = Coordinate(DEFAULT_COORDINATE, DEFAULT_COORDINATE) - updateMarkerPosition(player, defaultCoord) - updateDigitDisplay(player, defaultCoord) + val defaultCoord = Coordinate(DEFAULT_X, DEFAULT_Y) + renderCoordinate(player, defaultCoord) } private fun calculateScreenX(coordX: Int): Int { @@ -77,21 +83,32 @@ class CatapultInterfaceRenderer { // TODO: this is getting very close to 100%. s } private fun sendDigitModel(player: Player, childId: Int, digit: Int) { - val modelId = getDigitModelId(digit) - PacketRepository.send( - DisplayModel::class.java, - DisplayModelContext( - player, - DisplayModelContext.ModelType.MODEL, - modelId, - 0, - INTERFACE_ID, - childId - ) + if (!isValidDigitChild(childId)) { + return + } + val child = interfaceDefinition.children[childId] ?: return + val itemId = getDigitItemId(digit) + if (itemId < 0) { + return + } + player.packetDispatch.sendItemOnInterface(itemId, 1, INTERFACE_ID, childId) + player.packetDispatch.sendAngleOnInterface( + INTERFACE_ID, + childId, + child.modelZoom, + child.modelXAngle, + child.modelYAngle ) } - private fun getDigitModelId(digit: Int): Int { - return DataMap.get(DIGIT_MODEL_ENUM).getInt(digit) + internal fun isValidDigitChild(childId: Int): Boolean { + if (childId !in interfaceDefinition.children.indices) { + return false + } + return interfaceDefinition.children[childId]?.type == ComponentType.MODEL + } + + internal fun getDigitItemId(digit: Int): Int { + return DIGIT_ITEM_IDS.getOrNull(digit) ?: -1 } } diff --git a/Server/src/main/core/ServerConstants.kt b/Server/src/main/core/ServerConstants.kt index 8c5f493a4..6269b7284 100644 --- a/Server/src/main/core/ServerConstants.kt +++ b/Server/src/main/core/ServerConstants.kt @@ -5,7 +5,7 @@ import core.game.world.map.Location import core.tools.LogLevel import core.tools.mysql.Database import core.tools.secondsToTicks -import rs09.game.content.activity.castlewars.CastleWars +import content.minigame.castlewars.CastleWars import java.math.BigInteger import java.util.* From aa5c3c110367a9e6158d29d057867fde6b155cfd Mon Sep 17 00:00:00 2001 From: dam <27978131-real_damighty@users.noreply.gitlab.com> Date: Wed, 1 Apr 2026 19:13:23 +0300 Subject: [PATCH 08/26] detekt failure fix forceDoorsOpen() now uses Pair of IntRange instead of arrayOf(...) --- .../minigame/castlewars/CastleWarsGameState.kt | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsGameState.kt b/Server/src/main/content/minigame/castlewars/CastleWarsGameState.kt index c1fa9cfda..780f0d916 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsGameState.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsGameState.kt @@ -1171,12 +1171,12 @@ object CastleWarsGameState { * Searches the Castle Wars castle area for doors with matching IDs and replaces them. */ private fun forceDoorsOpen(team: CastleWarsTeam) { - val (minX, maxX, minY, maxY) = when (team) { - CastleWarsTeam.SARADOMIN -> arrayOf(2418, 2432, 3070, 3085) - CastleWarsTeam.ZAMORAK -> arrayOf(2368, 2382, 3125, 3140) + val (xRange, yRange) = when (team) { + CastleWarsTeam.SARADOMIN -> (2418..2432) to (3070..3085) + CastleWarsTeam.ZAMORAK -> (2368..2382) to (3125..3140) } - for (x in minX..maxX) { - for (y in minY..maxY) { + for (x in xRange) { + for (y in yRange) { val loc = Location.create(x, y, 0) val obj = RegionManager.getObject(loc) ?: continue val openId = getOpenDoorId(obj.id) @@ -1691,4 +1691,3 @@ fun Player.depleteCastleWarsBracelet() { equipment.replace(Item(nextId), EquipmentContainer.SLOT_HANDS) } } - From e258be1898ac2dd1ec915120105695caf7af910f Mon Sep 17 00:00:00 2001 From: dam <27978131-real_damighty@users.noreply.gitlab.com> Date: Wed, 1 Apr 2026 19:32:07 +0300 Subject: [PATCH 09/26] Zamorak castle steps destination fixes --- .../main/content/minigame/castlewars/CastleWarsListeners.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsListeners.kt b/Server/src/main/content/minigame/castlewars/CastleWarsListeners.kt index 72375d44e..bb319bf01 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsListeners.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsListeners.kt @@ -68,7 +68,11 @@ private val barrierCoordinates: Map> = mapOf( private val castleWarsStairDestinationOverrides: Map = mapOf( Location.create(2425, 3076, 2) to Location.create(2426, 3074, 3), Location.create(2425, 3074, 3) to Location.create(2425, 3077, 2), - Location.create(2430, 3081, 2) to Location.create(2427, 3081, 1) + Location.create(2430, 3081, 2) to Location.create(2427, 3081, 1), + Location.create(2380, 3127, 1) to Location.create(2380, 3130, 0), + Location.create(2371, 3126, 1) to Location.create(2369, 3127, 2), + Location.create(2369, 3126, 2) to Location.create(2372, 3126, 1), + Location.create(2374, 3131, 2) to Location.create(2373, 3133, 3) ) internal fun getCastleWarsStairDestination(start: Location): Location? { From 7c8aa93ce010b5415ec9f22085e6957e744ef2b5 Mon Sep 17 00:00:00 2001 From: dam <27978131-real_damighty@users.noreply.gitlab.com> Date: Wed, 1 Apr 2026 21:56:18 +0300 Subject: [PATCH 10/26] Climbing rope fixes Same-tile interaction still fails --- .../castlewars/CastleWarsGameState.kt | 55 +++++++++++--- .../castlewars/CastleWarsListeners.kt | 76 +++++++++++++------ 2 files changed, 96 insertions(+), 35 deletions(-) diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsGameState.kt b/Server/src/main/content/minigame/castlewars/CastleWarsGameState.kt index 780f0d916..f78d1f5bd 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsGameState.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsGameState.kt @@ -16,6 +16,7 @@ import core.game.node.scenery.Scenery import core.game.node.scenery.SceneryBuilder import core.game.world.map.Location import core.game.world.map.RegionManager +import core.game.world.update.flag.chunk.ObjectUpdateFlag import core.game.world.map.zone.ZoneBorders import core.tools.ticksPerMinute import org.rs09.consts.Items @@ -61,6 +62,11 @@ enum class BoulderState { COLLAPSED, WEAKENED, CLEARED } +data class ActiveClimbingRope( + val rope: Scenery, + val originalWall: Scenery +) + /** * Manages the state of a Castle Wars game including scores, flags, and game lifecycle. * This is a singleton that tracks the current game state. @@ -91,6 +97,8 @@ object CastleWarsGameState { var zamorakDroppedFlagScenery: Scenery? = null private set + private val activeClimbingRopes: MutableMap = mutableMapOf() + // Tick when each flag was dropped (for auto-return timer) private var saradominFlagDroppedTick: Int = 0 private var zamorakFlagDroppedTick: Int = 0 @@ -263,22 +271,47 @@ object CastleWarsGameState { resetBattlefield() } + fun registerClimbingRope(rope: Scenery, originalWall: Scenery) { + val key = Location.create(rope.location) + val chunk = RegionManager.getRegionChunk(rope.location) + + rope.isActive = true + rope.isRenderable = true + activeClimbingRopes[key] = ActiveClimbingRope(rope, Scenery(originalWall)) + chunk.objects[rope.location.chunkOffsetX][rope.location.chunkOffsetY] = rope + } + + fun getClimbingRope(location: Location): Scenery? { + return activeClimbingRopes[Location.create(location)]?.rope?.takeIf { it.isActive } + } + + fun removeClimbingRope(location: Location, sendUpdate: Boolean = true): Boolean { + val activeRope = activeClimbingRopes.remove(Location.create(location)) ?: return false + val chunk = RegionManager.getRegionChunk(activeRope.rope.location) + + activeRope.rope.isActive = false + chunk.objects[activeRope.rope.location.chunkOffsetX][activeRope.rope.location.chunkOffsetY] = + activeRope.originalWall + + if (sendUpdate) { + chunk.flag(ObjectUpdateFlag(activeRope.rope, true)) + } + return true + } + + fun clearClimbingRopes(sendUpdate: Boolean = true) { + val ropeLocations = activeClimbingRopes.keys.map { Location.create(it) } + for (location in ropeLocations) { + removeClimbingRope(location, sendUpdate) + } + } + /** * Reset the battlefield by removing climbing ropes, restoring modified battlements, * and closing any open doors. Called at the end of each match. */ private fun resetBattlefield() { - for (x in 2368..2431) { - for (y in 3072..3135) { - for (z in 0..3) { - val loc = Location.create(x, y, z) - val obj = RegionManager.getObject(loc) - if (obj != null && obj.id == CastleWars.cwCastleClimbingRope) { - SceneryBuilder.remove(obj) - } - } - } - } + clearClimbingRopes() // Reset modified battlements back to original state // 36313 -> 4446, 36314 -> 4447 for (x in 2368..2431) { diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsListeners.kt b/Server/src/main/content/minigame/castlewars/CastleWarsListeners.kt index bb319bf01..1849ae6ca 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsListeners.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsListeners.kt @@ -38,6 +38,11 @@ data class BarrierData( val insideTile: Location ) +data class ClimbingRopeTraversal( + val topOfWallTile: Location, + val baseOfWallTile: Location +) + private val barrierCoordinates: Map> = mapOf( CastleWars.zamorakEnergyBarrier to listOf( BarrierData( @@ -111,19 +116,49 @@ internal fun getBarrierPassTiles(playerLoc: Location, barrierData: BarrierData): } } +internal fun getClimbingRopeTraversal(rope: Scenery): ClimbingRopeTraversal { + val ropeLoc = rope.location + val wallDirection = rope.direction.opposite + val topOfWallTile = Location(wallDirection.stepY + ropeLoc.x, -wallDirection.stepX + ropeLoc.y, ropeLoc.z) + val baseOfWallTile = Location(-wallDirection.stepY + ropeLoc.x, wallDirection.stepX + ropeLoc.y, ropeLoc.z) + return ClimbingRopeTraversal(topOfWallTile, baseOfWallTile) +} + +internal fun isOnClimbingRopeBaseSide(playerLoc: Location, rope: Scenery): Boolean { + val traversal = getClimbingRopeTraversal(rope) + if (playerLoc == traversal.baseOfWallTile) { + return true + } + + val ropeLoc = rope.location + val baseVectorX = traversal.baseOfWallTile.x - ropeLoc.x + val baseVectorY = traversal.baseOfWallTile.y - ropeLoc.y + val playerVectorX = playerLoc.x - ropeLoc.x + val playerVectorY = playerLoc.y - ropeLoc.y + + return playerVectorX * baseVectorX + playerVectorY * baseVectorY > 0 +} + +internal fun getClimbingRopeInteractionDestination(playerLoc: Location, rope: Scenery): Location { + val traversal = getClimbingRopeTraversal(rope) + return if (isOnClimbingRopeBaseSide(playerLoc, rope)) { + traversal.baseOfWallTile + } else { + traversal.topOfWallTile + } +} + +internal fun canClimbUpClimbingRope(playerLoc: Location, rope: Scenery): Boolean { + return playerLoc == getClimbingRopeTraversal(rope).baseOfWallTile +} + class CastleWarsListeners : InteractionListener { private val pickaxeIds = intArrayOf(1265, 1267, 1269, 1271, 1273, 1275) override fun defineDestinationOverrides() { setDest(IntType.SCENERY, intArrayOf(CastleWars.cwCastleClimbingRope), "climb") { entity, node -> - val rope = node.asScenery() - val playerLoc = entity.location - val ropeLoc = rope.location - if (playerLoc.getDistance(ropeLoc) <= 2.0) { - return@setDest playerLoc - } - return@setDest ropeLoc + return@setDest getClimbingRopeInteractionDestination(entity.location, node.asScenery()) } setDest( @@ -242,17 +277,20 @@ class CastleWarsListeners : InteractionListener { CastleWars.cwClimbingRope, *CastleWars.cwCastleBattlementsMap.keys.toIntArray() ) { player, rope, wall -> + if (CastleWarsGameState.getClimbingRope(wall.location) != null) { + sendDialogue(player, "There is already a rope tied to the battlements.") + return@onUseWith true + } removeItem(player, rope) val wallScenery = wall.asScenery() val ropeWall = wallScenery.transform(CastleWars.cwCastleBattlementsMap.getValue(wall.id)) SceneryBuilder.replace(wallScenery, ropeWall, CastleWars.ropeAliveTicks, false) val hangingRope = Scenery(CastleWars.cwCastleClimbingRope, wall.location, 4, wall.direction.toInteger()) + CastleWarsGameState.registerClimbingRope(hangingRope, wallScenery) RegionManager.getRegionChunk(hangingRope.location).flag(ObjectUpdateFlag(hangingRope, false)) - hangingRope.isActive = true GameWorld.Pulser.submit(object : Pulse(CastleWars.ropeAliveTicks) { override fun pulse(): Boolean { - RegionManager.getRegionChunk(hangingRope.location).flag(ObjectUpdateFlag(hangingRope, true)) - hangingRope.isActive = false + CastleWarsGameState.removeClimbingRope(hangingRope.location) return true } }) @@ -260,21 +298,11 @@ class CastleWarsListeners : InteractionListener { } on(CastleWars.cwCastleClimbingRope, SCENERY, "climb") { player, rope -> - val ropeLoc = rope.asScenery().location - val dir = rope.asScenery().direction.opposite + val ropeScenery = rope.asScenery() + val traversal = getClimbingRopeTraversal(ropeScenery) - val topOfWallLoc = Location(dir.stepY + ropeLoc.x, -dir.stepX + ropeLoc.y, ropeLoc.z) - - val baseOfWallLoc = Location(-dir.stepY + ropeLoc.x, dir.stepX + ropeLoc.y, ropeLoc.z) - - val distToTop = player.location.getDistance(topOfWallLoc) - val distToBase = player.location.getDistance(baseOfWallLoc) - - val onRopeTile = - player.location.x == ropeLoc.x && player.location.y == ropeLoc.y && player.location.z == ropeLoc.z - - if (onRopeTile || distToBase <= distToTop) { - teleport(player, topOfWallLoc, TeleportManager.TeleportType.INSTANT) + if (canClimbUpClimbingRope(player.location, ropeScenery)) { + teleport(player, traversal.topOfWallTile, TeleportManager.TeleportType.INSTANT) } else { sendMessage(player, "You can't climb down the rope.") } From dcc4fe2ccf72f450b7077dcac53a4e79ebfa6d79 Mon Sep 17 00:00:00 2001 From: dam <27978131-real_damighty@users.noreply.gitlab.com> Date: Tue, 7 Apr 2026 22:11:04 +0300 Subject: [PATCH 11/26] Catapult firing --- .../content/minigame/castlewars/CastleWars.kt | 12 +++++++++- .../castlewars/CastleWarsGameState.kt | 24 ++++++++++++------- .../minigame/castlewars/CatapultConfig.kt | 15 +++++------- .../castlewars/CatapultFiringHandler.kt | 4 ++-- 4 files changed, 34 insertions(+), 21 deletions(-) diff --git a/Server/src/main/content/minigame/castlewars/CastleWars.kt b/Server/src/main/content/minigame/castlewars/CastleWars.kt index bb0f4784f..784e95f77 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWars.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWars.kt @@ -81,6 +81,16 @@ object CastleWars { val damagedCatapultIds: IntArray = intArrayOf(catapultDamaged1, catapultDamaged2) val allCatapultIds: IntArray = intArrayOf(catapult, catapultAlt, catapultDamaged1, catapultDamaged2) + fun getOperationalCatapultId(team: CastleWarsTeam): Int = when (team) { + CastleWarsTeam.SARADOMIN -> catapultAlt + CastleWarsTeam.ZAMORAK -> catapult + } + + fun getDamagedCatapultId(team: CastleWarsTeam): Int = when (team) { + CastleWarsTeam.SARADOMIN -> catapultDamaged2 + CastleWarsTeam.ZAMORAK -> catapultDamaged1 + } + // Trapdoors - allow going down from 2nd floor to respawn room const val saradominTrapdoor: Int = 4471 // Saradomin castle trapdoor const val zamorakTrapdoor: Int = 4472 // Zamorak castle trapdoor @@ -179,4 +189,4 @@ object CastleWars { const val gameTimeMinutes = GAME_TIME_MINUTES const val gameCooldownMinutes = GAME_COOLDOWN_MINUTES val ropeAliveTicks = ROPE_DURATION_TICKS -} \ No newline at end of file +} diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsGameState.kt b/Server/src/main/content/minigame/castlewars/CastleWarsGameState.kt index f78d1f5bd..070f32c7c 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsGameState.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsGameState.kt @@ -16,8 +16,8 @@ import core.game.node.scenery.Scenery import core.game.node.scenery.SceneryBuilder import core.game.world.map.Location import core.game.world.map.RegionManager -import core.game.world.update.flag.chunk.ObjectUpdateFlag import core.game.world.map.zone.ZoneBorders +import core.game.world.update.flag.chunk.ObjectUpdateFlag import core.tools.ticksPerMinute import org.rs09.consts.Items @@ -179,9 +179,9 @@ object CastleWarsGameState { val saradominFlagStandLocation: Location = Location.create(2429, 3074, 3) val zamorakFlagStandLocation: Location = Location.create(2370, 3133, 3) - // Catapult locations (on standard floor, z=0) - val saradominCatapultLocation: Location = Location.create(2426, 3073, 0) - val zamorakCatapultLocation: Location = Location.create(2373, 3134, 0) + // Catapult locations (battlement scenery anchor tiles on the battlefield side, z=0) + val saradominCatapultLocation: Location = Location.create(2413, 3088, 0) + val zamorakCatapultLocation: Location = Location.create(2384, 3117, 0) // Respawn locations val saradominRespawnLocation: Location get() = CastleWarsRespawnArea.saradominRespawnRoom.randomWalkableLoc @@ -266,8 +266,14 @@ object CastleWarsGameState { for (key in boulderLocations.keys) { updateBoulderScenery(key, BoulderState.COLLAPSED) } - replaceCatapultScenery(saradominCatapultLocation, CastleWars.catapult) - replaceCatapultScenery(zamorakCatapultLocation, CastleWars.catapult) + replaceCatapultScenery( + saradominCatapultLocation, + CastleWars.getOperationalCatapultId(CastleWarsTeam.SARADOMIN) + ) + replaceCatapultScenery( + zamorakCatapultLocation, + CastleWars.getOperationalCatapultId(CastleWarsTeam.ZAMORAK) + ) resetBattlefield() } @@ -1430,8 +1436,8 @@ object CastleWarsGameState { when (team) { CastleWarsTeam.SARADOMIN -> saradominCatapultOperational = false CastleWarsTeam.ZAMORAK -> zamorakCatapultOperational = false - } // Replace with damaged catapult scenery (4385 or 4386 have "Repair" option) - replaceCatapultScenery(location, CastleWars.catapultDamaged1) + } + replaceCatapultScenery(location, CastleWars.getDamagedCatapultId(team)) updateAllOverlays() } @@ -1480,7 +1486,7 @@ object CastleWarsGameState { CastleWarsTeam.SARADOMIN -> saradominCatapultOperational = true CastleWarsTeam.ZAMORAK -> zamorakCatapultOperational = true } - replaceCatapultScenery(location, CastleWars.catapult) + replaceCatapultScenery(location, CastleWars.getOperationalCatapultId(team)) updateAllOverlays() } diff --git a/Server/src/main/content/minigame/castlewars/CatapultConfig.kt b/Server/src/main/content/minigame/castlewars/CatapultConfig.kt index 8b3e574eb..5e66854ed 100644 --- a/Server/src/main/content/minigame/castlewars/CatapultConfig.kt +++ b/Server/src/main/content/minigame/castlewars/CatapultConfig.kt @@ -1,6 +1,5 @@ package content.minigame.castlewars -import core.game.world.map.Location import org.rs09.consts.Components /** @@ -36,9 +35,7 @@ object CatapultConfig { const val DEFAULT_X = CastleWarsConstants.CATAPULT_DEFAULT_X const val DEFAULT_Y = CastleWarsConstants.CATAPULT_DEFAULT_Y const val CENTER_OFFSET = 15 - - val SARADOMIN_BASE = Location.create(2426, 3073, 0) - val ZAMORAK_BASE = Location.create(2373, 3134, 0) + const val HORIZONTAL_TILE_SCALE = 2 const val SOUND_FIRE = 1440 const val SOUND_IMPACT = 851 @@ -82,10 +79,10 @@ enum class CatapultButton(val buttonId: Int, val deltaX: Int, val deltaY: Int) { * Enum representing team-specific catapult configurations. * Handles coordinate transformations based on team orientation. */ -enum class CatapultTeam(val baseLocation: Location) { - SARADOMIN(CatapultConfig.SARADOMIN_BASE) { +enum class CatapultTeam { + SARADOMIN { override fun calculateTargetX(catapultX: Int, interfaceX: Int): Int { - return catapultX + (interfaceX - CatapultConfig.CENTER_OFFSET) + return catapultX + ((interfaceX - CatapultConfig.CENTER_OFFSET) * CatapultConfig.HORIZONTAL_TILE_SCALE) } override fun calculateTargetY(catapultY: Int, interfaceY: Int): Int { @@ -93,9 +90,9 @@ enum class CatapultTeam(val baseLocation: Location) { } }, - ZAMORAK(CatapultConfig.ZAMORAK_BASE) { + ZAMORAK { override fun calculateTargetX(catapultX: Int, interfaceX: Int): Int { - return catapultX - (interfaceX - CatapultConfig.CENTER_OFFSET) + return catapultX - ((interfaceX - CatapultConfig.CENTER_OFFSET) * CatapultConfig.HORIZONTAL_TILE_SCALE) } override fun calculateTargetY(catapultY: Int, interfaceY: Int): Int { diff --git a/Server/src/main/content/minigame/castlewars/CatapultFiringHandler.kt b/Server/src/main/content/minigame/castlewars/CatapultFiringHandler.kt index 5b09d65e7..343a17405 100644 --- a/Server/src/main/content/minigame/castlewars/CatapultFiringHandler.kt +++ b/Server/src/main/content/minigame/castlewars/CatapultFiringHandler.kt @@ -26,7 +26,7 @@ import core.tools.RandomFunction /** * Handles firing the catapult and dealing impact damage. */ -class CatapultFiringHandler { // TODO: i dont think this even works.. at all even +class CatapultFiringHandler { /** * Fires the catapult at the specified coordinates. @@ -50,7 +50,7 @@ class CatapultFiringHandler { // TODO: i dont think this even works.. at all eve sendMessage(player, "You fire the catapult!") } - private fun calculateTargetLocation(team: CatapultTeam, catapultLocation: Location, coord: Coordinate): Location { + internal fun calculateTargetLocation(team: CatapultTeam, catapultLocation: Location, coord: Coordinate): Location { val targetX = team.calculateTargetX(catapultLocation.x, coord.x) val targetY = team.calculateTargetY(catapultLocation.y, coord.y) return Location.create(targetX, targetY, 0) From caa2d6dab631b8a52d2940b2aa0f1694161924d8 Mon Sep 17 00:00:00 2001 From: dam <27978131-real_damighty@users.noreply.gitlab.com> Date: Fri, 10 Apr 2026 00:12:29 +0300 Subject: [PATCH 12/26] Replaced silly PvP scaffolding with OvenBread's actual engineer solution --- .../global/handlers/PvPLogoutHandler.kt | 33 ------- .../castlewars/areas/CastleWarsArea.kt | 4 - .../castlewars/areas/CastleWarsGameArea.kt | 53 +++------- Server/src/main/core/api/MapArea.kt | 14 +++ Server/src/main/core/api/PvPCombatUtils.kt | 76 --------------- Server/src/main/core/api/PvPZone.kt | 51 ---------- .../core/game/node/entity/player/Player.java | 96 +------------------ .../node/entity/player/link/PvPStateAudit.kt | 67 ------------- .../core/game/world/map/zone/MapZone.java | 16 +++- .../core/game/world/map/zone/ZoneMonitor.java | 80 ++++------------ .../game/world/map/zone/ZoneRestriction.java | 14 --- Server/src/main/core/plugin/ClassScanner.kt | 25 +++++ 12 files changed, 90 insertions(+), 439 deletions(-) delete mode 100644 Server/src/main/content/global/handlers/PvPLogoutHandler.kt delete mode 100644 Server/src/main/core/api/PvPCombatUtils.kt delete mode 100644 Server/src/main/core/api/PvPZone.kt delete mode 100644 Server/src/main/core/game/node/entity/player/link/PvPStateAudit.kt diff --git a/Server/src/main/content/global/handlers/PvPLogoutHandler.kt b/Server/src/main/content/global/handlers/PvPLogoutHandler.kt deleted file mode 100644 index 4e0d26f5c..000000000 --- a/Server/src/main/content/global/handlers/PvPLogoutHandler.kt +++ /dev/null @@ -1,33 +0,0 @@ -package content.global.handlers - -import core.api.LogoutListener -import core.game.node.entity.player.Player -import core.game.node.entity.player.link.PvPStateAudit - -/** - * Global logout handler that ensures PvP state is always cleaned up on logout. - * This is a safety net to prevent PvP state leakage (Falador Massacre-style bugs). - * - * Even if a minigame's leave() or logout() method fails to clean up properly, - * this handler ensures the player's PvP flags are reset before they rejoin. - * - * This works alongside PvPStateAudit (which runs periodically) to provide - * comprehensive protection against PvP state leaking out of designated areas. - * - * @see core.game.node.entity.player.link.PvPStateAudit - * @see core.api.PvPZone - */ -class PvPLogoutHandler : LogoutListener { - - /** - * Called when a player logs out. - * Always cleans up PvP state as a final safety measure. - */ - override fun logout(player: Player) { - // Always clean up PvP state on logout as final safety net. - // This ensures that even if a minigame's leave() method fails, - // the player won't have PvP flags enabled when they log back in. - PvPStateAudit.cleanupPvPState(player) - } -} - diff --git a/Server/src/main/content/minigame/castlewars/areas/CastleWarsArea.kt b/Server/src/main/content/minigame/castlewars/areas/CastleWarsArea.kt index 0d1ed3d76..5743682d1 100644 --- a/Server/src/main/content/minigame/castlewars/areas/CastleWarsArea.kt +++ b/Server/src/main/content/minigame/castlewars/areas/CastleWarsArea.kt @@ -51,10 +51,6 @@ abstract class CastleWarsArea : MapArea, LogoutListener, InteractionListener { // Remove teleblock removeTimer(player, "teleblock") - // TODO: PvPStateAudit blows, has gotta go and actual safe handling of PvP generalized to cover current inauthentic deep wildy, - // future authentic minigames and that wont result in 7000 exploits that ruin the server (yes. thats a good idea. who said that) - // PvPStateAudit.cleanupPvPState(player) - player.equipment.removeAll(CastleWars.CW_ITEMS_TO_REMOVE) player.inventory.removeAll(CastleWars.CW_ITEMS_TO_REMOVE) (player.familiarManager.familiar as? BurdenBeast)?.container?.removeAll(CastleWars.CW_ITEMS_TO_REMOVE) diff --git a/Server/src/main/content/minigame/castlewars/areas/CastleWarsGameArea.kt b/Server/src/main/content/minigame/castlewars/areas/CastleWarsGameArea.kt index 3db8b03f2..e037c5240 100644 --- a/Server/src/main/content/minigame/castlewars/areas/CastleWarsGameArea.kt +++ b/Server/src/main/content/minigame/castlewars/areas/CastleWarsGameArea.kt @@ -7,7 +7,9 @@ import core.game.component.Component import core.game.event.EventHook import core.game.event.InterfaceCloseEvent import core.game.interaction.Option +import core.game.node.Node import core.game.node.entity.Entity +import core.game.node.entity.combat.CombatStyle import core.game.node.entity.player.Player import core.game.node.item.Item import core.game.world.map.Location @@ -20,7 +22,7 @@ import org.rs09.consts.Components /** * Handles the Castle Wars game map */ -class CastleWarsGameArea : CastleWarsArea(), TickListener, PvPZone { +class CastleWarsGameArea : CastleWarsArea(), TickListener { companion object { private val saradominStandardFloor: ZoneBorders = @@ -342,46 +344,21 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener, PvPZone { return handleDeath(entity, killer) } - // ==================== PvPZone Implementation ==================== TODO: PvPZone general review... it might be good but need to triple-check - - /** - * Check if attacker can attack victim in this zone. - * Only allows attacks between players on opposite teams. - * Uses self-healing team detection based on equipped cloak to ensure - * players are properly tracked even if they weren't added to sets correctly. - * @param attacker The player initiating the attack - * @param victim The player being attacked - * @return true if players are on opposite teams, false otherwise - */ - override fun canAttackPlayer(attacker: Player, victim: Player): Boolean { - if (ticksLeftInGame <= 0) return false + override fun isPvpAllowed(entity: Entity, target: Node?, style: CombatStyle?, message: Boolean): Boolean? { + val attacker = entity as? Player ?: return null + val victim = target as? Player ?: return null + if (ticksLeftInGame <= 0) { + return false + } val attackerTeam = CastleWarsGameState.getPlayerTeam(attacker) ?: return false val victimTeam = CastleWarsGameState.getPlayerTeam(victim) ?: return false - return attackerTeam != victimTeam - } - - /** - * Check combat level restrictions. - * Castle Wars has no combat level restrictions. - */ - override fun checkCombatLevel(attacker: Player, victim: Player): Boolean { + if (attackerTeam == victimTeam) { + if (message) { + sendMessage(attacker, "You can only attack players on the opposite team!") + } + return false + } return true } - /** - * Get the message to display when attack is blocked. - * @return The message string - */ - override fun getBlockedAttackMessage(): String { - return "You can only attack players on the opposite team!" // TODO: pretty sure this is inauthentic. seen the real one in vids, needs correction here (also does this even get called huh) - } - - /** - * Get the message to display when attack is blocked by combat level check. - * @return The message string - */ - override fun getCombatLevelBlockedMessage(): String { - return PvPZone.DEFAULT_COMBAT_LEVEL_MESSAGE - } - } diff --git a/Server/src/main/core/api/MapArea.kt b/Server/src/main/core/api/MapArea.kt index 28631f54d..e138d975c 100644 --- a/Server/src/main/core/api/MapArea.kt +++ b/Server/src/main/core/api/MapArea.kt @@ -1,6 +1,8 @@ package core.api +import core.game.node.Node import core.game.node.entity.Entity +import core.game.node.entity.combat.CombatStyle import core.game.world.map.Location import core.game.world.map.zone.MapZone import core.game.world.map.zone.RegionZone @@ -25,6 +27,18 @@ interface MapArea : ContentInterface { fun areaEnter(entity: Entity) {} fun areaLeave(entity: Entity, logout: Boolean) {} fun entityStep(entity: Entity, location: Location, lastLocation: Location) {} + /** + * Extends MapZone to cater to pvp, team games and all other unique targeting systems. + * Return true/false if you want to override the controls. + * Return null if you want the default check in MapZone. (default) + **/ + fun isPvpAllowed(entity: Entity, target: Node?, style: CombatStyle?, message: Boolean): Boolean? { return null } + /** + * Extends MapZone to cater to pvp, team games and all other unique targeting systems. + * Return true/false if you want to allow the deaths, especially false for "safe" areas. + * Return null if you want the default check in MapZone. (default) + **/ + fun canStartDeath(entity: Entity, killer: Entity): Boolean? { return null } companion object { val zoneMaps = HashMap() diff --git a/Server/src/main/core/api/PvPCombatUtils.kt b/Server/src/main/core/api/PvPCombatUtils.kt deleted file mode 100644 index ea904442f..000000000 --- a/Server/src/main/core/api/PvPCombatUtils.kt +++ /dev/null @@ -1,76 +0,0 @@ -package core.api - -import core.game.node.entity.player.Player -import kotlin.math.abs - -/** - * Utility object providing combat level range checking for PvP zones. - * Used by zones that implement the PvPZone interface to determine - * if two players are within the allowed combat level range for an area. - * - * @see PvPZone - */ -object PvPCombatUtils { - /** - * Check if two players are within a combat level range. - * Used for wilderness-style level restrictions where the allowed - * combat level difference is determined by the area's level. - * - * @param attacker The player initiating the attack - * @param victim The player being attacked - * @param range The maximum allowed combat level difference - * @return true if within allowed range, false otherwise - */ - @JvmStatic - fun withinCombatRange(attacker: Player, victim: Player, range: Int): Boolean { - val attackerLevel = attacker.properties.currentCombatLevel - val victimLevel = victim.properties.currentCombatLevel - return abs(attackerLevel - victimLevel) <= range - } - - /** - * Calculate wilderness-style combat range based on wilderness level. - * In the wilderness, the combat level range equals the wilderness level. - * For example, in level 5 wilderness, you can attack players within - * 5 combat levels of your own level. - * - * @param wildernessLevel The wilderness level of the area - * @return The combat level range (same as wilderness level) - */ - @JvmStatic - fun getWildernessStyleRange(wildernessLevel: Int): Int { - return wildernessLevel - } - - /** - * Check if combat between two players is allowed based on wilderness-style rules. - * Combines the wilderness level lookup with combat range checking. - * - * @param attacker The player initiating the attack - * @param victim The player being attacked - * @param wildernessLevel The wilderness level of the area - * @return true if combat is allowed, false otherwise - */ - @JvmStatic - fun checkWildernessStyleCombat(attacker: Player, victim: Player, wildernessLevel: Int): Boolean { - val range = getWildernessStyleRange(wildernessLevel) - return withinCombatRange(attacker, victim, range) - } - - /** - * Get a formatted message explaining why combat was blocked due to level difference. - * - * @param attacker The attacking player - * @param victim The victim player - * @param allowedRange The maximum allowed combat level difference - * @return A descriptive message explaining the level restriction - */ - @JvmStatic - fun getCombatLevelBlockedMessage(attacker: Player, victim: Player, allowedRange: Int): String { - val attackerLevel = attacker.properties.currentCombatLevel - val victimLevel = victim.properties.currentCombatLevel - val actualDifference = abs(attackerLevel - victimLevel) - return "The combat level difference ($actualDifference) exceeds the allowed range ($allowedRange)." - } -} - diff --git a/Server/src/main/core/api/PvPZone.kt b/Server/src/main/core/api/PvPZone.kt deleted file mode 100644 index 628e1b5d2..000000000 --- a/Server/src/main/core/api/PvPZone.kt +++ /dev/null @@ -1,51 +0,0 @@ -package core.api - -import core.game.node.entity.player.Player - -/** - * Interface for zones that allow player-vs-player combat. - * Zones implementing this interface must explicitly define PvP rules. - * This provides a centralized, explicit mechanism for PvP permission checking - * to prevent combat state leakage (e.g., Falador Massacre-style bugs). - */ -interface PvPZone { - /** - * Check if attacker can attack victim in this zone. - * This is the primary permission check for PvP combat. - * @param attacker The player initiating the attack - * @param victim The player being attacked - * @return true if attack is permitted, false otherwise - */ - fun canAttackPlayer(attacker: Player, victim: Player): Boolean - - /** - * Check if combat level restrictions apply between two players. - * Override this for zones with level-based restrictions (e.g., wilderness level range). - * @param attacker The player initiating the attack - * @param victim The player being attacked - * @return true if within allowed combat level range, false otherwise - */ - fun checkCombatLevel(attacker: Player, victim: Player): Boolean - - /** - * Get the message to display when attack is blocked by canAttackPlayer(). - * @return The message string to send to the attacking player - */ - fun getBlockedAttackMessage(): String - - /** - * Get the message to display when attack is blocked by combat level check. - * @return The message string to send to the attacking player - */ - fun getCombatLevelBlockedMessage(): String - - /** - * Companion object providing default values for the interface methods. - * Use these in Java implementations to get default behavior. - */ - companion object { - const val DEFAULT_BLOCKED_MESSAGE = "You cannot attack that player here." // TODO: correct - const val DEFAULT_COMBAT_LEVEL_MESSAGE = "The level difference between you and your opponent is too great." - } -} - diff --git a/Server/src/main/core/game/node/entity/player/Player.java b/Server/src/main/core/game/node/entity/player/Player.java index 3381df361..23abb9281 100644 --- a/Server/src/main/core/game/node/entity/player/Player.java +++ b/Server/src/main/core/game/node/entity/player/Player.java @@ -47,11 +47,8 @@ import core.game.system.task.Pulse; import core.game.world.map.*; import core.game.world.map.build.DynamicRegion; import core.game.world.map.path.Pathfinder; -import core.game.world.map.zone.RegionZone; import core.game.world.map.zone.ZoneRestriction; import core.game.world.map.zone.ZoneType; -import core.api.MapArea; -import core.api.PvPZone; import core.game.node.entity.combat.DeathContext; import core.game.world.update.flag.PlayerFlags; import core.game.world.update.flag.*; @@ -614,7 +611,6 @@ public class Player extends Entity { @Override public void commenceDeath(Entity killer) { if (!isPlaying()) return; - // TODO: review DeathContext, PvPStateAudit, PvPZone and related components. theyre all cool but also... cant this break a lot of shit? 100%. does it? uhhh... lemme check (later DeathContext context = new DeathContext( getLocation(), getZoneMonitor().getType(), @@ -794,26 +790,6 @@ public class Player extends Entity { } if (entity instanceof Player) { Player attacker = (Player) entity; - - // NEW: Check for PvPZone-based permission (minigames, etc.) - PvPZone pvpZone = findCommonPvPZone(attacker, this); - if (pvpZone != null) { - if (!pvpZone.canAttackPlayer(attacker, this)) { - if (message) { - attacker.getPacketDispatch().sendMessage(pvpZone.getBlockedAttackMessage()); - } - return false; - } - if (!pvpZone.checkCombatLevel(attacker, this)) { - if (message) { - attacker.getPacketDispatch().sendMessage(pvpZone.getCombatLevelBlockedMessage()); - } - return false; - } - return true; - } - - // LEGACY: Wilderness fallback (kept for backward compatibility) if (attacker.getSkullManager().isWilderness() && skullManager.isWilderness()) { if (!GameWorld.getSettings().getWild_pvp_enabled()) return false; @@ -821,77 +797,13 @@ public class Player extends Entity { return false; if (skullManager.hasWildernessProtection()) return false; - return super.isAttackable(entity, style, message); - } else return false; + } else if (!getZoneMonitor().isPvPable(entity, this, style, message)) { + return false; + } } return super.isAttackable(entity, style, message); } - /** - * Find a PvPZone that both the attacker and this player (victim) are in. - * @param attacker The attacking player - * @param victim The player being attacked (this) - * @return The common PvPZone, or null if no common zone exists - */ - private PvPZone findCommonPvPZone(Player attacker, Player victim) { - // First check: Direct PvPZone implementations (e.g., ActivityPlugin subclasses) - for (RegionZone zone : attacker.getZoneMonitor().getZones()) { - if (zone.getZone() instanceof PvPZone) { - for (RegionZone victimZone : victim.getZoneMonitor().getZones()) { - if (zone.getZone() == victimZone.getZone()) { - return (PvPZone) zone.getZone(); - } - } - } - } - - // Second check: MapArea implementations that implement PvPZone - // ClassScanner wraps MapAreas in anonymous MapZone classes, so we need to - // look up the original MapArea from MapArea.zoneMaps - for (RegionZone zone : attacker.getZoneMonitor().getZones()) { - String zoneName = zone.getZone().getName(); - // MapArea zones are named with "MapArea" suffix by ClassScanner - if (zoneName != null && zoneName.endsWith("MapArea")) { - // Look up the original MapArea class that implements PvPZone - for (java.util.Map.Entry entry : MapArea.Companion.getZoneMaps().entrySet()) { - if (entry.getKey().equals(zoneName) && entry.getValue() == zone.getZone()) { - // Find the MapArea instance - we need to check all loaded ContentInterfaces - // The MapArea interface stores zones but not the instances themselves - // We need to iterate GameWorld's content to find the matching MapArea - PvPZone pvpZone = findMapAreaPvPZone(zoneName, attacker, victim); - if (pvpZone != null) { - return pvpZone; - } - } - } - } - } - - return null; - } - - /** - * Find a MapArea that implements PvPZone and matches the zone name. - * This is needed because ClassScanner wraps MapAreas in anonymous classes. - */ - private PvPZone findMapAreaPvPZone(String zoneName, Player attacker, Player victim) { - // Check TickListeners - MapAreas often implement TickListener - for (Object listener : GameWorld.getTickListeners()) { - if (listener instanceof MapArea && listener instanceof PvPZone) { - String mapAreaZoneName = listener.getClass().getSimpleName() + "MapArea"; - if (mapAreaZoneName.equals(zoneName)) { - // Verify victim is also in this zone - for (RegionZone victimZone : victim.getZoneMonitor().getZones()) { - if (victimZone.getZone().getName().equals(zoneName)) { - return (PvPZone) listener; - } - } - } - } - } - return null; - } - @Override public boolean continueAttack(Entity target, CombatStyle style, boolean message) { if (target instanceof NPC) { @@ -913,7 +825,7 @@ public class Player extends Entity { return false; } return !skullManager.hasWildernessProtection(); - } else { + } else if (!getZoneMonitor().isPvPable(this, target, style, message)) { return false; } } diff --git a/Server/src/main/core/game/node/entity/player/link/PvPStateAudit.kt b/Server/src/main/core/game/node/entity/player/link/PvPStateAudit.kt deleted file mode 100644 index ffc711add..000000000 --- a/Server/src/main/core/game/node/entity/player/link/PvPStateAudit.kt +++ /dev/null @@ -1,67 +0,0 @@ -package core.game.node.entity.player.link - -import core.api.PvPZone -import core.api.TickListener -import core.api.log -import core.game.interaction.Option -import core.game.node.entity.player.Player -import core.game.world.map.zone.ZoneRestriction -import core.game.world.map.zone.impl.WildernessZone -import core.game.world.repository.Repository -import core.tools.Log - -/** - * Periodic audit that detects and fixes PvP state leakage. - * Runs every 10 ticks (6 seconds) to catch players who have - * PvP flags enabled outside valid PvP zones. - */ -class PvPStateAudit : TickListener { - private var tickCounter = 0 - - override fun tick() { - tickCounter++ - if (tickCounter < 10) return - tickCounter = 0 - - for (player in Repository.players) { - if (player == null || !player.isActive || player.isArtificial) continue - auditPlayer(player) - } - } - - private fun auditPlayer(player: Player) { - val hasPvPEnabled = player.skullManager.isWilderness - val inPvPZone = isInValidPvPZone(player) - if (hasPvPEnabled && !inPvPZone) { - log(this::class.java, Log.WARN, - "Player ${player.username} has PvP enabled but is not in a PvP zone! " + - "Location: ${player.location}, Cleaning up state.") - cleanupPvPState(player) - } - } - - private fun isInValidPvPZone(player: Player): Boolean { - for (zone in player.zoneMonitor.zones) { - if (zone.zone is PvPZone) return true - } - if (player.zoneMonitor.isRestricted(ZoneRestriction.PVP_ZONE)) return true - return WildernessZone.isInZone(player) - } - - companion object { - /** - * Clean up all PvP-related state from a player. - * Called when a player is found to have PvP flags enabled outside a PvP zone. - */ - @JvmStatic - fun cleanupPvPState(player: Player) { - player.skullManager.isWilderness = false - player.skullManager.isSkullCheckDisabled = false - player.skullManager.level = 0 - player.properties.isSafeZone = false - player.properties.isMultiZone = false - player.interaction.remove(Option._P_ATTACK) - } - } -} - diff --git a/Server/src/main/core/game/world/map/zone/MapZone.java b/Server/src/main/core/game/world/map/zone/MapZone.java index 7adb16dfc..752598f6f 100644 --- a/Server/src/main/core/game/world/map/zone/MapZone.java +++ b/Server/src/main/core/game/world/map/zone/MapZone.java @@ -128,7 +128,21 @@ public abstract class MapZone implements Zone { public boolean actionButton(Player player, int interfaceId, int buttonId, int slot, int itemId, int opcode) { return false; } - + + /** + * Checks if the entity is able to continue attacking another player within this zone. + * This is added like continueAttack for player vs player fighting. + * + * @param e the attacking entity + * @param target the target node being attacked + * @param style the combat style being used + * @param message whether to send a message to the player explaining why the attack cannot continue + * @return {@code true} if the zone allows the attack to continue + */ + public boolean continuePvp(Entity e, Node target, CombatStyle style, boolean message) { + return false; + } + /** * Checks if the entity is able to continue attacking the target within this zone. * diff --git a/Server/src/main/core/game/world/map/zone/ZoneMonitor.java b/Server/src/main/core/game/world/map/zone/ZoneMonitor.java index d3e7de92e..4fabe5995 100644 --- a/Server/src/main/core/game/world/map/zone/ZoneMonitor.java +++ b/Server/src/main/core/game/world/map/zone/ZoneMonitor.java @@ -1,7 +1,5 @@ package core.game.world.map.zone; -import core.api.MapArea; -import core.api.PvPZone; import core.game.interaction.Option; import core.game.node.Node; import core.game.node.entity.Entity; @@ -10,7 +8,6 @@ import core.game.node.entity.player.Player; import core.game.node.entity.player.link.music.MusicZone; import core.game.node.entity.player.link.request.RequestType; import core.game.node.item.Item; -import core.game.world.GameWorld; import core.game.world.map.Location; import core.game.world.map.Region; import org.rs09.consts.Items; @@ -150,6 +147,23 @@ public final class ZoneMonitor { return false; } + /** + * Checks whether any current zone explicitly allows PvP against the target. + * + * @param player The attacking entity. + * @param target The target. + * @param style The combat style used. + * @return {@code True} if so. + */ + public boolean isPvPable(Entity player, Node target, CombatStyle style, boolean message) { + for (RegionZone z : zones) { + if (z.getZone().continuePvp(player, target, style, message)) { + return true; + } + } + return false; + } + /** * Checks if the entity is able to continue attacking the target. * @@ -168,72 +182,12 @@ public final class ZoneMonitor { return false; } } - if (entity instanceof Player && target instanceof Player) { - Player attacker = (Player) entity; - Player victim = (Player) target; - // Skip wilderness check if both players are in a PvPZone (e.g., Castle Wars, Duel Arena) - // PvPZone handles its own attack permissions via canAttackPlayer() - if (!isInCommonPvPZone(attacker, victim)) { - if (!attacker.getSkullManager().isWilderness() || !victim.getSkullManager().isWilderness()) { - if (message) { - attacker.getPacketDispatch().sendMessage("You can only attack other players in the wilderness."); - } - return false; - } - } - } if (target instanceof Entity && !MapZone.checkMulti(entity, (Entity) target, message)) { return false; } return true; } - /** - * Check if both players are in a common PvPZone. - * Used to bypass the wilderness check for minigames like Castle Wars and Duel Arena. - * - * @param attacker The attacking player - * @param victim The player being attacked - * @return true if both players share a PvPZone - */ - private boolean isInCommonPvPZone(Player attacker, Player victim) { - // Check for direct PvPZone implementations (e.g., ActivityPlugin subclasses) - for (RegionZone zone : attacker.getZoneMonitor().getZones()) { - if (zone.getZone() instanceof PvPZone) { - for (RegionZone victimZone : victim.getZoneMonitor().getZones()) { - if (zone.getZone() == victimZone.getZone()) { - return true; - } - } - } - } - // Check for MapArea-based PvPZones (ClassScanner wraps MapAreas in anonymous MapZone classes) - for (RegionZone zone : attacker.getZoneMonitor().getZones()) { - String zoneName = zone.getZone().getName(); - if (zoneName != null && zoneName.endsWith("MapArea")) { - // Look up the original MapArea instance from TickListeners - for (Object listener : GameWorld.getTickListeners()) { - if (listener instanceof MapArea && listener instanceof PvPZone) { - String mapAreaZoneName = listener.getClass().getSimpleName() + "MapArea"; - if (mapAreaZoneName.equals(zoneName)) { - for (RegionZone victimZone : victim.getZoneMonitor().getZones()) { - if (victimZone.getZone().getName().equals(zoneName)) { - return true; - } - } - } - } - } - } - } - // Check for PVP_ZONE restriction flag - if (attacker.getZoneMonitor().isRestricted(ZoneRestriction.PVP_ZONE) && - victim.getZoneMonitor().isRestricted(ZoneRestriction.PVP_ZONE)) { - return true; - } - return false; - } - /** * Checks if the entity can interact with the target. * diff --git a/Server/src/main/core/game/world/map/zone/ZoneRestriction.java b/Server/src/main/core/game/world/map/zone/ZoneRestriction.java index 0d34313f1..64dd971db 100644 --- a/Server/src/main/core/game/world/map/zone/ZoneRestriction.java +++ b/Server/src/main/core/game/world/map/zone/ZoneRestriction.java @@ -47,20 +47,6 @@ public enum ZoneRestriction { * Dynamic regions are implicitly off-map and do not require this attribute. */ OFF_MAP, - - /** - * This zone allows player-vs-player combat. - * Used to mark zones where PvP is explicitly permitted. - * Zones with this restriction should implement the PvPZone interface - * to define specific combat rules (who can attack whom, combat level ranges, etc.). - * - * This flag is used by PvPStateAudit as an additional validation mechanism - * to ensure PvP combat state doesn't leak out of designated areas. - * - * @see core.api.PvPZone - * @see core.game.node.entity.player.link.PvPStateAudit - */ - PVP_ZONE, ; /** diff --git a/Server/src/main/core/plugin/ClassScanner.kt b/Server/src/main/core/plugin/ClassScanner.kt index 2ad6e5f4b..725a5427c 100644 --- a/Server/src/main/core/plugin/ClassScanner.kt +++ b/Server/src/main/core/plugin/ClassScanner.kt @@ -7,7 +7,9 @@ import core.game.activity.ActivityPlugin import core.game.bots.PlayerScripts import core.game.interaction.InteractionListener import core.game.interaction.InterfaceListener +import core.game.node.Node import core.game.node.entity.Entity +import core.game.node.entity.combat.CombatStyle import core.game.node.entity.npc.NPCBehavior import core.game.node.entity.player.info.login.LoginConfiguration import core.game.node.entity.player.info.login.PlayerSaveParser @@ -128,6 +130,29 @@ object ClassScanner { if(e != null && from != null && to != null) clazz.entityStep(e, to, from) return super.move(e, from, to) } + + // Additional hook for continueAttack for PVP area controls + override fun continuePvp(e: Entity, target: Node?, style: CombatStyle?, message: Boolean): Boolean { + val clazzAttack = clazz.isPvpAllowed(e, target, style, message) + if (clazzAttack == null) { // If clazz returns null, continue the usual check. + return super.continuePvp(e, target, style, message) + } else { + return clazzAttack // Otherwise return whatever function. + } + } + + // Additional hook for startDeath for PVP area controls + override fun startDeath(e: Entity?, killer: Entity?): Boolean { + if (e == null) { + return super.startDeath(null, killer) + } + val clazzAttack = clazz.canStartDeath(e, killer) + if (clazzAttack == null) { // If clazz returns null, continue the usual check. + return super.startDeath(e, killer) + } else { + return clazzAttack // Otherwise return whatever function. + } + } } for(border in clazz.defineAreaBorders()) zone.register(border) ZoneBuilder.configure(zone) From 8252a1f63c913c3cf5392186de931f0d53f011fa Mon Sep 17 00:00:00 2001 From: dam <27978131-real_damighty@users.noreply.gitlab.com> Date: Fri, 10 Apr 2026 00:12:57 +0300 Subject: [PATCH 13/26] default.conf enable_castle_wars=true --- Server/worldprops/default.conf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Server/worldprops/default.conf b/Server/worldprops/default.conf index 308db7417..bd8588064 100644 --- a/Server/worldprops/default.conf +++ b/Server/worldprops/default.conf @@ -92,7 +92,7 @@ jad_practice_enabled = false enable_global_chat = false #minimum HA value for announcements of bots selling on ge ge_announcement_limit = 500 -enable_castle_wars = false +enable_castle_wars = true personalized_shops = false bots_influence_ge_price = true #verbose cutscene logging (for cutscenes in the new system) From f1bf63d3539071dab72acc993cd1f5e4552e903f Mon Sep 17 00:00:00 2001 From: dam <27978131-real_damighty@users.noreply.gitlab.com> Date: Fri, 10 Apr 2026 00:27:16 +0300 Subject: [PATCH 14/26] MapArea canStartDeath() killer type nullability --- Server/src/main/core/api/MapArea.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Server/src/main/core/api/MapArea.kt b/Server/src/main/core/api/MapArea.kt index e138d975c..6b72737ef 100644 --- a/Server/src/main/core/api/MapArea.kt +++ b/Server/src/main/core/api/MapArea.kt @@ -5,7 +5,6 @@ import core.game.node.entity.Entity import core.game.node.entity.combat.CombatStyle import core.game.world.map.Location import core.game.world.map.zone.MapZone -import core.game.world.map.zone.RegionZone import core.game.world.map.zone.ZoneBorders import core.game.world.map.zone.ZoneRestriction @@ -38,7 +37,7 @@ interface MapArea : ContentInterface { * Return true/false if you want to allow the deaths, especially false for "safe" areas. * Return null if you want the default check in MapZone. (default) **/ - fun canStartDeath(entity: Entity, killer: Entity): Boolean? { return null } + fun canStartDeath(entity: Entity, killer: Entity?): Boolean? { return null } companion object { val zoneMaps = HashMap() From 6b76a3f8d0fa27d8f130a7296a8fde6709186e2d Mon Sep 17 00:00:00 2001 From: dam <27978131-real_damighty@users.noreply.gitlab.com> Date: Fri, 10 Apr 2026 19:16:59 +0300 Subject: [PATCH 15/26] Cleanup, burning catapults, player cap removed, stepping stone fixes --- .../content/minigame/castlewars/CastleWars.kt | 44 +++- .../castlewars/CastleWarsBarricadeNPC.kt | 2 +- .../castlewars/CastleWarsConstants.kt | 19 +- .../castlewars/CastleWarsDoorAttackPulse.kt | 1 - .../castlewars/CastleWarsFlagHandler.kt | 33 --- .../castlewars/CastleWarsGameState.kt | 231 ++++++++---------- .../castlewars/CastleWarsItemHandler.kt | 2 +- .../castlewars/CastleWarsListeners.kt | 29 +-- .../castlewars/CastleWarsTunnelMiningPulse.kt | 2 +- .../castlewars/areas/CastleWarsGameArea.kt | 1 - 10 files changed, 155 insertions(+), 209 deletions(-) diff --git a/Server/src/main/content/minigame/castlewars/CastleWars.kt b/Server/src/main/content/minigame/castlewars/CastleWars.kt index 784e95f77..2ca5fa970 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWars.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWars.kt @@ -4,6 +4,7 @@ import content.minigame.castlewars.CastleWarsConstants.GAME_COOLDOWN_MINUTES import content.minigame.castlewars.CastleWarsConstants.GAME_TIME_MINUTES import content.minigame.castlewars.CastleWarsConstants.ROPE_DURATION_TICKS import core.game.world.map.Location +import core.game.world.map.RegionManager import core.game.world.map.zone.ZoneBorders import org.rs09.consts.Items import org.rs09.consts.NPCs @@ -21,11 +22,22 @@ object CastleWars { const val castleWaterTap: Int = Scenery.TAP_4482 const val cwSteppingStones: Int = Scenery.STEPPING_STONE_4411 - // Stepping stone tile locations for flag auto-return check. - // If a flag is dropped on any of these tiles, it immediately returns to base. + private val steppingStoneLocations: Set by lazy(LazyThreadSafetyMode.NONE) { + val locations = mutableSetOf() + for (x in 2368..2431) { + for (y in 3072..3135) { + val location = Location.create(x, y, 0) + if (RegionManager.getObject(location)?.id == cwSteppingStones) { + locations += location + } + } + } + locations + } + + // Stepping stone tile locations are cached so dropped flag scenery cannot mask the underlying stone tile. fun isSteppingStoneTile(location: Location): Boolean { - val obj = core.game.world.map.RegionManager.getObject(location) - return obj != null && obj.id == cwSteppingStones + return steppingStoneLocations.contains(Location.create(location)) } // Energy barriers @@ -71,15 +83,24 @@ object CastleWars { const val tunnelRocksWeakened: Int = 4438 // Weakened rocks (1 explosive hit, still blocking) const val caveWall: Int = 4448 // Cave wall for causing collapse - // Catapults - can be operated, destroyed, and repaired - // Both 4381 and 4382 are operational catapults (different orientations in the map) - // Both 4385 and 4386 are damaged/destroyed catapults (have "Repair" option instead of "Operate") + // Catapults - can be operated, set on fire, destroyed, and repaired. + // 4381/4382 are the operational variants, 4904/4905 are the burning variants, 4385/4386 are damaged. const val catapult: Int = 4381 // Operational catapult variant 1 const val catapultAlt: Int = 4382 // Operational catapult variant 2 (different orientation) + const val catapultBurning1: Int = 4904 // Burning catapult variant 1 + const val catapultBurning2: Int = 4905 // Burning catapult variant 2 const val catapultDamaged1: Int = 4385 // Damaged catapult variant 1 const val catapultDamaged2: Int = 4386 // Damaged catapult variant 2 + val burningCatapultIds: IntArray = intArrayOf(catapultBurning1, catapultBurning2) val damagedCatapultIds: IntArray = intArrayOf(catapultDamaged1, catapultDamaged2) - val allCatapultIds: IntArray = intArrayOf(catapult, catapultAlt, catapultDamaged1, catapultDamaged2) + val allCatapultIds: IntArray = intArrayOf( + catapult, + catapultAlt, + catapultBurning1, + catapultBurning2, + catapultDamaged1, + catapultDamaged2 + ) fun getOperationalCatapultId(team: CastleWarsTeam): Int = when (team) { CastleWarsTeam.SARADOMIN -> catapultAlt @@ -91,6 +112,11 @@ object CastleWars { CastleWarsTeam.ZAMORAK -> catapultDamaged1 } + fun getBurningCatapultId(team: CastleWarsTeam): Int = when (team) { + CastleWarsTeam.SARADOMIN -> catapultBurning2 + CastleWarsTeam.ZAMORAK -> catapultBurning1 + } + // Trapdoors - allow going down from 2nd floor to respawn room const val saradominTrapdoor: Int = 4471 // Saradomin castle trapdoor const val zamorakTrapdoor: Int = 4472 // Zamorak castle trapdoor @@ -144,7 +170,7 @@ object CastleWars { const val cwBandages: Int = Items.BANDAGES_4049 const val cwToolkit: Int = Items.TOOLKIT_4051 const val cwBarricade: Int = Items.BARRICADE_4053 - const val cwManualBook: Int = Items.CASTLEWARS_MANUAL_4055 // TODO: unused? what happened + const val cwManualBook: Int = Items.CASTLEWARS_MANUAL_4055 const val cwTicketRewardCurrency: Int = Items.CASTLE_WARS_TICKET_4067 // Authoritative list of items to remove from players on game end or area exit. diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsBarricadeNPC.kt b/Server/src/main/content/minigame/castlewars/CastleWarsBarricadeNPC.kt index f974e6fe7..f53b06f3c 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsBarricadeNPC.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsBarricadeNPC.kt @@ -10,7 +10,7 @@ import org.rs09.consts.NPCs /** * Custom NPC class for Castle Wars barricades. * - * - Block all entity movement through them (like Pest Control Brawlers) TODO: so... it sucks? shit + * - Block all entity movement through them (like Pest Control Brawlers) * - Never retaliate when attacked (this was scary man they squared up) * - Never turn to face attackers or any direction * - Cannot attack or move diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsConstants.kt b/Server/src/main/content/minigame/castlewars/CastleWarsConstants.kt index a973d0579..e46a16e60 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsConstants.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsConstants.kt @@ -10,10 +10,10 @@ object CastleWarsConstants { /** Grace period after picking up own flag before castle entry check (prevents instant return) */ const val FLAG_RETURN_GRACE_PERIOD_TICKS = 3 - /** Ticks before a dropped flag automatically returns to its stand (~90 seconds) */ + /** Ticks before a dropped flag automatically returns to its stand */ const val FLAG_AUTO_RETURN_TICKS = 150 - /** Duration of climbing ropes before despawning (45 seconds at 600ms/tick) */ + /** Duration of climbing ropes before despawning */ const val ROPE_DURATION_TICKS = 75 /** Burning barricade pulse interval (3 ticks = 1.8 seconds) */ @@ -39,23 +39,20 @@ object CastleWarsConstants { /** Maximum barricades per team */ const val MAX_BARRICADES_PER_TEAM = 10 - /** Maximum players per team */ - const val MAX_PLAYERS_PER_TEAM = 25 // TODO: I dont believe this, where? Says who - /** Minimum players per team required to start a game */ const val MIN_PLAYERS_PER_TEAM = 2 /** Catapult targeting inaccuracy radius */ - const val CATAPULT_INACCURACY_RADIUS = 2 // TODO: says who + const val CATAPULT_INACCURACY_RADIUS = 2 /** Catapult impact damage radius */ - const val CATAPULT_IMPACT_RADIUS = 3 // TODO: says who + const val CATAPULT_IMPACT_RADIUS = 3 /** Catapult minimum damage */ - const val CATAPULT_MIN_DAMAGE = 10 // TODO: says who + const val CATAPULT_MIN_DAMAGE = 10 /** Catapult maximum damage */ - const val CATAPULT_MAX_DAMAGE = 20 // TODO: says who + const val CATAPULT_MAX_DAMAGE = 20 // ==================== Coordinate Defaults ==================== /** Default X coordinate for catapult targeting */ @@ -75,7 +72,7 @@ object CastleWarsConstants { const val BANDAGE_RUN_ENERGY_RESTORE = 30.0 /** Bandage cooldown in ticks (1 tick = faster than food's 2 ticks) */ - const val BANDAGE_COOLDOWN_TICKS = 1 // TODO: says who + const val BANDAGE_COOLDOWN_TICKS = 1 // ==================== Castle Wars Bracelet ==================== /** Bracelet damage bonus multiplier against flag carriers (20% extra) */ @@ -88,7 +85,7 @@ object CastleWarsConstants { /** Run energy drained when passing through an energy barrier */ const val ENERGY_BARRIER_DRAIN = 4.0 - // ==================== Backdoor Pick-Lock ==================== // TODO: says who + // ==================== Backdoor Pick-Lock ==================== /** Thieving level required for pick-lock (1 = no real requirement, all players can attempt) */ const val BACKDOOR_PICKLOCK_LEVEL = 1 diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsDoorAttackPulse.kt b/Server/src/main/content/minigame/castlewars/CastleWarsDoorAttackPulse.kt index 932e621db..539f8f916 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsDoorAttackPulse.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsDoorAttackPulse.kt @@ -14,7 +14,6 @@ import org.rs09.consts.Sounds * - Uses player's attack speed based on weapon * - Calculates damage based on combat stats * - Damages door health until destroyed or player stops - * TODO: 100% wild guesses and needs a fat refactoring */ class CastleWarsDoorAttackPulse( private val player: Player, diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsFlagHandler.kt b/Server/src/main/content/minigame/castlewars/CastleWarsFlagHandler.kt index ebf9ecbfe..a031e37f6 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsFlagHandler.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsFlagHandler.kt @@ -155,39 +155,6 @@ class CastleWarsFlagHandler : InteractionListener { } } - return@on true - } - // Legacy: Handle picking up dropped flags from the ground (ground items) - // This is kept for backwards compatibility but scenery is the primary method now - // TODO: cut - on(intArrayOf(CastleWars.saradominFlag, CastleWars.zamorakFlag), IntType.GROUNDITEM, "take") { player, node -> - if (!CastleWarsGameState.isGameActive) { - return@on false - } - val playerTeam = CastleWarsGameState.getPlayerTeam(player) ?: return@on false - val groundItem = node as? GroundItem ?: return@on false - val flagTeam = when (groundItem.id) { - CastleWars.saradominFlag -> CastleWarsTeam.SARADOMIN - CastleWars.zamorakFlag -> CastleWarsTeam.ZAMORAK - else -> return@on false - } - val isDroppedFlag = when (flagTeam) { - CastleWarsTeam.SARADOMIN -> CastleWarsGameState.saradominFlagState == FlagState.DROPPED - CastleWarsTeam.ZAMORAK -> CastleWarsGameState.zamorakFlagState == FlagState.DROPPED - } - if (!isDroppedFlag) { - return@on false - } - if (flagTeam == playerTeam) { - if (CastleWarsGameState.pickUpOwnFlag(player, flagTeam)) { - return@on true - } - } else { - if (CastleWarsGameState.pickUpFlag(player, flagTeam)) { - return@on true - } - } - return@on true } } diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsGameState.kt b/Server/src/main/content/minigame/castlewars/CastleWarsGameState.kt index 070f32c7c..ab8abb64b 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsGameState.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsGameState.kt @@ -266,14 +266,8 @@ object CastleWarsGameState { for (key in boulderLocations.keys) { updateBoulderScenery(key, BoulderState.COLLAPSED) } - replaceCatapultScenery( - saradominCatapultLocation, - CastleWars.getOperationalCatapultId(CastleWarsTeam.SARADOMIN) - ) - replaceCatapultScenery( - zamorakCatapultLocation, - CastleWars.getOperationalCatapultId(CastleWarsTeam.ZAMORAK) - ) + updateCatapultScenery(CastleWarsTeam.SARADOMIN) + updateCatapultScenery(CastleWarsTeam.ZAMORAK) resetBattlefield() } @@ -575,23 +569,21 @@ object CastleWarsGameState { */ fun dropFlag(player: Player) { val carriedFlag = getCarriedFlag(player) ?: return + val dropLocation = Location.create(player.location) + val shouldReturnToStand = CastleWars.isSteppingStoneTile(dropLocation) when (carriedFlag) { CastleWarsTeam.SARADOMIN -> { saradominFlagCarrier = null - saradominFlagState = FlagState.DROPPED - val droppedFlag = Scenery(CastleWars.saradominDroppedFlagScenery, player.location, 10, 0) - SceneryBuilder.add(droppedFlag) - saradominDroppedFlagScenery = droppedFlag - saradominFlagDroppedTick = getWorldTicks() + if (!shouldReturnToStand) { + addDroppedFlagScenery(CastleWarsTeam.SARADOMIN, dropLocation) + } } CastleWarsTeam.ZAMORAK -> { zamorakFlagCarrier = null - zamorakFlagState = FlagState.DROPPED - val droppedFlag = Scenery(CastleWars.zamorakDroppedFlagScenery, player.location, 10, 0) - SceneryBuilder.add(droppedFlag) - zamorakDroppedFlagScenery = droppedFlag - zamorakFlagDroppedTick = getWorldTicks() + if (!shouldReturnToStand) { + addDroppedFlagScenery(CastleWarsTeam.ZAMORAK, dropLocation) + } } } if (player.equipment.get(EquipmentContainer.SLOT_WEAPON)?.id == carriedFlag.flagItemId) { @@ -601,13 +593,12 @@ object CastleWarsGameState { player.inventory.remove(Item(carriedFlag.flagItemId)) } removeAttribute(player, CastleWarsConstants.ATTR_FLAG_PICKUP_TICK) - announceToAllPlayers("${carriedFlag.teamName} flag has been dropped!") - updateAllOverlays() - updateAllHintIcons() - // Stepping stone auto-return: if the flag was dropped on a stepping stone tile, - // immediately return it to the base stand (authentic RS behavior) - if (CastleWars.isSteppingStoneTile(player.location)) { + if (shouldReturnToStand) { returnFlagToStand(carriedFlag) + } else { + announceToAllPlayers("${carriedFlag.teamName} flag has been dropped!") + updateAllOverlays() + updateAllHintIcons() } } @@ -617,23 +608,21 @@ object CastleWarsGameState { */ fun dropOwnFlag(player: Player) { val carriedFlag = getOwnCarriedFlag(player) ?: return + val dropLocation = Location.create(player.location) + val shouldReturnToStand = CastleWars.isSteppingStoneTile(dropLocation) when (carriedFlag) { CastleWarsTeam.SARADOMIN -> { saradominOwnFlagCarrier = null - saradominFlagState = FlagState.DROPPED - val droppedFlag = Scenery(CastleWars.saradominDroppedFlagScenery, player.location, 10, 0) - SceneryBuilder.add(droppedFlag) - saradominDroppedFlagScenery = droppedFlag - saradominFlagDroppedTick = getWorldTicks() + if (!shouldReturnToStand) { + addDroppedFlagScenery(CastleWarsTeam.SARADOMIN, dropLocation) + } } CastleWarsTeam.ZAMORAK -> { zamorakOwnFlagCarrier = null - zamorakFlagState = FlagState.DROPPED - val droppedFlag = Scenery(CastleWars.zamorakDroppedFlagScenery, player.location, 10, 0) - SceneryBuilder.add(droppedFlag) - zamorakDroppedFlagScenery = droppedFlag - zamorakFlagDroppedTick = getWorldTicks() + if (!shouldReturnToStand) { + addDroppedFlagScenery(CastleWarsTeam.ZAMORAK, dropLocation) + } } } if (player.equipment.get(EquipmentContainer.SLOT_WEAPON)?.id == carriedFlag.flagItemId) { @@ -642,13 +631,12 @@ object CastleWarsGameState { if (player.inventory.contains(carriedFlag.flagItemId, 1)) { player.inventory.remove(Item(carriedFlag.flagItemId)) } - announceToAllPlayers("${carriedFlag.teamName} flag has been dropped!") - updateAllOverlays() - updateAllHintIcons() - // Stepping stone auto-return: if the flag was dropped on a stepping stone tile, - // immediately return it to the base stand (authentic RS behavior) - if (CastleWars.isSteppingStoneTile(player.location)) { + if (shouldReturnToStand) { returnFlagToStand(carriedFlag) + } else { + announceToAllPlayers("${carriedFlag.teamName} flag has been dropped!") + updateAllOverlays() + updateAllHintIcons() } } @@ -660,25 +648,23 @@ object CastleWarsGameState { * @param dropLocation The location where the flag should be dropped */ fun dropFlagAtLocation(player: Player, dropLocation: Location) { + val actualDropLocation = Location.create(dropLocation) + val shouldReturnToStand = CastleWars.isSteppingStoneTile(actualDropLocation) val carriedEnemyFlag = getCarriedFlag(player) if (carriedEnemyFlag != null) { when (carriedEnemyFlag) { CastleWarsTeam.SARADOMIN -> { saradominFlagCarrier = null - saradominFlagState = FlagState.DROPPED - val droppedFlag = Scenery(CastleWars.saradominDroppedFlagScenery, dropLocation, 10, 0) - SceneryBuilder.add(droppedFlag) - saradominDroppedFlagScenery = droppedFlag - saradominFlagDroppedTick = getWorldTicks() + if (!shouldReturnToStand) { + addDroppedFlagScenery(CastleWarsTeam.SARADOMIN, actualDropLocation) + } } CastleWarsTeam.ZAMORAK -> { zamorakFlagCarrier = null - zamorakFlagState = FlagState.DROPPED - val droppedFlag = Scenery(CastleWars.zamorakDroppedFlagScenery, dropLocation, 10, 0) - SceneryBuilder.add(droppedFlag) - zamorakDroppedFlagScenery = droppedFlag - zamorakFlagDroppedTick = getWorldTicks() + if (!shouldReturnToStand) { + addDroppedFlagScenery(CastleWarsTeam.ZAMORAK, actualDropLocation) + } } } if (player.equipment.get(EquipmentContainer.SLOT_WEAPON)?.id == carriedEnemyFlag.flagItemId) { @@ -687,12 +673,12 @@ object CastleWarsGameState { if (player.inventory.contains(carriedEnemyFlag.flagItemId, 1)) { player.inventory.remove(Item(carriedEnemyFlag.flagItemId)) } - announceToAllPlayers("${carriedEnemyFlag.teamName} flag has been dropped!") - updateAllOverlays() - updateAllHintIcons() - // Stepping stone auto-return check - if (CastleWars.isSteppingStoneTile(dropLocation)) { + if (shouldReturnToStand) { returnFlagToStand(carriedEnemyFlag) + } else { + announceToAllPlayers("${carriedEnemyFlag.teamName} flag has been dropped!") + updateAllOverlays() + updateAllHintIcons() } return } @@ -701,20 +687,16 @@ object CastleWarsGameState { when (carriedOwnFlag) { CastleWarsTeam.SARADOMIN -> { saradominOwnFlagCarrier = null - saradominFlagState = FlagState.DROPPED - val droppedFlag = Scenery(CastleWars.saradominDroppedFlagScenery, dropLocation, 10, 0) - SceneryBuilder.add(droppedFlag) - saradominDroppedFlagScenery = droppedFlag - saradominFlagDroppedTick = getWorldTicks() + if (!shouldReturnToStand) { + addDroppedFlagScenery(CastleWarsTeam.SARADOMIN, actualDropLocation) + } } CastleWarsTeam.ZAMORAK -> { zamorakOwnFlagCarrier = null - zamorakFlagState = FlagState.DROPPED - val droppedFlag = Scenery(CastleWars.zamorakDroppedFlagScenery, dropLocation, 10, 0) - SceneryBuilder.add(droppedFlag) - zamorakDroppedFlagScenery = droppedFlag - zamorakFlagDroppedTick = getWorldTicks() + if (!shouldReturnToStand) { + addDroppedFlagScenery(CastleWarsTeam.ZAMORAK, actualDropLocation) + } } } if (player.equipment.get(EquipmentContainer.SLOT_WEAPON)?.id == carriedOwnFlag.flagItemId) { @@ -723,13 +705,32 @@ object CastleWarsGameState { if (player.inventory.contains(carriedOwnFlag.flagItemId, 1)) { player.inventory.remove(Item(carriedOwnFlag.flagItemId)) } - announceToAllPlayers("${carriedOwnFlag.teamName} flag has been dropped!") - updateAllOverlays() - updateAllHintIcons() - - // Stepping stone auto-return check - if (CastleWars.isSteppingStoneTile(dropLocation)) { + if (shouldReturnToStand) { returnFlagToStand(carriedOwnFlag) + } else { + announceToAllPlayers("${carriedOwnFlag.teamName} flag has been dropped!") + updateAllOverlays() + updateAllHintIcons() + } + } + } + + private fun addDroppedFlagScenery(flagTeam: CastleWarsTeam, dropLocation: Location) { + when (flagTeam) { + CastleWarsTeam.SARADOMIN -> { + saradominFlagState = FlagState.DROPPED + val droppedFlag = Scenery(CastleWars.saradominDroppedFlagScenery, dropLocation, 10, 0) + SceneryBuilder.add(droppedFlag) + saradominDroppedFlagScenery = droppedFlag + saradominFlagDroppedTick = getWorldTicks() + } + + CastleWarsTeam.ZAMORAK -> { + zamorakFlagState = FlagState.DROPPED + val droppedFlag = Scenery(CastleWars.zamorakDroppedFlagScenery, dropLocation, 10, 0) + SceneryBuilder.add(droppedFlag) + zamorakDroppedFlagScenery = droppedFlag + zamorakFlagDroppedTick = getWorldTicks() } } } @@ -855,45 +856,6 @@ object CastleWarsGameState { updateAllHintIcons() } - /** - * Return a dropped flag to its stand (legacy) - * TODO: This is now only used for ground item pickup which is being cut coz its fake. Not real - * The new behavior uses pickUpOwnFlag() and castle entry detection. - * @param player The player returning the flag - * @param flagTeam The team whose flag is being returned - * @return true if the flag was successfully returned - */ - fun returnFlag(player: Player, flagTeam: CastleWarsTeam): Boolean { - val playerTeam = getPlayerTeam(player) ?: return false - if (playerTeam != flagTeam) { - return false - } - val flagState = - if (flagTeam == CastleWarsTeam.SARADOMIN) saradominFlagState else zamorakFlagState - if (flagState != FlagState.DROPPED) { - return false - } - when (flagTeam) { - CastleWarsTeam.SARADOMIN -> { - saradominFlagState = FlagState.SAFE - saradominDroppedFlagScenery?.let { SceneryBuilder.remove(it) } - saradominDroppedFlagScenery = null - updateFlagStandObject(CastleWarsTeam.SARADOMIN, FlagState.SAFE) - } - - CastleWarsTeam.ZAMORAK -> { - zamorakFlagState = FlagState.SAFE - zamorakDroppedFlagScenery?.let { SceneryBuilder.remove(it) } - zamorakDroppedFlagScenery = null - updateFlagStandObject(CastleWarsTeam.ZAMORAK, FlagState.SAFE) - } - } - announceToTeam(flagTeam, "${player.username} has returned the ${flagTeam.teamName} flag!") - updateAllOverlays() - updateAllHintIcons() - return true - } - /** * Return a dropped flag directly to its stand (instant, no player involved). * Used for auto-return (timeout, stepping stone) scenarios. @@ -1429,15 +1391,18 @@ object CastleWarsGameState { * Destroy a team's catapult */ fun destroyCatapult(team: CastleWarsTeam) { - val location = when (team) { - CastleWarsTeam.SARADOMIN -> saradominCatapultLocation - CastleWarsTeam.ZAMORAK -> zamorakCatapultLocation - } when (team) { - CastleWarsTeam.SARADOMIN -> saradominCatapultOperational = false - CastleWarsTeam.ZAMORAK -> zamorakCatapultOperational = false + CastleWarsTeam.SARADOMIN -> { + saradominCatapultOperational = false + saradominCatapultOnFire = false + } + + CastleWarsTeam.ZAMORAK -> { + zamorakCatapultOperational = false + zamorakCatapultOnFire = false + } } - replaceCatapultScenery(location, CastleWars.getDamagedCatapultId(team)) + updateCatapultScenery(team) updateAllOverlays() } @@ -1450,6 +1415,7 @@ object CastleWarsGameState { CastleWarsTeam.SARADOMIN -> saradominCatapultOnFire = true CastleWarsTeam.ZAMORAK -> zamorakCatapultOnFire = true } + updateCatapultScenery(team) updateAllOverlays() } @@ -1461,6 +1427,7 @@ object CastleWarsGameState { CastleWarsTeam.SARADOMIN -> saradominCatapultOnFire = false CastleWarsTeam.ZAMORAK -> zamorakCatapultOnFire = false } + updateCatapultScenery(team) updateAllOverlays() } @@ -1478,15 +1445,18 @@ object CastleWarsGameState { * Repair a team's catapult */ fun repairCatapult(team: CastleWarsTeam) { - val location = when (team) { - CastleWarsTeam.SARADOMIN -> saradominCatapultLocation - CastleWarsTeam.ZAMORAK -> zamorakCatapultLocation - } when (team) { - CastleWarsTeam.SARADOMIN -> saradominCatapultOperational = true - CastleWarsTeam.ZAMORAK -> zamorakCatapultOperational = true + CastleWarsTeam.SARADOMIN -> { + saradominCatapultOperational = true + saradominCatapultOnFire = false + } + + CastleWarsTeam.ZAMORAK -> { + zamorakCatapultOperational = true + zamorakCatapultOnFire = false + } } - replaceCatapultScenery(location, CastleWars.getOperationalCatapultId(team)) + updateCatapultScenery(team) updateAllOverlays() } @@ -1614,6 +1584,19 @@ object CastleWarsGameState { * Replace catapult scenery at a location with a new catapult ID. * Searches for any catapult object at the location and replaces it. */ + private fun updateCatapultScenery(team: CastleWarsTeam) { + val location = when (team) { + CastleWarsTeam.SARADOMIN -> saradominCatapultLocation + CastleWarsTeam.ZAMORAK -> zamorakCatapultLocation + } + val newId = when { + !isCatapultOperational(team) -> CastleWars.getDamagedCatapultId(team) + isCatapultOnFire(team) -> CastleWars.getBurningCatapultId(team) + else -> CastleWars.getOperationalCatapultId(team) + } + replaceCatapultScenery(location, newId) + } + private fun replaceCatapultScenery(location: Location, newId: Int) { // Get the object at the catapult location val existingObj = RegionManager.getObject(location) if (existingObj != null && existingObj.id in CastleWars.allCatapultIds) { diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsItemHandler.kt b/Server/src/main/content/minigame/castlewars/CastleWarsItemHandler.kt index 54dea8aac..f6872e90c 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsItemHandler.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsItemHandler.kt @@ -27,7 +27,7 @@ import org.rs09.consts.NPCs /** * Handles Castle Wars item mechanics: * - Bandages: Heal 10% of max HP, restore 30% run energy, can heal teammates - * - Barricades: Place barricade NPC with 5000 HP, max 10 per team TODO: 5000 HP BARRICADE HAHHAH FAT CHANCE. keeping that in for now + * - Barricades: Place barricade NPC with 50 HP, max 10 per team * - Explosive Potions: Instant-destroy barricades, 15% max HP self-damage on drop */ class CastleWarsItemHandler : InteractionListener { diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsListeners.kt b/Server/src/main/content/minigame/castlewars/CastleWarsListeners.kt index 1849ae6ca..6845467f4 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsListeners.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsListeners.kt @@ -71,6 +71,8 @@ private val barrierCoordinates: Map> = mapOf( ) private val castleWarsStairDestinationOverrides: Map = mapOf( + Location.create(2419, 3077, 0) to Location.create(2420, 3080, 1), + Location.create(2419, 3078, 0) to Location.create(2420, 3080, 1), Location.create(2425, 3076, 2) to Location.create(2426, 3074, 3), Location.create(2425, 3074, 3) to Location.create(2425, 3077, 2), Location.create(2430, 3081, 2) to Location.create(2427, 3081, 1), @@ -179,10 +181,6 @@ class CastleWarsListeners : InteractionListener { on(CastleWars.joinSaradominTeamPortal, IntType.SCENERY, "Enter") { player, _ -> if (GameWorld.settings?.enable_castle_wars != true) return@on false if (!player.isArtificial && joinError(player)) return@on true - if (CastleWarsWaitingArea.waitingSaradominPlayers.size + CastleWarsGameArea.saradominPlayers.size >= CastleWarsConstants.MAX_PLAYERS_PER_TEAM) { - sendMessage(player, "The team is currently full.") - return@on true - } player.properties.teleportLocation = CastleWarsWaitingArea.saradominWaitingRoom.randomWalkableLoc player.setAttribute(CastleWars.portalAttribute, CastleWars.saradominName) return@on true @@ -190,10 +188,6 @@ class CastleWarsListeners : InteractionListener { on(CastleWars.joinZamorakTeamPortal, IntType.SCENERY, "Enter") { player, _ -> if (GameWorld.settings?.enable_castle_wars != true) return@on false if (!player.isArtificial && joinError(player)) return@on true - if (CastleWarsWaitingArea.waitingZamorakPlayers.size + CastleWarsGameArea.zamorakPlayers.size >= CastleWarsConstants.MAX_PLAYERS_PER_TEAM) { - sendMessage(player, "The team is currently full.") - return@on true - } player.properties.teleportLocation = CastleWarsWaitingArea.zamorakWaitingRoom.randomWalkableLoc player.setAttribute(CastleWars.portalAttribute, CastleWars.zamorakName) return@on true @@ -212,21 +206,7 @@ class CastleWarsListeners : InteractionListener { val saraTotal = CastleWarsWaitingArea.waitingSaradominPlayers.size + CastleWarsGameArea.saradominPlayers.size val zamTotal = CastleWarsWaitingArea.waitingZamorakPlayers.size + CastleWarsGameArea.zamorakPlayers.size - val saraFull = saraTotal >= CastleWarsConstants.MAX_PLAYERS_PER_TEAM - val zamFull = zamTotal >= CastleWarsConstants.MAX_PLAYERS_PER_TEAM - if (saraFull && zamFull) { - sendMessage(player, "The team is currently full.") - return@on true - } when { - saraFull -> { - player.properties.teleportLocation = CastleWarsWaitingArea.zamorakWaitingRoom.randomWalkableLoc - } - - zamFull -> { - player.properties.teleportLocation = CastleWarsWaitingArea.saradominWaitingRoom.randomWalkableLoc - } - saraTotal < zamTotal -> { player.properties.teleportLocation = CastleWarsWaitingArea.saradominWaitingRoom.randomWalkableLoc } @@ -323,11 +303,6 @@ class CastleWarsListeners : InteractionListener { return@on true } - if (CastleWarsGameState.isCarryingAnyFlag(player)) { - CastleWarsGameState.dropFlagAtLocation(player, player.location) - sendMessage(player, "You drop the flag as you jump across!") - } - lock(player, 3) AgilityHandler.forceWalk(player, -1, player.location, stone.location, Animation(741), 10, 0.0, null, 1) runTask(player, 1) { diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsTunnelMiningPulse.kt b/Server/src/main/content/minigame/castlewars/CastleWarsTunnelMiningPulse.kt index 09a63b19a..e42d6838c 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsTunnelMiningPulse.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsTunnelMiningPulse.kt @@ -18,7 +18,7 @@ import core.game.world.update.flag.context.Animation * This pulse replaces the instant 3-tick delay with proper mining mechanics * where players must successfully "hit" the rock multiple times to clear it. */ -class CastleWarsTunnelMiningPulse( // TODO: tunnels in general are working great. but this... needs a thorough check from start to finish +class CastleWarsTunnelMiningPulse( private val player: Player, private val rocks: Scenery, private val boulderKey: String, diff --git a/Server/src/main/content/minigame/castlewars/areas/CastleWarsGameArea.kt b/Server/src/main/content/minigame/castlewars/areas/CastleWarsGameArea.kt index e037c5240..3ff5a0850 100644 --- a/Server/src/main/content/minigame/castlewars/areas/CastleWarsGameArea.kt +++ b/Server/src/main/content/minigame/castlewars/areas/CastleWarsGameArea.kt @@ -116,7 +116,6 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener { CastleWarsTeam.ZAMORAK -> zamorakPlayers } if (waitingSet.isEmpty()) return - if (gameSet.size >= CastleWarsConstants.MAX_PLAYERS_PER_TEAM) return val replacement = waitingSet.first() waitingSet.remove(replacement) gameSet.add(replacement) From 8fb52b6eda90ecff13d12fbf593f59513ba302c3 Mon Sep 17 00:00:00 2001 From: dam <27978131-real_damighty@users.noreply.gitlab.com> Date: Sat, 9 May 2026 21:19:05 +0300 Subject: [PATCH 16/26] Match idle dur to -1, logging out grace fix, head/cape fixes, flag carrying fixes, some tests --- .../minigame/castlewars/CastleWarsCommands.kt | 2 + .../CastleWarsEquipmentRestrictions.kt | 53 ++++++ .../castlewars/CastleWarsGameState.kt | 6 +- .../castlewars/CastleWarsShutdownHandler.kt | 2 +- .../castlewars/areas/CastleWarsArea.kt | 6 + .../castlewars/areas/CastleWarsGameArea.kt | 13 +- .../castlewars/areas/CastleWarsRespawnArea.kt | 1 + .../castlewars/areas/CastleWarsWaitingArea.kt | 4 +- .../core/game/global/action/EquipHandler.kt | 5 + .../minigame/castlewars/CastleWarsTests.kt | 179 ++++++++++++++++++ 10 files changed, 266 insertions(+), 5 deletions(-) create mode 100644 Server/src/main/content/minigame/castlewars/CastleWarsEquipmentRestrictions.kt create mode 100644 Server/src/test/kotlin/content/minigame/castlewars/CastleWarsTests.kt diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsCommands.kt b/Server/src/main/content/minigame/castlewars/CastleWarsCommands.kt index af2efca53..9f6dff6aa 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsCommands.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsCommands.kt @@ -53,6 +53,8 @@ class CastleWarsCommands : Commands { val zamSnapshot = zamorakPlayers.toSet() CastleWarsGameState.endGame() CastleWarsItemHandler.clearAllBarricades() + CastleWarsGameArea.ticksLeftInGame = -1 + CastleWarsWaitingArea.gameStartCountdown = -1 for (p in saraSnapshot) { p.interaction.remove(Option._P_ATTACK) p.properties.teleportLocation = CastleWars.lobbyBankArea.randomWalkableLoc diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsEquipmentRestrictions.kt b/Server/src/main/content/minigame/castlewars/CastleWarsEquipmentRestrictions.kt new file mode 100644 index 000000000..015edc79b --- /dev/null +++ b/Server/src/main/content/minigame/castlewars/CastleWarsEquipmentRestrictions.kt @@ -0,0 +1,53 @@ +package content.minigame.castlewars + +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.sendMessage +import core.game.node.entity.player.Player +import core.game.node.item.GroundItemManager + +object CastleWarsEquipmentRestrictions { + private val restrictedSlots = intArrayOf( + EquipmentSlot.HEAD.ordinal, + EquipmentSlot.CAPE.ordinal + ) + private val castleWarsTeamCapeIds = intArrayOf( + CastleWars.saradominTeamHoodedCloak, + CastleWars.zamorakTeamHoodedCloak + ) + + fun blocksEquip(player: Player, equipmentSlot: Int): Boolean { + if (equipmentSlot !in restrictedSlots || !isInCastleWarsArea(player)) { + return false + } + + sendMessage(player, "You can't wear hats, capes, or helms in the arena.") + return true + } + + fun removeInvalidEquipment(player: Player) { + removeEquipment(player, EquipmentSlot.HEAD.ordinal) + + val cape = player.equipment[EquipmentSlot.CAPE.ordinal] ?: return + if (cape.id !in castleWarsTeamCapeIds) { + removeEquipment(player, EquipmentSlot.CAPE.ordinal) + } + } + + private fun removeEquipment(player: Player, slot: Int) { + val item = player.equipment[slot] ?: return + player.equipment.remove(item) + if (!player.inventory.add(item)) { + GroundItemManager.create(item.copy(), player.location, player) + } + } + + private fun isInCastleWarsArea(player: Player): Boolean { + val allCastleWarsBorders = CastleWarsGameArea.areaBorders.toList() + + CastleWarsWaitingArea.areaBorders.toList() + + listOf(CastleWarsRespawnArea.saradominRespawnRoom, CastleWarsRespawnArea.zamorakRespawnRoom) + return allCastleWarsBorders.any { it.insideBorder(player.location) } + } +} diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsGameState.kt b/Server/src/main/content/minigame/castlewars/CastleWarsGameState.kt index ab8abb64b..aceccf790 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsGameState.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsGameState.kt @@ -513,7 +513,7 @@ object CastleWarsGameState { sendMessage(player, "You can't pick up your own team's flag!") return false } - if (isCarryingFlag(player)) { + if (isCarryingAnyFlag(player)) { sendMessage(player, "You're already carrying a flag!") return false } @@ -1643,6 +1643,10 @@ object CastleWarsGameState { */ fun transferFlag(from: Player, to: Player) { val carriedFlag = getCarriedFlag(from) ?: return + if (isCarryingAnyFlag(to)) { + sendMessage(to, "You're already carrying a flag!") + return + } if (from.equipment.get(EquipmentContainer.SLOT_WEAPON)?.id == carriedFlag.flagItemId) { from.equipment.remove(Item(carriedFlag.flagItemId), EquipmentContainer.SLOT_WEAPON, true) } diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsShutdownHandler.kt b/Server/src/main/content/minigame/castlewars/CastleWarsShutdownHandler.kt index 50093ed48..48584b005 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsShutdownHandler.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsShutdownHandler.kt @@ -73,6 +73,6 @@ class CastleWarsShutdownHandler : ShutdownListener { CastleWarsWaitingArea.waitingSaradominPlayers.clear() CastleWarsWaitingArea.waitingZamorakPlayers.clear() - CastleWarsGameArea.ticksLeftInGame = 0 + CastleWarsGameArea.ticksLeftInGame = -1 } } diff --git a/Server/src/main/content/minigame/castlewars/areas/CastleWarsArea.kt b/Server/src/main/content/minigame/castlewars/areas/CastleWarsArea.kt index 5743682d1..5ac678922 100644 --- a/Server/src/main/content/minigame/castlewars/areas/CastleWarsArea.kt +++ b/Server/src/main/content/minigame/castlewars/areas/CastleWarsArea.kt @@ -2,6 +2,7 @@ package content.minigame.castlewars.areas import content.global.skill.summoning.familiar.BurdenBeast import content.minigame.castlewars.CastleWars +import content.minigame.castlewars.CastleWarsEquipmentRestrictions import core.api.LogoutListener import core.api.MapArea import core.api.removeTimer @@ -12,6 +13,11 @@ import core.game.node.entity.player.Player abstract class CastleWarsArea : MapArea, LogoutListener, InteractionListener { + override fun areaEnter(entity: Entity) { + val player = entity as? Player ?: return + CastleWarsEquipmentRestrictions.removeInvalidEquipment(player) + } + override fun areaLeave(entity: Entity, logout: Boolean) { super.areaLeave(entity, logout) exitArea(entity as? Player ?: return) diff --git a/Server/src/main/content/minigame/castlewars/areas/CastleWarsGameArea.kt b/Server/src/main/content/minigame/castlewars/areas/CastleWarsGameArea.kt index 3ff5a0850..78f98f1cb 100644 --- a/Server/src/main/content/minigame/castlewars/areas/CastleWarsGameArea.kt +++ b/Server/src/main/content/minigame/castlewars/areas/CastleWarsGameArea.kt @@ -67,11 +67,12 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener { /** Players who have logged out but are within the grace period. Maps Player -> logout tick. */ val loggedOutPlayers = mutableMapOf() - var ticksLeftInGame = 0 + var ticksLeftInGame = -1 fun startGame() { CastleWarsGameState.resetGame() CastleWarsItemHandler.clearAllBarricades() + CastleWarsWaitingArea.gameStartCountdown = -1 saradominPlayers.addAll(CastleWarsWaitingArea.waitingSaradominPlayers) zamorakPlayers.addAll(CastleWarsWaitingArea.waitingZamorakPlayers) CastleWarsWaitingArea.waitingSaradominPlayers.clear() @@ -159,6 +160,7 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener { CastleWarsWaitingArea.postGameCooldown = true CastleWarsGameState.endGame() CastleWarsItemHandler.clearAllBarricades() + ticksLeftInGame = -1 saradominPlayers.forEach { player -> player.unhook(SaradominInterfaceCloseHook) player.properties.teleportLocation = CastleWars.lobbyBankArea.randomWalkableLoc @@ -242,6 +244,7 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener { } 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) @@ -253,6 +256,13 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener { } override fun tick() { + if (ticksLeftInGame < 0) { + return + } + if (ticksLeftInGame == 0) { + endGame() + return + } val previousMinutes = (ticksLeftInGame + ticksPerMinute - 1) / ticksPerMinute ticksLeftInGame-- val currentMinutes = (ticksLeftInGame + ticksPerMinute - 1) / ticksPerMinute @@ -266,6 +276,7 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener { } if (ticksLeftInGame == 0) { endGame() + return } else if (currentMinutes != previousMinutes) { CastleWarsGameState.updateAllOverlays() } diff --git a/Server/src/main/content/minigame/castlewars/areas/CastleWarsRespawnArea.kt b/Server/src/main/content/minigame/castlewars/areas/CastleWarsRespawnArea.kt index f0a306e50..117e96cfb 100644 --- a/Server/src/main/content/minigame/castlewars/areas/CastleWarsRespawnArea.kt +++ b/Server/src/main/content/minigame/castlewars/areas/CastleWarsRespawnArea.kt @@ -31,6 +31,7 @@ class CastleWarsRespawnArea : CastleWarsArea(), TickListener { override fun areaEnter(entity: Entity) { val player = entity as? Player ?: return + super.areaEnter(player) if (CastleWarsGameState.isCarryingAnyFlag(player)) { if (CastleWarsGameState.isCarryingFlag(player)) { CastleWarsGameState.dropFlag(player) diff --git a/Server/src/main/content/minigame/castlewars/areas/CastleWarsWaitingArea.kt b/Server/src/main/content/minigame/castlewars/areas/CastleWarsWaitingArea.kt index eb8f2a93e..6564cc55d 100644 --- a/Server/src/main/content/minigame/castlewars/areas/CastleWarsWaitingArea.kt +++ b/Server/src/main/content/minigame/castlewars/areas/CastleWarsWaitingArea.kt @@ -116,7 +116,7 @@ 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 - if (CastleWarsGameArea.ticksLeftInGame >= 0) { + 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 @@ -144,7 +144,7 @@ class CastleWarsWaitingArea : CastleWarsArea(), TickListener { for (player in waitingSaradominPlayers + waitingZamorakPlayers) { CastleWarsOverlay.sendLobbyUpdate( player, - (waitingSaradominPlayers.size >= minPlayers && waitingZamorakPlayers.size >= minPlayers) || CastleWarsGameArea.ticksLeftInGame >= 0, + (waitingSaradominPlayers.size >= minPlayers && waitingZamorakPlayers.size >= minPlayers) || CastleWarsGameArea.ticksLeftInGame > 0, (nextStart - 1) / ticksPerMinute + 1 // Displays max 5 minutes, min 1 minute, seems authentic ) } diff --git a/Server/src/main/core/game/global/action/EquipHandler.kt b/Server/src/main/core/game/global/action/EquipHandler.kt index 29299e238..3f9f681a5 100644 --- a/Server/src/main/core/game/global/action/EquipHandler.kt +++ b/Server/src/main/core/game/global/action/EquipHandler.kt @@ -1,5 +1,6 @@ package core.game.global.action +import content.minigame.castlewars.CastleWarsEquipmentRestrictions import core.game.event.ItemEquipEvent import core.game.event.ItemUnequipEvent import core.game.container.impl.EquipmentContainer @@ -35,6 +36,10 @@ class EquipHandler : InteractionListener { val item = node.asItem() val itemEquipmentSlot = item.definition.getConfiguration(ItemConfigParser.EQUIP_SLOT, -1) + if (CastleWarsEquipmentRestrictions.blocksEquip(player, itemEquipmentSlot)) { + return + } + val currentEquippedItem = player.equipment[itemEquipmentSlot] if (item == null || currentEquippedItem == item || item.name.toLowerCase().contains("goblin mail")) { return diff --git a/Server/src/test/kotlin/content/minigame/castlewars/CastleWarsTests.kt b/Server/src/test/kotlin/content/minigame/castlewars/CastleWarsTests.kt new file mode 100644 index 000000000..55394bbb5 --- /dev/null +++ b/Server/src/test/kotlin/content/minigame/castlewars/CastleWarsTests.kt @@ -0,0 +1,179 @@ +package content.minigame.castlewars + +import TestUtils +import content.minigame.castlewars.areas.CastleWarsGameArea +import content.minigame.castlewars.areas.CastleWarsWaitingArea +import core.api.EquipmentSlot +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.item.Item +import core.game.world.map.Location +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.rs09.consts.Items + +class CastleWarsTests { + + companion object { + init { + TestUtils.preTestSetup() + if (InteractionListeners.get("equip", IntType.ITEM.ordinal) == null) { + EquipHandler().defineListeners() + } + } + } + + @AfterEach + fun cleanup() { + CastleWarsGameArea.saradominPlayers.clear() + CastleWarsGameArea.zamorakPlayers.clear() + CastleWarsGameArea.loggedOutPlayers.clear() + CastleWarsWaitingArea.waitingSaradominPlayers.clear() + CastleWarsWaitingArea.waitingZamorakPlayers.clear() + CastleWarsWaitingArea.gameStartCountdown = -1 + CastleWarsWaitingArea.postGameCooldown = false + CastleWarsGameArea.ticksLeftInGame = -1 + CastleWarsGameState.resetGame() + } + + @Test + fun idleGameAreaTickDoesNotCountDownForever() { + CastleWarsGameArea.ticksLeftInGame = -1 + + CastleWarsGameArea().tick() + + assertEquals(-1, CastleWarsGameArea.ticksLeftInGame) + } + + @Test + fun matchEndReturnsLifecycleToIdle() { + CastleWarsGameArea.ticksLeftInGame = 1 + + CastleWarsGameArea().tick() + + assertEquals(-1, CastleWarsGameArea.ticksLeftInGame) + assertTrue(CastleWarsWaitingArea.postGameCooldown) + } + + @Test + fun activeGameLogoutKeepsPlayerInGraceSet() { + TestUtils.getMockPlayer("cwlogout").use { player -> + val startLocation = Location.create(2400, 3100, 0) + player.location = startLocation + CastleWarsGameArea.ticksLeftInGame = 100 + CastleWarsGameArea.saradominPlayers.add(player) + + CastleWarsGameArea().logout(player) + + assertTrue(CastleWarsGameArea.saradominPlayers.contains(player)) + assertTrue(CastleWarsGameArea.loggedOutPlayers.containsKey(player)) + assertEquals(startLocation, player.location) + } + } + + @Test + fun castleWarsPlayersCannotEquipHelmsAfterEntry() { + TestUtils.getMockPlayer("cwhelm").use { player -> + player.location = Location.create(2380, 9490, 0) + player.inventory.add(Item(Items.BRONZE_FULL_HELM_1155)) + + InteractionListeners.run( + Items.BRONZE_FULL_HELM_1155, + IntType.ITEM, + "wear", + player, + player.inventory[0] + ) + + assertNull(player.equipment[EquipmentSlot.HEAD.ordinal]) + assertTrue(player.inventory.contains(Items.BRONZE_FULL_HELM_1155, 1)) + } + } + + @Test + fun castleWarsPlayersCannotManuallyEquipTeamCloaksAfterEntry() { + TestUtils.getMockPlayer("cwcloak").use { player -> + player.location = Location.create(2380, 9490, 0) + player.inventory.add(Item(Items.HOODED_CLOAK_4041)) + + InteractionListeners.run( + Items.HOODED_CLOAK_4041, + IntType.ITEM, + "wear", + player, + player.inventory[0] + ) + + assertNull(player.equipment[EquipmentSlot.CAPE.ordinal]) + assertTrue(player.inventory.contains(Items.HOODED_CLOAK_4041, 1)) + } + } + + @Test + fun castleWarsRestrictionDoesNotReplaceNormalEquipListenersOutsideArena() { + var didRun = false + object : InteractionListener { + override fun defineListeners() { + onEquip(Items.BRONZE_MED_HELM_1139) { _, _ -> + didRun = true + return@onEquip true + } + } + }.defineListeners() + + TestUtils.getMockPlayer("cwoutside").use { player -> + player.location = Location.create(3222, 3222, 0) + player.inventory.add(Item(Items.BRONZE_MED_HELM_1139)) + + InteractionListeners.run( + Items.BRONZE_MED_HELM_1139, + IntType.ITEM, + "wear", + player, + player.inventory[0] + ) + + assertTrue(didRun) + assertEquals(Items.BRONZE_MED_HELM_1139, player.equipment[EquipmentSlot.HEAD.ordinal]?.id) + } + } + + @Test + fun castleWarsAreaEntryRemovesBypassedHelms() { + TestUtils.getMockPlayer("cwentryhelm").use { player -> + player.location = CastleWarsWaitingArea.saradominWaitingRoom.randomWalkableLoc + player.equipment.replace(Item(Items.BRONZE_FULL_HELM_1155), EquipmentSlot.HEAD.ordinal) + + CastleWarsWaitingArea().areaEnter(player) + + assertNull(player.equipment[EquipmentSlot.HEAD.ordinal]) + assertTrue(player.inventory.contains(Items.BRONZE_FULL_HELM_1155, 1)) + } + } + + @Test + fun ownFlagCarrierCannotPickUpEnemyFlag() { + TestUtils.getMockPlayer("cwsara").use { saradomin -> + TestUtils.getMockPlayer("cwzam").use { zamorak -> + saradomin.location = Location.create(2400, 3100, 0) + zamorak.location = Location.create(2401, 3100, 0) + CastleWarsGameArea.saradominPlayers.add(saradomin) + CastleWarsGameArea.zamorakPlayers.add(zamorak) + + assertTrue(CastleWarsGameState.pickUpFlag(zamorak, CastleWarsTeam.SARADOMIN)) + CastleWarsGameState.dropFlag(zamorak) + assertTrue(CastleWarsGameState.pickUpOwnFlag(saradomin, CastleWarsTeam.SARADOMIN)) + + assertFalse(CastleWarsGameState.pickUpFlag(saradomin, CastleWarsTeam.ZAMORAK)) + assertTrue(CastleWarsGameState.isCarryingOwnFlag(saradomin)) + assertFalse(CastleWarsGameState.isCarryingFlag(saradomin)) + } + } + } +} From 87210b4b3c98ea3839d9088a46635d4fe2127a1b Mon Sep 17 00:00:00 2001 From: dam <27978131-real_damighty@users.noreply.gitlab.com> Date: Mon, 11 May 2026 16:55:07 +0300 Subject: [PATCH 17/26] Removed explosive potion AoE, temp backdoor thieving success changes --- .../castlewars/CastleWarsConstants.kt | 12 +-- .../castlewars/CastleWarsItemHandler.kt | 3 - .../castlewars/CastleWarsListeners.kt | 43 +++++--- .../minigame/castlewars/CatapultConfig.kt | 10 +- .../minigame/castlewars/CastleWarsTests.kt | 98 +++++++++++++++++++ 5 files changed, 138 insertions(+), 28 deletions(-) diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsConstants.kt b/Server/src/main/content/minigame/castlewars/CastleWarsConstants.kt index e46a16e60..7d9b92cf1 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsConstants.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsConstants.kt @@ -86,14 +86,14 @@ object CastleWarsConstants { const val ENERGY_BARRIER_DRAIN = 4.0 // ==================== Backdoor Pick-Lock ==================== - /** Thieving level required for pick-lock (1 = no real requirement, all players can attempt) */ - const val BACKDOOR_PICKLOCK_LEVEL = 1 + /** Thieving level required for pick-lock, matching Knight of Ardougne pickpocketing */ + const val BACKDOOR_PICKLOCK_LEVEL = 55 - /** Base pick-lock success chance (out of 100) at minimum level */ - const val BACKDOOR_PICKLOCK_BASE_CHANCE = 50 + /** Low roll bound for pick-lock success, matching Knight of Ardougne pickpocketing */ + const val BACKDOOR_PICKLOCK_LOW = 50.0 - /** Maximum pick-lock success chance (out of 100) */ - const val BACKDOOR_PICKLOCK_MAX_CHANCE = 95 + /** High roll bound for pick-lock success, matching Knight of Ardougne pickpocketing */ + const val BACKDOOR_PICKLOCK_HIGH = 240.0 /** Ticks to lock player during pick-lock attempt */ const val BACKDOOR_PICKLOCK_TICKS = 3 diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsItemHandler.kt b/Server/src/main/content/minigame/castlewars/CastleWarsItemHandler.kt index f6872e90c..ac40ff057 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsItemHandler.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsItemHandler.kt @@ -219,8 +219,6 @@ class CastleWarsItemHandler : InteractionListener { } cleanupDeadBarricades() - dealExplosiveAoeDamage(player, player.location) - return@on true } @@ -348,4 +346,3 @@ class CastleWarsItemHandler : InteractionListener { .filter { it.id in barricadeIds && it.isActive } } } - diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsListeners.kt b/Server/src/main/content/minigame/castlewars/CastleWarsListeners.kt index 6845467f4..56ce42fc0 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsListeners.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsListeners.kt @@ -15,6 +15,7 @@ import core.game.global.action.SpecialLadders import core.game.interaction.IntType import core.game.interaction.InteractionListener import core.game.node.entity.player.Player +import core.game.node.entity.skill.Skills import core.game.node.entity.player.link.TeleportManager import core.game.node.item.Item import core.game.node.scenery.Scenery @@ -27,6 +28,7 @@ import core.game.world.map.RegionManager import core.game.world.update.flag.chunk.ObjectUpdateFlag import core.game.world.update.flag.context.Animation import core.tools.Log +import core.tools.RandomFunction import org.rs09.consts.Components import org.rs09.consts.Items import org.rs09.consts.Sounds @@ -48,24 +50,24 @@ private val barrierCoordinates: Map> = mapOf( BarrierData( Location.create(2373, 3127, 1), Direction.WEST, Location.create(2373, 3126, 1), - Location.create(2373, 3128, 1) + Location.create(2373, 3127, 1) ), BarrierData( Location.create(2376, 3131, 1), Direction.SOUTH, Location.create(2377, 3131, 1), - Location.create(2375, 3131, 1) + Location.create(2376, 3131, 1) ) ), CastleWars.saradominEnergyBarrier to listOf( BarrierData( Location.create(2426, 3080, 1), Direction.EAST, Location.create(2426, 3081, 1), - Location.create(2426, 3079, 1) + Location.create(2426, 3080, 1) ), BarrierData( Location.create(2423, 3076, 1), Direction.NORTH, Location.create(2422, 3076, 1), - Location.create(2424, 3076, 1) + Location.create(2423, 3076, 1) ) ) ) @@ -142,6 +144,9 @@ internal fun isOnClimbingRopeBaseSide(playerLoc: Location, rope: Scenery): Boole } internal fun getClimbingRopeInteractionDestination(playerLoc: Location, rope: Scenery): Location { + if (playerLoc == rope.location) { + return playerLoc + } val traversal = getClimbingRopeTraversal(rope) return if (isOnClimbingRopeBaseSide(playerLoc, rope)) { traversal.baseOfWallTile @@ -151,7 +156,15 @@ internal fun getClimbingRopeInteractionDestination(playerLoc: Location, rope: Sc } internal fun canClimbUpClimbingRope(playerLoc: Location, rope: Scenery): Boolean { - return playerLoc == getClimbingRopeTraversal(rope).baseOfWallTile + return playerLoc == rope.location || playerLoc == getClimbingRopeTraversal(rope).baseOfWallTile +} + +internal fun getBackdoorPicklockSuccessChance(thievingLevel: Int): Double { + return RandomFunction.getSkillSuccessChance( + CastleWarsConstants.BACKDOOR_PICKLOCK_LOW, + CastleWarsConstants.BACKDOOR_PICKLOCK_HIGH, + thievingLevel + ) } class CastleWarsListeners : InteractionListener { @@ -567,19 +580,21 @@ class CastleWarsListeners : InteractionListener { sendMessage(player, "You can't pick the lock from this side.") return@on true } + val thievingLevel = player.skills.getLevel(Skills.THIEVING) + if (thievingLevel < CastleWarsConstants.BACKDOOR_PICKLOCK_LEVEL) { + sendMessage( + player, + "You need a Thieving level of ${CastleWarsConstants.BACKDOOR_PICKLOCK_LEVEL} to pick this lock." + ) + return@on true + } lock(player, CastleWarsConstants.BACKDOOR_PICKLOCK_TICKS) animate(player, 2246) sendMessage(player, "You attempt to pick the lock...") - val thievingLevel = player.skills.getLevel(core.game.node.entity.skill.Skills.THIEVING) - val successChance = (CastleWarsConstants.BACKDOOR_PICKLOCK_BASE_CHANCE + - (thievingLevel - CastleWarsConstants.BACKDOOR_PICKLOCK_LEVEL)) - .coerceIn( - CastleWarsConstants.BACKDOOR_PICKLOCK_BASE_CHANCE, - CastleWarsConstants.BACKDOOR_PICKLOCK_MAX_CHANCE - ) + val successChance = getBackdoorPicklockSuccessChance(thievingLevel) runTask(player, CastleWarsConstants.BACKDOOR_PICKLOCK_TICKS) { - if ((1..100).random() <= successChance) { + if (RandomFunction.randomDouble(1.0, 100.0) <= successChance) { openBackdoor(player, door) sendMessage(player, "You successfully pick the lock!") } else { @@ -589,7 +604,7 @@ class CastleWarsListeners : InteractionListener { return@on true } - on(CastleWars.backdoorOpenIds, IntType.SCENERY, "Close") { player, node -> + on(CastleWars.backdoorOpenIds, IntType.SCENERY, "Close", "Lock") { player, node -> val door = node.asScenery() val doorTeam = if (door.id == CastleWars.saradominBackdoorOpen) CastleWarsTeam.SARADOMIN else CastleWarsTeam.ZAMORAK diff --git a/Server/src/main/content/minigame/castlewars/CatapultConfig.kt b/Server/src/main/content/minigame/castlewars/CatapultConfig.kt index 5e66854ed..6a899a88b 100644 --- a/Server/src/main/content/minigame/castlewars/CatapultConfig.kt +++ b/Server/src/main/content/minigame/castlewars/CatapultConfig.kt @@ -35,7 +35,7 @@ object CatapultConfig { const val DEFAULT_X = CastleWarsConstants.CATAPULT_DEFAULT_X const val DEFAULT_Y = CastleWarsConstants.CATAPULT_DEFAULT_Y const val CENTER_OFFSET = 15 - const val HORIZONTAL_TILE_SCALE = 2 + const val TILE_SCALE = 2 const val SOUND_FIRE = 1440 const val SOUND_IMPACT = 851 @@ -82,21 +82,21 @@ enum class CatapultButton(val buttonId: Int, val deltaX: Int, val deltaY: Int) { enum class CatapultTeam { SARADOMIN { override fun calculateTargetX(catapultX: Int, interfaceX: Int): Int { - return catapultX + ((interfaceX - CatapultConfig.CENTER_OFFSET) * CatapultConfig.HORIZONTAL_TILE_SCALE) + return catapultX + ((interfaceX - CatapultConfig.CENTER_OFFSET) * CatapultConfig.TILE_SCALE) } override fun calculateTargetY(catapultY: Int, interfaceY: Int): Int { - return catapultY + interfaceY + return catapultY + (interfaceY * CatapultConfig.TILE_SCALE) } }, ZAMORAK { override fun calculateTargetX(catapultX: Int, interfaceX: Int): Int { - return catapultX - ((interfaceX - CatapultConfig.CENTER_OFFSET) * CatapultConfig.HORIZONTAL_TILE_SCALE) + return catapultX - ((interfaceX - CatapultConfig.CENTER_OFFSET) * CatapultConfig.TILE_SCALE) } override fun calculateTargetY(catapultY: Int, interfaceY: Int): Int { - return catapultY - interfaceY + return catapultY - (interfaceY * CatapultConfig.TILE_SCALE) } }; diff --git a/Server/src/test/kotlin/content/minigame/castlewars/CastleWarsTests.kt b/Server/src/test/kotlin/content/minigame/castlewars/CastleWarsTests.kt index 55394bbb5..dba17d5f4 100644 --- a/Server/src/test/kotlin/content/minigame/castlewars/CastleWarsTests.kt +++ b/Server/src/test/kotlin/content/minigame/castlewars/CastleWarsTests.kt @@ -3,16 +3,20 @@ 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 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.item.Item +import core.game.node.scenery.Scenery import core.game.world.map.Location import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test @@ -26,6 +30,13 @@ class CastleWarsTests { if (InteractionListeners.get("equip", IntType.ITEM.ordinal) == null) { EquipHandler().defineListeners() } + if (InteractionListeners.get(CastleWars.saradominBackdoorOpen, IntType.SCENERY.ordinal, "lock") == null) { + CastleWarsListeners().defineDestinationOverrides() + CastleWarsListeners().defineListeners() + } + if (InteractionListeners.get(CastleWars.cwExplosivePotion, IntType.ITEM.ordinal, "drop") == null) { + CastleWarsItemHandler().defineListeners() + } } } @@ -176,4 +187,91 @@ class CastleWarsTests { } } } + + @Test + fun ownTeamBackdoorLockOptionIsHandled() { + assertNotNull( + InteractionListeners.get(CastleWars.saradominBackdoorOpen, IntType.SCENERY.ordinal, "lock") + ) + assertNotNull( + InteractionListeners.get(CastleWars.zamorakBackdoorOpen, IntType.SCENERY.ordinal, "lock") + ) + } + + @Test + fun backdoorPicklockMatchesKnightOfArdougneDifficulty() { + TestUtils.getMockPlayer("cwthief").use { player -> + player.skills.setStaticLevel(Skills.THIEVING, CastleWarsConstants.BACKDOOR_PICKLOCK_LEVEL) + player.skills.setLevel(Skills.THIEVING, CastleWarsConstants.BACKDOOR_PICKLOCK_LEVEL) + + assertEquals(Pickpockets.KNIGHT_OF_ADROUGNE.requiredLevel, CastleWarsConstants.BACKDOOR_PICKLOCK_LEVEL) + assertEquals( + Pickpockets.KNIGHT_OF_ADROUGNE.getSuccessChance(player), + getBackdoorPicklockSuccessChance(player.skills.getLevel(Skills.THIEVING)) + ) + } + } + + @Test + fun catapultInterfaceCoordinatesScaleByTwoTilesOnBothAxes() { + val handler = CatapultFiringHandler() + val catapult = Location.create(2400, 3100, 0) + + val saradominBase = handler.calculateTargetLocation(CatapultTeam.SARADOMIN, catapult, Coordinate(8, 13)) + val saradominEast = handler.calculateTargetLocation(CatapultTeam.SARADOMIN, catapult, Coordinate(9, 13)) + val saradominNorth = handler.calculateTargetLocation(CatapultTeam.SARADOMIN, catapult, Coordinate(8, 14)) + assertEquals(2, saradominEast.x - saradominBase.x) + assertEquals(2, saradominNorth.y - saradominBase.y) + + val zamorakBase = handler.calculateTargetLocation(CatapultTeam.ZAMORAK, catapult, Coordinate(8, 13)) + val zamorakEast = handler.calculateTargetLocation(CatapultTeam.ZAMORAK, catapult, Coordinate(9, 13)) + val zamorakNorth = handler.calculateTargetLocation(CatapultTeam.ZAMORAK, catapult, Coordinate(8, 14)) + assertEquals(-2, zamorakEast.x - zamorakBase.x) + assertEquals(-2, zamorakNorth.y - zamorakBase.y) + } + + @Test + fun climbingRopeOnPlayerTileCanBeClimbed() { + val rope = Scenery(CastleWars.cwCastleClimbingRope, Location.create(2420, 3078, 1), 4, 0) + + assertEquals(rope.location, getClimbingRopeInteractionDestination(rope.location, rope)) + assertTrue(canClimbUpClimbingRope(rope.location, rope)) + } + + @Test + fun respawnEnergyBarrierPassesAcrossOneWallTile() { + val barrierTile = Location.create(2426, 3080, 1) + val outsideTile = Location.create(2426, 3081, 1) + val barrier = BarrierData(barrierTile, core.game.world.map.Direction.EAST, outsideTile, barrierTile) + + assertEquals(outsideTile to barrierTile, getBarrierPassTiles(outsideTile, barrier)) + assertEquals(barrierTile to outsideTile, getBarrierPassTiles(barrierTile, barrier)) + } + + @Test + fun droppingExplosivePotionOnlyDamagesDropper() { + TestUtils.getMockPlayer("cwexploder").use { dropper -> + TestUtils.getMockPlayer("cwtarget").use { target -> + dropper.location = Location.create(2400, 3100, 0) + target.location = Location.create(2401, 3100, 0) + dropper.skills.lifepoints = 50 + target.skills.lifepoints = 50 + CastleWarsGameArea.ticksLeftInGame = 100 + CastleWarsGameArea.saradominPlayers.add(dropper) + CastleWarsGameArea.zamorakPlayers.add(target) + dropper.inventory.add(Item(CastleWars.cwExplosivePotion)) + + InteractionListeners.run( + CastleWars.cwExplosivePotion, + IntType.ITEM, + "drop", + dropper, + dropper.inventory[0] + ) + + assertEquals(35, dropper.skills.lifepoints) + assertEquals(50, target.skills.lifepoints) + } + } + } } From 76abee373cb0c09666b317ace79d96873ee503a5 Mon Sep 17 00:00:00 2001 From: dam <27978131-real_damighty@users.noreply.gitlab.com> Date: Mon, 11 May 2026 18:58:59 +0300 Subject: [PATCH 18/26] Team balancing & substitution mechanics - Guthix assignment now uses a shared selector: combat-level vacancy target first, otherwise numerically smaller team. - Added the 60 combat-level imbalance threshold. - Replaced silent first-waiter teleporting with a substitution dialog: Do you wish to join the current game? - First acceptance claims the slot; later acceptances fail. - Declining keeps the player in the waiting room and excludes them from that current substitution offer. - Spawn-room exit portals now actually remove the leaving player from the active team and trigger the vacancy flow. - Waiting-room entry and game ticks now check for combat-level imbalance vacancies. --- .../castlewars/CastleWarsConstants.kt | 3 + .../castlewars/CastleWarsListeners.kt | 25 +--- .../castlewars/areas/CastleWarsGameArea.kt | 122 +++++++++++++++--- .../castlewars/areas/CastleWarsWaitingArea.kt | 2 + .../minigame/castlewars/CastleWarsTests.kt | 111 +++++++++++++++- 5 files changed, 222 insertions(+), 41 deletions(-) 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) + } } From b417156cba7d2f0a6f6edc23f6e14cba9fbd3c2d Mon Sep 17 00:00:00 2001 From: dam <27978131-real_damighty@users.noreply.gitlab.com> Date: Mon, 11 May 2026 19:44:18 +0300 Subject: [PATCH 19/26] Quite a lot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Waiting-room timer now stays visible and uses 2-minute cycles when idle, and active-game wait displays remaining game time + 5 minutes. - Game start now requires 2 Saradomin + 2 Zamorak players, with no dev-mode shortcut or team cap logic. - Removed combat-level “vacancy” substitution; substitutions now trigger only from real same-team departures. - Added spawn-room two-minute removal with vacancy offers. - Exit portals now only work for the matching team. - Active-game logout/relogin now preserves location within two minutes and sends expired logouts to the team waiting room; waiting-room logout returns to lobby and loses queue. - End-game tickets now follow 2 win / 1 draw / 0 loss, without the old 5-per-team or 10-minute gate. - Active games no longer end just because one team is temporarily empty. - Join validation now rejects food, capes/helmets, coins, noted/non-combat items while allowing combat gear, potions, runes, and Hunter butterflies. --- .../minigame/castlewars/CastleWarsCommands.kt | 6 +- .../castlewars/CastleWarsConstants.kt | 37 +- .../castlewars/CastleWarsGameState.kt | 31 +- .../castlewars/CastleWarsListeners.kt | 54 ++- .../minigame/castlewars/CastleWarsOverlay.kt | 5 +- .../CastleWarsSubstitutionDialogue.kt | 38 ++ .../content/minigame/castlewars/Lanthus.kt | 6 +- .../castlewars/areas/CastleWarsGameArea.kt | 207 +++++++---- .../castlewars/areas/CastleWarsRespawnArea.kt | 43 ++- .../castlewars/areas/CastleWarsWaitingArea.kt | 48 ++- .../minigame/castlewars/CastleWarsTests.kt | 333 ++++++++++++++++-- 11 files changed, 609 insertions(+), 199 deletions(-) create mode 100644 Server/src/main/content/minigame/castlewars/CastleWarsSubstitutionDialogue.kt 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) } } From 7d72b4b09d86455214172072e0e9e2ffdc7a1f5e Mon Sep 17 00:00:00 2001 From: dam <27978131-real_damighty@users.noreply.gitlab.com> Date: Tue, 12 May 2026 16:35:36 +0300 Subject: [PATCH 20/26] Barricade poison imm, flag hoarder Steal from-option, bandage changes, bracelet changes, authentic backdoor lockpick chance And reformatted basically every CW file --- .../content/minigame/castlewars/CastleWars.kt | 10 +- .../castlewars/CastleWarsBarricadeNPC.kt | 4 + .../castlewars/CastleWarsCatapultInterface.kt | 4 +- .../minigame/castlewars/CastleWarsCommands.kt | 4 +- .../castlewars/CastleWarsConstants.kt | 18 +-- .../castlewars/CastleWarsDoorAttackPulse.kt | 4 +- .../CastleWarsEquipmentRestrictions.kt | 14 +- .../castlewars/CastleWarsFlagHandler.kt | 1 - .../castlewars/CastleWarsGameState.kt | 90 +++++++++-- .../castlewars/CastleWarsItemHandler.kt | 50 +++--- .../castlewars/CastleWarsListeners.kt | 118 ++++++-------- .../minigame/castlewars/CastleWarsOverlay.kt | 2 +- .../castlewars/CastleWarsShutdownHandler.kt | 9 +- .../minigame/castlewars/CatapultConfig.kt | 19 ++- .../castlewars/CatapultFiringHandler.kt | 3 +- .../castlewars/CatapultInterfaceRenderer.kt | 9 +- .../content/minigame/castlewars/Lanthus.kt | 62 +++---- .../castlewars/areas/CastleWarsArea.kt | 8 +- .../castlewars/areas/CastleWarsGameArea.kt | 23 +-- .../castlewars/areas/CastleWarsRespawnArea.kt | 9 +- .../castlewars/areas/CastleWarsWaitingArea.kt | 31 ++-- .../main/core/game/interaction/Option.java | 2 + .../node/entity/combat/CombatSwingHandler.kt | 4 + .../minigame/castlewars/CastleWarsTests.kt | 153 ++++++++++++++++-- 24 files changed, 405 insertions(+), 246 deletions(-) diff --git a/Server/src/main/content/minigame/castlewars/CastleWars.kt b/Server/src/main/content/minigame/castlewars/CastleWars.kt index 2ca5fa970..599566e95 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWars.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWars.kt @@ -94,12 +94,7 @@ object CastleWars { val burningCatapultIds: IntArray = intArrayOf(catapultBurning1, catapultBurning2) val damagedCatapultIds: IntArray = intArrayOf(catapultDamaged1, catapultDamaged2) val allCatapultIds: IntArray = intArrayOf( - catapult, - catapultAlt, - catapultBurning1, - catapultBurning2, - catapultDamaged1, - catapultDamaged2 + catapult, catapultAlt, catapultBurning1, catapultBurning2, catapultDamaged1, catapultDamaged2 ) fun getOperationalCatapultId(team: CastleWarsTeam): Int = when (team) { @@ -154,8 +149,7 @@ object CastleWars { Scenery.TABLE_36586 to Items.BANDAGES_4049 // Zamorak Bandages Table ) val cwCastleBattlementsMap = mapOf( - Scenery.BATTLEMENTS_4446 to Scenery.BATTLEMENTS_36313, - Scenery.BATTLEMENTS_4447 to Scenery.BATTLEMENTS_36314 + Scenery.BATTLEMENTS_4446 to Scenery.BATTLEMENTS_36313, Scenery.BATTLEMENTS_4447 to Scenery.BATTLEMENTS_36314 ) // Item IDs diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsBarricadeNPC.kt b/Server/src/main/content/minigame/castlewars/CastleWarsBarricadeNPC.kt index f53b06f3c..3510dad56 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsBarricadeNPC.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsBarricadeNPC.kt @@ -50,6 +50,10 @@ class CastleWarsBarricadeNPC : AbstractNPC { return } + override fun isPoisonImmune(): Boolean { + return true + } + override fun construct(id: Int, location: Location, vararg objects: Any?): AbstractNPC { return CastleWarsBarricadeNPC(id, location) } diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsCatapultInterface.kt b/Server/src/main/content/minigame/castlewars/CastleWarsCatapultInterface.kt index 8eb134c47..448e3eec3 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsCatapultInterface.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsCatapultInterface.kt @@ -75,9 +75,7 @@ class CastleWarsCatapultInterface : InterfaceListener { } private fun handleCoordinateChange( - player: Player, - currentCoord: Coordinate, - button: CatapultButton + player: Player, currentCoord: Coordinate, button: CatapultButton ): Boolean { val newCoord = currentCoord.withDelta(button.deltaX, button.deltaY).clamp() diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsCommands.kt b/Server/src/main/content/minigame/castlewars/CastleWarsCommands.kt index 0a29cd9be..aae404781 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsCommands.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsCommands.kt @@ -32,9 +32,7 @@ class CastleWarsCommands : Commands { } val saraCount = CastleWarsWaitingArea.waitingSaradominPlayers.size val zamCount = CastleWarsWaitingArea.waitingZamorakPlayers.size - if (saraCount < CastleWarsConstants.MIN_PLAYERS_PER_TEAM || - zamCount < CastleWarsConstants.MIN_PLAYERS_PER_TEAM - ) { + if (saraCount < CastleWarsConstants.MIN_PLAYERS_PER_TEAM || zamCount < CastleWarsConstants.MIN_PLAYERS_PER_TEAM) { sendMessage( player, "Both teams need at least ${CastleWarsConstants.MIN_PLAYERS_PER_TEAM} players. Saradomin: $saraCount, Zamorak: $zamCount" diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsConstants.kt b/Server/src/main/content/minigame/castlewars/CastleWarsConstants.kt index 3dc4e1bb8..689828bf9 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsConstants.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsConstants.kt @@ -89,17 +89,14 @@ object CastleWarsConstants { const val ENERGY_BARRIER_DRAIN = 4.0 // ==================== Backdoor Pick-Lock ==================== - /** Thieving level required for pick-lock, matching Knight of Ardougne pickpocketing */ - const val BACKDOOR_PICKLOCK_LEVEL = 55 + /** Backdoor pick-lock success at Thieving level 1 */ + const val BACKDOOR_PICKLOCK_LEVEL_ONE_SUCCESS = 6.3 - /** Low roll bound for pick-lock success, matching Knight of Ardougne pickpocketing */ - const val BACKDOOR_PICKLOCK_LOW = 50.0 + /** Backdoor pick-lock success at Thieving level 99 */ + const val BACKDOOR_PICKLOCK_LEVEL_NINETY_NINE_SUCCESS = 100.0 - /** High roll bound for pick-lock success, matching Knight of Ardougne pickpocketing */ - const val BACKDOOR_PICKLOCK_HIGH = 240.0 - - /** Ticks to lock player during pick-lock attempt */ - const val BACKDOOR_PICKLOCK_TICKS = 3 + /** Highest Thieving level used by the linear backdoor pick-lock curve */ + const val BACKDOOR_PICKLOCK_MAX_LEVEL = 99 // ==================== Cleanup Intervals ==================== /** How often to run inactive player cleanup (every 10 ticks = 6 seconds) */ @@ -152,4 +149,7 @@ object CastleWarsConstants { /** Player attribute key for tracking when enemy flag was picked up */ const val ATTR_FLAG_PICKUP_TICK = "cw:flag_pickup_tick" + + /** Player attribute key: team whose bracelet effect was activated when the match started */ + const val ATTR_ACTIVE_BRACELET_TEAM = "cw:active_bracelet_team" } diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsDoorAttackPulse.kt b/Server/src/main/content/minigame/castlewars/CastleWarsDoorAttackPulse.kt index 539f8f916..5d8a0269d 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsDoorAttackPulse.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsDoorAttackPulse.kt @@ -16,9 +16,7 @@ import org.rs09.consts.Sounds * - Damages door health until destroyed or player stops */ class CastleWarsDoorAttackPulse( - private val player: Player, - private val door: Scenery, - private val doorTeam: CastleWarsTeam + private val player: Player, private val door: Scenery, private val doorTeam: CastleWarsTeam ) : Pulse() { private var ticksUntilNextHit = 0 diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsEquipmentRestrictions.kt b/Server/src/main/content/minigame/castlewars/CastleWarsEquipmentRestrictions.kt index 015edc79b..5692fdee4 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsEquipmentRestrictions.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsEquipmentRestrictions.kt @@ -10,12 +10,10 @@ import core.game.node.item.GroundItemManager object CastleWarsEquipmentRestrictions { private val restrictedSlots = intArrayOf( - EquipmentSlot.HEAD.ordinal, - EquipmentSlot.CAPE.ordinal + EquipmentSlot.HEAD.ordinal, EquipmentSlot.CAPE.ordinal ) private val castleWarsTeamCapeIds = intArrayOf( - CastleWars.saradominTeamHoodedCloak, - CastleWars.zamorakTeamHoodedCloak + CastleWars.saradominTeamHoodedCloak, CastleWars.zamorakTeamHoodedCloak ) fun blocksEquip(player: Player, equipmentSlot: Int): Boolean { @@ -45,9 +43,11 @@ object CastleWarsEquipmentRestrictions { } private fun isInCastleWarsArea(player: Player): Boolean { - val allCastleWarsBorders = CastleWarsGameArea.areaBorders.toList() + - CastleWarsWaitingArea.areaBorders.toList() + - listOf(CastleWarsRespawnArea.saradominRespawnRoom, CastleWarsRespawnArea.zamorakRespawnRoom) + val allCastleWarsBorders = + CastleWarsGameArea.areaBorders.toList() + CastleWarsWaitingArea.areaBorders.toList() + listOf( + CastleWarsRespawnArea.saradominRespawnRoom, + CastleWarsRespawnArea.zamorakRespawnRoom + ) return allCastleWarsBorders.any { it.insideBorder(player.location) } } } diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsFlagHandler.kt b/Server/src/main/content/minigame/castlewars/CastleWarsFlagHandler.kt index a031e37f6..285296ec2 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsFlagHandler.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsFlagHandler.kt @@ -3,7 +3,6 @@ package content.minigame.castlewars import core.api.sendMessage import core.game.interaction.IntType import core.game.interaction.InteractionListener -import core.game.node.item.GroundItem import core.game.world.map.Location /** diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsGameState.kt b/Server/src/main/content/minigame/castlewars/CastleWarsGameState.kt index 7ccf9e916..97a9ae181 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsGameState.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsGameState.kt @@ -7,6 +7,7 @@ import content.minigame.castlewars.areas.CastleWarsRespawnArea import content.minigame.castlewars.areas.CastleWarsWaitingArea import core.api.* import core.game.container.impl.EquipmentContainer +import core.game.interaction.Option import core.game.node.entity.combat.ImpactHandler import core.game.node.entity.player.Player import core.game.node.entity.player.link.HintIconManager @@ -62,8 +63,7 @@ enum class BoulderState { } data class ActiveClimbingRope( - val rope: Scenery, - val originalWall: Scenery + val rope: Scenery, val originalWall: Scenery ) /** @@ -225,8 +225,7 @@ object CastleWarsGameState { * Get the remaining game time in minutes */ val remainingMinutes: Int - get() = (CastleWarsGameArea.ticksLeftInGame + CastleWarsConstants.TICKS_PER_MINUTE - 1) / - CastleWarsConstants.TICKS_PER_MINUTE + get() = (CastleWarsGameArea.ticksLeftInGame + CastleWarsConstants.TICKS_PER_MINUTE - 1) / CastleWarsConstants.TICKS_PER_MINUTE /** * Reset the game state for a new game @@ -896,8 +895,7 @@ object CastleWarsGameState { if (carriedFlag == playerTeam) { return false } - val ownFlagState = - if (playerTeam == CastleWarsTeam.SARADOMIN) saradominFlagState else zamorakFlagState + val ownFlagState = if (playerTeam == CastleWarsTeam.SARADOMIN) saradominFlagState else zamorakFlagState if (ownFlagState != FlagState.SAFE) { sendMessage(player, "You can't capture while your own flag is not at its stand!") return false @@ -950,16 +948,19 @@ object CastleWarsGameState { } /** - * Calculate bonus damage from Castle Wars bracelet against flag carriers. - * Returns the bonus damage amount (20% of base hit) if attacker has bracelet - * and target is carrying a flag, otherwise returns 0. + * Calculate bonus damage from an active Castle Wars bracelet against the carrier of the wearer's team flag. */ fun getBraceletBonusDamage(attacker: Player, target: Player, baseHit: Int): Int { - if (!attacker.hasCastleWarsBracelet()) return 0 - if (!isCarryingAnyFlag(target)) return 0 + if (baseHit <= 0) return 0 + val braceletTeam = attacker.activeCastleWarsBraceletTeam() ?: return 0 + if (getCarriedFlag(target) != braceletTeam) return 0 return (baseHit * CastleWarsConstants.BRACELET_FLAG_CARRIER_DAMAGE_BONUS).toInt() } + fun applyBraceletDamageBoost(attacker: Player, target: Player, hit: Int): Int { + return hit + getBraceletBonusDamage(attacker, target, hit) + } + /** * Get which enemy flag a player is carrying */ @@ -1076,6 +1077,9 @@ object CastleWarsGameState { player.inventory.removeAll(CastleWars.CW_ITEMS_TO_REMOVE) player.equipment.removeAll(CastleWars.CW_ITEMS_TO_REMOVE) (player.familiarManager.familiar as? BurdenBeast)?.container?.removeAll(CastleWars.CW_ITEMS_TO_REMOVE) + clearActiveCastleWarsBracelet(player) + player.interaction.remove(Option._P_ATTACK) + player.interaction.remove(Option._P_STEAL_FROM) } /** @@ -1622,7 +1626,6 @@ object CastleWarsGameState { /** * Check if a teammate can steal the flag from a flag carrier. * Returns true if the carrier has held the flag for longer than FLAG_STEAL_TIMER_TICKS (3 minutes). - * Use bandages on a teammate carrying a flag to take it from them. */ fun canTeammateStealFlag(carrier: Player): Boolean { val pickupTick = getAttribute(carrier, CastleWarsConstants.ATTR_FLAG_PICKUP_TICK, 0) @@ -1630,9 +1633,60 @@ object CastleWarsGameState { return getWorldTicks() - pickupTick >= CastleWarsConstants.FLAG_STEAL_TIMER_TICKS } + fun setActiveGamePlayerOptions(player: Player) { + player.interaction.set(Option._P_ATTACK) + player.interaction.set(Option._P_STEAL_FROM) + } + + fun activateBraceletIfEquipped(player: Player, team: CastleWarsTeam) { + if (player.hasCastleWarsBracelet()) { + setAttribute(player, CastleWarsConstants.ATTR_ACTIVE_BRACELET_TEAM, team.name) + } else { + clearActiveCastleWarsBracelet(player) + } + } + + fun clearActiveCastleWarsBracelet(player: Player) { + removeAttribute(player, CastleWarsConstants.ATTR_ACTIVE_BRACELET_TEAM) + } + + fun stealFlagFromTeammate(player: Player, carrier: Player): Boolean { + if (!isGameActive) { + sendMessage(player, "You can only steal flags during a Castle Wars game.") + return false + } + if (player == carrier) { + sendMessage(player, "You can't steal the flag from yourself.") + return false + } + val playerTeam = getPlayerTeam(player) + val carrierTeam = getPlayerTeam(carrier) + if (playerTeam == null || carrierTeam == null) { + sendMessage(player, "You can only steal flags in Castle Wars.") + return false + } + if (playerTeam != carrierTeam) { + sendMessage(player, "You can only steal the flag from a teammate.") + return false + } + if (!isCarryingFlag(carrier)) { + sendMessage(player, "That player isn't carrying the enemy flag.") + return false + } + if (!canTeammateStealFlag(carrier)) { + sendMessage(player, "That player hasn't held the flag long enough.") + return false + } + if (isCarryingAnyFlag(player)) { + sendMessage(player, "You're already carrying a flag!") + return false + } + transferFlag(carrier, player) + return true + } + /** * Transfer the enemy flag from one player to another teammate. - * Used when bandages are used on a flag carrier after the steal timer expires. */ fun transferFlag(from: Player, to: Player) { val carriedFlag = getCarriedFlag(from) ?: return @@ -1678,12 +1732,22 @@ fun Player.hasCastleWarsBracelet(): Boolean { return bracelet.id == Items.CASTLEWAR_BRACE3_11079 || bracelet.id == Items.CASTLEWAR_BRACE2_11081 || bracelet.id == Items.CASTLEWAR_BRACE1_11083 } +fun Player.activeCastleWarsBraceletTeam(): CastleWarsTeam? { + val teamName = getAttribute(CastleWarsConstants.ATTR_ACTIVE_BRACELET_TEAM, null as String?) ?: return null + return CastleWarsTeam.values().firstOrNull { it.name == teamName } +} + +fun Player.hasActiveCastleWarsBracelet(): Boolean { + return activeCastleWarsBraceletTeam() != null +} + /** * Deplete one charge from the Castle Wars bracelet. * Bracelet(3) -> Bracelet(2) -> Bracelet(1) -> removed. * Called at the end of each game for players wearing the bracelet. */ fun Player.depleteCastleWarsBracelet() { + if (!hasActiveCastleWarsBracelet()) return val bracelet = equipment.get(EquipmentContainer.SLOT_HANDS) ?: return val nextId = when (bracelet.id) { Items.CASTLEWAR_BRACE3_11079 -> Items.CASTLEWAR_BRACE2_11081 diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsItemHandler.kt b/Server/src/main/content/minigame/castlewars/CastleWarsItemHandler.kt index ac40ff057..1f80e65e2 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsItemHandler.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsItemHandler.kt @@ -63,16 +63,12 @@ class CastleWarsItemHandler : InteractionListener { fun dealExplosiveAoeDamage(attacker: Player, location: Location, radius: Int = 1) { val attackerTeam = CastleWarsGameState.getPlayerTeam(attacker) ?: return - RegionManager.getLocalPlayers(location, radius) - .filter { target -> - target != attacker && - target.isActive && - CastleWarsGameState.getPlayerTeam(target) != null && - CastleWarsGameState.getPlayerTeam(target) != attackerTeam - } - .forEach { target -> - val damage = CastleWarsConstants.EXPLOSIVE_POTION_SELF_DAMAGE - .coerceAtMost(target.skills.lifepoints) + RegionManager.getLocalPlayers(location, radius).filter { target -> + target != attacker && target.isActive && CastleWarsGameState.getPlayerTeam(target) != null && CastleWarsGameState.getPlayerTeam( + target + ) != attackerTeam + }.forEach { target -> + val damage = CastleWarsConstants.EXPLOSIVE_POTION_SELF_DAMAGE.coerceAtMost(target.skills.lifepoints) target.impactHandler.manualHit( attacker, damage, ImpactHandler.HitsplatType.NORMAL ) @@ -89,31 +85,26 @@ class CastleWarsItemHandler : InteractionListener { return@on true } - onUseWith(IntType.PLAYER, CastleWars.cwBandages, -1) { player, used, with -> - val target = with as? Player ?: return@onUseWith false + onUseWithPlayer(CastleWars.cwBandages) { player, used, with -> + val target = with as? Player ?: return@onUseWithPlayer false val playerTeam = CastleWarsGameState.getPlayerTeam(player) val targetTeam = CastleWarsGameState.getPlayerTeam(target) if (playerTeam == null || targetTeam == null) { sendMessage(player, "You can only use bandages in Castle Wars.") - return@onUseWith false + return@onUseWithPlayer false } if (playerTeam != targetTeam) { sendMessage(player, "You can only heal your teammates!") - return@onUseWith false - } - - if (CastleWarsGameState.isCarryingFlag(target) && CastleWarsGameState.canTeammateStealFlag(target)) { - CastleWarsGameState.transferFlag(target, player) - return@onUseWith true + return@onUseWithPlayer false } if (useBandage(player, target)) { removeItem(player, used.asItem()) } - return@onUseWith true + return@onUseWithPlayer true } on(CastleWars.cwBarricade, IntType.ITEM, "set-up") { player, node -> @@ -131,8 +122,7 @@ class CastleWarsItemHandler : InteractionListener { cleanupDeadBarricades() if (getBarricadeCount(team) >= MAX_BARRICADES_PER_TEAM) { sendMessage( - player, - "Your team already has the maximum number of barricades ($MAX_BARRICADES_PER_TEAM)." + player, "Your team already has the maximum number of barricades ($MAX_BARRICADES_PER_TEAM)." ) return@on false } @@ -161,8 +151,7 @@ class CastleWarsItemHandler : InteractionListener { onUseWith( IntType.NPC, CastleWars.cwExplosivePotion, *intArrayOf( - NPCs.BARRICADE_1532, NPCs.BARRICADE_1533, - NPCs.BARRICADE_1534, NPCs.BARRICADE_1535 + NPCs.BARRICADE_1532, NPCs.BARRICADE_1533, NPCs.BARRICADE_1534, NPCs.BARRICADE_1535 ) ) { player, used, with -> val barricade = with as? NPC ?: return@onUseWith false @@ -224,8 +213,7 @@ class CastleWarsItemHandler : InteractionListener { onUseWith( IntType.NPC, Items.TINDERBOX_590, *intArrayOf( - NPCs.BARRICADE_1532, NPCs.BARRICADE_1533, - NPCs.BARRICADE_1534, NPCs.BARRICADE_1535 + NPCs.BARRICADE_1532, NPCs.BARRICADE_1533, NPCs.BARRICADE_1534, NPCs.BARRICADE_1535 ) ) { player, _, with -> val barricade = with as? NPC ?: return@onUseWith false @@ -304,13 +292,13 @@ class CastleWarsItemHandler : InteractionListener { return false } - if (target.clocks[Clocks.NEXT_EAT] >= getWorldTicks()) { + if (target.clocks[Clocks.NEXT_EAT] > getWorldTicks()) { return false } target.clocks[Clocks.NEXT_EAT] = getWorldTicks() + BANDAGE_COOLDOWN_TICKS val maxHp = target.skills.getStaticLevel(Skills.HITPOINTS) - val healPercent = if (user.hasCastleWarsBracelet()) { + val healPercent = if (user.hasActiveCastleWarsBracelet()) { CastleWarsConstants.BRACELET_BANDAGE_HEAL_PERCENTAGE } else { BANDAGE_HEAL_PERCENTAGE @@ -338,11 +326,9 @@ class CastleWarsItemHandler : InteractionListener { private fun getLocalBarricades(location: Location, radius: Int): List { val barricadeIds = intArrayOf( - NPCs.BARRICADE_1532, NPCs.BARRICADE_1533, - NPCs.BARRICADE_1534, NPCs.BARRICADE_1535 + NPCs.BARRICADE_1532, NPCs.BARRICADE_1533, NPCs.BARRICADE_1534, NPCs.BARRICADE_1535 ) - return RegionManager.getLocalNpcs(location, radius) - .filter { it.id in barricadeIds && it.isActive } + return RegionManager.getLocalNpcs(location, radius).filter { it.id in barricadeIds && it.isActive } } } diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsListeners.kt b/Server/src/main/content/minigame/castlewars/CastleWarsListeners.kt index 573e9b9d2..404b59652 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsListeners.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsListeners.kt @@ -15,8 +15,8 @@ import core.game.global.action.SpecialLadders import core.game.interaction.IntType import core.game.interaction.InteractionListener import core.game.node.entity.player.Player -import core.game.node.entity.skill.Skills import core.game.node.entity.player.link.TeleportManager +import core.game.node.entity.skill.Skills import core.game.node.item.Item import core.game.node.scenery.Scenery import core.game.node.scenery.SceneryBuilder @@ -34,38 +34,35 @@ import org.rs09.consts.Items import org.rs09.consts.Sounds data class BarrierData( - val location: Location, - val direction: Direction, - val outsideTile: Location, - val insideTile: Location + val location: Location, val direction: Direction, val outsideTile: Location, val insideTile: Location ) data class ClimbingRopeTraversal( - val topOfWallTile: Location, - val baseOfWallTile: Location + val topOfWallTile: Location, val baseOfWallTile: Location ) private val barrierCoordinates: Map> = mapOf( CastleWars.zamorakEnergyBarrier to listOf( BarrierData( - Location.create(2373, 3127, 1), Direction.WEST, + Location.create(2373, 3127, 1), + Direction.WEST, Location.create(2373, 3126, 1), Location.create(2373, 3127, 1) - ), - BarrierData( - Location.create(2376, 3131, 1), Direction.SOUTH, + ), BarrierData( + Location.create(2376, 3131, 1), + Direction.SOUTH, Location.create(2377, 3131, 1), Location.create(2376, 3131, 1) ) - ), - CastleWars.saradominEnergyBarrier to listOf( + ), CastleWars.saradominEnergyBarrier to listOf( BarrierData( Location.create(2426, 3080, 1), - Direction.EAST, Location.create(2426, 3081, 1), + Direction.EAST, + Location.create(2426, 3081, 1), Location.create(2426, 3080, 1) - ), - BarrierData( - Location.create(2423, 3076, 1), Direction.NORTH, + ), BarrierData( + Location.create(2423, 3076, 1), + Direction.NORTH, Location.create(2422, 3076, 1), Location.create(2423, 3076, 1) ) @@ -160,11 +157,11 @@ internal fun canClimbUpClimbingRope(playerLoc: Location, rope: Scenery): Boolean } internal fun getBackdoorPicklockSuccessChance(thievingLevel: Int): Double { - return RandomFunction.getSkillSuccessChance( - CastleWarsConstants.BACKDOOR_PICKLOCK_LOW, - CastleWarsConstants.BACKDOOR_PICKLOCK_HIGH, - thievingLevel - ) + val level = thievingLevel.coerceIn(1, CastleWarsConstants.BACKDOOR_PICKLOCK_MAX_LEVEL) + val levelProgress = (level - 1).toDouble() / (CastleWarsConstants.BACKDOOR_PICKLOCK_MAX_LEVEL - 1) + val chanceRange = + CastleWarsConstants.BACKDOOR_PICKLOCK_LEVEL_NINETY_NINE_SUCCESS - CastleWarsConstants.BACKDOOR_PICKLOCK_LEVEL_ONE_SUCCESS + return CastleWarsConstants.BACKDOOR_PICKLOCK_LEVEL_ONE_SUCCESS + chanceRange * levelProgress } class CastleWarsListeners : InteractionListener { @@ -177,9 +174,7 @@ class CastleWarsListeners : InteractionListener { } setDest( - IntType.SCENERY, - intArrayOf(CastleWars.saradominEnergyBarrier, CastleWars.zamorakEnergyBarrier), - "Pass" + IntType.SCENERY, intArrayOf(CastleWars.saradominEnergyBarrier, CastleWars.zamorakEnergyBarrier), "Pass" ) { entity, node -> val barrier = node.asScenery() val playerLoc = entity.location @@ -232,11 +227,8 @@ class CastleWarsListeners : InteractionListener { } else { val formattedItemName = - (ItemDefinition.forId(rewardItem).name.lowercase() + "s.") - .replace("pes.", "pe.") - .replace( - "bronze ", - "" + (ItemDefinition.forId(rewardItem).name.lowercase() + "s.").replace("pes.", "pe.").replace( + "bronze ", "" ) if (rewardItem == Items.TOOLKIT_4051) { @@ -249,9 +241,7 @@ class CastleWarsListeners : InteractionListener { } onUseWith( - SCENERY, - CastleWars.cwClimbingRope, - *CastleWars.cwCastleBattlementsMap.keys.toIntArray() + SCENERY, CastleWars.cwClimbingRope, *CastleWars.cwCastleBattlementsMap.keys.toIntArray() ) { player, rope, wall -> if (CastleWarsGameState.getClimbingRope(wall.location) != null) { sendDialogue(player, "There is already a rope tied to the battlements.") @@ -321,10 +311,7 @@ class CastleWarsListeners : InteractionListener { } val currentBarrier = RegionManager.getObject( - barrierScenery.location.z, - barrierScenery.location.x, - barrierScenery.location.y, - barrierScenery.id + barrierScenery.location.z, barrierScenery.location.x, barrierScenery.location.y, barrierScenery.id ) if (currentBarrier == null || !currentBarrier.isActive) { sendMessage(player, "The barrier is not working properly. Please try again.") @@ -353,10 +340,7 @@ class CastleWarsListeners : InteractionListener { } val currentBarrier = RegionManager.getObject( - barrierScenery.location.z, - barrierScenery.location.x, - barrierScenery.location.y, - barrierScenery.id + barrierScenery.location.z, barrierScenery.location.x, barrierScenery.location.y, barrierScenery.id ) if (currentBarrier == null || !currentBarrier.isActive) { sendMessage(player, "The barrier is not working properly. Please try again.") @@ -464,9 +448,7 @@ class CastleWarsListeners : InteractionListener { } onUseWith( - SCENERY, - CastleWars.cwExplosivePotion, - *(CastleWars.largeDoorIds + CastleWars.largeDoorOpenIds) + SCENERY, CastleWars.cwExplosivePotion, *(CastleWars.largeDoorIds + CastleWars.largeDoorOpenIds) ) { player, used, with -> val door = with.asScenery() val doorTeam = getDoorTeam(door.location) @@ -502,9 +484,7 @@ class CastleWarsListeners : InteractionListener { } onUseWith( - SCENERY, - CastleWars.cwToolkit, - *(CastleWars.largeDoorIds + CastleWars.largeDoorOpenIds) + SCENERY, CastleWars.cwToolkit, *(CastleWars.largeDoorIds + CastleWars.largeDoorOpenIds) ) { player, used, with -> val door = with.asScenery() val doorTeam = getDoorTeam(door.location) @@ -562,29 +542,25 @@ class CastleWarsListeners : InteractionListener { return@on true } val thievingLevel = player.skills.getLevel(Skills.THIEVING) - if (thievingLevel < CastleWarsConstants.BACKDOOR_PICKLOCK_LEVEL) { - sendMessage( - player, - "You need a Thieving level of ${CastleWarsConstants.BACKDOOR_PICKLOCK_LEVEL} to pick this lock." - ) - return@on true - } - lock(player, CastleWarsConstants.BACKDOOR_PICKLOCK_TICKS) animate(player, 2246) sendMessage(player, "You attempt to pick the lock...") val successChance = getBackdoorPicklockSuccessChance(thievingLevel) - runTask(player, CastleWarsConstants.BACKDOOR_PICKLOCK_TICKS) { - if (RandomFunction.randomDouble(1.0, 100.0) <= successChance) { - openBackdoor(player, door) - sendMessage(player, "You successfully pick the lock!") - } else { - sendMessage(player, "You fail to pick the lock.") - } + if (RandomFunction.randomDouble(1.0, 100.0) <= successChance) { + openBackdoor(player, door) + sendMessage(player, "You successfully pick the lock!") + } else { + sendMessage(player, "You fail to pick the lock.") } return@on true } + on(IntType.PLAYER, "Steal from") { player, node -> + val carrier = node as? Player ?: return@on false + CastleWarsGameState.stealFlagFromTeammate(player, carrier) + return@on true + } + on(CastleWars.backdoorOpenIds, IntType.SCENERY, "Close", "Lock") { player, node -> val door = node.asScenery() val doorTeam = if (door.id == CastleWars.saradominBackdoorOpen) CastleWarsTeam.SARADOMIN @@ -905,8 +881,7 @@ class CastleWarsListeners : InteractionListener { } private fun passEnergyBarrier( - player: Player, - barrier: Scenery + player: Player, barrier: Scenery ) { val currentBarrier = RegionManager.getObject(barrier.location.z, barrier.location.x, barrier.location.y, barrier.id) @@ -1042,10 +1017,9 @@ class CastleWarsListeners : InteractionListener { return equipmentSlot != EquipmentSlot.HEAD && equipmentSlot != EquipmentSlot.CAPE } val itemName = definition.name.lowercase() - return isRune(itemName) || - definition.hasAction("drink") || - itemName.contains("potion") || - isHunterButterfly(itemName) + return isRune(itemName) || definition.hasAction("drink") || itemName.contains("potion") || isHunterButterfly( + itemName + ) } private fun isRune(itemName: String): Boolean { @@ -1053,11 +1027,9 @@ class CastleWarsListeners : InteractionListener { } 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") + 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? { diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsOverlay.kt b/Server/src/main/content/minigame/castlewars/CastleWarsOverlay.kt index 460a208b9..f34bc805c 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsOverlay.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsOverlay.kt @@ -1,6 +1,6 @@ package content.minigame.castlewars -import core.api.* +import core.api.setVarp import core.game.node.entity.player.Player /** diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsShutdownHandler.kt b/Server/src/main/content/minigame/castlewars/CastleWarsShutdownHandler.kt index 48584b005..86dfcf41b 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsShutdownHandler.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsShutdownHandler.kt @@ -1,12 +1,12 @@ package content.minigame.castlewars +import content.minigame.castlewars.areas.CastleWarsGameArea +import content.minigame.castlewars.areas.CastleWarsRespawnArea +import content.minigame.castlewars.areas.CastleWarsWaitingArea import core.api.ShutdownListener import core.game.node.entity.player.Player import core.game.world.map.Location import core.game.world.map.RegionManager -import content.minigame.castlewars.areas.CastleWarsGameArea -import content.minigame.castlewars.areas.CastleWarsRespawnArea -import content.minigame.castlewars.areas.CastleWarsWaitingArea /** * Handles Castle Wars cleanup when the server shuts down. @@ -23,8 +23,7 @@ class CastleWarsShutdownHandler : ShutdownListener { allTrackedPlayers += CastleWarsWaitingArea.waitingZamorakPlayers val respawnBorders = arrayOf( - CastleWarsRespawnArea.saradominRespawnRoom, - CastleWarsRespawnArea.zamorakRespawnRoom + CastleWarsRespawnArea.saradominRespawnRoom, CastleWarsRespawnArea.zamorakRespawnRoom ) for (border in respawnBorders) { val centerX = (border.southWestX + border.northEastX) / 2 diff --git a/Server/src/main/content/minigame/castlewars/CatapultConfig.kt b/Server/src/main/content/minigame/castlewars/CatapultConfig.kt index 6a899a88b..286dfa969 100644 --- a/Server/src/main/content/minigame/castlewars/CatapultConfig.kt +++ b/Server/src/main/content/minigame/castlewars/CatapultConfig.kt @@ -62,12 +62,16 @@ object CatapultConfig { * Enum representing the buttons on the catapult interface. */ enum class CatapultButton(val buttonId: Int, val deltaX: Int, val deltaY: Int) { - LEFT(CatapultConfig.BUTTON_LEFT, -1, 0), - RIGHT(CatapultConfig.BUTTON_RIGHT, 1, 0), - UP(CatapultConfig.BUTTON_UP, 0, 1), - DOWN(CatapultConfig.BUTTON_DOWN, 0, -1), - FIRE(CatapultConfig.BUTTON_FIRE, 0, 0), - CLOSE(CatapultConfig.BUTTON_CLOSE, 0, 0), + LEFT(CatapultConfig.BUTTON_LEFT, -1, 0), RIGHT(CatapultConfig.BUTTON_RIGHT, 1, 0), UP( + CatapultConfig.BUTTON_UP, + 0, + 1 + ), + DOWN(CatapultConfig.BUTTON_DOWN, 0, -1), FIRE(CatapultConfig.BUTTON_FIRE, 0, 0), CLOSE( + CatapultConfig.BUTTON_CLOSE, + 0, + 0 + ), UNKNOWN(-1, 0, 0); companion object { @@ -116,8 +120,7 @@ enum class CatapultTeam { */ data class Coordinate(val x: Int, val y: Int) { fun clamp(): Coordinate = Coordinate( - x.coerceIn(0, CatapultConfig.MAX_COORDINATE), - y.coerceIn(0, CatapultConfig.MAX_COORDINATE) + x.coerceIn(0, CatapultConfig.MAX_COORDINATE), y.coerceIn(0, CatapultConfig.MAX_COORDINATE) ) fun withDelta(deltaX: Int, deltaY: Int): Coordinate = Coordinate(x + deltaX, y + deltaY) diff --git a/Server/src/main/content/minigame/castlewars/CatapultFiringHandler.kt b/Server/src/main/content/minigame/castlewars/CatapultFiringHandler.kt index 343a17405..8359d4b68 100644 --- a/Server/src/main/content/minigame/castlewars/CatapultFiringHandler.kt +++ b/Server/src/main/content/minigame/castlewars/CatapultFiringHandler.kt @@ -101,8 +101,7 @@ class CatapultFiringHandler { } private fun dealImpactDamage(attacker: Player, targetLocation: Location) { - RegionManager.getLocalPlayers(targetLocation, IMPACT_RADIUS) - .filter { isValidTarget(it, attacker) } + RegionManager.getLocalPlayers(targetLocation, IMPACT_RADIUS).filter { isValidTarget(it, attacker) } .forEach { target -> val damage = RandomFunction.random(MIN_DAMAGE, MAX_DAMAGE) target.impactHandler.manualHit(attacker, damage, ImpactHandler.HitsplatType.NORMAL) diff --git a/Server/src/main/content/minigame/castlewars/CatapultInterfaceRenderer.kt b/Server/src/main/content/minigame/castlewars/CatapultInterfaceRenderer.kt index bf4914e11..f7f80748d 100644 --- a/Server/src/main/content/minigame/castlewars/CatapultInterfaceRenderer.kt +++ b/Server/src/main/content/minigame/castlewars/CatapultInterfaceRenderer.kt @@ -40,8 +40,7 @@ class CatapultInterfaceRenderer { val screenY = calculateScreenY(coord.y) PacketRepository.send( - RepositionChild::class.java, - ChildPositionContext(player, INTERFACE_ID, MARKER_CHILD_ID, screenX, screenY) + RepositionChild::class.java, ChildPositionContext(player, INTERFACE_ID, MARKER_CHILD_ID, screenX, screenY) ) } @@ -93,11 +92,7 @@ class CatapultInterfaceRenderer { } player.packetDispatch.sendItemOnInterface(itemId, 1, INTERFACE_ID, childId) player.packetDispatch.sendAngleOnInterface( - INTERFACE_ID, - childId, - child.modelZoom, - child.modelXAngle, - child.modelYAngle + INTERFACE_ID, childId, child.modelZoom, child.modelXAngle, child.modelYAngle ) } diff --git a/Server/src/main/content/minigame/castlewars/Lanthus.kt b/Server/src/main/content/minigame/castlewars/Lanthus.kt index 9e9fb54a7..4be45c92a 100644 --- a/Server/src/main/content/minigame/castlewars/Lanthus.kt +++ b/Server/src/main/content/minigame/castlewars/Lanthus.kt @@ -2,11 +2,7 @@ package content.minigame.castlewars import content.minigame.castlewars.areas.CastleWarsGameArea import content.minigame.castlewars.areas.CastleWarsWaitingArea -import core.api.TickListener -import core.api.findNPC -import core.api.openDialogue -import core.api.sendChat -import core.api.sendMessage +import core.api.* import core.game.dialogue.DialogueFile import core.game.dialogue.FacialExpression import core.game.interaction.IntType @@ -16,48 +12,53 @@ import core.tools.END_DIALOGUE import org.rs09.consts.Items import org.rs09.consts.NPCs -class Lanthus: InteractionListener, TickListener { +class Lanthus : InteractionListener, TickListener { override fun defineListeners() { on(NPCs.LANTHUS_1526, IntType.NPC, "talk-to") { player, npcNode -> val lanthusNpc = npcNode.asNpc() - openDialogue(player, object : DialogueFile(){ + openDialogue(player, object : DialogueFile() { override fun handle(componentID: Int, buttonID: Int) { - when(stage) { + when (stage) { 0 -> npcl(FacialExpression.FRIENDLY, "Good day, how may I help you?").also { stage = 2 } 2 -> options( "What is this place?", "What do you have for trade?", "Do you have a manual? I'd like to learn how to play!" ).also { stage++ } + 3 -> when (buttonID) { 1 -> player("What is this place?").also { stage = 4 } 2 -> lanthusNpc.openShop(player).also { stage = END_DIALOGUE } 3 -> player("Do you have a manual? I'd like to learn how to play!").also { stage = 50 } } + 4 -> npcl( FacialExpression.FRIENDLY, "This is the great Castle Wars arena! Here you can fight for the glory of Saradomin or Zamorak." ).also { stage++ } + 5 -> options( - "Really, how do I do that?", - "Are there any rules?", - "What can I win?" + "Really, how do I do that?", "Are there any rules?", "What can I win?" ).also { stage++ } + 6 -> when (buttonID) { 1 -> player("Really, how do I do that?").also { stage = 7 } 2 -> player("Are there any rules?").also { stage = 80 } 3 -> player("What can I win?").also { stage = 90 } } + 7 -> npcl( FacialExpression.FRIENDLY, "Easy, you just step through one of the three portals. To join Zamorak, pass through the red portal. To join Saradomin, pass through the blue portal. If you don't mind then pass through the green portal.", - ).also { stage ++ } + ).also { stage++ } + 8 -> options( "Are there any rules?", "What can I win?", "What do you have for trade?", "Do you have a manual? I'd like to learn how to play!" ).also { stage++ } + 9 -> when (buttonID) { 1 -> player("Are there any rules?").also { stage = 80 } 2 -> player("What can I win?").also { stage = 90 } @@ -66,31 +67,37 @@ class Lanthus: InteractionListener, TickListener { } 80 -> npcl( - FacialExpression.FRIENDLY, "Of course, there are always rules. Firstly you can't wear a cape as you enter the portal, you'll be given your team colours to wear while in the arena. You're also prohibited from taking non-combat related items in") - .also { stage ++ } + FacialExpression.FRIENDLY, + "Of course, there are always rules. Firstly you can't wear a cape as you enter the portal, you'll be given your team colours to wear while in the arena. You're also prohibited from taking non-combat related items in" + ).also { stage++ } 81 -> npcl( - FacialExpression.FRIENDLY, " with you. So you should only have equipment, potions, and runes on you. Secondly, attacking your own team or your team's defences isn't allowed. You don't want to be angering" - ).also { stage ++ } + FacialExpression.FRIENDLY, + " with you. So you should only have equipment, potions, and runes on you. Secondly, attacking your own team or your team's defences isn't allowed. You don't want to be angering" + ).also { stage++ } 82 -> npcl( - FacialExpression.FRIENDLY, "your patron god, do you? Other than that, just have fun and enjoy it!" - ).also { stage ++ } + FacialExpression.FRIENDLY, + "your patron god, do you? Other than that, just have fun and enjoy it!" + ).also { stage++ } 50 -> npcl( - FacialExpression.FRIENDLY, - "Sure, here you go.").also { stage = END_DIALOGUE }.also { player.inventory.add(Item(Items.CASTLEWARS_MANUAL_4055)) } + FacialExpression.FRIENDLY, "Sure, here you go." + ).also { stage = END_DIALOGUE } + .also { player.inventory.add(Item(Items.CASTLEWARS_MANUAL_4055)) } - 83 -> player("Great! Oh, how do I win the game?").also { stage ++ } + 83 -> player("Great! Oh, how do I win the game?").also { stage++ } 84 -> npcl( FacialExpression.FRIENDLY, "The aim is to get into your opponents' castle and take their team standard. Then bring that back and capture it on your team's standard." - ).also { stage ++ } + ).also { stage++ } + 85 -> options( "What can I win?", "What do you have to trade?", "Do you have a manual? I'd like to learn how to play!" - ).also { stage ++ } + ).also { stage++ } + 86 -> when (buttonID) { 1 -> player("What can I win?").also { stage = 90 } 2 -> lanthusNpc.openShop(player).also { stage = END_DIALOGUE } @@ -100,12 +107,14 @@ class Lanthus: InteractionListener, TickListener { 90 -> npcl( FacialExpression.FRIENDLY, "Players on the winning team will receive 2 Castle Wars Tickets which you can trade back to me for other items. In the event of a draw every player will get 1 ticket. Losers receive no tickets." - ).also { stage ++ } + ).also { stage++ } + 91 -> options( "Are there any rules?", "What do you have to trade?", "Do you have a manual? I'd like to learn how to play!" - ).also { stage ++ } + ).also { stage++ } + 92 -> when (buttonID) { 1 -> player("Are there any rules?").also { stage = 80 } 2 -> lanthusNpc.openShop(player).also { stage = END_DIALOGUE } @@ -141,8 +150,7 @@ class Lanthus: InteractionListener, TickListener { return } - val minutesLeft = - (countdown + CastleWarsConstants.TICKS_PER_MINUTE - 1) / CastleWarsConstants.TICKS_PER_MINUTE + 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) { diff --git a/Server/src/main/content/minigame/castlewars/areas/CastleWarsArea.kt b/Server/src/main/content/minigame/castlewars/areas/CastleWarsArea.kt index 5ac678922..a23734ff5 100644 --- a/Server/src/main/content/minigame/castlewars/areas/CastleWarsArea.kt +++ b/Server/src/main/content/minigame/castlewars/areas/CastleWarsArea.kt @@ -64,9 +64,11 @@ abstract class CastleWarsArea : MapArea, LogoutListener, InteractionListener { override fun defineListeners() { onUnequip(intArrayOf(CastleWars.saradominTeamHoodedCloak, CastleWars.zamorakTeamHoodedCloak)) { player, _ -> - val allCastleWarsBorders = CastleWarsGameArea.areaBorders.toList() + - CastleWarsWaitingArea.areaBorders.toList() + - listOf(CastleWarsRespawnArea.saradominRespawnRoom, CastleWarsRespawnArea.zamorakRespawnRoom) + val allCastleWarsBorders = + CastleWarsGameArea.areaBorders.toList() + CastleWarsWaitingArea.areaBorders.toList() + listOf( + CastleWarsRespawnArea.saradominRespawnRoom, + CastleWarsRespawnArea.zamorakRespawnRoom + ) for (border in allCastleWarsBorders) { if (border.insideBorder(player)) { sendMessage(player, "You can't remove your team's colours") diff --git a/Server/src/main/content/minigame/castlewars/areas/CastleWarsGameArea.kt b/Server/src/main/content/minigame/castlewars/areas/CastleWarsGameArea.kt index a93b4c8c3..d5d1d86d8 100644 --- a/Server/src/main/content/minigame/castlewars/areas/CastleWarsGameArea.kt +++ b/Server/src/main/content/minigame/castlewars/areas/CastleWarsGameArea.kt @@ -64,10 +64,7 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener, LoginListener { val zamorakPlayers = mutableSetOf() data class LogoutRecord( - val player: Player, - val team: CastleWarsTeam, - val logoutTick: Int, - val logoutLocation: Location + 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. */ @@ -94,10 +91,14 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener, LoginListener { ticksLeftInGame = CastleWars.gameTimeMinutes * CastleWarsConstants.TICKS_PER_MINUTE saradominPlayers.forEach { player -> player.properties.teleportLocation = CastleWarsRespawnArea.saradominRespawnRoom.randomWalkableLoc + CastleWarsGameState.activateBraceletIfEquipped(player, CastleWarsTeam.SARADOMIN) + CastleWarsGameState.setActiveGamePlayerOptions(player) setAttribute(player, CastleWarsConstants.ATTR_JOIN_TICK, getWorldTicks()) } zamorakPlayers.forEach { player -> player.properties.teleportLocation = CastleWarsRespawnArea.zamorakRespawnRoom.randomWalkableLoc + CastleWarsGameState.activateBraceletIfEquipped(player, CastleWarsTeam.ZAMORAK) + CastleWarsGameState.setActiveGamePlayerOptions(player) setAttribute(player, CastleWarsConstants.ATTR_JOIN_TICK, getWorldTicks()) } val allPlayers = saradominPlayers + zamorakPlayers @@ -201,6 +202,7 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener, LoginListener { zamorakPlayers.remove(player) CastleWarsGameState.clearHintIconForPlayer(player) player.interaction.remove(Option._P_ATTACK) + player.interaction.remove(Option._P_STEAL_FROM) if (offerReplacement) { fillVacancy(leavingTeam) } @@ -283,9 +285,7 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener, LoginListener { } val elapsedMillis = System.currentTimeMillis() - savedLogoutTime val logoutLocation = getAttribute( - player, - CastleWarsConstants.ATTR_LOGOUT_LOCATION, - record?.logoutLocation ?: player.location + player, CastleWarsConstants.ATTR_LOGOUT_LOCATION, record?.logoutLocation ?: player.location ) if (ticksLeftInGame > 0 && elapsedMillis < CastleWarsConstants.LOGOUT_GRACE_PERIOD_MILLIS) { player.location = Location.create(logoutLocation) @@ -372,14 +372,15 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener, LoginListener { log(this::class.java, Log.WARN, "${player.name} entered Castle Wars but is NOT on a team?!") } } - player.interaction.set(Option._P_ATTACK) + CastleWarsGameState.setActiveGamePlayerOptions(player) } override fun exitArea(player: Player) { super.exitArea(player) - val stillInCastleWars = areaBorders.any { it.insideBorder(player.location) } || - CastleWarsRespawnArea.saradominRespawnRoom.insideBorder(player.location) || - CastleWarsRespawnArea.zamorakRespawnRoom.insideBorder(player.location) + val stillInCastleWars = + areaBorders.any { it.insideBorder(player.location) } || CastleWarsRespawnArea.saradominRespawnRoom.insideBorder( + player.location + ) || CastleWarsRespawnArea.zamorakRespawnRoom.insideBorder(player.location) if (!stillInCastleWars) { removePlayerFromGame(player) } diff --git a/Server/src/main/content/minigame/castlewars/areas/CastleWarsRespawnArea.kt b/Server/src/main/content/minigame/castlewars/areas/CastleWarsRespawnArea.kt index 1b3bdc60e..2fd8dac7d 100644 --- a/Server/src/main/content/minigame/castlewars/areas/CastleWarsRespawnArea.kt +++ b/Server/src/main/content/minigame/castlewars/areas/CastleWarsRespawnArea.kt @@ -3,7 +3,9 @@ package content.minigame.castlewars.areas import content.minigame.castlewars.CastleWars import content.minigame.castlewars.CastleWarsConstants import content.minigame.castlewars.CastleWarsGameState -import core.api.* +import core.api.TickListener +import core.api.getWorldTicks +import core.api.sendMessage import core.game.node.entity.Entity import core.game.node.entity.player.Player import core.game.node.item.Item @@ -47,6 +49,7 @@ class CastleWarsRespawnArea : CastleWarsArea(), TickListener { player.equipment.replace(Item(CastleWars.zamorakTeamHoodedCloak), 1) zamorakPlayersInRespawnRoom[player] = getWorldTicks() } + CastleWarsGameState.setActiveGamePlayerOptions(player) } override fun tick() { @@ -62,9 +65,7 @@ class CastleWarsRespawnArea : CastleWarsArea(), TickListener { 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 + player.isActive && player.session != null && currentTick - entryTick >= CastleWarsConstants.LOGOUT_GRACE_PERIOD_TICKS }.keys for (player in expiredPlayers) { players.remove(player) diff --git a/Server/src/main/content/minigame/castlewars/areas/CastleWarsWaitingArea.kt b/Server/src/main/content/minigame/castlewars/areas/CastleWarsWaitingArea.kt index 1d954bb99..bacd07a61 100644 --- a/Server/src/main/content/minigame/castlewars/areas/CastleWarsWaitingArea.kt +++ b/Server/src/main/content/minigame/castlewars/areas/CastleWarsWaitingArea.kt @@ -27,8 +27,7 @@ class CastleWarsWaitingArea : CastleWarsArea(), TickListener { internal var gameStartCountdown = -1 fun hasMinimumPlayers(): Boolean { - return waitingSaradominPlayers.size >= CastleWarsConstants.MIN_PLAYERS_PER_TEAM && - waitingZamorakPlayers.size >= CastleWarsConstants.MIN_PLAYERS_PER_TEAM + return waitingSaradominPlayers.size >= CastleWarsConstants.MIN_PLAYERS_PER_TEAM && waitingZamorakPlayers.size >= CastleWarsConstants.MIN_PLAYERS_PER_TEAM } } @@ -40,8 +39,7 @@ class CastleWarsWaitingArea : CastleWarsArea(), TickListener { val player = entity as? Player ?: return super.areaEnter(player) registerTimer( - player, - spawnTimer("teleblock", (CastleWars.gameCooldownMinutes + CastleWars.gameTimeMinutes) * 60 * 2) + player, spawnTimer("teleblock", (CastleWars.gameCooldownMinutes + CastleWars.gameTimeMinutes) * 60 * 2) ) // Set team attribute and equip the hooded cloak on the entity based on which waiting room they're in @@ -54,8 +52,10 @@ class CastleWarsWaitingArea : CastleWarsArea(), TickListener { } var transformed = false - if (player.attributes[CastleWars.portalAttribute] == CastleWars.guthixName - && (hasGodItem(player, God.ZAMORAK) || hasGodItem(player, God.SARADOMIN)) + if (player.attributes[CastleWars.portalAttribute] == CastleWars.guthixName && (hasGodItem( + player, + God.ZAMORAK + ) || hasGodItem(player, God.SARADOMIN)) ) { // https://www.youtube.com/watch?v=Vg3Om-jZNIY // Turn the player into a sheep @@ -67,8 +67,10 @@ class CastleWarsWaitingArea : CastleWarsArea(), TickListener { ) transformed = true } - if (player.attributes[CastleWars.portalAttribute] == CastleWars.zamorakName - && (hasGodItem(player, God.SARADOMIN) || hasGodItem(player, God.GUTHIX)) + if (player.attributes[CastleWars.portalAttribute] == CastleWars.zamorakName && (hasGodItem( + player, + God.SARADOMIN + ) || hasGodItem(player, God.GUTHIX)) ) { // https://youtu.be/hmF-CU9OEY4?t=63 // Turn the player into an imp! @@ -80,8 +82,10 @@ class CastleWarsWaitingArea : CastleWarsArea(), TickListener { ) transformed = true } - if (player.attributes[CastleWars.portalAttribute] == CastleWars.saradominName - && (hasGodItem(player, God.ZAMORAK) || hasGodItem(player, God.GUTHIX)) + if (player.attributes[CastleWars.portalAttribute] == CastleWars.saradominName && (hasGodItem( + player, + God.ZAMORAK + ) || hasGodItem(player, God.GUTHIX)) ) { // https://www.youtube.com/watch?v=Vg3Om-jZNIY // Turn the player into a rabbit! @@ -120,8 +124,8 @@ class CastleWarsWaitingArea : CastleWarsArea(), TickListener { val nextStart: Int if (CastleWarsGameArea.ticksLeftInGame > 0) { gameStartCountdown = -1 - nextStart = CastleWarsGameArea.ticksLeftInGame + - CastleWars.gameCooldownMinutes * CastleWarsConstants.TICKS_PER_MINUTE + nextStart = + CastleWarsGameArea.ticksLeftInGame + CastleWars.gameCooldownMinutes * CastleWarsConstants.TICKS_PER_MINUTE } else { if (gameStartCountdown < 0) { gameStartCountdown = CastleWarsConstants.WAITING_ROOM_COUNTDOWN_TICKS @@ -131,8 +135,7 @@ class CastleWarsWaitingArea : CastleWarsArea(), TickListener { } for (player in waitingSaradominPlayers + waitingZamorakPlayers) { CastleWarsOverlay.sendLobbyUpdate( - player, - ticksToDisplayMinutes(nextStart) + player, ticksToDisplayMinutes(nextStart) ) } if (CastleWarsGameArea.ticksLeftInGame <= 0 && nextStart <= 0) { diff --git a/Server/src/main/core/game/interaction/Option.java b/Server/src/main/core/game/interaction/Option.java index 8c99a1b57..03b68e1cc 100644 --- a/Server/src/main/core/game/interaction/Option.java +++ b/Server/src/main/core/game/interaction/Option.java @@ -32,6 +32,8 @@ public final class Option { public static final Option _P_PICKPOCKET = new Option("Pickpocket", 4); + public static final Option _P_STEAL_FROM = new Option("Steal from", 4); + public static final Option _P_EXAMINE = new Option("Examine", 7); diff --git a/Server/src/main/core/game/node/entity/combat/CombatSwingHandler.kt b/Server/src/main/core/game/node/entity/combat/CombatSwingHandler.kt index d4324b233..908a07394 100644 --- a/Server/src/main/core/game/node/entity/combat/CombatSwingHandler.kt +++ b/Server/src/main/core/game/node/entity/combat/CombatSwingHandler.kt @@ -23,6 +23,7 @@ import core.game.world.update.flag.context.Animation import core.tools.RandomFunction import core.game.system.config.ItemConfigParser import core.tools.Log +import content.minigame.castlewars.CastleWarsGameState import org.rs09.consts.Sounds import java.util.* import kotlin.math.floor @@ -585,6 +586,9 @@ abstract class CombatSwingHandler(var type: CombatStyle?) { if (player.equipment[3] != null && player.equipment[3].id == 14726 && state.style == CombatStyle.MAGIC) { hit += (hit.toDouble() * 0.15).toInt() } + if (victim is Player) { + hit = CastleWarsGameState.applyBraceletDamageBoost(player, victim.asPlayer(), hit) + } } if (attacker is Familiar && victim is Player) { if (victim.prayer[PrayerType.PROTECT_FROM_SUMMONING]) { diff --git a/Server/src/test/kotlin/content/minigame/castlewars/CastleWarsTests.kt b/Server/src/test/kotlin/content/minigame/castlewars/CastleWarsTests.kt index d999cc05a..25792e479 100644 --- a/Server/src/test/kotlin/content/minigame/castlewars/CastleWarsTests.kt +++ b/Server/src/test/kotlin/content/minigame/castlewars/CastleWarsTests.kt @@ -1,11 +1,11 @@ package content.minigame.castlewars import TestUtils -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.* +import core.game.interaction.Option import core.game.node.entity.skill.Skills import core.game.global.action.EquipHandler import core.game.interaction.IntType @@ -544,17 +544,12 @@ class CastleWarsTests { } @Test - fun backdoorPicklockMatchesKnightOfArdougneDifficulty() { - TestUtils.getMockPlayer("cwthief").use { player -> - player.skills.setStaticLevel(Skills.THIEVING, CastleWarsConstants.BACKDOOR_PICKLOCK_LEVEL) - player.skills.setLevel(Skills.THIEVING, CastleWarsConstants.BACKDOOR_PICKLOCK_LEVEL) - - assertEquals(Pickpockets.KNIGHT_OF_ADROUGNE.requiredLevel, CastleWarsConstants.BACKDOOR_PICKLOCK_LEVEL) - assertEquals( - Pickpockets.KNIGHT_OF_ADROUGNE.getSuccessChance(player), - getBackdoorPicklockSuccessChance(player.skills.getLevel(Skills.THIEVING)) - ) - } + fun backdoorPicklockUsesLinearThievingCurveFromLevelOneToNinetyNine() { + assertEquals(6.3, getBackdoorPicklockSuccessChance(1), 0.0001) + assertEquals(53.15, getBackdoorPicklockSuccessChance(50), 0.01) + assertEquals(100.0, getBackdoorPicklockSuccessChance(99), 0.0001) + assertEquals(6.3, getBackdoorPicklockSuccessChance(0), 0.0001) + assertEquals(100.0, getBackdoorPicklockSuccessChance(120), 0.0001) } @Test @@ -620,6 +615,140 @@ class CastleWarsTests { } } + @Test + fun playerPlacedBarricadesArePoisonImmune() { + TestUtils.getMockPlayer("cwpoisoner").use { player -> + val barricade = CastleWarsBarricadeNPC.spawn(Location.create(2400, 3100, 0), CastleWarsTeam.SARADOMIN) + + applyPoison(barricade, player, 10) + + assertTrue(barricade.isPoisonImmune) + assertFalse(isPoisoned(barricade)) + barricade.clear() + } + } + + @Test + fun matchStartActivatesBraceletEffectsAndStealFromOption() { + TestUtils.getMockPlayer("cwbrace1").use { braceletPlayer -> + TestUtils.getMockPlayer("cwbrace2").use { sara2 -> + TestUtils.getMockPlayer("cwbracez1").use { zam1 -> + TestUtils.getMockPlayer("cwbracez2").use { zam2 -> + braceletPlayer.equipment.replace(Item(Items.CASTLEWAR_BRACE3_11079), EquipmentSlot.HANDS.ordinal) + CastleWarsWaitingArea.waitingSaradominPlayers.addAll(listOf(braceletPlayer, sara2)) + CastleWarsWaitingArea.waitingZamorakPlayers.addAll(listOf(zam1, zam2)) + + CastleWarsGameArea.startGame() + + assertEquals(CastleWarsTeam.SARADOMIN, braceletPlayer.activeCastleWarsBraceletTeam()) + assertFalse(sara2.hasActiveCastleWarsBracelet()) + assertNotNull(braceletPlayer.interaction[Option._P_STEAL_FROM.index]) + assertNotNull(zam1.interaction[Option._P_STEAL_FROM.index]) + } + } + } + } + } + + @Test + fun bandagesHealTenPercentRestoreRunEnergyAndCurePoison() { + TestUtils.getMockPlayer("cwbandage").use { player -> + CastleWarsGameArea.ticksLeftInGame = 100 + CastleWarsGameArea.saradominPlayers.add(player) + player.skills.setStaticLevel(Skills.HITPOINTS, 99) + player.skills.lifepoints = 50 + player.settings.runEnergy = 20.0 + applyPoison(player, player, 10) + player.inventory.add(Item(CastleWars.cwBandages)) + + InteractionListeners.run( + CastleWars.cwBandages, + IntType.ITEM, + "Heal", + player, + player.inventory[0] + ) + + assertEquals(59, player.skills.lifepoints) + assertEquals(50.0, player.settings.runEnergy) + assertFalse(isPoisoned(player)) + assertFalse(player.inventory.contains(CastleWars.cwBandages, 1)) + } + } + + @Test + fun activeBraceletBandagesHealTeammatesForFifteenPercent() { + TestUtils.getMockPlayer("cwhealer").use { healer -> + TestUtils.getMockPlayer("cwpatient").use { patient -> + CastleWarsGameArea.ticksLeftInGame = 100 + CastleWarsGameArea.saradominPlayers.addAll(listOf(healer, patient)) + setAttribute(healer, CastleWarsConstants.ATTR_ACTIVE_BRACELET_TEAM, CastleWarsTeam.SARADOMIN.name) + patient.skills.setStaticLevel(Skills.HITPOINTS, 99) + patient.skills.lifepoints = 50 + patient.settings.runEnergy = 20.0 + applyPoison(patient, patient, 10) + healer.inventory.add(Item(CastleWars.cwBandages)) + val handler = InteractionListeners.get(-1, CastleWars.cwBandages, IntType.PLAYER.ordinal) + assertNotNull(handler) + + assertTrue(handler!!.invoke(healer, healer.inventory[0], patient)) + + assertEquals(64, patient.skills.lifepoints) + assertEquals(50.0, patient.settings.runEnergy) + assertFalse(isPoisoned(patient)) + assertFalse(healer.inventory.contains(CastleWars.cwBandages, 1)) + } + } + } + + @Test + fun activeBraceletDamageBoostOnlyAppliesAgainstCarrierOfWearersTeamFlag() { + TestUtils.getMockPlayer("cwattacker").use { attacker -> + TestUtils.getMockPlayer("cwcarrier").use { carrier -> + TestUtils.getMockPlayer("cwothercarrier").use { otherCarrier -> + CastleWarsGameArea.ticksLeftInGame = 100 + CastleWarsGameArea.saradominPlayers.addAll(listOf(attacker, otherCarrier)) + CastleWarsGameArea.zamorakPlayers.add(carrier) + setAttribute(attacker, CastleWarsConstants.ATTR_ACTIVE_BRACELET_TEAM, CastleWarsTeam.SARADOMIN.name) + + assertTrue(CastleWarsGameState.pickUpFlag(carrier, CastleWarsTeam.SARADOMIN)) + assertEquals(2, CastleWarsGameState.getBraceletBonusDamage(attacker, carrier, 10)) + assertEquals(12, CastleWarsGameState.applyBraceletDamageBoost(attacker, carrier, 10)) + + CastleWarsGameState.dropFlag(carrier) + assertTrue(CastleWarsGameState.pickUpFlag(otherCarrier, CastleWarsTeam.ZAMORAK)) + assertEquals(0, CastleWarsGameState.getBraceletBonusDamage(attacker, otherCarrier, 10)) + + CastleWarsGameState.clearActiveCastleWarsBracelet(attacker) + assertEquals(0, CastleWarsGameState.getBraceletBonusDamage(attacker, carrier, 10)) + } + } + } + } + + @Test + fun teammatesCanStealEnemyFlagAfterCarrierHasHeldItForThreeMinutes() { + TestUtils.getMockPlayer("cwcarrier").use { carrier -> + TestUtils.getMockPlayer("cwstealer").use { stealer -> + CastleWarsGameArea.ticksLeftInGame = 1000 + CastleWarsGameArea.saradominPlayers.addAll(listOf(carrier, stealer)) + + assertTrue(CastleWarsGameState.pickUpFlag(carrier, CastleWarsTeam.ZAMORAK)) + assertFalse(CastleWarsGameState.stealFlagFromTeammate(stealer, carrier)) + + setAttribute( + carrier, + CastleWarsConstants.ATTR_FLAG_PICKUP_TICK, + getWorldTicks() - CastleWarsConstants.FLAG_STEAL_TIMER_TICKS + ) + + assertTrue(CastleWarsGameState.stealFlagFromTeammate(stealer, carrier)) + assertFalse(CastleWarsGameState.isCarryingFlag(carrier)) + assertEquals(CastleWarsTeam.ZAMORAK, CastleWarsGameState.getCarriedFlag(stealer)) + } + } + } + private fun assertSubstitutionDialogue(player: Player) { assertTrue(player.dialogueInterpreter.dialogue.file is CastleWarsSubstitutionDialogue) } From eca0f81a1ce833bdaf7bc09c4034f88b0755b8e3 Mon Sep 17 00:00:00 2001 From: dam <27978131-real_damighty@users.noreply.gitlab.com> Date: Tue, 12 May 2026 20:24:00 +0300 Subject: [PATCH 21/26] Catapult cooldown Player who fires catapult has to wait for their shot to land before being able to operate catapult again --- .../castlewars/CastleWarsCatapultInterface.kt | 5 + .../castlewars/CastleWarsListeners.kt | 8 +- .../minigame/castlewars/CatapultConfig.kt | 9 +- .../castlewars/CatapultFiringHandler.kt | 37 ++++++- .../minigame/castlewars/CastleWarsTests.kt | 104 ++++++++++++------ 5 files changed, 115 insertions(+), 48 deletions(-) diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsCatapultInterface.kt b/Server/src/main/content/minigame/castlewars/CastleWarsCatapultInterface.kt index 448e3eec3..c4db59533 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsCatapultInterface.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsCatapultInterface.kt @@ -53,6 +53,11 @@ class CastleWarsCatapultInterface : InterfaceListener { } private fun handleFire(player: Player, coord: Coordinate, team: CastleWarsTeam): Boolean { + if (CatapultFiringHandler.hasShotInFlight(player)) { + CatapultFiringHandler.sendShotInFlightMessage(player) + return closeAndReturn(player, true) + } + if (!hasRocks(player)) { sendMessage(player, "You don't have any rocks to fire!") return true diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsListeners.kt b/Server/src/main/content/minigame/castlewars/CastleWarsListeners.kt index 404b59652..2d5ede95d 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsListeners.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsListeners.kt @@ -228,8 +228,8 @@ class CastleWarsListeners : InteractionListener { } else { val formattedItemName = (ItemDefinition.forId(rewardItem).name.lowercase() + "s.").replace("pes.", "pe.").replace( - "bronze ", "" - ) + "bronze ", "" + ) if (rewardItem == Items.TOOLKIT_4051) { sendDialogue(player, "Your inventory is too full to hold a toolkit.") @@ -700,6 +700,10 @@ class CastleWarsListeners : InteractionListener { sendMessage(player, "The catapult is on fire! Use a bucket of water to extinguish it.") return@on true } + if (CatapultFiringHandler.hasShotInFlight(player)) { + CatapultFiringHandler.sendShotInFlightMessage(player) + return@on true + } if (!inInventory(player, CastleWars.cwRock)) { sendMessage(player, "You need rocks to fire the catapult!") return@on true diff --git a/Server/src/main/content/minigame/castlewars/CatapultConfig.kt b/Server/src/main/content/minigame/castlewars/CatapultConfig.kt index 286dfa969..3bc8fdc4a 100644 --- a/Server/src/main/content/minigame/castlewars/CatapultConfig.kt +++ b/Server/src/main/content/minigame/castlewars/CatapultConfig.kt @@ -56,6 +56,7 @@ object CatapultConfig { const val INACCURACY_RADIUS = CastleWarsConstants.CATAPULT_INACCURACY_RADIUS const val ATTR_LOCATION = "catapult:location" + const val ATTR_SHOT_IN_FLIGHT = "catapult:shot-in-flight" } /** @@ -63,14 +64,10 @@ object CatapultConfig { */ enum class CatapultButton(val buttonId: Int, val deltaX: Int, val deltaY: Int) { LEFT(CatapultConfig.BUTTON_LEFT, -1, 0), RIGHT(CatapultConfig.BUTTON_RIGHT, 1, 0), UP( - CatapultConfig.BUTTON_UP, - 0, - 1 + CatapultConfig.BUTTON_UP, 0, 1 ), DOWN(CatapultConfig.BUTTON_DOWN, 0, -1), FIRE(CatapultConfig.BUTTON_FIRE, 0, 0), CLOSE( - CatapultConfig.BUTTON_CLOSE, - 0, - 0 + CatapultConfig.BUTTON_CLOSE, 0, 0 ), UNKNOWN(-1, 0, 0); diff --git a/Server/src/main/content/minigame/castlewars/CatapultFiringHandler.kt b/Server/src/main/content/minigame/castlewars/CatapultFiringHandler.kt index 8359d4b68..9eb8d1da6 100644 --- a/Server/src/main/content/minigame/castlewars/CatapultFiringHandler.kt +++ b/Server/src/main/content/minigame/castlewars/CatapultFiringHandler.kt @@ -1,5 +1,6 @@ package content.minigame.castlewars +import content.minigame.castlewars.CatapultConfig.ATTR_SHOT_IN_FLIGHT import content.minigame.castlewars.CatapultConfig.GFX_BOULDER_PROJECTILE import content.minigame.castlewars.CatapultConfig.GFX_IMPACT import content.minigame.castlewars.CatapultConfig.IMPACT_RADIUS @@ -28,6 +29,24 @@ import core.tools.RandomFunction */ class CatapultFiringHandler { + companion object { + internal fun hasShotInFlight(player: Player): Boolean { + return getAttribute(player, ATTR_SHOT_IN_FLIGHT, false) + } + + internal fun markShotInFlight(player: Player) { + setAttribute(player, ATTR_SHOT_IN_FLIGHT, true) + } + + internal fun clearShotInFlight(player: Player) { + removeAttribute(player, ATTR_SHOT_IN_FLIGHT) + } + + internal fun sendShotInFlightMessage(player: Player) { + sendMessage(player, "You need to wait for your previous catapult shot to land.") + } + } + /** * Fires the catapult at the specified coordinates. * @param player The player firing the catapult @@ -36,6 +55,13 @@ class CatapultFiringHandler { * @param catapultLocation The actual world location of the catapult */ fun fire(player: Player, coord: Coordinate, team: CatapultTeam, catapultLocation: Location) { + if (hasShotInFlight(player)) { + sendShotInFlightMessage(player) + return + } + + markShotInFlight(player) + val targetLocation = calculateTargetLocation(team, catapultLocation, coord) val finalLocation = applyInaccuracy(targetLocation) @@ -94,22 +120,25 @@ class CatapultFiringHandler { private fun scheduleImpact(player: Player, targetLocation: Location, impactDelayTicks: Int) { submitWorldPulse(object : Pulse(impactDelayTicks) { override fun pulse(): Boolean { - dealImpactDamage(player, targetLocation) + try { + dealImpactDamage(player, targetLocation) + } finally { + clearShotInFlight(player) + } return true } }) } private fun dealImpactDamage(attacker: Player, targetLocation: Location) { - RegionManager.getLocalPlayers(targetLocation, IMPACT_RADIUS).filter { isValidTarget(it, attacker) } - .forEach { target -> + RegionManager.getLocalPlayers(targetLocation, IMPACT_RADIUS).filter { isValidTarget(it) }.forEach { target -> val damage = RandomFunction.random(MIN_DAMAGE, MAX_DAMAGE) target.impactHandler.manualHit(attacker, damage, ImpactHandler.HitsplatType.NORMAL) sendMessage(target, "You are hit by a catapult rock!") } } - private fun isValidTarget(target: Player, attacker: Player): Boolean { + private fun isValidTarget(target: Player): Boolean { return CastleWarsGameState.getPlayerTeam(target) != null } } diff --git a/Server/src/test/kotlin/content/minigame/castlewars/CastleWarsTests.kt b/Server/src/test/kotlin/content/minigame/castlewars/CastleWarsTests.kt index 25792e479..a57b531ca 100644 --- a/Server/src/test/kotlin/content/minigame/castlewars/CastleWarsTests.kt +++ b/Server/src/test/kotlin/content/minigame/castlewars/CastleWarsTests.kt @@ -5,23 +5,17 @@ import content.minigame.castlewars.areas.CastleWarsGameArea import content.minigame.castlewars.areas.CastleWarsRespawnArea import content.minigame.castlewars.areas.CastleWarsWaitingArea import core.api.* -import core.game.interaction.Option -import core.game.node.entity.skill.Skills +import core.game.component.Component import core.game.global.action.EquipHandler -import core.game.interaction.IntType -import core.game.interaction.InteractionListener -import core.game.interaction.InteractionListeners +import core.game.interaction.* import core.game.node.entity.player.Player +import core.game.node.entity.skill.Skills 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 -import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertNotNull -import org.junit.jupiter.api.Assertions.assertNull -import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Test import org.rs09.consts.Items @@ -40,6 +34,9 @@ class CastleWarsTests { if (InteractionListeners.get(CastleWars.cwExplosivePotion, IntType.ITEM.ordinal, "drop") == null) { CastleWarsItemHandler().defineListeners() } + if (InterfaceListeners.get(CatapultConfig.INTERFACE_ID) == null) { + CastleWarsCatapultInterface().defineInterfaceListeners() + } } } @@ -440,11 +437,7 @@ class CastleWarsTests { player.inventory.add(Item(Items.BRONZE_FULL_HELM_1155)) InteractionListeners.run( - Items.BRONZE_FULL_HELM_1155, - IntType.ITEM, - "wear", - player, - player.inventory[0] + Items.BRONZE_FULL_HELM_1155, IntType.ITEM, "wear", player, player.inventory[0] ) assertNull(player.equipment[EquipmentSlot.HEAD.ordinal]) @@ -459,11 +452,7 @@ class CastleWarsTests { player.inventory.add(Item(Items.HOODED_CLOAK_4041)) InteractionListeners.run( - Items.HOODED_CLOAK_4041, - IntType.ITEM, - "wear", - player, - player.inventory[0] + Items.HOODED_CLOAK_4041, IntType.ITEM, "wear", player, player.inventory[0] ) assertNull(player.equipment[EquipmentSlot.CAPE.ordinal]) @@ -488,11 +477,7 @@ class CastleWarsTests { player.inventory.add(Item(Items.BRONZE_MED_HELM_1139)) InteractionListeners.run( - Items.BRONZE_MED_HELM_1139, - IntType.ITEM, - "wear", - player, - player.inventory[0] + Items.BRONZE_MED_HELM_1139, IntType.ITEM, "wear", player, player.inventory[0] ) assertTrue(didRun) @@ -570,6 +555,59 @@ class CastleWarsTests { assertEquals(-2, zamorakNorth.y - zamorakBase.y) } + @Test + fun catapultShotInFlightBlocksOnlyShooterUntilLanding() { + TestUtils.getMockPlayer("cwcatlock1").use { shooter -> + TestUtils.getMockPlayer("cwcatlock2").use { other -> + val catapult = Scenery( + CastleWars.getOperationalCatapultId(CastleWarsTeam.SARADOMIN), + CastleWarsGameState.saradominCatapultLocation, + 10, + 0 + ) + val operateCatapult = InteractionListeners.get( + catapult.id, IntType.SCENERY.ordinal, "operate" + )!! + + CastleWarsGameArea.saradominPlayers.addAll(listOf(shooter, other)) + shooter.inventory.add(Item(CastleWars.cwRock, 2)) + other.inventory.add(Item(CastleWars.cwRock)) + + operateCatapult(shooter, catapult) + assertTrue(shooter.interfaceManager.hasMainComponent(CatapultConfig.INTERFACE_ID)) + + InterfaceListeners.run( + shooter, Component(CatapultConfig.INTERFACE_ID), 0, CatapultConfig.BUTTON_FIRE, 0, 0 + ) + + assertTrue(CatapultFiringHandler.hasShotInFlight(shooter)) + assertFalse(CatapultFiringHandler.hasShotInFlight(other)) + assertFalse(shooter.interfaceManager.hasMainComponent(CatapultConfig.INTERFACE_ID)) + + val rocksAfterFirstShot = shooter.inventory.getAmount(CastleWars.cwRock) + shooter.setAttribute(CatapultConfig.ATTR_LOCATION, catapult.location) + shooter.interfaceManager.open(Component(CatapultConfig.INTERFACE_ID)) + InterfaceListeners.run( + shooter, Component(CatapultConfig.INTERFACE_ID), 0, CatapultConfig.BUTTON_FIRE, 0, 0 + ) + assertEquals(rocksAfterFirstShot, shooter.inventory.getAmount(CastleWars.cwRock)) + assertFalse(shooter.interfaceManager.hasMainComponent(CatapultConfig.INTERFACE_ID)) + + operateCatapult(shooter, catapult) + assertFalse(shooter.interfaceManager.hasMainComponent(CatapultConfig.INTERFACE_ID)) + + operateCatapult(other, catapult) + assertTrue(other.interfaceManager.hasMainComponent(CatapultConfig.INTERFACE_ID)) + + TestUtils.advanceTicks(10, false) + + assertFalse(CatapultFiringHandler.hasShotInFlight(shooter)) + operateCatapult(shooter, catapult) + assertTrue(shooter.interfaceManager.hasMainComponent(CatapultConfig.INTERFACE_ID)) + } + } + } + @Test fun climbingRopeOnPlayerTileCanBeClimbed() { val rope = Scenery(CastleWars.cwCastleClimbingRope, Location.create(2420, 3078, 1), 4, 0) @@ -602,11 +640,7 @@ class CastleWarsTests { dropper.inventory.add(Item(CastleWars.cwExplosivePotion)) InteractionListeners.run( - CastleWars.cwExplosivePotion, - IntType.ITEM, - "drop", - dropper, - dropper.inventory[0] + CastleWars.cwExplosivePotion, IntType.ITEM, "drop", dropper, dropper.inventory[0] ) assertEquals(35, dropper.skills.lifepoints) @@ -634,7 +668,9 @@ class CastleWarsTests { TestUtils.getMockPlayer("cwbrace2").use { sara2 -> TestUtils.getMockPlayer("cwbracez1").use { zam1 -> TestUtils.getMockPlayer("cwbracez2").use { zam2 -> - braceletPlayer.equipment.replace(Item(Items.CASTLEWAR_BRACE3_11079), EquipmentSlot.HANDS.ordinal) + braceletPlayer.equipment.replace( + Item(Items.CASTLEWAR_BRACE3_11079), EquipmentSlot.HANDS.ordinal + ) CastleWarsWaitingArea.waitingSaradominPlayers.addAll(listOf(braceletPlayer, sara2)) CastleWarsWaitingArea.waitingZamorakPlayers.addAll(listOf(zam1, zam2)) @@ -662,11 +698,7 @@ class CastleWarsTests { player.inventory.add(Item(CastleWars.cwBandages)) InteractionListeners.run( - CastleWars.cwBandages, - IntType.ITEM, - "Heal", - player, - player.inventory[0] + CastleWars.cwBandages, IntType.ITEM, "Heal", player, player.inventory[0] ) assertEquals(59, player.skills.lifepoints) From 029870a1bd5e878e05afb3d2fadfa811837426da Mon Sep 17 00:00:00 2001 From: dam <27978131-real_damighty@users.noreply.gitlab.com> Date: Wed, 13 May 2026 19:19:49 +0300 Subject: [PATCH 22/26] Deterministic trapdoor destination --- .../castlewars/CastleWarsListeners.kt | 9 +++--- .../minigame/castlewars/CastleWarsTests.kt | 30 +++++++++++++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsListeners.kt b/Server/src/main/content/minigame/castlewars/CastleWarsListeners.kt index 2d5ede95d..c3c157c89 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsListeners.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsListeners.kt @@ -81,6 +81,9 @@ private val castleWarsStairDestinationOverrides: Map = mapOf Location.create(2374, 3131, 2) to Location.create(2373, 3133, 3) ) +private val saradominTrapdoorDestination: Location = Location.create(2429, 3075, 1) +private val zamorakTrapdoorDestination: Location = Location.create(2370, 3132, 1) + internal fun getCastleWarsStairDestination(start: Location): Location? { return SpecialLadders.getDestination(start) ?: castleWarsStairDestinationOverrides[start] } @@ -814,8 +817,7 @@ class CastleWarsListeners : InteractionListener { sendMessage(player, "You cannot take the flag into your respawn room!") return@on true } - val destination = CastleWarsRespawnArea.saradominRespawnRoom.randomWalkableLoc - teleport(player, destination, TeleportManager.TeleportType.INSTANT) + teleport(player, saradominTrapdoorDestination, TeleportManager.TeleportType.INSTANT) return@on true } on(CastleWars.zamorakTrapdoor, IntType.SCENERY, "Go-down") { player, node -> @@ -828,8 +830,7 @@ class CastleWarsListeners : InteractionListener { sendMessage(player, "You cannot take the flag into your respawn room!") return@on true } - val destination = CastleWarsRespawnArea.zamorakRespawnRoom.randomWalkableLoc - teleport(player, destination, TeleportManager.TeleportType.INSTANT) + teleport(player, zamorakTrapdoorDestination, TeleportManager.TeleportType.INSTANT) return@on true } diff --git a/Server/src/test/kotlin/content/minigame/castlewars/CastleWarsTests.kt b/Server/src/test/kotlin/content/minigame/castlewars/CastleWarsTests.kt index a57b531ca..7e1530181 100644 --- a/Server/src/test/kotlin/content/minigame/castlewars/CastleWarsTests.kt +++ b/Server/src/test/kotlin/content/minigame/castlewars/CastleWarsTests.kt @@ -359,6 +359,36 @@ class CastleWarsTests { } } + @Test + fun trapdoorsTeleportPlayersToSpecificTilesInsideRespawnRooms() { + TestUtils.getMockPlayer("cwtrapdoorsara").use { saradominPlayer -> + TestUtils.getMockPlayer("cwtrapdoorzam").use { zamorakPlayer -> + CastleWarsGameArea.saradominPlayers.add(saradominPlayer) + CastleWarsGameArea.zamorakPlayers.add(zamorakPlayer) + + assertTrue( + runSceneryInteraction( + CastleWars.saradominTrapdoor, + "Go-down", + saradominPlayer, + Scenery(CastleWars.saradominTrapdoor, Location.create(2429, 3075, 2), 10, 0) + ) + ) + assertTrue( + runSceneryInteraction( + CastleWars.zamorakTrapdoor, + "Go-down", + zamorakPlayer, + Scenery(CastleWars.zamorakTrapdoor, Location.create(2370, 3132, 2), 10, 0) + ) + ) + + assertEquals(Location.create(2429, 3075, 1), saradominPlayer.properties.teleportLocation) + assertEquals(Location.create(2370, 3132, 1), zamorakPlayer.properties.teleportLocation) + } + } + } + @Test fun gameEndAwardsDrawTicketWithoutFivePerTeamOrTenMinuteGate() { TestUtils.getMockPlayer("cwticket1").use { sara1 -> From 9716d6caf53df8cee5d0de272adb099cfe0bc7df Mon Sep 17 00:00:00 2001 From: dam <27978131-real_damighty@users.noreply.gitlab.com> Date: Wed, 27 May 2026 13:31:07 +0300 Subject: [PATCH 23/26] Unforeseen consequences Messageless teleblock, hard ass helmet/cape slot blocking in Castle Wars, many CW fixes --- .../castlewars/CastleWarsCatapultInterface.kt | 45 +- .../minigame/castlewars/CastleWarsCommands.kt | 21 +- .../castlewars/CastleWarsConstants.kt | 3 + .../castlewars/CastleWarsDoorAttackPulse.kt | 31 +- .../castlewars/CastleWarsFlagHandler.kt | 5 +- .../castlewars/CastleWarsGameState.kt | 161 +++-- .../castlewars/CastleWarsItemHandler.kt | 40 +- .../castlewars/CastleWarsListeners.kt | 9 +- .../castlewars/areas/CastleWarsGameArea.kt | 151 ++++- .../castlewars/areas/CastleWarsRespawnArea.kt | 3 + .../castlewars/areas/CastleWarsWaitingArea.kt | 18 +- .../container/impl/EquipmentContainer.java | 49 +- .../core/game/global/action/EquipHandler.kt | 26 +- .../core/game/system/timer/impl/Teleblock.kt | 6 +- Server/src/test/kotlin/content/EquipTests.kt | 27 +- .../minigame/castlewars/CastleWarsTests.kt | 590 +++++++++++++++++- 16 files changed, 1043 insertions(+), 142 deletions(-) diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsCatapultInterface.kt b/Server/src/main/content/minigame/castlewars/CastleWarsCatapultInterface.kt index c4db59533..830d38650 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsCatapultInterface.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsCatapultInterface.kt @@ -9,6 +9,7 @@ import core.game.interaction.InterfaceListener import core.game.node.entity.player.Player import core.game.system.task.Pulse import core.game.world.map.Location +import core.game.world.map.RegionManager class CastleWarsCatapultInterface : InterfaceListener { @@ -67,18 +68,58 @@ class CastleWarsCatapultInterface : InterfaceListener { sendMessage(player, "Error: Catapult location not found.") return true } + val catapultTeam = getCatapultTeam(catapultLocation) + if (!canFireCatapult(player, team, catapultTeam, catapultLocation)) { + return closeAndReturn(player, true) + } if (!removeItem(player, CastleWars.cwRock)) { sendMessage(player, "You don't have any rocks to fire!") return true } - val catapultTeam = CatapultTeam.fromCastleWarsTeam(team) - firingHandler.fire(player, coord, catapultTeam, catapultLocation) + firingHandler.fire(player, coord, CatapultTeam.fromCastleWarsTeam(catapultTeam), catapultLocation) closeInterface(player) return true } + private fun canFireCatapult( + player: Player, + playerTeam: CastleWarsTeam, + catapultTeam: CastleWarsTeam, + catapultLocation: Location + ): Boolean { + if (playerTeam != catapultTeam) { + sendMessage(player, "You can't use the enemy's catapult!") + return false + } + + val currentCatapult = RegionManager.getObject(catapultLocation) + if (currentCatapult == null || !currentCatapult.isActive || currentCatapult.id !in CastleWars.allCatapultIds) { + sendMessage(player, "The catapult is no longer there.") + return false + } + if (currentCatapult.id in CastleWars.damagedCatapultIds || !CastleWarsGameState.isCatapultOperational( + catapultTeam + ) + ) { + sendMessage(player, "The catapult is destroyed! Use a toolkit to repair it.") + return false + } + if (currentCatapult.id in CastleWars.burningCatapultIds || CastleWarsGameState.isCatapultOnFire( + catapultTeam + ) + ) { + sendMessage(player, "The catapult is on fire! Use a bucket of water to extinguish it.") + return false + } + return true + } + + private fun getCatapultTeam(location: Location): CastleWarsTeam { + return if (location.y < 3100) CastleWarsTeam.SARADOMIN else CastleWarsTeam.ZAMORAK + } + private fun handleCoordinateChange( player: Player, currentCoord: Coordinate, button: CatapultButton ): Boolean { diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsCommands.kt b/Server/src/main/content/minigame/castlewars/CastleWarsCommands.kt index aae404781..f7ab01484 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsCommands.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsCommands.kt @@ -1,12 +1,9 @@ package content.minigame.castlewars import content.minigame.castlewars.areas.CastleWarsGameArea -import content.minigame.castlewars.areas.CastleWarsGameArea.Companion.saradominPlayers -import content.minigame.castlewars.areas.CastleWarsGameArea.Companion.zamorakPlayers import content.minigame.castlewars.areas.CastleWarsWaitingArea import core.api.Commands import core.api.sendMessage -import core.game.interaction.Option import core.game.system.command.Privilege /** @@ -48,23 +45,7 @@ class CastleWarsCommands : Commands { sendMessage(player, "No Castle Wars game is currently running.") return@define } - // Snapshot the sets before endGame() modifies them via teleport callbacks - val saraSnapshot = saradominPlayers.toSet() - val zamSnapshot = zamorakPlayers.toSet() - CastleWarsGameState.endGame() - CastleWarsItemHandler.clearAllBarricades() - CastleWarsGameArea.ticksLeftInGame = -1 - CastleWarsWaitingArea.gameStartCountdown = -1 - for (p in saraSnapshot) { - p.interaction.remove(Option._P_ATTACK) - p.properties.teleportLocation = CastleWars.lobbyBankArea.randomWalkableLoc - } - for (p in zamSnapshot) { - p.interaction.remove(Option._P_ATTACK) - p.properties.teleportLocation = CastleWars.lobbyBankArea.randomWalkableLoc - } - saradominPlayers.clear() - zamorakPlayers.clear() + CastleWarsGameArea.endCurrentGame() sendMessage(player, "Castle Wars game ended by admin command.") } diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsConstants.kt b/Server/src/main/content/minigame/castlewars/CastleWarsConstants.kt index 689828bf9..856706b4c 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsConstants.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsConstants.kt @@ -120,6 +120,9 @@ object CastleWarsConstants { /** Waiting-room cycle while no game is active: 2 minutes = 200 ticks */ const val WAITING_ROOM_COUNTDOWN_TICKS = 2 * TICKS_PER_MINUTE + /** How long a substitution offer can block later vacancy offers */ + const val VACANCY_OFFER_TIMEOUT_TICKS = TICKS_PER_MINUTE + // ==================== Player Attribute Keys ==================== /** Attribute key: world tick when player joined the current game */ const val ATTR_JOIN_TICK = "cw:join_tick" diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsDoorAttackPulse.kt b/Server/src/main/content/minigame/castlewars/CastleWarsDoorAttackPulse.kt index 5d8a0269d..3883e6d6d 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsDoorAttackPulse.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsDoorAttackPulse.kt @@ -3,11 +3,14 @@ package content.minigame.castlewars import core.api.animate import core.api.playAudio import core.game.container.impl.EquipmentContainer +import core.game.node.entity.combat.equipment.WeaponInterface import core.game.node.entity.player.Player import core.game.node.entity.skill.Skills import core.game.node.scenery.Scenery import core.game.system.task.Pulse +import core.tools.RandomFunction import org.rs09.consts.Sounds +import kotlin.math.floor /** * Handles attacking the Castle Wars front doors. @@ -54,18 +57,24 @@ class CastleWarsDoorAttackPulse( } private fun calculateDamage(): Int { - // Doors are weak to crush > slash > stab - val strengthLevel = player.skills.getLevel(Skills.STRENGTH) + var effectiveStrength = player.skills.getLevel(Skills.STRENGTH).toDouble() + when (player.properties.attackStyle.style) { + WeaponInterface.STYLE_AGGRESSIVE -> effectiveStrength += 3.0 + WeaponInterface.STYLE_CONTROLLED -> effectiveStrength += 1.0 + } + effectiveStrength += 8.0 + + val strengthBonus = player.properties.bonuses[11] + 64 + val maxHit = floor(0.5 + (effectiveStrength * strengthBonus / 640.0)).toInt().coerceAtLeast(1) val bonusType = player.properties.attackStyle.bonusType - val styleBonus = when (bonusType) { - 2 -> 10 // Crush - 1 -> 4 // Slash - 0 -> 1 // Stab - else -> 3 // Default - } // Calculate damage: base from strength + style bonus - val damage = - (strengthLevel / 12) + styleBonus // Clamp to 1-25 range (max 25% of door health per hit) TODO: 100% inauthentic - return damage.coerceIn(1, 25) + val styleMultiplier = when (bonusType) { + WeaponInterface.BONUS_CRUSH -> 1.25 + WeaponInterface.BONUS_SLASH -> 1.0 + WeaponInterface.BONUS_STAB -> 0.85 + else -> 0.75 + } + val styleAdjustedMax = floor(maxHit * styleMultiplier).toInt().coerceAtLeast(1) + return RandomFunction.random(styleAdjustedMax + 1).coerceIn(0, 25) } private fun getAttackAnimation(): Int { diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsFlagHandler.kt b/Server/src/main/content/minigame/castlewars/CastleWarsFlagHandler.kt index 285296ec2..e820a863e 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsFlagHandler.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsFlagHandler.kt @@ -117,10 +117,12 @@ class CastleWarsFlagHandler : InteractionListener { onUnequip(intArrayOf(CastleWars.saradominFlag, CastleWars.zamorakFlag)) { player, _ -> if (CastleWarsGameState.isCarryingFlag(player)) { CastleWarsGameState.dropFlag(player) + return@onUnequip false } else if (CastleWarsGameState.isCarryingOwnFlag(player)) { CastleWarsGameState.dropOwnFlag(player) + return@onUnequip false } - return@onUnequip false + return@onUnequip true } on(CastleWars.droppedFlagSceneryIds, IntType.SCENERY, "take") { player, node -> if (!CastleWarsGameState.isGameActive) { @@ -158,4 +160,3 @@ class CastleWarsFlagHandler : InteractionListener { } } } - diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsGameState.kt b/Server/src/main/content/minigame/castlewars/CastleWarsGameState.kt index 97a9ae181..46b52e72c 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsGameState.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsGameState.kt @@ -13,6 +13,7 @@ import core.game.node.entity.player.Player import core.game.node.entity.player.link.HintIconManager import core.game.node.item.GroundItemManager import core.game.node.item.Item +import core.game.node.scenery.Constructed import core.game.node.scenery.Scenery import core.game.node.scenery.SceneryBuilder import core.game.world.map.Location @@ -165,6 +166,8 @@ object CastleWarsGameState { private set var zamorakCatapultOnFire: Boolean = false private set + private var saradominCatapultFireGeneration: Int = 0 + private var zamorakCatapultFireGeneration: Int = 0 // Boulder locations for lookup val boulderLocations = mapOf( @@ -262,6 +265,8 @@ object CastleWarsGameState { zamorakCatapultOperational = true saradominCatapultOnFire = false zamorakCatapultOnFire = false + invalidateCatapultFire(CastleWarsTeam.SARADOMIN) + invalidateCatapultFire(CastleWarsTeam.ZAMORAK) for (key in boulderLocations.keys) { updateBoulderScenery(key, BoulderState.COLLAPSED) } @@ -331,28 +336,62 @@ object CastleWarsGameState { 2418..2432 to 3070..3085, // Saradomin castle area 2368..2382 to 3125..3140 // Zamorak castle area ) + val resetDoorOpenIds = CastleWars.largeDoorOpenIds + CastleWars.backdoorOpenIds for ((xRange, yRange) in doorResetAreas) { for (x in xRange) { for (y in yRange) { val loc = Location.create(x, y, 0) val obj = RegionManager.getObject(loc) - if (obj != null && obj.id in CastleWars.largeDoorOpenIds) { - val closedId = when (obj.id) { - 4425 -> 4423 - 4426 -> 4424 - 4429 -> 4427 - 4430 -> 4428 - else -> null - } - if (closedId != null) { - SceneryBuilder.replace(obj, Scenery(closedId, loc, obj.type, obj.rotation)) - } + if (obj != null && obj.id in resetDoorOpenIds) { + closeResetDoor(obj) } } } } } + private fun closeResetDoor(obj: Scenery) { + if (obj is Constructed) { + val replaced = obj.replaced + if (replaced != null) { + SceneryBuilder.replace(obj, replaced) + return + } + } + val closedId = getClosedDoorId(obj.id) ?: getClosedBackdoorId(obj.id) ?: return + SceneryBuilder.replace(obj, Scenery(closedId, obj.location, obj.type, obj.rotation)) + } + + private fun getClosedDoorId(openDoorId: Int): Int? = when (openDoorId) { + 4425 -> 4423 + 4426 -> 4424 + 4429 -> 4427 + 4430 -> 4428 + else -> null + } + + private fun getClosedBackdoorId(openDoorId: Int): Int? = when (openDoorId) { + CastleWars.saradominBackdoorOpen -> CastleWars.saradominBackdoor + CastleWars.zamorakBackdoorOpen -> CastleWars.zamorakBackdoor + else -> null + } + + fun clearCastleWarsGroundItems() { + GroundItemManager.getItems().toList() + .filter { item -> + val location = item.location ?: return@filter false + item.id in CastleWars.CW_ITEMS_TO_REMOVE && isCastleWarsGroundCleanupLocation(location) + } + .forEach { GroundItemManager.destroy(it) } + } + + private fun isCastleWarsGroundCleanupLocation(location: Location): Boolean { + return CastleWarsGameArea.areaBorders.any { it.insideBorder(location) } || + CastleWarsWaitingArea.areaBorders.any { it.insideBorder(location) } || + CastleWarsRespawnArea.saradominRespawnRoom.insideBorder(location) || + CastleWarsRespawnArea.zamorakRespawnRoom.insideBorder(location) + } + /** * Get the team a player is on. * Includes a self-healing mechanism if the player is in the area but not in the sets. @@ -566,7 +605,7 @@ object CastleWarsGameState { * Drop a flag (when a player dies or leaves) - creates scenery object * @param player The player dropping the flag */ - fun dropFlag(player: Player) { + fun dropFlag(player: Player, broadcast: Boolean = true) { val carriedFlag = getCarriedFlag(player) ?: return val dropLocation = Location.create(player.location) val shouldReturnToStand = CastleWars.isSteppingStoneTile(dropLocation) @@ -593,8 +632,8 @@ object CastleWarsGameState { } removeAttribute(player, CastleWarsConstants.ATTR_FLAG_PICKUP_TICK) if (shouldReturnToStand) { - returnFlagToStand(carriedFlag) - } else { + returnFlagToStand(carriedFlag, broadcast) + } else if (broadcast) { announceToAllPlayers("${carriedFlag.teamName} flag has been dropped!") updateAllOverlays() updateAllHintIcons() @@ -605,7 +644,7 @@ object CastleWarsGameState { * Drop own flag (when a player carrying their own team's flag dies or leaves) * @param player The player dropping the flag */ - fun dropOwnFlag(player: Player) { + fun dropOwnFlag(player: Player, broadcast: Boolean = true) { val carriedFlag = getOwnCarriedFlag(player) ?: return val dropLocation = Location.create(player.location) val shouldReturnToStand = CastleWars.isSteppingStoneTile(dropLocation) @@ -631,8 +670,8 @@ object CastleWarsGameState { player.inventory.remove(Item(carriedFlag.flagItemId)) } if (shouldReturnToStand) { - returnFlagToStand(carriedFlag) - } else { + returnFlagToStand(carriedFlag, broadcast) + } else if (broadcast) { announceToAllPlayers("${carriedFlag.teamName} flag has been dropped!") updateAllOverlays() updateAllHintIcons() @@ -859,7 +898,7 @@ object CastleWarsGameState { * Return a dropped flag directly to its stand (instant, no player involved). * Used for auto-return (timeout, stepping stone) scenarios. */ - fun returnFlagToStand(flagTeam: CastleWarsTeam) { + fun returnFlagToStand(flagTeam: CastleWarsTeam, broadcast: Boolean = true) { when (flagTeam) { CastleWarsTeam.SARADOMIN -> { saradominDroppedFlagScenery?.let { SceneryBuilder.remove(it) } @@ -879,9 +918,11 @@ object CastleWarsGameState { updateFlagStandObject(CastleWarsTeam.ZAMORAK, FlagState.SAFE) } } - announceToAllPlayers("The ${flagTeam.teamName} flag has been returned to its stand!") - updateAllOverlays() - updateAllHintIcons() + if (broadcast) { + announceToAllPlayers("The ${flagTeam.teamName} flag has been returned to its stand!") + updateAllOverlays() + updateAllHintIcons() + } } /** @@ -1077,6 +1118,7 @@ object CastleWarsGameState { player.inventory.removeAll(CastleWars.CW_ITEMS_TO_REMOVE) player.equipment.removeAll(CastleWars.CW_ITEMS_TO_REMOVE) (player.familiarManager.familiar as? BurdenBeast)?.container?.removeAll(CastleWars.CW_ITEMS_TO_REMOVE) + removeTimer(player, "teleblock") clearActiveCastleWarsBracelet(player) player.interaction.remove(Option._P_ATTACK) player.interaction.remove(Option._P_STEAL_FROM) @@ -1085,27 +1127,34 @@ object CastleWarsGameState { /** * End the game and distribute rewards */ - fun endGame() { - val allPlayers = getAllGamePlayers() - for (player in allPlayers) { + fun endGame(extraRewardRecipients: Map = emptyMap()) { + val rewardRecipients = linkedMapOf() + for (player in getAllGamePlayers()) { val team = getPlayerTeam(player) ?: continue + rewardRecipients[player] = team + } + for ((player, team) in extraRewardRecipients) { + if (!rewardRecipients.containsKey(player)) { + rewardRecipients[player] = team + } + } + for ((player, team) in rewardRecipients) { val tickets = when { saradominScore > zamorakScore && team == CastleWarsTeam.SARADOMIN -> 2 zamorakScore > saradominScore && team == CastleWarsTeam.ZAMORAK -> 2 saradominScore == zamorakScore -> 1 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 ""}!") - } if (isCarryingFlag(player)) { - dropFlag(player) + dropFlag(player, broadcast = false) } else if (isCarryingOwnFlag(player)) { - dropOwnFlag(player) + dropOwnFlag(player, broadcast = false) } player.depleteCastleWarsBracelet() restoreAndCleanupPlayer(player) + if (tickets > 0) { + awardCastleWarsTickets(player, tickets) + } } val resultMessage = when { saradominScore > zamorakScore -> "Saradomin wins the game!" @@ -1116,6 +1165,17 @@ object CastleWarsGameState { resetGame() } + private fun awardCastleWarsTickets(player: Player, tickets: Int) { + val reward = Item(Items.CASTLE_WARS_TICKET_4067, tickets) + val ticketText = "Castle Wars ticket${if (tickets > 1) "s" else ""}" + if (player.inventory.add(reward)) { + sendMessage(player, "You have received $tickets $ticketText!") + return + } + GroundItemManager.create(reward, CastleWars.lobbyBankArea.randomWalkableLoc, player) + sendMessage(player, "Your $ticketText dropped to the ground because your inventory is full.") + } + /** * Handle a player leaving the game */ @@ -1389,11 +1449,13 @@ object CastleWarsGameState { CastleWarsTeam.SARADOMIN -> { saradominCatapultOperational = false saradominCatapultOnFire = false + invalidateCatapultFire(team) } CastleWarsTeam.ZAMORAK -> { zamorakCatapultOperational = false zamorakCatapultOnFire = false + invalidateCatapultFire(team) } } updateCatapultScenery(team) @@ -1404,13 +1466,21 @@ object CastleWarsGameState { * Set a team's catapult on fire (degrades over time, not instantly destroyed). * Tinderbox sets fire, catapult degrades until extinguished or destroyed. */ - fun setCatapultOnFire(team: CastleWarsTeam) { + fun setCatapultOnFire(team: CastleWarsTeam): Int { when (team) { - CastleWarsTeam.SARADOMIN -> saradominCatapultOnFire = true - CastleWarsTeam.ZAMORAK -> zamorakCatapultOnFire = true + CastleWarsTeam.SARADOMIN -> { + saradominCatapultOnFire = true + invalidateCatapultFire(team) + } + + CastleWarsTeam.ZAMORAK -> { + zamorakCatapultOnFire = true + invalidateCatapultFire(team) + } } updateCatapultScenery(team) updateAllOverlays() + return getCatapultFireGeneration(team) } /** @@ -1418,13 +1488,34 @@ object CastleWarsGameState { */ fun extinguishCatapult(team: CastleWarsTeam) { when (team) { - CastleWarsTeam.SARADOMIN -> saradominCatapultOnFire = false - CastleWarsTeam.ZAMORAK -> zamorakCatapultOnFire = false + CastleWarsTeam.SARADOMIN -> { + saradominCatapultOnFire = false + invalidateCatapultFire(team) + } + + CastleWarsTeam.ZAMORAK -> { + zamorakCatapultOnFire = false + invalidateCatapultFire(team) + } } updateCatapultScenery(team) updateAllOverlays() } + fun getCatapultFireGeneration(team: CastleWarsTeam): Int { + return when (team) { + CastleWarsTeam.SARADOMIN -> saradominCatapultFireGeneration + CastleWarsTeam.ZAMORAK -> zamorakCatapultFireGeneration + } + } + + private fun invalidateCatapultFire(team: CastleWarsTeam) { + when (team) { + CastleWarsTeam.SARADOMIN -> saradominCatapultFireGeneration++ + CastleWarsTeam.ZAMORAK -> zamorakCatapultFireGeneration++ + } + } + /** * Check if a team's catapult is on fire */ diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsItemHandler.kt b/Server/src/main/content/minigame/castlewars/CastleWarsItemHandler.kt index 1f80e65e2..497a12bfd 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsItemHandler.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsItemHandler.kt @@ -8,6 +8,7 @@ import content.minigame.castlewars.CastleWarsConstants.BURNING_BARRICADE_MAX_TIC import content.minigame.castlewars.CastleWarsConstants.BURNING_BARRICADE_PULSE_INTERVAL import content.minigame.castlewars.CastleWarsConstants.EXPLOSIVE_POTION_SELF_DAMAGE import content.minigame.castlewars.CastleWarsConstants.MAX_BARRICADES_PER_TEAM +import content.minigame.castlewars.areas.CastleWarsGameArea import core.api.* import core.game.interaction.Clocks import core.game.interaction.IntType @@ -87,20 +88,6 @@ class CastleWarsItemHandler : InteractionListener { onUseWithPlayer(CastleWars.cwBandages) { player, used, with -> val target = with as? Player ?: return@onUseWithPlayer false - - val playerTeam = CastleWarsGameState.getPlayerTeam(player) - val targetTeam = CastleWarsGameState.getPlayerTeam(target) - - if (playerTeam == null || targetTeam == null) { - sendMessage(player, "You can only use bandages in Castle Wars.") - return@onUseWithPlayer false - } - - if (playerTeam != targetTeam) { - sendMessage(player, "You can only heal your teammates!") - return@onUseWithPlayer false - } - if (useBandage(player, target)) { removeItem(player, used.asItem()) } @@ -231,6 +218,7 @@ class CastleWarsItemHandler : InteractionListener { playAudio(player, 2599) + burningBarricadePulses.remove(barricade)?.stop() barricade.transform(burningId) sendMessage(player, "You set the barricade on fire!") @@ -272,6 +260,7 @@ class CastleWarsItemHandler : InteractionListener { } barricade.transform(originalId) + burningBarricadePulses.remove(barricade)?.stop() replaceSlot(player, used.asItem().slot, Item(Items.BUCKET_1925)) @@ -283,6 +272,21 @@ class CastleWarsItemHandler : InteractionListener { } private fun useBandage(user: Player, target: Player): Boolean { + if (!CastleWarsGameState.isGameActive) { + sendMessage(user, "You can only use bandages during a Castle Wars game.") + return false + } + val userTeam = getActiveGameTeam(user) + val targetTeam = getActiveGameTeam(target) + if (userTeam == null || targetTeam == null) { + sendMessage(user, "You can only use bandages during a Castle Wars game.") + return false + } + if (userTeam != targetTeam) { + sendMessage(user, "You can only heal your teammates!") + return false + } + if (CastleWarsGameState.isCarryingOwnFlag(target)) { if (user == target) { sendMessage(user, "You can't heal while carrying your team's flag!") @@ -324,6 +328,14 @@ class CastleWarsItemHandler : InteractionListener { return true } + private fun getActiveGameTeam(player: Player): CastleWarsTeam? { + return when { + CastleWarsGameArea.saradominPlayers.contains(player) -> CastleWarsTeam.SARADOMIN + CastleWarsGameArea.zamorakPlayers.contains(player) -> CastleWarsTeam.ZAMORAK + else -> null + } + } + private fun getLocalBarricades(location: Location, radius: Int): List { val barricadeIds = intArrayOf( NPCs.BARRICADE_1532, NPCs.BARRICADE_1533, NPCs.BARRICADE_1534, NPCs.BARRICADE_1535 diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsListeners.kt b/Server/src/main/content/minigame/castlewars/CastleWarsListeners.kt index c3c157c89..bdbd97134 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsListeners.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsListeners.kt @@ -473,7 +473,7 @@ class CastleWarsListeners : InteractionListener { } val previousHealth = CastleWarsGameState.getDoorHealth(doorTeam) - CastleWarsGameState.damageDoor(doorTeam, 25) + CastleWarsGameState.damageDoor(doorTeam, CastleWarsConstants.EXPLOSIVE_POTION_DOOR_DAMAGE) removeItem(player, used.asItem()) playAudio(player, 2599) animate(player, 827) @@ -508,7 +508,7 @@ class CastleWarsListeners : InteractionListener { sendMessage(player, "The door is already fully repaired.") return@onUseWith false } - CastleWarsGameState.repairDoor(doorTeam, 10) + CastleWarsGameState.repairDoor(doorTeam, CastleWarsConstants.TOOLKIT_DOOR_REPAIR) animate(player, 898) sendMessage(player, "You repair some of the door's damage.") return@onUseWith true @@ -734,16 +734,15 @@ class CastleWarsListeners : InteractionListener { sendMessage(player, "The catapult is already on fire!") return@onUseWith false } - CastleWarsGameState.setCatapultOnFire(catapultTeam) + val fireGeneration = CastleWarsGameState.setCatapultOnFire(catapultTeam) playAudio(player, 2599) sendMessage(player, "You set the catapult on fire!") GameWorld.Pulser.submit(object : Pulse(50) { override fun pulse(): Boolean { - if (CastleWarsGameState.isCatapultOnFire(catapultTeam) && CastleWarsGameState.isCatapultOperational( + if (CastleWarsGameState.getCatapultFireGeneration(catapultTeam) == fireGeneration && CastleWarsGameState.isCatapultOnFire(catapultTeam) && CastleWarsGameState.isCatapultOperational( catapultTeam ) ) { - CastleWarsGameState.extinguishCatapult(catapultTeam) CastleWarsGameState.destroyCatapult(catapultTeam) } return true diff --git a/Server/src/main/content/minigame/castlewars/areas/CastleWarsGameArea.kt b/Server/src/main/content/minigame/castlewars/areas/CastleWarsGameArea.kt index d5d1d86d8..d9a1e660f 100644 --- a/Server/src/main/content/minigame/castlewars/areas/CastleWarsGameArea.kt +++ b/Server/src/main/content/minigame/castlewars/areas/CastleWarsGameArea.kt @@ -11,6 +11,7 @@ import core.game.node.Node import core.game.node.entity.Entity import core.game.node.entity.combat.CombatStyle import core.game.node.entity.player.Player +import core.game.node.entity.player.info.login.PlayerParser import core.game.node.item.Item import core.game.world.map.Location import core.game.world.map.zone.ZoneBorders @@ -73,7 +74,9 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener, LoginListener { var ticksLeftInGame = -1 private val activeVacancyOffers = mutableMapOf>() + private val activeVacancyOfferTicks = mutableMapOf() private val declinedSubstitutionOffers = mutableMapOf>() + private val openVacancies = mutableMapOf() fun startGame() { if (!CastleWarsWaitingArea.hasMinimumPlayers()) { @@ -118,7 +121,9 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener, LoginListener { fun clearSubstitutionState() { activeVacancyOffers.clear() + activeVacancyOfferTicks.clear() declinedSubstitutionOffers.clear() + openVacancies.clear() } fun selectGuthixTeam(): CastleWarsTeam { @@ -139,15 +144,18 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener, LoginListener { */ fun fillVacancy(team: CastleWarsTeam) { if (ticksLeftInGame <= 0) return - if (activeVacancyOffers[team]?.isNotEmpty() == true) return + if ((openVacancies[team] ?: 0) <= 0) return val waitingSet = when (team) { CastleWarsTeam.SARADOMIN -> CastleWarsWaitingArea.waitingSaradominPlayers CastleWarsTeam.ZAMORAK -> CastleWarsWaitingArea.waitingZamorakPlayers } + pruneVacancyOffer(team, waitingSet) + if (activeVacancyOffers[team]?.isNotEmpty() == true) return val declined = declinedSubstitutionOffers[team].orEmpty() val candidates = waitingSet.filter { it !in declined } if (candidates.isEmpty()) return activeVacancyOffers[team] = candidates.toMutableSet() + activeVacancyOfferTicks[team] = getWorldTicks() for (candidate in candidates) { openDialogue(candidate, CastleWarsSubstitutionDialogue(team)) } @@ -160,14 +168,30 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener, LoginListener { CastleWarsTeam.SARADOMIN -> CastleWarsWaitingArea.waitingSaradominPlayers CastleWarsTeam.ZAMORAK -> CastleWarsWaitingArea.waitingZamorakPlayers } - if (!waitingSet.remove(player)) return false + if (!waitingSet.remove(player)) { + offer.remove(player) + if (offer.isEmpty()) { + activeVacancyOffers.remove(team) + activeVacancyOfferTicks.remove(team) + } + return false + } activeVacancyOffers.remove(team) + activeVacancyOfferTicks.remove(team) declinedSubstitutionOffers[team]?.remove(player) + val remainingVacancies = ((openVacancies[team] ?: 0) - 1).coerceAtLeast(0) + if (remainingVacancies > 0) { + openVacancies[team] = remainingVacancies + } else { + openVacancies.remove(team) + } val gameSet = when (team) { CastleWarsTeam.SARADOMIN -> saradominPlayers CastleWarsTeam.ZAMORAK -> zamorakPlayers } gameSet.add(player) + CastleWarsGameState.activateBraceletIfEquipped(player, team) + CastleWarsGameState.setActiveGamePlayerOptions(player) setAttribute(player, CastleWarsConstants.ATTR_JOIN_TICK, getWorldTicks()) val respawnLoc = when (team) { CastleWarsTeam.SARADOMIN -> CastleWarsRespawnArea.saradominRespawnRoom.randomWalkableLoc @@ -175,6 +199,9 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener, LoginListener { } player.properties.teleportLocation = respawnLoc sendMessage(player, "A spot has opened up! You have been moved into the Castle Wars game.") + if ((openVacancies[team] ?: 0) > 0) { + fillVacancy(team) + } return true } @@ -183,6 +210,42 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener, LoginListener { declinedSubstitutionOffers.getOrPut(team) { mutableSetOf() }.add(player) if (activeVacancyOffers[team]?.isEmpty() == true) { activeVacancyOffers.remove(team) + activeVacancyOfferTicks.remove(team) + fillVacancy(team) + } + } + + fun removeVacancyOffer(player: Player) { + val teamsToRefill = mutableSetOf() + for (team in CastleWarsTeam.values()) { + activeVacancyOffers[team]?.remove(player) + declinedSubstitutionOffers[team]?.remove(player) + if (activeVacancyOffers[team]?.isEmpty() == true) { + activeVacancyOffers.remove(team) + activeVacancyOfferTicks.remove(team) + teamsToRefill.add(team) + } + if (declinedSubstitutionOffers[team]?.isEmpty() == true) { + declinedSubstitutionOffers.remove(team) + } + } + for (team in teamsToRefill) { + fillVacancy(team) + } + } + + private fun pruneVacancyOffer(team: CastleWarsTeam, waitingSet: Set) { + val offer = activeVacancyOffers[team] ?: return + val createdTick = activeVacancyOfferTicks[team] ?: getWorldTicks() + if (getWorldTicks() - createdTick >= CastleWarsConstants.VACANCY_OFFER_TIMEOUT_TICKS) { + activeVacancyOffers.remove(team) + activeVacancyOfferTicks.remove(team) + return + } + offer.removeAll { it !in waitingSet || !it.isActive } + if (offer.isEmpty()) { + activeVacancyOffers.remove(team) + activeVacancyOfferTicks.remove(team) } } @@ -204,11 +267,16 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener, LoginListener { player.interaction.remove(Option._P_ATTACK) player.interaction.remove(Option._P_STEAL_FROM) if (offerReplacement) { + openVacancy(leavingTeam) fillVacancy(leavingTeam) } return leavingTeam } + private fun openVacancy(team: CastleWarsTeam) { + openVacancies[team] = (openVacancies[team] ?: 0) + 1 + } + fun playerKey(player: Player): String { return player.username.lowercase() } @@ -272,6 +340,18 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener, LoginListener { clearLogoutAttributes(player) } + private fun moveLoggedOutPlayerToLobby(player: Player) { + removeFromTeam(player) + CastleWarsGameState.clearHintIconForPlayer(player) + CastleWarsGameState.restoreAndCleanupPlayer(player) + player.interaction.remove(Option._P_ATTACK) + player.interaction.remove(Option._P_STEAL_FROM) + val lobbyLocation = CastleWars.lobbyBankArea.randomWalkableLoc + player.location = lobbyLocation + player.properties.teleportLocation = lobbyLocation + clearLogoutState(player) + } + fun restoreActiveLogoutOnLogin(player: Player) { val savedTeamName = getAttribute(player, CastleWarsConstants.ATTR_LOGOUT_TEAM, "") val savedLogoutTime = getAttribute(player, CastleWarsConstants.ATTR_LOGOUT_TIME_MILLIS, 0L) @@ -287,6 +367,10 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener, LoginListener { val logoutLocation = getAttribute( player, CastleWarsConstants.ATTR_LOGOUT_LOCATION, record?.logoutLocation ?: player.location ) + if (ticksLeftInGame <= 0) { + moveLoggedOutPlayerToLobby(player) + return + } if (ticksLeftInGame > 0 && elapsedMillis < CastleWarsConstants.LOGOUT_GRACE_PERIOD_MILLIS) { player.location = Location.create(logoutLocation) setTeam(player, team) @@ -295,6 +379,7 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener, LoginListener { } moveLoggedOutPlayerToWaitingRoom(player, team) if (ticksLeftInGame > 0) { + openVacancy(team) fillVacancy(team) } } @@ -324,24 +409,41 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener, LoginListener { } } } - } - private fun endGame() { // TODO: ropes, rocks in tunnels, ground items, castle doors, catapults - CastleWarsGameState.endGame() - CastleWarsItemHandler.clearAllBarricades() - clearSubstitutionState() - ticksLeftInGame = -1 - saradominPlayers.forEach { player -> - player.unhook(SaradominInterfaceCloseHook) - player.properties.teleportLocation = CastleWars.lobbyBankArea.randomWalkableLoc + fun endCurrentGame(startCooldown: Boolean = true) { + val saraSnapshot = saradominPlayers.toSet() + val zamSnapshot = zamorakPlayers.toSet() + val loggedOutSnapshot = loggedOutPlayers.values.toList() + val loggedOutRewardRecipients = loggedOutSnapshot.associate { it.player to it.team } + CastleWarsGameState.endGame(loggedOutRewardRecipients) + for (record in loggedOutSnapshot) { + moveLoggedOutPlayerToLobby(record.player) + if (!record.player.isActive || record.player.session == null) { + PlayerParser.saveImmediately(record.player) + } + } + CastleWarsItemHandler.clearAllBarricades() + CastleWarsGameState.clearCastleWarsGroundItems() + clearSubstitutionState() + ticksLeftInGame = -1 + CastleWarsWaitingArea.gameStartCountdown = if (startCooldown) { + CastleWars.gameCooldownMinutes * CastleWarsConstants.TICKS_PER_MINUTE + } else { + -1 + } + saraSnapshot.forEach { player -> + player.unhook(SaradominInterfaceCloseHook) + player.properties.teleportLocation = CastleWars.lobbyBankArea.randomWalkableLoc + } + zamSnapshot.forEach { player -> + player.unhook(ZamorakInterfaceCloseHook) + player.properties.teleportLocation = CastleWars.lobbyBankArea.randomWalkableLoc + } + saradominPlayers.clear() + zamorakPlayers.clear() + CastleWarsRespawnArea.saradominPlayersInRespawnRoom.clear() + CastleWarsRespawnArea.zamorakPlayersInRespawnRoom.clear() } - zamorakPlayers.forEach { player -> - player.unhook(ZamorakInterfaceCloseHook) - player.properties.teleportLocation = CastleWars.lobbyBankArea.randomWalkableLoc - } - saradominPlayers.clear() - zamorakPlayers.clear() - loggedOutPlayers.clear() } override fun defineAreaBorders(): Array { @@ -353,7 +455,10 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener, LoginListener { super.areaEnter(player) zone.zoneType = ZoneType.CASTLE_WARS.id - registerTimer(player, spawnTimer("teleblock", (CastleWars.gameTimeMinutes) * 60 * 2)) + registerTimer( + player, + spawnTimer("teleblock", CastleWars.gameTimeMinutes * CastleWarsConstants.TICKS_PER_MINUTE, false) + ) val team = CastleWarsGameState.getPlayerTeam(player) when (team) { CastleWarsTeam.SARADOMIN -> { @@ -425,7 +530,7 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener, LoginListener { return } if (ticksLeftInGame == 0) { - endGame() + endCurrentGame() return } val previousMinutes = @@ -434,7 +539,7 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener, LoginListener { val currentMinutes = (ticksLeftInGame + CastleWarsConstants.TICKS_PER_MINUTE - 1) / CastleWarsConstants.TICKS_PER_MINUTE if (ticksLeftInGame == 0) { - endGame() + endCurrentGame() return } else if (currentMinutes != previousMinutes) { CastleWarsGameState.updateAllOverlays() @@ -474,9 +579,11 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener, LoginListener { for ((key, record) in expiredLogouts) { val player = record.player loggedOutPlayers.remove(key) + clearLogoutAttributes(player) removeFromTeam(player) CastleWarsGameState.clearHintIconForPlayer(player) CastleWarsGameState.restoreAndCleanupPlayer(player) + openVacancy(record.team) fillVacancy(record.team) } // Remove truly inactive players (not in grace period) @@ -489,11 +596,13 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener, LoginListener { for (player in inactiveSara) { saradominPlayers.remove(player) CastleWarsGameState.clearHintIconForPlayer(player) + openVacancy(CastleWarsTeam.SARADOMIN) fillVacancy(CastleWarsTeam.SARADOMIN) } for (player in inactiveZam) { zamorakPlayers.remove(player) CastleWarsGameState.clearHintIconForPlayer(player) + openVacancy(CastleWarsTeam.ZAMORAK) fillVacancy(CastleWarsTeam.ZAMORAK) } // Also cleanup hint icons for any other inactive players diff --git a/Server/src/main/content/minigame/castlewars/areas/CastleWarsRespawnArea.kt b/Server/src/main/content/minigame/castlewars/areas/CastleWarsRespawnArea.kt index 2fd8dac7d..2ef507f55 100644 --- a/Server/src/main/content/minigame/castlewars/areas/CastleWarsRespawnArea.kt +++ b/Server/src/main/content/minigame/castlewars/areas/CastleWarsRespawnArea.kt @@ -63,6 +63,7 @@ class CastleWarsRespawnArea : CastleWarsArea(), TickListener { } private fun removePlayersOverSpawnLimit(players: MutableMap) { + players.entries.removeIf { (player, _) -> !player.isActive || player.session == null } val currentTick = getWorldTicks() val expiredPlayers = players.filter { (player, entryTick) -> player.isActive && player.session != null && currentTick - entryTick >= CastleWarsConstants.LOGOUT_GRACE_PERIOD_TICKS @@ -79,6 +80,8 @@ class CastleWarsRespawnArea : CastleWarsArea(), TickListener { if (!defineAreaBorders().any { it.insideBorder(player.location) }) { return } + saradominPlayersInRespawnRoom.remove(player) + zamorakPlayersInRespawnRoom.remove(player) if (CastleWarsGameArea.ticksLeftInGame > 0 && CastleWarsGameState.getPlayerTeam(player) != null) { CastleWarsGameArea().logout(player) return diff --git a/Server/src/main/content/minigame/castlewars/areas/CastleWarsWaitingArea.kt b/Server/src/main/content/minigame/castlewars/areas/CastleWarsWaitingArea.kt index bacd07a61..e66a93747 100644 --- a/Server/src/main/content/minigame/castlewars/areas/CastleWarsWaitingArea.kt +++ b/Server/src/main/content/minigame/castlewars/areas/CastleWarsWaitingArea.kt @@ -3,6 +3,7 @@ package content.minigame.castlewars.areas import content.minigame.castlewars.CastleWars import content.minigame.castlewars.CastleWarsConstants import content.minigame.castlewars.CastleWarsOverlay +import content.minigame.castlewars.CastleWarsTeam import core.api.* import core.game.component.Component import core.game.node.entity.Entity @@ -39,16 +40,28 @@ class CastleWarsWaitingArea : CastleWarsArea(), TickListener { val player = entity as? Player ?: return super.areaEnter(player) registerTimer( - player, spawnTimer("teleblock", (CastleWars.gameCooldownMinutes + CastleWars.gameTimeMinutes) * 60 * 2) + player, + spawnTimer( + "teleblock", + (CastleWars.gameCooldownMinutes + CastleWars.gameTimeMinutes) * CastleWarsConstants.TICKS_PER_MINUTE, + false + ) ) // Set team attribute and equip the hooded cloak on the entity based on which waiting room they're in - if (zamorakWaitingRoom.insideBorder(player.location)) { + val joinedTeam = if (zamorakWaitingRoom.insideBorder(player.location)) { player.equipment.replace(Item(CastleWars.zamorakTeamHoodedCloak), 1) waitingZamorakPlayers.add(player) + CastleWarsTeam.ZAMORAK } else if (saradominWaitingRoom.insideBorder(player.location)) { player.equipment.replace(Item(CastleWars.saradominTeamHoodedCloak), 1) waitingSaradominPlayers.add(player) + CastleWarsTeam.SARADOMIN + } else { + null + } + if (joinedTeam != null && CastleWarsGameArea.ticksLeftInGame > 0) { + CastleWarsGameArea.fillVacancy(joinedTeam) } var transformed = false @@ -118,6 +131,7 @@ class CastleWarsWaitingArea : CastleWarsArea(), TickListener { // Remove player from waiting room set (whichever one that is) waitingSaradominPlayers.remove(player) waitingZamorakPlayers.remove(player) + CastleWarsGameArea.removeVacancyOffer(player) } override fun tick() { diff --git a/Server/src/main/core/game/container/impl/EquipmentContainer.java b/Server/src/main/core/game/container/impl/EquipmentContainer.java index d398d9f3b..a524534e8 100644 --- a/Server/src/main/core/game/container/impl/EquipmentContainer.java +++ b/Server/src/main/core/game/container/impl/EquipmentContainer.java @@ -2,6 +2,7 @@ package core.game.container.impl; import content.global.skill.skillcapeperks.SkillcapeEquipmentPlugin; import content.global.skill.skillcapeperks.SkillcapePerks; +import content.minigame.castlewars.CastleWars; import core.game.container.Container; import core.game.container.ContainerEvent; import core.game.container.ContainerListener; @@ -146,9 +147,17 @@ public final class EquipmentContainer extends Container { } boolean listenersSayWeCanUnequip = runUnequipHooks(current, newItem); + if(!listenersSayWeCanUnequip) { + return false; + } + ArrayList itemsToMoveToInventory = new ArrayList<>(current); + itemsToMoveToInventory.removeIf(item -> isCastleWarsFlag(item) && !containsItem(item)); + if(itemsToMoveToInventory.isEmpty()) { + return true; + } boolean allRemoved = true; - for(Item item : current) { + for(Item item : itemsToMoveToInventory) { if(!remove(item)) { allRemoved = false; break; @@ -157,7 +166,7 @@ public final class EquipmentContainer extends Container { boolean allAdded = allRemoved; if(allRemoved) { - for (Item item : current) { + for (Item item : itemsToMoveToInventory) { if (!player.getInventory().add(item, true, preferredSlot)) { allAdded = false; break; @@ -168,7 +177,7 @@ public final class EquipmentContainer extends Container { if (listenersSayWeCanUnequip && allRemoved && allAdded) return true; else { //put things back if we couldn't remove everything - for(Item item : current) { + for(Item item : itemsToMoveToInventory) { if(!containsItem(item)) { add(item); } @@ -182,6 +191,9 @@ public final class EquipmentContainer extends Container { int neededSlots = 0; for(Item item : current) { + if(isCastleWarsFlag(item)) { + continue; + } if(!item.getDefinition().isStackable()) { neededSlots++; } else { @@ -193,6 +205,10 @@ public final class EquipmentContainer extends Container { return neededSlots; } + private boolean isCastleWarsFlag(Item item) { + return item.getId() == CastleWars.saradominFlag || item.getId() == CastleWars.zamorakFlag; + } + @Nullable private Item getSecondaryEquipIfApplicable(Item newItem, int equipmentSlot) { Item secondaryEquipItem = null; @@ -211,19 +227,28 @@ public final class EquipmentContainer extends Container { for(Item currentItem : currentItems) { Plugin plugin = currentItem.getDefinition().getConfiguration("equipment", null); - if (plugin != null) { - Object object = plugin.fireEvent("unequip", player, currentItem); - if (object != null && !((Boolean) object)) { - canContinue = false; + if (plugin != null) { + Object object = plugin.fireEvent("unequip", player, currentItem); + if (object != null && !((Boolean) object)) { + if (isCastleWarsFlag(currentItem) && !containsItem(currentItem)) { + continue; + } + canContinue = false; + break; + } + } + + canContinue = InteractionListeners.run(currentItem.getId(), player, currentItem, false); + + if(!canContinue) { + if (isCastleWarsFlag(currentItem) && !containsItem(currentItem)) { + canContinue = true; + continue; + } break; } } - canContinue = InteractionListeners.run(currentItem.getId(), player, currentItem, false); - - if(!canContinue) break; - } - return canContinue; } diff --git a/Server/src/main/core/game/global/action/EquipHandler.kt b/Server/src/main/core/game/global/action/EquipHandler.kt index 3f9f681a5..4b44e8e74 100644 --- a/Server/src/main/core/game/global/action/EquipHandler.kt +++ b/Server/src/main/core/game/global/action/EquipHandler.kt @@ -1,5 +1,6 @@ package core.game.global.action +import content.minigame.castlewars.CastleWars import content.minigame.castlewars.CastleWarsEquipmentRestrictions import core.game.event.ItemEquipEvent import core.game.event.ItemUnequipEvent @@ -47,7 +48,11 @@ class EquipHandler : InteractionListener { if(currentEquippedItem != null){ if(!InteractionListeners.run(currentEquippedItem.id, player, currentEquippedItem, false)){ - return + val droppedCastleWarsFlag = + isCastleWarsFlag(currentEquippedItem.id) && player.equipment[itemEquipmentSlot] == null + if (!droppedCastleWarsFlag) { + return + } } } @@ -123,10 +128,12 @@ class EquipHandler : InteractionListener { if (slot == EquipmentContainer.SLOT_WEAPON) { player.packetDispatch.sendString("", 92, 0) } - val maximumAdd = player.inventory.getMaximumAdd(item) - if (maximumAdd < item.amount) { - player.packetDispatch.sendMessage("Not enough free space in your inventory.") - return + if (!isCastleWarsFlag(item.id)) { + val maximumAdd = player.inventory.getMaximumAdd(item) + if (maximumAdd < item.amount) { + player.packetDispatch.sendMessage("Not enough free space in your inventory.") + return + } } val plugin = item.definition.getConfiguration>("equipment", null) if (plugin != null) { @@ -137,6 +144,11 @@ class EquipHandler : InteractionListener { if (!InteractionListeners.run(itemId, player, item, false)) { return } + val maximumAdd = player.inventory.getMaximumAdd(item) + if (maximumAdd < item.amount) { + player.packetDispatch.sendMessage("Not enough free space in your inventory.") + return + } if (player.equipment.remove(item)) { /* TODO: Send different unequip sound based on what is being unequipped.*/ playAudio(player, Sounds.EQUIP_FUN_2238) @@ -150,5 +162,9 @@ class EquipHandler : InteractionListener { SlayerEquipmentFlags.updateFlags(player) } } + + private fun isCastleWarsFlag(itemId: Int): Boolean { + return itemId == CastleWars.saradominFlag || itemId == CastleWars.zamorakFlag + } } } diff --git a/Server/src/main/core/game/system/timer/impl/Teleblock.kt b/Server/src/main/core/game/system/timer/impl/Teleblock.kt index 47f33a8d4..69d96944a 100644 --- a/Server/src/main/core/game/system/timer/impl/Teleblock.kt +++ b/Server/src/main/core/game/system/timer/impl/Teleblock.kt @@ -7,18 +7,20 @@ import core.game.system.timer.PersistTimer import core.game.system.timer.RSTimer import core.game.system.timer.TimerFlag -class Teleblock : PersistTimer (1, "teleblock", flags = arrayOf(TimerFlag.ClearOnDeath)) { +class Teleblock(private val sendChatboxMessage: Boolean = true) : + PersistTimer(1, "teleblock", flags = arrayOf(TimerFlag.ClearOnDeath)) { override fun run (entity: Entity) : Boolean { return false } override fun onRegister (entity: Entity) { if (entity !is Player) return + if (!sendChatboxMessage) return sendMessage (entity, "You have been teleblocked.") } override fun getTimer (vararg args: Any) : RSTimer { - val t = Teleblock() + val t = Teleblock(args.getOrNull(1) as? Boolean ?: true) t.runInterval = args.getOrNull(0) as? Int ?: 100 return t } diff --git a/Server/src/test/kotlin/content/EquipTests.kt b/Server/src/test/kotlin/content/EquipTests.kt index 96a903a8a..a078296db 100644 --- a/Server/src/test/kotlin/content/EquipTests.kt +++ b/Server/src/test/kotlin/content/EquipTests.kt @@ -17,7 +17,12 @@ import core.game.node.entity.player.info.Rights class EquipTests { companion object { - init {TestUtils.preTestSetup(); EquipHandler().defineListeners()} + init { + TestUtils.preTestSetup() + if (InteractionListeners.get("equip", IntType.ITEM.ordinal) == null) { + EquipHandler().defineListeners() + } + } } @Test fun equipShouldFireEquipListeners() { @@ -52,6 +57,26 @@ class EquipTests { Assertions.assertEquals(true, didRun) } + @Test fun fullInventoryUnequipShouldNotFireUnequipListeners() { + var didRun = false + val listener = object : InteractionListener { + override fun defineListeners() { + onUnequip(Items.ABYSSAL_WHIP_4151) { _, _ -> didRun = true; return@onUnequip true } + } + } + listener.defineListeners() + + TestUtils.getMockPlayer("fullunequip").use { player -> + player.equipment.replace(Item(Items.ABYSSAL_WHIP_4151), EquipmentSlot.WEAPON.ordinal) + player.inventory.add(Item(Items.CUP_OF_TEA_1978, player.inventory.capacity())) + + EquipHandler.unequip(player, EquipmentSlot.WEAPON.ordinal, Items.ABYSSAL_WHIP_4151) + + Assertions.assertEquals(false, didRun) + Assertions.assertEquals(Items.ABYSSAL_WHIP_4151, player.equipment[EquipmentSlot.WEAPON.ordinal].id) + } + } + @Test fun equippingItemThatReplacesAnotherItemShouldCallUnequipListenersForTheReplacedItem() { var didRun = false val listener = object : InteractionListener { diff --git a/Server/src/test/kotlin/content/minigame/castlewars/CastleWarsTests.kt b/Server/src/test/kotlin/content/minigame/castlewars/CastleWarsTests.kt index 7e1530181..a7fac1c40 100644 --- a/Server/src/test/kotlin/content/minigame/castlewars/CastleWarsTests.kt +++ b/Server/src/test/kotlin/content/minigame/castlewars/CastleWarsTests.kt @@ -1,6 +1,7 @@ package content.minigame.castlewars import TestUtils +import MockSession import content.minigame.castlewars.areas.CastleWarsGameArea import content.minigame.castlewars.areas.CastleWarsRespawnArea import content.minigame.castlewars.areas.CastleWarsWaitingArea @@ -8,12 +9,18 @@ import core.api.* import core.game.component.Component import core.game.global.action.EquipHandler import core.game.interaction.* +import core.game.node.Node import core.game.node.entity.player.Player import core.game.node.entity.skill.Skills +import core.game.node.item.GroundItemManager import core.game.node.item.Item import core.game.node.scenery.Scenery +import core.game.node.scenery.SceneryBuilder +import core.game.system.timer.TimerRegistry +import core.game.system.timer.impl.Teleblock import core.game.world.GameWorld import core.game.world.map.Location +import core.game.world.map.RegionManager import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Test @@ -30,6 +37,7 @@ class CastleWarsTests { if (InteractionListeners.get(CastleWars.saradominBackdoorOpen, IntType.SCENERY.ordinal, "lock") == null) { CastleWarsListeners().defineDestinationOverrides() CastleWarsListeners().defineListeners() + CastleWarsFlagHandler().defineListeners() } if (InteractionListeners.get(CastleWars.cwExplosivePotion, IntType.ITEM.ordinal, "drop") == null) { CastleWarsItemHandler().defineListeners() @@ -37,6 +45,9 @@ class CastleWarsTests { if (InterfaceListeners.get(CatapultConfig.INTERFACE_ID) == null) { CastleWarsCatapultInterface().defineInterfaceListeners() } + if (!TimerRegistry.timerMap.containsKey("teleblock")) { + TimerRegistry.registerTimer(Teleblock()) + } } } @@ -52,6 +63,7 @@ class CastleWarsTests { CastleWarsGameArea.clearSubstitutionState() CastleWarsRespawnArea.saradominPlayersInRespawnRoom.clear() CastleWarsRespawnArea.zamorakPlayersInRespawnRoom.clear() + CastleWarsItemHandler.clearAllBarricades() CastleWarsGameState.resetGame() } @@ -97,18 +109,125 @@ class CastleWarsTests { } } + @Test + fun enteringWaitingRoomAfterVacancyReceivesSubstitutionOffer() { + TestUtils.getMockPlayer("cwlateleaver").use { leaver -> + TestUtils.getMockPlayer("cwlatesub").use { waiting -> + CastleWarsGameArea.ticksLeftInGame = 100 + CastleWarsGameArea.saradominPlayers.add(leaver) + + CastleWarsGameArea.removePlayerFromGame(leaver) + waiting.location = CastleWarsWaitingArea.saradominWaitingRoom.randomWalkableLoc + CastleWarsWaitingArea().areaEnter(waiting) + + assertSubstitutionDialogue(waiting) + } + } + } + @Test fun decliningSubstitutionKeepsPlayerInWaitingRoom() { - TestUtils.getMockPlayer("cwdecline").use { waiting -> - CastleWarsGameArea.ticksLeftInGame = 100 - CastleWarsWaitingArea.waitingZamorakPlayers.add(waiting) + TestUtils.getMockPlayer("cwdeclineleave").use { leaver -> + TestUtils.getMockPlayer("cwdecline").use { waiting -> + CastleWarsGameArea.ticksLeftInGame = 100 + CastleWarsGameArea.zamorakPlayers.add(leaver) + CastleWarsWaitingArea.waitingZamorakPlayers.add(waiting) - CastleWarsGameArea.fillVacancy(CastleWarsTeam.ZAMORAK) - CastleWarsGameArea.declineVacancyOffer(waiting, CastleWarsTeam.ZAMORAK) + CastleWarsGameArea.removePlayerFromGame(leaver) + CastleWarsGameArea.declineVacancyOffer(waiting, CastleWarsTeam.ZAMORAK) - assertTrue(CastleWarsWaitingArea.waitingZamorakPlayers.contains(waiting)) - assertFalse(CastleWarsGameArea.zamorakPlayers.contains(waiting)) - assertFalse(CastleWarsGameArea.claimVacancy(waiting, CastleWarsTeam.ZAMORAK)) + assertTrue(CastleWarsWaitingArea.waitingZamorakPlayers.contains(waiting)) + assertFalse(CastleWarsGameArea.zamorakPlayers.contains(waiting)) + assertFalse(CastleWarsGameArea.claimVacancy(waiting, CastleWarsTeam.ZAMORAK)) + } + } + } + + @Test + fun decliningLastSubstitutionOfferReissuesToAvailableWaitingPlayer() { + TestUtils.getMockPlayer("cwdeclinereissueleave").use { leaver -> + TestUtils.getMockPlayer("cwdeclinereissueold").use { declined -> + TestUtils.getMockPlayer("cwdeclinereissuenew").use { replacement -> + CastleWarsGameArea.ticksLeftInGame = 100 + CastleWarsGameArea.zamorakPlayers.add(leaver) + CastleWarsWaitingArea.waitingZamorakPlayers.add(declined) + + CastleWarsGameArea.removePlayerFromGame(leaver) + assertSubstitutionDialogue(declined) + + CastleWarsWaitingArea.waitingZamorakPlayers.add(replacement) + CastleWarsGameArea.declineVacancyOffer(declined, CastleWarsTeam.ZAMORAK) + + assertSubstitutionDialogue(replacement) + assertFalse(CastleWarsGameArea.claimVacancy(declined, CastleWarsTeam.ZAMORAK)) + assertTrue(CastleWarsGameArea.claimVacancy(replacement, CastleWarsTeam.ZAMORAK)) + } + } + } + } + + @Test + fun leavingWaitingRoomClearsStaleSubstitutionOffer() { + TestUtils.getMockPlayer("cwstaleleaver").use { leaver -> + TestUtils.getMockPlayer("cwstalesub").use { stale -> + TestUtils.getMockPlayer("cwfreshsub").use { fresh -> + CastleWarsGameArea.ticksLeftInGame = 100 + CastleWarsGameArea.saradominPlayers.add(leaver) + stale.location = CastleWarsWaitingArea.saradominWaitingRoom.randomWalkableLoc + CastleWarsWaitingArea.waitingSaradominPlayers.add(stale) + + CastleWarsGameArea.removePlayerFromGame(leaver) + CastleWarsWaitingArea().exitArea(stale) + CastleWarsWaitingArea.waitingSaradominPlayers.add(fresh) + CastleWarsGameArea.fillVacancy(CastleWarsTeam.SARADOMIN) + + assertSubstitutionDialogue(fresh) + assertTrue(CastleWarsGameArea.claimVacancy(fresh, CastleWarsTeam.SARADOMIN)) + } + } + } + } + + @Test + fun leavingLastSubstitutionOfferReissuesToAvailableWaitingPlayer() { + TestUtils.getMockPlayer("cwleavereissueleaver").use { leaver -> + TestUtils.getMockPlayer("cwleavereissueold").use { stale -> + TestUtils.getMockPlayer("cwleavereissuenew").use { replacement -> + CastleWarsGameArea.ticksLeftInGame = 100 + CastleWarsGameArea.saradominPlayers.add(leaver) + stale.location = CastleWarsWaitingArea.saradominWaitingRoom.randomWalkableLoc + CastleWarsWaitingArea.waitingSaradominPlayers.add(stale) + + CastleWarsGameArea.removePlayerFromGame(leaver) + assertSubstitutionDialogue(stale) + + CastleWarsWaitingArea.waitingSaradominPlayers.add(replacement) + CastleWarsWaitingArea().exitArea(stale) + + assertSubstitutionDialogue(replacement) + assertTrue(CastleWarsGameArea.claimVacancy(replacement, CastleWarsTeam.SARADOMIN)) + } + } + } + } + + @Test + fun expiredSubstitutionOfferDoesNotBlockLaterVacancy() { + TestUtils.getMockPlayer("cwexpireleaver").use { leaver -> + TestUtils.getMockPlayer("cwignoredsub").use { ignored -> + TestUtils.getMockPlayer("cwlatersub").use { later -> + CastleWarsGameArea.ticksLeftInGame = 200 + CastleWarsGameArea.zamorakPlayers.add(leaver) + CastleWarsWaitingArea.waitingZamorakPlayers.add(ignored) + + CastleWarsGameArea.removePlayerFromGame(leaver) + TestUtils.advanceTicks(CastleWarsConstants.VACANCY_OFFER_TIMEOUT_TICKS, false) + CastleWarsWaitingArea.waitingZamorakPlayers.add(later) + CastleWarsGameArea.fillVacancy(CastleWarsTeam.ZAMORAK) + + assertSubstitutionDialogue(later) + } + } } } @@ -201,13 +320,16 @@ class CastleWarsTests { } @Test - fun matchEndReturnsLifecycleToIdle() { + fun matchEndStartsPostGameCooldown() { CastleWarsGameArea.ticksLeftInGame = 1 CastleWarsGameArea().tick() assertEquals(-1, CastleWarsGameArea.ticksLeftInGame) - assertEquals(-1, CastleWarsWaitingArea.gameStartCountdown) + assertEquals( + CastleWars.gameCooldownMinutes * CastleWarsConstants.TICKS_PER_MINUTE, + CastleWarsWaitingArea.gameStartCountdown + ) } @Test @@ -256,6 +378,91 @@ class CastleWarsTests { } } + @Test + fun endedGameClearsLoggedOutPlayerState() { + TestUtils.getMockPlayer("cwendedlogout").use { player -> + player.location = Location.create(2401, 3101, 0) + CastleWarsGameArea.ticksLeftInGame = 500 + CastleWarsGameArea.saradominPlayers.add(player) + player.inventory.add(Item(CastleWars.cwBandages)) + registerTimer(player, spawnTimer("teleblock", 100, false)) + + CastleWarsGameArea().logout(player) + assertEquals(CastleWarsTeam.SARADOMIN.name, getAttribute(player, CastleWarsConstants.ATTR_LOGOUT_TEAM, "")) + + CastleWarsGameArea.endCurrentGame(startCooldown = false) + + assertFalse(CastleWarsGameArea.loggedOutPlayers.containsKey(CastleWarsGameArea.playerKey(player))) + assertEquals("", getAttribute(player, CastleWarsConstants.ATTR_LOGOUT_TEAM, "")) + assertFalse(player.inventory.contains(CastleWars.cwBandages, 1)) + assertFalse(hasTimerActive(player, "teleblock")) + assertTrue(CastleWars.lobbyBankArea.insideBorder(player.location)) + } + } + + @Test + fun expiredLogoutCleanupClearsSavedStateBeforePlayerRelogs() { + TestUtils.getMockPlayer("cwexpiredlogout").use { player -> + TestUtils.getMockPlayer("cwexpiredfirstsub").use { firstReplacement -> + TestUtils.getMockPlayer("cwexpiredsecondsub").use { secondReplacement -> + val logoutLocation = Location.create(2400, 3100, 0) + player.location = logoutLocation + CastleWarsGameArea.ticksLeftInGame = CastleWarsConstants.PLAYER_CLEANUP_INTERVAL_TICKS + 1 + CastleWarsGameArea.saradominPlayers.add(player) + CastleWarsGameArea.loggedOutPlayers[CastleWarsGameArea.playerKey(player)] = + CastleWarsGameArea.Companion.LogoutRecord( + player, + CastleWarsTeam.SARADOMIN, + getWorldTicks() - CastleWarsConstants.LOGOUT_GRACE_PERIOD_TICKS, + logoutLocation + ) + setAttribute(player, CastleWarsConstants.ATTR_LOGOUT_TEAM, CastleWarsTeam.SARADOMIN.name) + setAttribute( + player, + CastleWarsConstants.ATTR_LOGOUT_TIME_MILLIS, + System.currentTimeMillis() - CastleWarsConstants.LOGOUT_GRACE_PERIOD_MILLIS - 1 + ) + setAttribute(player, CastleWarsConstants.ATTR_LOGOUT_LOCATION, Location.create(logoutLocation)) + CastleWarsWaitingArea.waitingSaradominPlayers.add(firstReplacement) + + CastleWarsGameArea().tick() + + assertEquals("", getAttribute(player, CastleWarsConstants.ATTR_LOGOUT_TEAM, "")) + assertFalse(CastleWarsGameArea.loggedOutPlayers.containsKey(CastleWarsGameArea.playerKey(player))) + assertSubstitutionDialogue(firstReplacement) + assertTrue(CastleWarsGameArea.claimVacancy(firstReplacement, CastleWarsTeam.SARADOMIN)) + + CastleWarsWaitingArea.waitingSaradominPlayers.add(secondReplacement) + CastleWarsGameArea().login(player) + + assertFalse(secondReplacement.dialogueInterpreter.dialogue?.file is CastleWarsSubstitutionDialogue) + assertFalse(CastleWarsGameArea.claimVacancy(secondReplacement, CastleWarsTeam.SARADOMIN)) + } + } + } + } + + @Test + fun staleLogoutAttributesAfterEndedGameRestoreToLobbyOnLogin() { + TestUtils.getMockPlayer("cwstalelogin").use { player -> + player.location = Location.create(2401, 3101, 0) + CastleWarsGameArea.ticksLeftInGame = -1 + setAttribute(player, CastleWarsConstants.ATTR_LOGOUT_TEAM, CastleWarsTeam.SARADOMIN.name) + setAttribute(player, CastleWarsConstants.ATTR_LOGOUT_TIME_MILLIS, System.currentTimeMillis()) + setAttribute(player, CastleWarsConstants.ATTR_LOGOUT_LOCATION, Location.create(player.location)) + player.inventory.add(Item(CastleWars.cwBandages)) + registerTimer(player, spawnTimer("teleblock", 100, false)) + + CastleWarsGameArea().login(player) + + assertFalse(CastleWarsWaitingArea.waitingSaradominPlayers.contains(player)) + assertEquals("", getAttribute(player, CastleWarsConstants.ATTR_LOGOUT_TEAM, "")) + assertFalse(player.inventory.contains(CastleWars.cwBandages, 1)) + assertFalse(hasTimerActive(player, "teleblock")) + assertTrue(CastleWars.lobbyBankArea.insideBorder(player.location)) + } + } + @Test fun activeGameLogoutAfterTwoMinutesReturnsPlayerToTeamWaitingRoom() { TestUtils.getMockPlayer("cwslowrelog").use { player -> @@ -295,6 +502,21 @@ class CastleWarsTests { } } + @Test + fun spawnRoomLogoutClearsRespawnRoomTracking() { + TestUtils.getMockPlayer("cwspawntrack").use { player -> + player.location = CastleWarsRespawnArea.saradominRespawnRoom.randomWalkableLoc + CastleWarsGameArea.ticksLeftInGame = 500 + CastleWarsGameArea.saradominPlayers.add(player) + CastleWarsRespawnArea().areaEnter(player) + + CastleWarsRespawnArea().logout(player) + + assertFalse(CastleWarsRespawnArea.saradominPlayersInRespawnRoom.containsKey(player)) + assertFalse(CastleWarsRespawnArea.zamorakPlayersInRespawnRoom.containsKey(player)) + } + } + @Test fun waitingRoomLogoutReturnsPlayerToLobbyAndLosesQueuePosition() { TestUtils.getMockPlayer("cwwaitlogout").use { player -> @@ -410,6 +632,122 @@ class CastleWarsTests { } } + @Test + fun loggedOutGracePlayerReceivesEndGameTicket() { + TestUtils.getMockPlayer("cwlogoutticket").use { player -> + TestUtils.getMockPlayer("cwlogoutticketz").use { opponent -> + CastleWarsGameArea.ticksLeftInGame = 100 + CastleWarsGameArea.saradominPlayers.add(player) + CastleWarsGameArea.zamorakPlayers.add(opponent) + CastleWarsGameArea.loggedOutPlayers[CastleWarsGameArea.playerKey(player)] = + CastleWarsGameArea.Companion.LogoutRecord( + player, + CastleWarsTeam.SARADOMIN, + getWorldTicks(), + Location.create(2400, 3100, 0) + ) + player.isActive = false + try { + CastleWarsGameArea.endCurrentGame(startCooldown = false) + } finally { + player.isActive = true + } + + assertEquals(1, player.inventory.getAmount(CastleWars.cwTicketRewardCurrency)) + assertTrue(CastleWars.lobbyBankArea.insideBorder(player.location)) + } + } + } + + @Test + fun gameEndCleansCastleWarsSuppliesBeforeAwardingTickets() { + TestUtils.getMockPlayer("cwfullticket").use { player -> + TestUtils.getMockPlayer("cwfullticketz").use { opponent -> + player.inventory.add(Item(CastleWars.cwBandages, player.inventory.capacity())) + CastleWarsGameArea.saradominPlayers.add(player) + CastleWarsGameArea.zamorakPlayers.add(opponent) + + CastleWarsGameState.endGame() + + assertEquals(1, player.inventory.getAmount(CastleWars.cwTicketRewardCurrency)) + assertFalse(player.inventory.contains(CastleWars.cwBandages, 1)) + } + } + } + + @Test + fun matchEndClearsCastleWarsGroundItemsInArena() { + TestUtils.getMockPlayer("cwgroundcleanup").use { player -> + val arenaItem = GroundItemManager.create( + Item(CastleWars.cwBandages), + Location.create(2400, 3100, 0), + player + ) + + assertTrue(GroundItemManager.getItems().contains(arenaItem)) + + CastleWarsGameArea.endCurrentGame(startCooldown = false) + + assertFalse(GroundItemManager.getItems().contains(arenaItem)) + } + } + + @Test + fun fullInventoryEndGameTicketsDropAtLobbyBank() { + TestUtils.getMockPlayer("cwfullnormal").use { player -> + TestUtils.getMockPlayer("cwfullnormalz").use { opponent -> + player.location = Location.create(2400, 3100, 0) + player.inventory.add(Item(Items.CUP_OF_TEA_1978, player.inventory.capacity())) + CastleWarsGameArea.saradominPlayers.add(player) + CastleWarsGameArea.zamorakPlayers.add(opponent) + + CastleWarsGameState.endGame() + + val droppedTickets = GroundItemManager.getItems().filter { + it.id == CastleWars.cwTicketRewardCurrency && it.droppedBy(player) + } + assertTrue(droppedTickets.isNotEmpty()) + assertTrue(droppedTickets.all { CastleWars.lobbyBankArea.insideBorder(it.location) }) + droppedTickets.forEach { GroundItemManager.destroy(it) } + } + } + } + + @Test + fun gameEndCleansCarriedFlagsWithoutDropOrReturnMessages() { + TestUtils.getMockPlayer("cwquietflag").use { carrier -> + TestUtils.getMockPlayer("cwquietflagz").use { opponent -> + val carrierSession = carrier.session as MockSession + val opponentSession = opponent.session as MockSession + + CastleWarsGameArea.ticksLeftInGame = 100 + CastleWarsGameArea.saradominPlayers.add(carrier) + CastleWarsGameArea.zamorakPlayers.add(opponent) + assertTrue(CastleWarsGameState.pickUpFlag(carrier, CastleWarsTeam.ZAMORAK)) + carrierSession.clear() + opponentSession.clear() + + CastleWarsGameState.endGame() + + val messages = gameMessagePayloads(carrierSession) + gameMessagePayloads(opponentSession) + assertFalse(messages.any { it.contains("flag has been dropped") }) + assertFalse(messages.any { it.contains("flag has been returned") }) + } + } + } + + @Test + fun cleanupRemovesCastleWarsTeleblock() { + TestUtils.getMockPlayer("cwcleanblock").use { player -> + registerTimer(player, spawnTimer("teleblock", 100, false)) + assertTrue(hasTimerActive(player, "teleblock")) + + CastleWarsGameState.restoreAndCleanupPlayer(player) + + assertFalse(hasTimerActive(player, "teleblock")) + } + } + @Test fun joinPortalRejectsFoodAndInventoryHelmets() { withCastleWarsEnabled { @@ -548,6 +886,42 @@ class CastleWarsTests { } } + @Test + fun fullInventoryFlagCarrierCanUnequipToDropFlag() { + TestUtils.getMockPlayer("cwfulldrop").use { carrier -> + CastleWarsGameArea.ticksLeftInGame = 100 + CastleWarsGameArea.saradominPlayers.add(carrier) + + assertTrue(CastleWarsGameState.pickUpFlag(carrier, CastleWarsTeam.ZAMORAK)) + carrier.inventory.add(Item(Items.CUP_OF_TEA_1978, carrier.inventory.capacity())) + + EquipHandler.unequip(carrier, EquipmentSlot.WEAPON.ordinal, CastleWars.zamorakFlag) + + assertFalse(CastleWarsGameState.isCarryingFlag(carrier)) + assertNull(carrier.equipment[EquipmentSlot.WEAPON.ordinal]) + } + } + + @Test + fun equippingWeaponWhileCarryingFlagDropsFlagAndEquipsWeapon() { + TestUtils.getMockPlayer("cwflagswap").use { carrier -> + CastleWarsGameArea.ticksLeftInGame = 100 + CastleWarsGameArea.saradominPlayers.add(carrier) + carrier.inventory.add(Item(Items.BRONZE_SWORD_1277)) + + assertTrue(CastleWarsGameState.pickUpFlag(carrier, CastleWarsTeam.ZAMORAK)) + + InteractionListeners.run( + Items.BRONZE_SWORD_1277, IntType.ITEM, "wield", carrier, carrier.inventory[0] + ) + + assertFalse(CastleWarsGameState.isCarryingFlag(carrier)) + assertEquals(Items.BRONZE_SWORD_1277, carrier.equipment[EquipmentSlot.WEAPON.ordinal]?.id) + assertFalse(carrier.inventory.contains(CastleWars.zamorakFlag, 1)) + assertEquals(FlagState.DROPPED, CastleWarsGameState.zamorakFlagState) + } + } + @Test fun ownTeamBackdoorLockOptionIsHandled() { assertNotNull( @@ -558,6 +932,27 @@ class CastleWarsTests { ) } + @Test + fun resetGameRestoresOpenedBackdoorsToClosedScenery() { + val loc = (2418..2432).asSequence() + .flatMap { x -> (3070..3085).asSequence().map { y -> Location.create(x, y, 0) } } + .first { RegionManager.getObject(it) == null } + val closed = Scenery(CastleWars.saradominBackdoor, loc, 10, 0) + val open = Scenery(CastleWars.saradominBackdoorOpen, loc, 10, 0) + try { + SceneryBuilder.add(closed) + SceneryBuilder.replace(requireNotNull(RegionManager.getObject(loc)), open, -1, true) + + assertEquals(CastleWars.saradominBackdoorOpen, RegionManager.getObject(loc)?.id) + + CastleWarsGameState.resetGame() + + assertEquals(CastleWars.saradominBackdoor, RegionManager.getObject(loc)?.id) + } finally { + RegionManager.getObject(loc)?.let { SceneryBuilder.remove(it) } + } + } + @Test fun backdoorPicklockUsesLinearThievingCurveFromLevelOneToNinetyNine() { assertEquals(6.3, getBackdoorPicklockSuccessChance(1), 0.0001) @@ -595,6 +990,7 @@ class CastleWarsTests { 10, 0 ) + ensureCatapultObject(catapult) val operateCatapult = InteractionListeners.get( catapult.id, IntType.SCENERY.ordinal, "operate" )!! @@ -638,6 +1034,84 @@ class CastleWarsTests { } } + @Test + fun staleCatapultInterfaceCannotFireAfterCatapultIsDestroyed() { + TestUtils.getMockPlayer("cwcatstale").use { shooter -> + val catapult = Scenery( + CastleWars.getOperationalCatapultId(CastleWarsTeam.SARADOMIN), + CastleWarsGameState.saradominCatapultLocation, + 10, + 0 + ) + ensureCatapultObject(catapult) + val operateCatapult = InteractionListeners.get( + catapult.id, IntType.SCENERY.ordinal, "operate" + )!! + CastleWarsGameArea.saradominPlayers.add(shooter) + shooter.inventory.add(Item(CastleWars.cwRock)) + + operateCatapult(shooter, catapult) + assertTrue(shooter.interfaceManager.hasMainComponent(CatapultConfig.INTERFACE_ID)) + CastleWarsGameState.destroyCatapult(CastleWarsTeam.SARADOMIN) + + InterfaceListeners.run( + shooter, Component(CatapultConfig.INTERFACE_ID), 0, CatapultConfig.BUTTON_FIRE, 0, 0 + ) + + assertEquals(1, shooter.inventory.getAmount(CastleWars.cwRock)) + assertFalse(CatapultFiringHandler.hasShotInFlight(shooter)) + assertFalse(shooter.interfaceManager.hasMainComponent(CatapultConfig.INTERFACE_ID)) + } + } + + @Test + fun staleCatapultInterfaceCannotFireWhenCatapultObjectIsMissing() { + TestUtils.getMockPlayer("cwcatmissing").use { shooter -> + CastleWarsGameArea.saradominPlayers.add(shooter) + shooter.inventory.add(Item(CastleWars.cwRock)) + shooter.setAttribute(CatapultConfig.ATTR_LOCATION, Location.create(2400, 3090, 0)) + shooter.interfaceManager.open(Component(CatapultConfig.INTERFACE_ID)) + + InterfaceListeners.run( + shooter, Component(CatapultConfig.INTERFACE_ID), 0, CatapultConfig.BUTTON_FIRE, 0, 0 + ) + + assertEquals(1, shooter.inventory.getAmount(CastleWars.cwRock)) + assertFalse(CatapultFiringHandler.hasShotInFlight(shooter)) + assertFalse(shooter.interfaceManager.hasMainComponent(CatapultConfig.INTERFACE_ID)) + } + } + + @Test + fun extinguishedCatapultFirePulseDoesNotDestroyRelitCatapult() { + TestUtils.getMockPlayer("cwcatfirez").use { attacker -> + TestUtils.getMockPlayer("cwcatfires").use { defender -> + val catapult = Scenery( + CastleWars.getOperationalCatapultId(CastleWarsTeam.SARADOMIN), + CastleWarsGameState.saradominCatapultLocation, + 10, + 0 + ) + ensureCatapultObject(catapult) + CastleWarsGameArea.zamorakPlayers.add(attacker) + CastleWarsGameArea.saradominPlayers.add(defender) + CastleWarsGameArea.ticksLeftInGame = 100 + defender.inventory.add(Item(Items.BUCKET_OF_WATER_1929)) + + assertTrue(runUseWith(Item(Items.TINDERBOX_590), catapult, IntType.SCENERY, attacker)) + TestUtils.advanceTicks(25, false) + assertTrue(runUseWith(defender.inventory[0], catapult, IntType.SCENERY, defender)) + assertFalse(CastleWarsGameState.isCatapultOnFire(CastleWarsTeam.SARADOMIN)) + + assertTrue(runUseWith(Item(Items.TINDERBOX_590), catapult, IntType.SCENERY, attacker)) + TestUtils.advanceTicks(25, false) + + assertTrue(CastleWarsGameState.isCatapultOperational(CastleWarsTeam.SARADOMIN)) + assertTrue(CastleWarsGameState.isCatapultOnFire(CastleWarsTeam.SARADOMIN)) + } + } + } + @Test fun climbingRopeOnPlayerTileCanBeClimbed() { val rope = Scenery(CastleWars.cwCastleClimbingRope, Location.create(2420, 3078, 1), 4, 0) @@ -692,6 +1166,33 @@ class CastleWarsTests { } } + @Test + fun extinguishingBarricadeStopsPreviousBurnPulseBeforeRelight() { + TestUtils.getMockPlayer("cwbarrfire").use { player -> + val barricade = CastleWarsBarricadeNPC.spawn(Location.create(2400, 3100, 0), CastleWarsTeam.SARADOMIN) + CastleWarsGameArea.ticksLeftInGame = 100 + CastleWarsItemHandler.saradominBarricades.add(barricade) + player.inventory.add(Item(Items.BUCKET_OF_WATER_1929)) + + assertTrue(runUseWith(Item(Items.TINDERBOX_590), barricade, IntType.NPC, player)) + val firstPulse = CastleWarsItemHandler.burningBarricadePulses[barricade] + assertNotNull(firstPulse) + assertTrue(firstPulse!!.isRunning) + + assertTrue(runUseWith(player.inventory[0], barricade, IntType.NPC, player)) + + assertFalse(firstPulse.isRunning) + assertFalse(CastleWarsItemHandler.burningBarricadePulses.containsKey(barricade)) + + assertTrue(runUseWith(Item(Items.TINDERBOX_590), barricade, IntType.NPC, player)) + val secondPulse = CastleWarsItemHandler.burningBarricadePulses[barricade] + assertNotNull(secondPulse) + assertNotSame(firstPulse, secondPulse) + assertFalse(firstPulse.isRunning) + barricade.clear() + } + } + @Test fun matchStartActivatesBraceletEffectsAndStealFromOption() { TestUtils.getMockPlayer("cwbrace1").use { braceletPlayer -> @@ -716,6 +1217,24 @@ class CastleWarsTests { } } + @Test + fun substitutionActivatesBraceletEffects() { + TestUtils.getMockPlayer("cwsubbraceleave").use { leaver -> + TestUtils.getMockPlayer("cwsubbrace").use { substitute -> + substitute.equipment.replace(Item(Items.CASTLEWAR_BRACE3_11079), EquipmentSlot.HANDS.ordinal) + CastleWarsGameArea.ticksLeftInGame = 100 + CastleWarsGameArea.saradominPlayers.add(leaver) + CastleWarsWaitingArea.waitingSaradominPlayers.add(substitute) + + CastleWarsGameArea.removePlayerFromGame(leaver) + + assertTrue(CastleWarsGameArea.claimVacancy(substitute, CastleWarsTeam.SARADOMIN)) + assertEquals(CastleWarsTeam.SARADOMIN, substitute.activeCastleWarsBraceletTeam()) + assertNotNull(substitute.interaction[Option._P_STEAL_FROM.index]) + } + } + } + @Test fun bandagesHealTenPercentRestoreRunEnergyAndCurePoison() { TestUtils.getMockPlayer("cwbandage").use { player -> @@ -738,6 +1257,36 @@ class CastleWarsTests { } } + @Test + fun bandagesCannotBeUsedOutsideActiveCastleWarsGame() { + TestUtils.getMockPlayer("cwbandageoutside").use { player -> + player.skills.setStaticLevel(Skills.HITPOINTS, 99) + player.skills.lifepoints = 50 + player.inventory.add(Item(CastleWars.cwBandages)) + + InteractionListeners.run( + CastleWars.cwBandages, IntType.ITEM, "Heal", player, player.inventory[0] + ) + + assertEquals(50, player.skills.lifepoints) + assertTrue(player.inventory.contains(CastleWars.cwBandages, 1)) + } + } + + @Test + fun castleWarsTeleblockDoesNotSendChatboxMessage() { + TestUtils.getMockPlayer("cwsilentteleblock").use { player -> + val session = player.session as MockSession + player.location = CastleWarsWaitingArea.saradominWaitingRoom.randomWalkableLoc + session.clear() + + CastleWarsWaitingArea().areaEnter(player) + + assertTrue(hasTimerActive(player, "teleblock")) + assertFalse(session.hasPacketReady(70)) + } + } + @Test fun activeBraceletBandagesHealTeammatesForFifteenPercent() { TestUtils.getMockPlayer("cwhealer").use { healer -> @@ -815,6 +1364,27 @@ class CastleWarsTests { assertTrue(player.dialogueInterpreter.dialogue.file is CastleWarsSubstitutionDialogue) } + private fun ensureCatapultObject(catapult: Scenery) { + val current = RegionManager.getObject(catapult.location) + if (current != null && current.id in CastleWars.allCatapultIds) { + if (current.id != catapult.id) { + SceneryBuilder.replace(current, catapult) + } + return + } + SceneryBuilder.add(catapult) + } + + private fun runUseWith(used: Item, with: Node, type: IntType, player: Player): Boolean { + val handler = InteractionListeners.get(used.id, with.id, type.ordinal) + assertNotNull(handler) + return handler!!.invoke(player, used, with) + } + + private fun gameMessagePayloads(session: MockSession): List { + return session.getPacketsWithOpcode(70).map { String(it, Charsets.ISO_8859_1) } + } + private fun withCastleWarsEnabled(block: () -> Unit) { val settings = GameWorld.settings!! val original = settings.enable_castle_wars From 31becd5e4ac9edc9914daee34b262cde27233c4f Mon Sep 17 00:00:00 2001 From: dam <27978131-real_damighty@users.noreply.gitlab.com> Date: Wed, 3 Jun 2026 00:06:33 +0300 Subject: [PATCH 24/26] Fixed Castle Wars compilation errors for chunk.objects API and getLocalNPCs rename --- .../minigame/castlewars/CastleWarsGameState.kt | 11 ++--------- .../content/minigame/castlewars/CastleWarsGameZone.kt | 5 ++--- .../minigame/castlewars/CastleWarsItemHandler.kt | 2 +- 3 files changed, 5 insertions(+), 13 deletions(-) diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsGameState.kt b/Server/src/main/content/minigame/castlewars/CastleWarsGameState.kt index 46b52e72c..98cbed706 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsGameState.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsGameState.kt @@ -277,12 +277,11 @@ object CastleWarsGameState { fun registerClimbingRope(rope: Scenery, originalWall: Scenery) { val key = Location.create(rope.location) - val chunk = RegionManager.getRegionChunk(rope.location) rope.isActive = true rope.isRenderable = true activeClimbingRopes[key] = ActiveClimbingRope(rope, Scenery(originalWall)) - chunk.objects[rope.location.chunkOffsetX][rope.location.chunkOffsetY] = rope + SceneryBuilder.add(rope) } fun getClimbingRope(location: Location): Scenery? { @@ -291,15 +290,9 @@ object CastleWarsGameState { fun removeClimbingRope(location: Location, sendUpdate: Boolean = true): Boolean { val activeRope = activeClimbingRopes.remove(Location.create(location)) ?: return false - val chunk = RegionManager.getRegionChunk(activeRope.rope.location) activeRope.rope.isActive = false - chunk.objects[activeRope.rope.location.chunkOffsetX][activeRope.rope.location.chunkOffsetY] = - activeRope.originalWall - - if (sendUpdate) { - chunk.flag(ObjectUpdateFlag(activeRope.rope, true)) - } + SceneryBuilder.replace(activeRope.rope, activeRope.originalWall) return true } diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsGameZone.kt b/Server/src/main/content/minigame/castlewars/CastleWarsGameZone.kt index 8831cf08c..d9aa4e91a 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsGameZone.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsGameZone.kt @@ -8,7 +8,7 @@ import core.game.node.entity.impl.PulseManager import core.game.node.entity.player.Player import core.game.system.timer.impl.Poison import core.game.world.map.Location -import core.game.world.map.RegionManager.getLocalNpcs +import core.game.world.map.RegionManager.getLocalNPCs import core.game.world.map.path.Pathfinder import core.game.world.map.zone.* @@ -103,7 +103,7 @@ class CastleWarsGameZone : MapZone("CastleWarsGame", true, ZoneRestriction.RANDO val isMovingUp = to.z > from.z val isChangingZLevel = to.z != from.z - for (n in getLocalNpcs(e, 5)) { + for (n in getLocalNPCs(e.location, 5)) { if (n.isInvisible || n === e) { continue } @@ -138,4 +138,3 @@ class CastleWarsGameZone : MapZone("CastleWarsGame", true, ZoneRestriction.RANDO return ZoneType.CASTLE_WARS.id } } - diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsItemHandler.kt b/Server/src/main/content/minigame/castlewars/CastleWarsItemHandler.kt index 497a12bfd..ab29eed83 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsItemHandler.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsItemHandler.kt @@ -341,6 +341,6 @@ class CastleWarsItemHandler : InteractionListener { NPCs.BARRICADE_1532, NPCs.BARRICADE_1533, NPCs.BARRICADE_1534, NPCs.BARRICADE_1535 ) - return RegionManager.getLocalNpcs(location, radius).filter { it.id in barricadeIds && it.isActive } + return RegionManager.getLocalNPCs(location, radius).filter { it.id in barricadeIds && it.isActive } } } From 5b4545ea4be2a040e8c5a348f36634aaa7497a2f Mon Sep 17 00:00:00 2001 From: dam <27978131-real_damighty@users.noreply.gitlab.com> Date: Thu, 4 Jun 2026 20:26:20 +0300 Subject: [PATCH 25/26] Thanos save me --- Server/data/configs/shops.json | 9 --------- 1 file changed, 9 deletions(-) diff --git a/Server/data/configs/shops.json b/Server/data/configs/shops.json index 2f285314c..126acfb0d 100644 --- a/Server/data/configs/shops.json +++ b/Server/data/configs/shops.json @@ -2178,15 +2178,6 @@ "title": "Castle Wars Ticket Exchange", "stock": "{4068,1,100}-{4069,1,100}-{4070,1,100}-{4071,1,100}-{4072,1,100}-{4503,1,100}-{4504,1,100}-{4505,1,100}-{4506,1,100}-{4507,1,100}-{4508,1,100}-{4509,1,100}-{4510,1,100}-{4511,1,100}-{4512,1,100}-{4513,1,100}-{4514,1,100}-{4515,1,100}-{4516,1,100}" }, - { - "npcs": "5111", - "high_alch": "0", - "currency": "995", - "general_store": "false", - "id": "256", - "title": "Leon's Prototype Crossbow", - "stock": "{10156,2,100}" - }, { "npcs": "2039", "high_alch": "0", From 4ba22ba1a7d6b433e936f705343f10ac5f4e629b Mon Sep 17 00:00:00 2001 From: dam <27978131-real_damighty@users.noreply.gitlab.com> Date: Thu, 23 Jul 2026 14:28:52 +0300 Subject: [PATCH 26/26] Big fix batch, return to non-MobyDick2 --- Server/data/configs/item_configs.json | 2 + Server/data/configs/shops.json | 11 +- .../castlewars/CastleWarsBarricadeNPC.kt | 6 +- .../castlewars/CastleWarsCatapultInterface.kt | 14 +- .../castlewars/CastleWarsDoorAttackPulse.kt | 2 +- .../CastleWarsEquipmentRestrictions.kt | 22 +-- .../castlewars/CastleWarsFlagHandler.kt | 10 +- .../castlewars/CastleWarsGameState.kt | 139 ++++++------------ .../minigame/castlewars/CastleWarsGameZone.kt | 34 +---- .../castlewars/CastleWarsItemHandler.kt | 36 ++--- .../castlewars/CastleWarsListeners.kt | 106 ++++++------- .../castlewars/CastleWarsShutdownHandler.kt | 26 +++- .../CastleWarsSubstitutionDialogue.kt | 7 +- .../castlewars/CastleWarsTunnelMiningPulse.kt | 25 +++- .../castlewars/CatapultFiringHandler.kt | 10 +- .../castlewars/CatapultInterfaceRenderer.kt | 22 +-- .../content/minigame/castlewars/Lanthus.kt | 11 +- .../castlewars/areas/CastleWarsArea.kt | 20 ++- .../castlewars/areas/CastleWarsGameArea.kt | 102 +++++++------ .../castlewars/areas/CastleWarsRespawnArea.kt | 33 ++++- .../castlewars/areas/CastleWarsWaitingArea.kt | 5 +- Server/src/main/core/api/MapArea.kt | 15 +- .../container/impl/EquipmentContainer.java | 13 +- .../core/game/global/action/EquipHandler.kt | 26 ++-- .../node/entity/combat/CombatSwingHandler.kt | 3 +- .../game/node/entity/combat/DeathContext.kt | 13 +- .../core/game/node/entity/player/Player.java | 17 ++- .../game/system/config/ItemConfigParser.kt | 9 +- .../core/game/world/map/zone/MapZone.java | 18 ++- .../core/game/world/map/zone/ZoneMonitor.java | 28 +++- Server/src/main/core/plugin/ClassScanner.kt | 20 ++- .../minigame/castlewars/CastleWarsTests.kt | 87 +++++++++++ 32 files changed, 504 insertions(+), 388 deletions(-) diff --git a/Server/data/configs/item_configs.json b/Server/data/configs/item_configs.json index bfdd22535..37003e975 100644 --- a/Server/data/configs/item_configs.json +++ b/Server/data/configs/item_configs.json @@ -34820,6 +34820,7 @@ "defence_anim": "420", "durability": null, "equipment_slot": "3", + "unequip_without_inventory_space": "true", "render_anim": "131", "run_anim": "1427", "stand_anim": "1421", @@ -34850,6 +34851,7 @@ "defence_anim": "420", "durability": null, "equipment_slot": "3", + "unequip_without_inventory_space": "true", "render_anim": "131", "run_anim": "1427", "stand_anim": "1421", diff --git a/Server/data/configs/shops.json b/Server/data/configs/shops.json index 126acfb0d..f4186de26 100644 --- a/Server/data/configs/shops.json +++ b/Server/data/configs/shops.json @@ -2178,6 +2178,15 @@ "title": "Castle Wars Ticket Exchange", "stock": "{4068,1,100}-{4069,1,100}-{4070,1,100}-{4071,1,100}-{4072,1,100}-{4503,1,100}-{4504,1,100}-{4505,1,100}-{4506,1,100}-{4507,1,100}-{4508,1,100}-{4509,1,100}-{4510,1,100}-{4511,1,100}-{4512,1,100}-{4513,1,100}-{4514,1,100}-{4515,1,100}-{4516,1,100}" }, + { + "npcs": "5111", + "high_alch": "0", + "currency": "995", + "general_store": "false", + "id": "256", + "title": "Leon's Prototype Crossbow", + "stock": "{10156,2,100}" + }, { "npcs": "2039", "high_alch": "0", @@ -2187,4 +2196,4 @@ "title": "Uglug's Stuffsies", "stock": "{4844,100,10}-{10927,0,100}-{2862,100,100}-{1777,10,200}-{2876,0,500}-{2878,10,100}-{4850,0,100}-{946,5,100}-{4773,0,1000}-{4778,0,1000}-{4783,0,1000}-{4788,0,1500}-{4793,0,2000}-{4798,0,3000}-{4803,0,4000}-{4827,0,2000}" } -] \ No newline at end of file +] diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsBarricadeNPC.kt b/Server/src/main/content/minigame/castlewars/CastleWarsBarricadeNPC.kt index 3510dad56..842b798f4 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsBarricadeNPC.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsBarricadeNPC.kt @@ -11,7 +11,7 @@ import org.rs09.consts.NPCs * Custom NPC class for Castle Wars barricades. * * - Block all entity movement through them (like Pest Control Brawlers) - * - Never retaliate when attacked (this was scary man they squared up) + * - Never retaliate when attacked * - Never turn to face attackers or any direction * - Cannot attack or move */ @@ -46,9 +46,7 @@ class CastleWarsBarricadeNPC : AbstractNPC { return false } - override fun attack(node: Node?) { - return - } + override fun attack(node: Node?) {} override fun isPoisonImmune(): Boolean { return true diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsCatapultInterface.kt b/Server/src/main/content/minigame/castlewars/CastleWarsCatapultInterface.kt index 830d38650..104101237 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsCatapultInterface.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsCatapultInterface.kt @@ -39,7 +39,7 @@ class CastleWarsCatapultInterface : InterfaceListener { } private fun handleButtonClick(player: Player, buttonId: Int): Boolean { - val team = getPlayerTeam(player) ?: return closeAndReturn(player, false) + val team = CastleWarsGameState.getActiveGameTeam(player) ?: return closeAndReturn(player, true) val currentCoord = getCurrentCoordinate(player) return when (val button = CatapultButton.fromId(buttonId)) { @@ -89,6 +89,10 @@ class CastleWarsCatapultInterface : InterfaceListener { catapultTeam: CastleWarsTeam, catapultLocation: Location ): Boolean { + if (!CastleWarsGameState.isGameActive || CastleWarsGameState.getActiveGameTeam(player) != playerTeam) { + sendMessage(player, "The Castle Wars game is no longer active.") + return false + } if (playerTeam != catapultTeam) { sendMessage(player, "You can't use the enemy's catapult!") return false @@ -99,6 +103,10 @@ class CastleWarsCatapultInterface : InterfaceListener { sendMessage(player, "The catapult is no longer there.") return false } + if (player.location.z != catapultLocation.z || player.location.getDistance(catapultLocation) > 2.0) { + sendMessage(player, "You have moved too far away from the catapult.") + return false + } if (currentCatapult.id in CastleWars.damagedCatapultIds || !CastleWarsGameState.isCatapultOperational( catapultTeam ) @@ -143,10 +151,6 @@ class CastleWarsCatapultInterface : InterfaceListener { renderer.renderCoordinate(player, getCurrentCoordinate(player)) } - private fun getPlayerTeam(player: Player): CastleWarsTeam? { - return CastleWarsGameState.getPlayerTeam(player) - } - private fun getCatapultLocation(player: Player): Location? { return getAttribute(player, ATTR_LOCATION, null) } diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsDoorAttackPulse.kt b/Server/src/main/content/minigame/castlewars/CastleWarsDoorAttackPulse.kt index 3883e6d6d..539cb1fc4 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsDoorAttackPulse.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsDoorAttackPulse.kt @@ -25,7 +25,7 @@ class CastleWarsDoorAttackPulse( private var ticksUntilNextHit = 0 override fun pulse(): Boolean { - if (!player.isActive || CastleWarsGameState.isDoorDestroyed(doorTeam) || CastleWarsGameState.getPlayerTeam( + if (!player.isActive || CastleWarsGameState.isDoorDestroyed(doorTeam) || CastleWarsGameState.getActiveGameTeam( player ) == null ) { diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsEquipmentRestrictions.kt b/Server/src/main/content/minigame/castlewars/CastleWarsEquipmentRestrictions.kt index 5692fdee4..ec16fb0bf 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsEquipmentRestrictions.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsEquipmentRestrictions.kt @@ -16,8 +16,15 @@ object CastleWarsEquipmentRestrictions { CastleWars.saradominTeamHoodedCloak, CastleWars.zamorakTeamHoodedCloak ) + private val castleWarsBorders by lazy { + CastleWarsGameArea.areaBorders.toList() + CastleWarsWaitingArea.areaBorders.toList() + listOf( + CastleWarsRespawnArea.saradominRespawnRoom, + CastleWarsRespawnArea.zamorakRespawnRoom + ) + } + fun blocksEquip(player: Player, equipmentSlot: Int): Boolean { - if (equipmentSlot !in restrictedSlots || !isInCastleWarsArea(player)) { + if (equipmentSlot !in restrictedSlots) { return false } @@ -34,6 +41,10 @@ object CastleWarsEquipmentRestrictions { } } + fun isInCastleWarsArea(player: Player): Boolean { + return castleWarsBorders.any { it.insideBorder(player.location) } + } + private fun removeEquipment(player: Player, slot: Int) { val item = player.equipment[slot] ?: return player.equipment.remove(item) @@ -41,13 +52,4 @@ object CastleWarsEquipmentRestrictions { GroundItemManager.create(item.copy(), player.location, player) } } - - private fun isInCastleWarsArea(player: Player): Boolean { - val allCastleWarsBorders = - CastleWarsGameArea.areaBorders.toList() + CastleWarsWaitingArea.areaBorders.toList() + listOf( - CastleWarsRespawnArea.saradominRespawnRoom, - CastleWarsRespawnArea.zamorakRespawnRoom - ) - return allCastleWarsBorders.any { it.insideBorder(player.location) } - } } diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsFlagHandler.kt b/Server/src/main/content/minigame/castlewars/CastleWarsFlagHandler.kt index e820a863e..b1fcc00a4 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsFlagHandler.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsFlagHandler.kt @@ -43,7 +43,7 @@ class CastleWarsFlagHandler : InteractionListener { sendMessage(player, "There is no game in progress.") return@on true } - val playerTeam = CastleWarsGameState.getPlayerTeam(player) + val playerTeam = CastleWarsGameState.getActiveGameTeam(player) if (playerTeam == null) { sendMessage(player, "You are not in a Castle Wars game.") return@on true @@ -129,7 +129,7 @@ class CastleWarsFlagHandler : InteractionListener { sendMessage(player, "There is no game in progress.") return@on true } - val playerTeam = CastleWarsGameState.getPlayerTeam(player) + val playerTeam = CastleWarsGameState.getActiveGameTeam(player) if (playerTeam == null) { sendMessage(player, "You are not in a Castle Wars game.") return@on true @@ -149,11 +149,9 @@ class CastleWarsFlagHandler : InteractionListener { return@on true } if (flagTeam == playerTeam) { - if (CastleWarsGameState.pickUpOwnFlag(player, flagTeam)) { // Message already sent by pickUpOwnFlag - } + CastleWarsGameState.pickUpOwnFlag(player, flagTeam) } else { - if (CastleWarsGameState.pickUpFlag(player, flagTeam)) { // Message already sent by pickUpFlag - } + CastleWarsGameState.pickUpFlag(player, flagTeam) } return@on true diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsGameState.kt b/Server/src/main/content/minigame/castlewars/CastleWarsGameState.kt index 98cbed706..5e9806ae7 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsGameState.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsGameState.kt @@ -19,7 +19,6 @@ import core.game.node.scenery.SceneryBuilder 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 org.rs09.consts.Items /** @@ -64,7 +63,9 @@ enum class BoulderState { } data class ActiveClimbingRope( - val rope: Scenery, val originalWall: Scenery + val rope: Scenery, + val originalWall: Scenery, + val ropeWall: Scenery ) /** @@ -275,12 +276,12 @@ object CastleWarsGameState { resetBattlefield() } - fun registerClimbingRope(rope: Scenery, originalWall: Scenery) { + fun registerClimbingRope(rope: Scenery, originalWall: Scenery, ropeWall: Scenery) { val key = Location.create(rope.location) rope.isActive = true rope.isRenderable = true - activeClimbingRopes[key] = ActiveClimbingRope(rope, Scenery(originalWall)) + activeClimbingRopes[key] = ActiveClimbingRope(rope, Scenery(originalWall), Scenery(ropeWall)) SceneryBuilder.add(rope) } @@ -288,18 +289,21 @@ object CastleWarsGameState { return activeClimbingRopes[Location.create(location)]?.rope?.takeIf { it.isActive } } - fun removeClimbingRope(location: Location, sendUpdate: Boolean = true): Boolean { - val activeRope = activeClimbingRopes.remove(Location.create(location)) ?: return false + fun removeClimbingRope(rope: Scenery): Boolean { + val key = Location.create(rope.location) + val activeRope = activeClimbingRopes[key] ?: return false + if (activeRope.rope !== rope) return false + activeClimbingRopes.remove(key) - activeRope.rope.isActive = false - SceneryBuilder.replace(activeRope.rope, activeRope.originalWall) + SceneryBuilder.remove(activeRope.rope) + SceneryBuilder.replace(activeRope.ropeWall, activeRope.originalWall, false, false) return true } - fun clearClimbingRopes(sendUpdate: Boolean = true) { - val ropeLocations = activeClimbingRopes.keys.map { Location.create(it) } - for (location in ropeLocations) { - removeClimbingRope(location, sendUpdate) + fun clearClimbingRopes() { + val ropes = activeClimbingRopes.values.map { it.rope } + for (rope in ropes) { + removeClimbingRope(rope) } } @@ -385,33 +389,9 @@ object CastleWarsGameState { CastleWarsRespawnArea.zamorakRespawnRoom.insideBorder(location) } - /** - * Get the team a player is on. - * Includes a self-healing mechanism if the player is in the area but not in the sets. - */ - fun getPlayerTeam(player: Player): CastleWarsTeam? { + fun getActiveGameTeam(player: Player): CastleWarsTeam? { if (CastleWarsGameArea.saradominPlayers.contains(player)) return CastleWarsTeam.SARADOMIN if (CastleWarsGameArea.zamorakPlayers.contains(player)) return CastleWarsTeam.ZAMORAK - if (CastleWarsWaitingArea.waitingSaradominPlayers.contains(player)) return CastleWarsTeam.SARADOMIN - if (CastleWarsWaitingArea.waitingZamorakPlayers.contains(player)) return CastleWarsTeam.ZAMORAK - if (isGameActive) { - val inCWArea = - CastleWarsGameArea.areaBorders.any { it.insideBorder(player.location) } || CastleWarsRespawnArea.saradominRespawnRoom.insideBorder( - player.location - ) || CastleWarsRespawnArea.zamorakRespawnRoom.insideBorder( - player.location - ) - if (inCWArea) { - val capeId = player.equipment.get(EquipmentContainer.SLOT_CAPE)?.id ?: -1 - if (capeId == CastleWars.saradominTeamHoodedCloak) { - CastleWarsGameArea.saradominPlayers.add(player) - return CastleWarsTeam.SARADOMIN - } else if (capeId == CastleWars.zamorakTeamHoodedCloak) { - CastleWarsGameArea.zamorakPlayers.add(player) - return CastleWarsTeam.ZAMORAK - } - } - } return null } @@ -422,7 +402,7 @@ object CastleWarsGameState { */ fun checkFlagCarriers() { saradominFlagCarrier?.let { player -> - if (!player.isActive || getPlayerTeam(player) == null) { + if (!player.isActive || getActiveGameTeam(player) == null) { dropFlag(player) } else { val weapon = player.equipment.get(EquipmentContainer.SLOT_WEAPON) @@ -432,7 +412,7 @@ object CastleWarsGameState { } } zamorakFlagCarrier?.let { player -> - if (!player.isActive || getPlayerTeam(player) == null) { + if (!player.isActive || getActiveGameTeam(player) == null) { dropFlag(player) } else { val weapon = player.equipment.get(EquipmentContainer.SLOT_WEAPON) @@ -442,7 +422,7 @@ object CastleWarsGameState { } } saradominOwnFlagCarrier?.let { player -> - if (!player.isActive || getPlayerTeam(player) == null) { + if (!player.isActive || getActiveGameTeam(player) == null) { dropOwnFlag(player) } else { val weapon = player.equipment.get(EquipmentContainer.SLOT_WEAPON) @@ -454,7 +434,7 @@ object CastleWarsGameState { } } zamorakOwnFlagCarrier?.let { player -> - if (!player.isActive || getPlayerTeam(player) == null) { + if (!player.isActive || getActiveGameTeam(player) == null) { dropOwnFlag(player) } else { val weapon = player.equipment.get(EquipmentContainer.SLOT_WEAPON) @@ -465,7 +445,6 @@ object CastleWarsGameState { } } } - updateAllHintIcons() } /** @@ -539,7 +518,7 @@ object CastleWarsGameState { * @return true if the flag was successfully picked up */ fun pickUpFlag(player: Player, flagTeam: CastleWarsTeam): Boolean { - val playerTeam = getPlayerTeam(player) ?: return false + val playerTeam = getActiveGameTeam(player) ?: return false if (playerTeam == flagTeam) { sendMessage(player, "You can't pick up your own team's flag!") return false @@ -572,7 +551,7 @@ object CastleWarsGameState { if (currentWeapon != null) { if (!player.inventory.add(currentWeapon)) { GroundItemManager.create(currentWeapon, player.location, player) - sendMessage(player, "Your weapon was dropped because your inventory is full.") // TODO: nah? + sendMessage(player, "Your weapon was dropped because your inventory is full.") } else { sendMessage(player, "Your weapon has been unequipped.") } @@ -582,7 +561,7 @@ object CastleWarsGameState { player.equipment.remove(shield) if (!player.inventory.add(shield)) { GroundItemManager.create(shield, player.location, player) - sendMessage(player, "Your shield was dropped because your inventory is full.") // TODO: nah? + sendMessage(player, "Your shield was dropped because your inventory is full.") } else { sendMessage(player, "Your shield has been unequipped.") } @@ -776,7 +755,7 @@ object CastleWarsGameState { * @return true if the flag was successfully picked up */ fun pickUpOwnFlag(player: Player, flagTeam: CastleWarsTeam): Boolean { - val playerTeam = getPlayerTeam(player) ?: return false // Must be your own team's flag + val playerTeam = getActiveGameTeam(player) ?: return false // Must be your own team's flag if (playerTeam != flagTeam) { sendMessage(player, "That's not your team's flag!") return false @@ -842,7 +821,7 @@ object CastleWarsGameState { * A grace period prevents instant return if the flag is picked up while already inside the castle. */ private fun checkOwnFlagReturn(player: Player) { - val playerTeam = getPlayerTeam(player) ?: return + val playerTeam = getActiveGameTeam(player) ?: return val carriedFlag = getOwnCarriedFlag(player) ?: return if (carriedFlag != playerTeam) return val pickupTick = when (playerTeam) { @@ -924,7 +903,7 @@ object CastleWarsGameState { * @return true if the flag was successfully captured */ fun captureFlag(player: Player): Boolean { - val playerTeam = getPlayerTeam(player) ?: return false + val playerTeam = getActiveGameTeam(player) ?: return false val carriedFlag = getCarriedFlag(player) ?: return false if (carriedFlag == playerTeam) { return false @@ -957,6 +936,7 @@ object CastleWarsGameState { announceToAllPlayers("${playerTeam.teamName} has captured the ${carriedFlag.teamName} flag!") announceToAllPlayers("Score: Saradomin $saradominScore - $zamorakScore Zamorak") updateAllOverlays() + updateAllHintIcons() return true } @@ -1034,7 +1014,7 @@ object CastleWarsGameState { * Update the hint icon for a single player */ private fun updateHintIconForPlayer(player: Player) { - val playerTeam = getPlayerTeam(player) ?: return + val playerTeam = getActiveGameTeam(player) ?: return val enemyTeam = playerTeam.opposite() playerHintIconSlots[player]?.let { slot -> HintIconManager.removeHintIcon(player, slot) @@ -1046,8 +1026,7 @@ object CastleWarsGameState { val carrier = if (enemyTeam == CastleWarsTeam.SARADOMIN) saradominFlagCarrier ?: saradominOwnFlagCarrier else zamorakFlagCarrier ?: zamorakOwnFlagCarrier carrier?.let { - if (it == player) { - } else { + if (it != player) { val slot = HintIconManager.registerHintIcon(player, it) if (slot >= 0) { playerHintIconSlots[player] = slot @@ -1123,7 +1102,7 @@ object CastleWarsGameState { fun endGame(extraRewardRecipients: Map = emptyMap()) { val rewardRecipients = linkedMapOf() for (player in getAllGamePlayers()) { - val team = getPlayerTeam(player) ?: continue + val team = getActiveGameTeam(player) ?: continue rewardRecipients[player] = team } for ((player, team) in extraRewardRecipients) { @@ -1190,6 +1169,7 @@ object CastleWarsGameState { * @param damage Amount of damage (0-100) */ fun damageDoor(team: CastleWarsTeam, damage: Int) { + require(damage >= 0) { "Door damage cannot be negative" } val previousHealth = getDoorHealth(team) when (team) { CastleWarsTeam.SARADOMIN -> { @@ -1252,6 +1232,7 @@ object CastleWarsGameState { * @param repair Amount to repair (0-100) */ fun repairDoor(team: CastleWarsTeam, repair: Int) { + require(repair >= 0) { "Door repair amount cannot be negative" } when (team) { CastleWarsTeam.SARADOMIN -> { saradominDoorHealth = (saradominDoorHealth + repair).coerceAtMost(100) @@ -1314,7 +1295,7 @@ object CastleWarsGameState { "saradomin2" -> saradominBoulder2State "zamorak1" -> zamorakBoulder1State "zamorak2" -> zamorakBoulder2State - else -> BoulderState.COLLAPSED + else -> throw IllegalArgumentException("Unknown Castle Wars boulder key: $boulderKey") } } @@ -1327,6 +1308,7 @@ object CastleWarsGameState { "saradomin2" -> saradominBoulder2State = state "zamorak1" -> zamorakBoulder1State = state "zamorak2" -> zamorakBoulder2State = state + else -> throw IllegalArgumentException("Unknown Castle Wars boulder key: $boulderKey") } } @@ -1561,7 +1543,7 @@ object CastleWarsGameState { player, CASTLE_WARS_TIMER_VARP, remainingMinutes ) // Boulder varbits: 0 = collapsed/blocked (green/secure), 1 = cleared (red/open) - val team = getPlayerTeam(player) ?: return + val team = getActiveGameTeam(player) ?: return when (team) { CastleWarsTeam.SARADOMIN -> { setVarbit(player, SARA_DOOR_HEALTH_VARBIT, saradominDoorHealth) @@ -1581,48 +1563,11 @@ object CastleWarsGameState { } } - /** - * Get all players currently in the Castle Wars game area zones. - * Uses RegionManager to find all players in the zone borders, - * ensuring we reach players even if they weren't properly added to the tracked sets. - * @return Set of all players in the game area who have a valid team - */ + /** Get all connected players tracked as active participants. */ private fun getAllGamePlayers(): Set { - val players = mutableSetOf() - players.addAll((CastleWarsGameArea.saradominPlayers + CastleWarsGameArea.zamorakPlayers).filter { + return (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 - val plane = border.plane - val radius = maxOf( - kotlin.math.abs(border.northEastX - border.southWestX), - kotlin.math.abs(border.northEastY - border.southWestY) - ) / 2 + 10 // Add buffer - val centerLocation = Location.create(centerX, centerY, plane) - for (player in RegionManager.getLocalPlayers( - centerLocation, radius - )) { - if (border.insideBorder(player.location) && getPlayerTeam(player) != null) { - players.add(player) - } - } - } - val saraRespawn = CastleWarsRespawnArea.saradominRespawnRoom - val zamRespawn = CastleWarsRespawnArea.zamorakRespawnRoom - for (respawnBorder in arrayOf(saraRespawn, zamRespawn)) { - val centerX = (respawnBorder.southWestX + respawnBorder.northEastX) / 2 - val centerY = (respawnBorder.southWestY + respawnBorder.northEastY) / 2 - val plane = respawnBorder.plane - val centerLocation = Location.create(centerX, centerY, plane) - for (player in RegionManager.getLocalPlayers(centerLocation, 20)) { - if (respawnBorder.insideBorder(player.location) && getPlayerTeam(player) != null) { - players.add(player) - } - } - } - return players + }.toSet() } /** @@ -1631,7 +1576,7 @@ object CastleWarsGameState { * @return Set of all players on the specified team */ private fun getTeamPlayers(team: CastleWarsTeam): Set { - return getAllGamePlayers().filter { getPlayerTeam(it) == team }.toSet() + return getAllGamePlayers().filter { getActiveGameTeam(it) == team }.toSet() } /** @@ -1743,8 +1688,8 @@ object CastleWarsGameState { sendMessage(player, "You can't steal the flag from yourself.") return false } - val playerTeam = getPlayerTeam(player) - val carrierTeam = getPlayerTeam(carrier) + val playerTeam = getActiveGameTeam(player) + val carrierTeam = getActiveGameTeam(carrier) if (playerTeam == null || carrierTeam == null) { sendMessage(player, "You can only steal flags in Castle Wars.") return false diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsGameZone.kt b/Server/src/main/content/minigame/castlewars/CastleWarsGameZone.kt index d9aa4e91a..42bda2e67 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsGameZone.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsGameZone.kt @@ -8,50 +8,24 @@ import core.game.node.entity.impl.PulseManager import core.game.node.entity.player.Player import core.game.system.timer.impl.Poison import core.game.world.map.Location -import core.game.world.map.RegionManager.getLocalNPCs import core.game.world.map.path.Pathfinder import core.game.world.map.zone.* /** * MapZone for Castle Wars game area that handles death and respawn. - * This is registered in addition to the MapArea to provide death handling, - * since MapArea doesn't support overriding the death method. + * This is registered in addition to the MapArea to provide death handling. */ class CastleWarsGameZone : MapZone("CastleWarsGame", true, ZoneRestriction.RANDOM_EVENTS), StartupListener { companion object { - private val saradominStandardFloor = ZoneBorders(Location.create(2426, 3073, 3), Location.create(2430, 3077, 3)) - private val zamorakStandardFloor = ZoneBorders(Location.create(2373, 3134, 3), Location.create(2369, 3130, 3)) - private val saradominUpperFloor = ZoneBorders(Location.create(2431, 3080, 2), Location.create(2423, 3072, 2)) - private val zamorakUpperFloor = ZoneBorders(Location.create(2368, 3127, 2), Location.create(2376, 3135, 2)) - private val saradominFloor = ZoneBorders(Location.create(2420, 3072, 1), Location.create(2431, 3083, 1)) - private val zamorakFloor = ZoneBorders(Location.create(2379, 3135, 1), Location.create(2368, 3124, 1)) - private val battleField = ZoneBorders(Location.create(2368, 3135, 0), Location.create(2431, 3072, 0)) - private val saradominTunnels = ZoneBorders(Location.create(2430, 9481, 0), Location.create(2385, 9504, 0)) - private val zamorakTunnels = ZoneBorders(Location.create(2401, 9503, 0), Location.create(2366, 9529, 0)) - private val centerTunnels = ZoneBorders(Location.create(2396, 9493, 0), Location.create(2405, 9515, 0)) - val allBorders = arrayOf( - saradominStandardFloor, - zamorakStandardFloor, - saradominUpperFloor, - zamorakUpperFloor, - saradominFloor, - zamorakFloor, - battleField, - saradominTunnels, - zamorakTunnels, - centerTunnels - ) - /** * Handle Castle Wars death with poison preservation. - * Shared between CastleWarsGameZone.death() and CastleWarsGameArea.handleDeath(). * In Castle Wars, poison persists through death unlike normal safe deaths. */ fun handleCastleWarsDeath(player: Player): Boolean { if (CastleWarsGameArea.ticksLeftInGame <= 0) return false - val team = CastleWarsGameState.getPlayerTeam(player) ?: return false + val team = CastleWarsGameState.getActiveGameTeam(player) ?: return false if (CastleWarsGameState.isCarryingFlag(player)) { CastleWarsGameState.dropFlag(player) @@ -83,7 +57,7 @@ class CastleWarsGameZone : MapZone("CastleWarsGame", true, ZoneRestriction.RANDO override fun startup() { ZoneBuilder.configure(this) - for (border in allBorders) { + for (border in CastleWarsGameArea.areaBorders) { register(border) } } @@ -103,7 +77,7 @@ class CastleWarsGameZone : MapZone("CastleWarsGame", true, ZoneRestriction.RANDO val isMovingUp = to.z > from.z val isChangingZLevel = to.z != from.z - for (n in getLocalNPCs(e.location, 5)) { + for (n in findLocalNPCs(e.location, 5)) { if (n.isInvisible || n === e) { continue } diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsItemHandler.kt b/Server/src/main/content/minigame/castlewars/CastleWarsItemHandler.kt index ab29eed83..111311044 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsItemHandler.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsItemHandler.kt @@ -8,7 +8,6 @@ import content.minigame.castlewars.CastleWarsConstants.BURNING_BARRICADE_MAX_TIC import content.minigame.castlewars.CastleWarsConstants.BURNING_BARRICADE_PULSE_INTERVAL import content.minigame.castlewars.CastleWarsConstants.EXPLOSIVE_POTION_SELF_DAMAGE import content.minigame.castlewars.CastleWarsConstants.MAX_BARRICADES_PER_TEAM -import content.minigame.castlewars.areas.CastleWarsGameArea import core.api.* import core.game.interaction.Clocks import core.game.interaction.IntType @@ -63,11 +62,10 @@ class CastleWarsItemHandler : InteractionListener { } fun dealExplosiveAoeDamage(attacker: Player, location: Location, radius: Int = 1) { - val attackerTeam = CastleWarsGameState.getPlayerTeam(attacker) ?: return + val attackerTeam = CastleWarsGameState.getActiveGameTeam(attacker) ?: return RegionManager.getLocalPlayers(location, radius).filter { target -> - target != attacker && target.isActive && CastleWarsGameState.getPlayerTeam(target) != null && CastleWarsGameState.getPlayerTeam( - target - ) != attackerTeam + val targetTeam = CastleWarsGameState.getActiveGameTeam(target) + target != attacker && target.isActive && targetTeam != null && targetTeam != attackerTeam }.forEach { target -> val damage = CastleWarsConstants.EXPLOSIVE_POTION_SELF_DAMAGE.coerceAtMost(target.skills.lifepoints) target.impactHandler.manualHit( @@ -100,7 +98,7 @@ class CastleWarsItemHandler : InteractionListener { return@on false } - val team = CastleWarsGameState.getPlayerTeam(player) + val team = CastleWarsGameState.getActiveGameTeam(player) if (team == null) { sendMessage(player, "You're not in a Castle Wars game.") return@on false @@ -143,7 +141,7 @@ class CastleWarsItemHandler : InteractionListener { ) { player, used, with -> val barricade = with as? NPC ?: return@onUseWith false - if (!CastleWarsGameState.isGameActive) { + if (!isActiveGamePlayer(player)) { return@onUseWith false } @@ -170,7 +168,7 @@ class CastleWarsItemHandler : InteractionListener { } on(CastleWars.cwExplosivePotion, IntType.ITEM, "drop") { player, node -> - if (!CastleWarsGameState.isGameActive) { + if (!isActiveGamePlayer(player)) { return@on false } @@ -205,7 +203,7 @@ class CastleWarsItemHandler : InteractionListener { ) { player, _, with -> val barricade = with as? NPC ?: return@onUseWith false - if (!CastleWarsGameState.isGameActive) { + if (!isActiveGamePlayer(player)) { return@onUseWith false } @@ -225,7 +223,9 @@ class CastleWarsItemHandler : InteractionListener { val firePulse = object : core.game.system.task.Pulse(BURNING_BARRICADE_PULSE_INTERVAL) { var tickCount = 0 override fun pulse(): Boolean { - if (!barricade.isActive || barricade.id != NPCs.BARRICADE_1533 || tickCount >= BURNING_BARRICADE_MAX_TICKS) { + if (!CastleWarsGameState.isGameActive || !barricade.isActive || + barricade.id != NPCs.BARRICADE_1533 || tickCount >= BURNING_BARRICADE_MAX_TICKS + ) { burningBarricadePulses.remove(barricade) if (barricade.isActive && barricade.id == NPCs.BARRICADE_1533) { barricade.skills.lifepoints = 0 @@ -249,7 +249,7 @@ class CastleWarsItemHandler : InteractionListener { onUseWith(IntType.NPC, Items.BUCKET_OF_WATER_1929, NPCs.BARRICADE_1533) { player, used, with -> val barricade = with as? NPC ?: return@onUseWith false - if (!CastleWarsGameState.isGameActive) { + if (!isActiveGamePlayer(player)) { return@onUseWith false } @@ -276,8 +276,8 @@ class CastleWarsItemHandler : InteractionListener { sendMessage(user, "You can only use bandages during a Castle Wars game.") return false } - val userTeam = getActiveGameTeam(user) - val targetTeam = getActiveGameTeam(target) + val userTeam = CastleWarsGameState.getActiveGameTeam(user) + val targetTeam = CastleWarsGameState.getActiveGameTeam(target) if (userTeam == null || targetTeam == null) { sendMessage(user, "You can only use bandages during a Castle Wars game.") return false @@ -328,12 +328,8 @@ class CastleWarsItemHandler : InteractionListener { return true } - private fun getActiveGameTeam(player: Player): CastleWarsTeam? { - return when { - CastleWarsGameArea.saradominPlayers.contains(player) -> CastleWarsTeam.SARADOMIN - CastleWarsGameArea.zamorakPlayers.contains(player) -> CastleWarsTeam.ZAMORAK - else -> null - } + private fun isActiveGamePlayer(player: Player): Boolean { + return CastleWarsGameState.isGameActive && CastleWarsGameState.getActiveGameTeam(player) != null } private fun getLocalBarricades(location: Location, radius: Int): List { @@ -341,6 +337,6 @@ class CastleWarsItemHandler : InteractionListener { NPCs.BARRICADE_1532, NPCs.BARRICADE_1533, NPCs.BARRICADE_1534, NPCs.BARRICADE_1535 ) - return RegionManager.getLocalNPCs(location, radius).filter { it.id in barricadeIds && it.isActive } + return findLocalNPCs(location, radius).filter { it.id in barricadeIds && it.isActive } } } diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsListeners.kt b/Server/src/main/content/minigame/castlewars/CastleWarsListeners.kt index bdbd97134..c2d5114af 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsListeners.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsListeners.kt @@ -25,7 +25,6 @@ import core.game.world.GameWorld import core.game.world.map.Direction import core.game.world.map.Location import core.game.world.map.RegionManager -import core.game.world.update.flag.chunk.ObjectUpdateFlag import core.game.world.update.flag.context.Animation import core.tools.Log import core.tools.RandomFunction @@ -246,20 +245,20 @@ class CastleWarsListeners : InteractionListener { onUseWith( SCENERY, CastleWars.cwClimbingRope, *CastleWars.cwCastleBattlementsMap.keys.toIntArray() ) { player, rope, wall -> + if (!requireActiveGamePlayer(player)) return@onUseWith false if (CastleWarsGameState.getClimbingRope(wall.location) != null) { sendDialogue(player, "There is already a rope tied to the battlements.") return@onUseWith true } - removeItem(player, rope) val wallScenery = wall.asScenery() val ropeWall = wallScenery.transform(CastleWars.cwCastleBattlementsMap.getValue(wall.id)) - SceneryBuilder.replace(wallScenery, ropeWall, CastleWars.ropeAliveTicks, false) + SceneryBuilder.replace(wallScenery, ropeWall, false, false) val hangingRope = Scenery(CastleWars.cwCastleClimbingRope, wall.location, 4, wall.direction.toInteger()) - CastleWarsGameState.registerClimbingRope(hangingRope, wallScenery) - RegionManager.getRegionChunk(hangingRope.location).flag(ObjectUpdateFlag(hangingRope, false)) + CastleWarsGameState.registerClimbingRope(hangingRope, wallScenery, ropeWall) + removeItem(player, rope) GameWorld.Pulser.submit(object : Pulse(CastleWars.ropeAliveTicks) { override fun pulse(): Boolean { - CastleWarsGameState.removeClimbingRope(hangingRope.location) + CastleWarsGameState.removeClimbingRope(hangingRope) return true } }) @@ -301,7 +300,7 @@ class CastleWarsListeners : InteractionListener { } on(CastleWars.saradominEnergyBarrier, IntType.SCENERY, "Pass") { player, barrier -> - val playerTeam = CastleWarsGameState.getPlayerTeam(player) + val playerTeam = CastleWarsGameState.getActiveGameTeam(player) if (playerTeam != CastleWarsTeam.SARADOMIN) { sendMessage(player, "Only the Saradomin team may pass through this barrier.") return@on true @@ -330,7 +329,7 @@ class CastleWarsListeners : InteractionListener { return@on true } on(CastleWars.zamorakEnergyBarrier, IntType.SCENERY, "Pass") { player, barrier -> - val playerTeam = CastleWarsGameState.getPlayerTeam(player) + val playerTeam = CastleWarsGameState.getActiveGameTeam(player) if (playerTeam != CastleWarsTeam.ZAMORAK) { sendMessage(player, "Only the Zamorak team may pass through this barrier.") return@on true @@ -370,8 +369,8 @@ class CastleWarsListeners : InteractionListener { on(CastleWars.largeDoorIds, IntType.SCENERY, "Open") { player, node -> val door = node.asScenery() - val doorTeam = getDoorTeam(door.location) - val playerTeam = CastleWarsGameState.getPlayerTeam(player) + val doorTeam = getCastleTeam(door.location) + val playerTeam = CastleWarsGameState.getActiveGameTeam(player) if (playerTeam == null) { // Not in Castle Wars game - shouldn't happen in game area @@ -404,8 +403,8 @@ class CastleWarsListeners : InteractionListener { on(CastleWars.largeDoorOpenIds, IntType.SCENERY, "Close") { player, node -> val door = node.asScenery() - val doorTeam = getDoorTeam(door.location) - val playerTeam = CastleWarsGameState.getPlayerTeam(player) + val doorTeam = getCastleTeam(door.location) + val playerTeam = CastleWarsGameState.getActiveGameTeam(player) if (playerTeam == null) { return@on false @@ -429,8 +428,8 @@ class CastleWarsListeners : InteractionListener { on(CastleWars.largeDoorIds, IntType.SCENERY, "Attack") { player, node -> val door = node.asScenery() - val doorTeam = getDoorTeam(door.location) - val playerTeam = CastleWarsGameState.getPlayerTeam(player) + val doorTeam = getCastleTeam(door.location) + val playerTeam = CastleWarsGameState.getActiveGameTeam(player) if (playerTeam == null) { return@on false @@ -454,8 +453,8 @@ class CastleWarsListeners : InteractionListener { SCENERY, CastleWars.cwExplosivePotion, *(CastleWars.largeDoorIds + CastleWars.largeDoorOpenIds) ) { player, used, with -> val door = with.asScenery() - val doorTeam = getDoorTeam(door.location) - val playerTeam = CastleWarsGameState.getPlayerTeam(player) + val doorTeam = getCastleTeam(door.location) + val playerTeam = CastleWarsGameState.getActiveGameTeam(player) if (playerTeam == null) { sendMessage(player, "You can only use this in Castle Wars.") @@ -488,10 +487,10 @@ class CastleWarsListeners : InteractionListener { onUseWith( SCENERY, CastleWars.cwToolkit, *(CastleWars.largeDoorIds + CastleWars.largeDoorOpenIds) - ) { player, used, with -> + ) { player, _, with -> val door = with.asScenery() - val doorTeam = getDoorTeam(door.location) - val playerTeam = CastleWarsGameState.getPlayerTeam(player) + val doorTeam = getCastleTeam(door.location) + val playerTeam = CastleWarsGameState.getActiveGameTeam(player) if (playerTeam == null) { sendMessage(player, "You can only use this in Castle Wars.") @@ -517,7 +516,7 @@ class CastleWarsListeners : InteractionListener { val door = node.asScenery() val doorTeam = if (door.id == CastleWars.saradominBackdoor) CastleWarsTeam.SARADOMIN else CastleWarsTeam.ZAMORAK - val playerTeam = CastleWarsGameState.getPlayerTeam(player) ?: return@on false + val playerTeam = CastleWarsGameState.getActiveGameTeam(player) ?: return@on false if (playerTeam == doorTeam) { openBackdoor(player, door) } else { @@ -535,7 +534,7 @@ class CastleWarsListeners : InteractionListener { val door = node.asScenery() val doorTeam = if (door.id == CastleWars.saradominBackdoor) CastleWarsTeam.SARADOMIN else CastleWarsTeam.ZAMORAK - val playerTeam = CastleWarsGameState.getPlayerTeam(player) ?: return@on false + val playerTeam = CastleWarsGameState.getActiveGameTeam(player) ?: return@on false if (playerTeam == doorTeam) { sendMessage(player, "You don't need to pick the lock on your own door.") return@on true @@ -568,7 +567,7 @@ class CastleWarsListeners : InteractionListener { val door = node.asScenery() val doorTeam = if (door.id == CastleWars.saradominBackdoorOpen) CastleWarsTeam.SARADOMIN else CastleWarsTeam.ZAMORAK - val playerTeam = CastleWarsGameState.getPlayerTeam(player) ?: return@on false + val playerTeam = CastleWarsGameState.getActiveGameTeam(player) ?: return@on false if (playerTeam != doorTeam) { sendMessage(player, "You can't close the enemy's door!") return@on true @@ -579,6 +578,7 @@ class CastleWarsListeners : InteractionListener { } on(CastleWars.tunnelRocks, IntType.SCENERY, "Mine") { player, node -> + if (!requireActiveGamePlayer(player)) return@on true val rocks = node.asScenery() val boulderKey = CastleWarsGameState.getBoulderKey(rocks.location) if (boulderKey == null) { @@ -590,6 +590,7 @@ class CastleWarsListeners : InteractionListener { } on(CastleWars.tunnelRocksWeakened, IntType.SCENERY, "Mine") { player, node -> + if (!requireActiveGamePlayer(player)) return@on true val rocks = node.asScenery() val boulderKey = CastleWarsGameState.getBoulderKey(rocks.location) if (boulderKey == null) { @@ -601,6 +602,7 @@ class CastleWarsListeners : InteractionListener { } onUseWith(SCENERY, CastleWars.cwExplosivePotion, CastleWars.tunnelRocks) { player, used, with -> + if (!requireActiveGamePlayer(player)) return@onUseWith false val rocks = with.asScenery() val boulderKey = CastleWarsGameState.getBoulderKey(rocks.location) if (boulderKey == null) { @@ -616,6 +618,7 @@ class CastleWarsListeners : InteractionListener { } onUseWith(SCENERY, CastleWars.cwExplosivePotion, CastleWars.tunnelRocksWeakened) { player, used, with -> + if (!requireActiveGamePlayer(player)) return@onUseWith false val rocks = with.asScenery() val boulderKey = CastleWarsGameState.getBoulderKey(rocks.location) if (boulderKey == null) { @@ -631,6 +634,7 @@ class CastleWarsListeners : InteractionListener { } onUseWith(SCENERY, CastleWars.cwExplosivePotion, CastleWars.caveWall) { player, used, with -> + if (!requireActiveGamePlayer(player)) return@onUseWith false val wall = with.asScenery() val boulderKey = CastleWarsGameState.getNearestBoulderKey(wall.location) if (boulderKey == null) { @@ -649,7 +653,8 @@ class CastleWarsListeners : InteractionListener { return@onUseWith true } - onUseWith(SCENERY, pickaxeIds, CastleWars.caveWall) { player, used, with -> + onUseWith(SCENERY, pickaxeIds, CastleWars.caveWall) { player, _, with -> + if (!requireActiveGamePlayer(player)) return@onUseWith false val wall = with.asScenery() val boulderKey = CastleWarsGameState.getNearestBoulderKey(wall.location) if (boulderKey == null) { @@ -665,6 +670,7 @@ class CastleWarsListeners : InteractionListener { } on(CastleWars.caveWall, IntType.SCENERY, "Collapse") { player, node -> + if (!requireActiveGamePlayer(player)) return@on true val wall = node.asScenery() val boulderKey = CastleWarsGameState.getNearestBoulderKey(wall.location) if (boulderKey == null) { @@ -681,8 +687,8 @@ class CastleWarsListeners : InteractionListener { on(CastleWars.allCatapultIds, IntType.SCENERY, "Operate") { player, node -> val catapult = node.asScenery() - val catapultTeam = getCatapultTeam(catapult.location) - val playerTeam = CastleWarsGameState.getPlayerTeam(player) + val catapultTeam = getCastleTeam(catapult.location) + val playerTeam = CastleWarsGameState.getActiveGameTeam(player) if (playerTeam == null) { sendMessage(player, "You need to be in a Castle Wars game to use this!") return@on true @@ -713,15 +719,15 @@ class CastleWarsListeners : InteractionListener { } setVarbit(player, CastleWarsGameState.VARBIT_CATAPULT_X, CatapultConfig.DEFAULT_X) setVarbit(player, CastleWarsGameState.VARBIT_CATAPULT_Y, CatapultConfig.DEFAULT_Y) - player.setAttribute("catapult:location", catapult.location) + player.setAttribute(CatapultConfig.ATTR_LOCATION, catapult.location) player.interfaceManager.open(Component(Components.CASTLEWARS_CATAPULT_54)) return@on true } onUseWith(SCENERY, org.rs09.consts.Items.TINDERBOX_590, *CastleWars.allCatapultIds) { player, _, with -> val catapult = with.asScenery() - val catapultTeam = getCatapultTeam(catapult.location) - val playerTeam = CastleWarsGameState.getPlayerTeam(player) ?: return@onUseWith false + val catapultTeam = getCastleTeam(catapult.location) + val playerTeam = CastleWarsGameState.getActiveGameTeam(player) ?: return@onUseWith false if (playerTeam == catapultTeam) { sendMessage(player, "You can't destroy your own catapult!") return@onUseWith false @@ -753,8 +759,8 @@ class CastleWarsListeners : InteractionListener { onUseWith(SCENERY, CastleWars.cwExplosivePotion, *CastleWars.allCatapultIds) { player, used, with -> val catapult = with.asScenery() - val catapultTeam = getCatapultTeam(catapult.location) - val playerTeam = CastleWarsGameState.getPlayerTeam(player) ?: return@onUseWith false + val catapultTeam = getCastleTeam(catapult.location) + val playerTeam = CastleWarsGameState.getActiveGameTeam(player) ?: return@onUseWith false if (playerTeam == catapultTeam) { sendMessage(player, "You can't destroy your own catapult!") return@onUseWith false @@ -772,8 +778,8 @@ class CastleWarsListeners : InteractionListener { onUseWith(SCENERY, Items.BUCKET_OF_WATER_1929, *CastleWars.allCatapultIds) { player, used, with -> val catapult = with.asScenery() - val catapultTeam = getCatapultTeam(catapult.location) - val playerTeam = CastleWarsGameState.getPlayerTeam(player) ?: return@onUseWith false + val catapultTeam = getCastleTeam(catapult.location) + val playerTeam = CastleWarsGameState.getActiveGameTeam(player) ?: return@onUseWith false if (playerTeam != catapultTeam) { sendMessage(player, "You can't help the enemy's catapult!") return@onUseWith false @@ -789,10 +795,10 @@ class CastleWarsListeners : InteractionListener { return@onUseWith true } - onUseWith(SCENERY, CastleWars.cwToolkit, *CastleWars.allCatapultIds) { player, used, with -> + onUseWith(SCENERY, CastleWars.cwToolkit, *CastleWars.allCatapultIds) { player, _, with -> val catapult = with.asScenery() - val catapultTeam = getCatapultTeam(catapult.location) - val playerTeam = CastleWarsGameState.getPlayerTeam(player) ?: return@onUseWith false + val catapultTeam = getCastleTeam(catapult.location) + val playerTeam = CastleWarsGameState.getActiveGameTeam(player) ?: return@onUseWith false if (playerTeam != catapultTeam) { sendMessage(player, "You can't repair the enemy's catapult!") return@onUseWith false @@ -806,8 +812,8 @@ class CastleWarsListeners : InteractionListener { return@onUseWith true } - on(CastleWars.saradominTrapdoor, IntType.SCENERY, "Go-down") { player, node -> - val playerTeam = CastleWarsGameState.getPlayerTeam(player) + on(CastleWars.saradominTrapdoor, IntType.SCENERY, "Go-down") { player, _ -> + val playerTeam = CastleWarsGameState.getActiveGameTeam(player) if (playerTeam != CastleWarsTeam.SARADOMIN) { sendMessage(player, "Only the Saradomin team may use this trapdoor.") return@on true @@ -819,8 +825,8 @@ class CastleWarsListeners : InteractionListener { teleport(player, saradominTrapdoorDestination, TeleportManager.TeleportType.INSTANT) return@on true } - on(CastleWars.zamorakTrapdoor, IntType.SCENERY, "Go-down") { player, node -> - val playerTeam = CastleWarsGameState.getPlayerTeam(player) + on(CastleWars.zamorakTrapdoor, IntType.SCENERY, "Go-down") { player, _ -> + val playerTeam = CastleWarsGameState.getActiveGameTeam(player) if (playerTeam != CastleWarsTeam.ZAMORAK) { sendMessage(player, "Only the Zamorak team may use this trapdoor.") return@on true @@ -840,7 +846,7 @@ class CastleWarsListeners : InteractionListener { player.properties.teleportLocation = destination return@on true } - val playerTeam = CastleWarsGameState.getPlayerTeam(player) + val playerTeam = CastleWarsGameState.getActiveGameTeam(player) if (playerTeam == null) { player.properties.teleportLocation = destination return@on true @@ -917,12 +923,16 @@ class CastleWarsListeners : InteractionListener { AgilityHandler.forceWalk(player, -1, startTile, endTile, Animation.create(819), 12, 0.0, null) } - private fun getDoorTeam(location: Location): CastleWarsTeam { + private fun getCastleTeam(location: Location): CastleWarsTeam { return if (location.y < 3100) CastleWarsTeam.SARADOMIN else CastleWarsTeam.ZAMORAK } - private fun getCatapultTeam(location: Location): CastleWarsTeam { - return if (location.y < 3100) CastleWarsTeam.SARADOMIN else CastleWarsTeam.ZAMORAK + private fun requireActiveGamePlayer(player: Player): Boolean { + if (CastleWarsGameState.isGameActive && CastleWarsGameState.getActiveGameTeam(player) != null) { + return true + } + sendMessage(player, "You can only do that during a Castle Wars game.") + return false } private fun getOpenDoorVariant(closedId: Int): Int? = when (closedId) { @@ -933,14 +943,6 @@ class CastleWarsListeners : InteractionListener { else -> null } - private fun getClosedDoorVariant(openId: Int): Int? = when (openId) { - 4425 -> 4423 - 4426 -> 4424 - 4429 -> 4427 - 4430 -> 4428 - else -> null - } - private fun isPlayerOutsideFrontDoor(player: Player, door: Scenery, doorTeam: CastleWarsTeam): Boolean { val playerY = player.location.y val doorY = door.location.y @@ -994,7 +996,7 @@ class CastleWarsListeners : InteractionListener { } private fun leaveGameThroughPortal(player: Player, portalTeam: CastleWarsTeam) { - if (CastleWarsGameState.getPlayerTeam(player) != portalTeam) { + if (CastleWarsGameState.getActiveGameTeam(player) != portalTeam) { sendMessage(player, "Only the ${portalTeam.teamName} team may use this portal.") return } diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsShutdownHandler.kt b/Server/src/main/content/minigame/castlewars/CastleWarsShutdownHandler.kt index 86dfcf41b..9870ef83d 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsShutdownHandler.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsShutdownHandler.kt @@ -4,6 +4,7 @@ import content.minigame.castlewars.areas.CastleWarsGameArea import content.minigame.castlewars.areas.CastleWarsRespawnArea import content.minigame.castlewars.areas.CastleWarsWaitingArea import core.api.ShutdownListener +import core.api.removeAttributes import core.game.node.entity.player.Player import core.game.world.map.Location import core.game.world.map.RegionManager @@ -21,6 +22,9 @@ class CastleWarsShutdownHandler : ShutdownListener { allTrackedPlayers += CastleWarsGameArea.zamorakPlayers allTrackedPlayers += CastleWarsWaitingArea.waitingSaradominPlayers allTrackedPlayers += CastleWarsWaitingArea.waitingZamorakPlayers + allTrackedPlayers += CastleWarsRespawnArea.saradominPlayersInRespawnRoom.keys + allTrackedPlayers += CastleWarsRespawnArea.zamorakPlayersInRespawnRoom.keys + allTrackedPlayers += CastleWarsGameArea.loggedOutPlayers.values.map { it.player } val respawnBorders = arrayOf( CastleWarsRespawnArea.saradominRespawnRoom, CastleWarsRespawnArea.zamorakRespawnRoom @@ -54,24 +58,38 @@ class CastleWarsShutdownHandler : ShutdownListener { for (player in allTrackedPlayers) { if (CastleWarsGameState.isCarryingFlag(player)) { - CastleWarsGameState.dropFlag(player) + CastleWarsGameState.dropFlag(player, broadcast = false) } else if (CastleWarsGameState.isCarryingOwnFlag(player)) { - CastleWarsGameState.dropOwnFlag(player) + CastleWarsGameState.dropOwnFlag(player, broadcast = false) } CastleWarsGameState.restoreAndCleanupPlayer(player) - player.location = CastleWars.lobbyBankArea.randomWalkableLoc + val lobbyLocation = CastleWars.lobbyBankArea.randomWalkableLoc + player.location = lobbyLocation + player.properties.teleportLocation = lobbyLocation + removeAttributes( + player, + CastleWarsConstants.ATTR_LOGOUT_TICK, + CastleWarsConstants.ATTR_LOGOUT_TIME_MILLIS, + CastleWarsConstants.ATTR_LOGOUT_TEAM, + CastleWarsConstants.ATTR_LOGOUT_LOCATION, + CastleWarsConstants.ATTR_JOIN_TICK + ) } CastleWarsItemHandler.clearAllBarricades() - + CastleWarsGameState.clearCastleWarsGroundItems() CastleWarsGameState.resetGame() + CastleWarsGameArea.clearSubstitutionState() CastleWarsGameArea.saradominPlayers.clear() CastleWarsGameArea.zamorakPlayers.clear() CastleWarsGameArea.loggedOutPlayers.clear() CastleWarsWaitingArea.waitingSaradominPlayers.clear() CastleWarsWaitingArea.waitingZamorakPlayers.clear() + CastleWarsRespawnArea.saradominPlayersInRespawnRoom.clear() + CastleWarsRespawnArea.zamorakPlayersInRespawnRoom.clear() CastleWarsGameArea.ticksLeftInGame = -1 + CastleWarsWaitingArea.gameStartCountdown = -1 } } diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsSubstitutionDialogue.kt b/Server/src/main/content/minigame/castlewars/CastleWarsSubstitutionDialogue.kt index cddbf6ea1..00ddb2724 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsSubstitutionDialogue.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsSubstitutionDialogue.kt @@ -10,6 +10,7 @@ import core.tools.START_DIALOGUE class CastleWarsSubstitutionDialogue(private val team: CastleWarsTeam) : DialogueFile() { override fun handle(componentID: Int, buttonID: Int) { + val currentPlayer = player ?: return when (stage) { START_DIALOGUE -> showTopics( Topic("Yes.", ACCEPT, true), @@ -18,14 +19,14 @@ class CastleWarsSubstitutionDialogue(private val team: CastleWarsTeam) : Dialogu ) ACCEPT -> { - if (!CastleWarsGameArea.claimVacancy(player!!, team)) { - sendMessage(player!!, "That Castle Wars vacancy is no longer available.") + if (!CastleWarsGameArea.claimVacancy(currentPlayer, team)) { + sendMessage(currentPlayer, "That Castle Wars vacancy is no longer available.") } stage = END_DIALOGUE } DECLINE -> { - CastleWarsGameArea.declineVacancyOffer(player!!, team) + CastleWarsGameArea.declineVacancyOffer(currentPlayer, team) stage = END_DIALOGUE } } diff --git a/Server/src/main/content/minigame/castlewars/CastleWarsTunnelMiningPulse.kt b/Server/src/main/content/minigame/castlewars/CastleWarsTunnelMiningPulse.kt index e42d6838c..4cf352da2 100644 --- a/Server/src/main/content/minigame/castlewars/CastleWarsTunnelMiningPulse.kt +++ b/Server/src/main/content/minigame/castlewars/CastleWarsTunnelMiningPulse.kt @@ -29,8 +29,6 @@ class CastleWarsTunnelMiningPulse( private const val REQUIRED_LEVEL = 1 private const val RATE = 0.40 - private const val MINING_ANIMATION = 625 - private const val EXPERIENCE = 0.0 } @@ -65,15 +63,18 @@ class CastleWarsTunnelMiningPulse( } private fun checkRequirements(): Boolean { + if (!canContinueCastleWarsMining(player, rocks)) { + return false + } + val expectedState = if (isWeakened) BoulderState.WEAKENED else BoulderState.COLLAPSED + if (CastleWarsGameState.getBoulderState(boulderKey) != expectedState) { + return false + } val pickaxe = SkillingTool.getPickaxe(player) if (pickaxe == null) { sendMessage(player, "You need a pickaxe to mine these rocks.") return false } - if (!rocks.isActive) { - return false - } - return true } @@ -159,6 +160,12 @@ class CastleWarsCaveWallMiningPulse( } private fun checkRequirements(): Boolean { + if (!canContinueCastleWarsMining(player, caveWall)) { + return false + } + if (!CastleWarsGameState.isBoulderCleared(boulderKey)) { + return false + } val pickaxe = SkillingTool.getPickaxe(player) if (pickaxe == null) { sendMessage(player, "You need a pickaxe to mine the cave wall.") @@ -198,3 +205,9 @@ class CastleWarsCaveWallMiningPulse( sendMessage(player, "You collapse the tunnel!") } } + +private fun canContinueCastleWarsMining(player: Player, scenery: Scenery): Boolean { + return player.isActive && CastleWarsGameState.isGameActive && + CastleWarsGameState.getActiveGameTeam(player) != null && scenery.isActive && + player.location.z == scenery.location.z && player.location.getDistance(scenery.location) <= 2.0 +} diff --git a/Server/src/main/content/minigame/castlewars/CatapultFiringHandler.kt b/Server/src/main/content/minigame/castlewars/CatapultFiringHandler.kt index 9eb8d1da6..9e9dc35fa 100644 --- a/Server/src/main/content/minigame/castlewars/CatapultFiringHandler.kt +++ b/Server/src/main/content/minigame/castlewars/CatapultFiringHandler.kt @@ -69,7 +69,7 @@ class CatapultFiringHandler { val speed = PROJECTILE_BASE_SPEED + (distance * PROJECTILE_SPEED_PER_TILE) val impactDelayTicks = (speed / 30).coerceAtLeast(1) - sendProjectile(catapultLocation, finalLocation) + sendProjectile(catapultLocation, finalLocation, distance, speed) playFireEffects(player, finalLocation, impactDelayTicks) scheduleImpact(player, finalLocation, impactDelayTicks) @@ -88,10 +88,7 @@ class CatapultFiringHandler { return Location.create(finalX, finalY, 0) } - private fun sendProjectile(from: Location, to: Location) { - val distance = from.getDistance(to).toInt() - val speed = PROJECTILE_BASE_SPEED + (distance * PROJECTILE_SPEED_PER_TILE) - + private fun sendProjectile(from: Location, to: Location, distance: Int, speed: Int) { Projectile.create( from, to, @@ -139,6 +136,7 @@ class CatapultFiringHandler { } private fun isValidTarget(target: Player): Boolean { - return CastleWarsGameState.getPlayerTeam(target) != null + return CastleWarsGameState.isGameActive && target.isActive && + CastleWarsGameState.getActiveGameTeam(target) != null } } diff --git a/Server/src/main/content/minigame/castlewars/CatapultInterfaceRenderer.kt b/Server/src/main/content/minigame/castlewars/CatapultInterfaceRenderer.kt index f7f80748d..27746e638 100644 --- a/Server/src/main/content/minigame/castlewars/CatapultInterfaceRenderer.kt +++ b/Server/src/main/content/minigame/castlewars/CatapultInterfaceRenderer.kt @@ -1,7 +1,5 @@ package content.minigame.castlewars -import content.minigame.castlewars.CatapultConfig.DEFAULT_X -import content.minigame.castlewars.CatapultConfig.DEFAULT_Y import content.minigame.castlewars.CatapultConfig.DIGIT_CHILD_IDS import content.minigame.castlewars.CatapultConfig.DIGIT_ITEM_IDS import content.minigame.castlewars.CatapultConfig.INTERFACE_ID @@ -17,6 +15,7 @@ import core.game.node.entity.player.Player import core.net.packet.PacketRepository import core.net.packet.context.ChildPositionContext import core.net.packet.out.RepositionChild +import kotlin.math.roundToInt /** * Handles rendering the catapult interface including marker position and digit displays. @@ -61,24 +60,13 @@ class CatapultInterfaceRenderer { sendDigitModel(player, DIGIT_CHILD_IDS[3], yOnes) } - /** - * Resets the interface to default coordinates. - * @param player The player whose interface to reset - */ - fun resetToDefaults(player: Player) { - val defaultCoord = Coordinate(DEFAULT_X, DEFAULT_Y) - renderCoordinate(player, defaultCoord) + internal fun calculateScreenX(coordX: Int): Int { + return MAP_BASE_X + (coordX.toDouble() / MAX_COORDINATE * MAP_WIDTH).roundToInt() } - private fun calculateScreenX(coordX: Int): Int { - val pixelsPerUnit = MAP_WIDTH / MAX_COORDINATE - return MAP_BASE_X + (coordX * pixelsPerUnit) - } - - private fun calculateScreenY(coordY: Int): Int { - val pixelsPerUnit = MAP_HEIGHT / MAX_COORDINATE + internal fun calculateScreenY(coordY: Int): Int { // Y is inverted - 0 is at the bottom, MAX is at the top - return MAP_BASE_Y + ((MAX_COORDINATE - coordY) * pixelsPerUnit) + return MAP_BASE_Y + ((MAX_COORDINATE - coordY).toDouble() / MAX_COORDINATE * MAP_HEIGHT).roundToInt() } private fun sendDigitModel(player: Player, childId: Int, digit: Int) { diff --git a/Server/src/main/content/minigame/castlewars/Lanthus.kt b/Server/src/main/content/minigame/castlewars/Lanthus.kt index 4be45c92a..ccccb0cdb 100644 --- a/Server/src/main/content/minigame/castlewars/Lanthus.kt +++ b/Server/src/main/content/minigame/castlewars/Lanthus.kt @@ -81,10 +81,13 @@ class Lanthus : InteractionListener, TickListener { "your patron god, do you? Other than that, just have fun and enjoy it!" ).also { stage++ } - 50 -> npcl( - FacialExpression.FRIENDLY, "Sure, here you go." - ).also { stage = END_DIALOGUE } - .also { player.inventory.add(Item(Items.CASTLEWARS_MANUAL_4055)) } + 50 -> { + if (addItem(player, Items.CASTLEWARS_MANUAL_4055)) { + npcl(FacialExpression.FRIENDLY, "Sure, here you go.") + } else { + npcl(FacialExpression.FRIENDLY, "You don't have enough inventory space.") + }.also { stage = END_DIALOGUE } + } 83 -> player("Great! Oh, how do I win the game?").also { stage++ } 84 -> npcl( diff --git a/Server/src/main/content/minigame/castlewars/areas/CastleWarsArea.kt b/Server/src/main/content/minigame/castlewars/areas/CastleWarsArea.kt index a23734ff5..037b89739 100644 --- a/Server/src/main/content/minigame/castlewars/areas/CastleWarsArea.kt +++ b/Server/src/main/content/minigame/castlewars/areas/CastleWarsArea.kt @@ -10,9 +10,14 @@ import core.api.sendMessage import core.game.interaction.InteractionListener import core.game.node.entity.Entity import core.game.node.entity.player.Player +import core.game.node.item.Item abstract class CastleWarsArea : MapArea, LogoutListener, InteractionListener { + override fun canEquipItem(player: Player, item: Item, equipmentSlot: Int): Boolean? { + return !CastleWarsEquipmentRestrictions.blocksEquip(player, equipmentSlot) + } + override fun areaEnter(entity: Entity) { val player = entity as? Player ?: return CastleWarsEquipmentRestrictions.removeInvalidEquipment(player) @@ -64,18 +69,11 @@ abstract class CastleWarsArea : MapArea, LogoutListener, InteractionListener { override fun defineListeners() { onUnequip(intArrayOf(CastleWars.saradominTeamHoodedCloak, CastleWars.zamorakTeamHoodedCloak)) { player, _ -> - val allCastleWarsBorders = - CastleWarsGameArea.areaBorders.toList() + CastleWarsWaitingArea.areaBorders.toList() + listOf( - CastleWarsRespawnArea.saradominRespawnRoom, - CastleWarsRespawnArea.zamorakRespawnRoom - ) - for (border in allCastleWarsBorders) { - if (border.insideBorder(player)) { - sendMessage(player, "You can't remove your team's colours") - return@onUnequip false - } + if (CastleWarsEquipmentRestrictions.isInCastleWarsArea(player)) { + sendMessage(player, "You can't remove your team's colours") + return@onUnequip false } - return@onUnequip true + true } } diff --git a/Server/src/main/content/minigame/castlewars/areas/CastleWarsGameArea.kt b/Server/src/main/content/minigame/castlewars/areas/CastleWarsGameArea.kt index d9a1e660f..af7e8cddf 100644 --- a/Server/src/main/content/minigame/castlewars/areas/CastleWarsGameArea.kt +++ b/Server/src/main/content/minigame/castlewars/areas/CastleWarsGameArea.kt @@ -4,6 +4,7 @@ import content.minigame.castlewars.* import content.minigame.castlewars.CastleWarsConstants.PLAYER_CLEANUP_INTERVAL_TICKS import core.api.* import core.game.component.Component +import core.game.container.impl.EquipmentContainer import core.game.event.EventHook import core.game.event.InterfaceCloseEvent import core.game.interaction.Option @@ -18,6 +19,7 @@ import core.game.world.map.zone.ZoneBorders import core.game.world.map.zone.ZoneType import core.tools.Log import org.rs09.consts.Components +import java.util.Locale /** * Handles the Castle Wars game map @@ -111,14 +113,6 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener, LoginListener { } } - /** - * Handle player death in Castle Wars - delegates to CastleWarsGameZone. - * This ensures consistent death behavior including poison preservation. - */ - fun handleDeath(player: Player, killer: Entity?): Boolean { - return CastleWarsGameZone.handleCastleWarsDeath(player) - } - fun clearSubstitutionState() { activeVacancyOffers.clear() activeVacancyOfferTicks.clear() @@ -263,9 +257,6 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener, LoginListener { clearLogoutState(player) saradominPlayers.remove(player) zamorakPlayers.remove(player) - CastleWarsGameState.clearHintIconForPlayer(player) - player.interaction.remove(Option._P_ATTACK) - player.interaction.remove(Option._P_STEAL_FROM) if (offerReplacement) { openVacancy(leavingTeam) fillVacancy(leavingTeam) @@ -278,7 +269,7 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener, LoginListener { } fun playerKey(player: Player): String { - return player.username.lowercase() + return player.username.lowercase(Locale.ROOT) } private fun setTeam(player: Player, team: CastleWarsTeam) { @@ -334,8 +325,8 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener, LoginListener { CastleWarsGameState.clearHintIconForPlayer(player) CastleWarsGameState.restoreAndCleanupPlayer(player) player.interaction.remove(Option._P_ATTACK) - player.location = waitingRoomFor(team).randomWalkableLoc - player.equipment.replace(Item(team.cloakId), 1) + relocateImmediately(player, waitingRoomFor(team).randomWalkableLoc) + player.equipment.replace(Item(team.cloakId), EquipmentContainer.SLOT_CAPE) waitingSetFor(team).add(player) clearLogoutAttributes(player) } @@ -347,11 +338,17 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener, LoginListener { player.interaction.remove(Option._P_ATTACK) player.interaction.remove(Option._P_STEAL_FROM) val lobbyLocation = CastleWars.lobbyBankArea.randomWalkableLoc - player.location = lobbyLocation - player.properties.teleportLocation = lobbyLocation + relocateImmediately(player, lobbyLocation) clearLogoutState(player) } + @Suppress("DEPRECATION") + private fun relocateImmediately(player: Player, destination: Location) { + // Logout restoration may run before the player can process a normal teleport. + player.location = destination + player.properties.teleportLocation = destination + } + fun restoreActiveLogoutOnLogin(player: Player) { val savedTeamName = getAttribute(player, CastleWarsConstants.ATTR_LOGOUT_TEAM, "") val savedLogoutTime = getAttribute(player, CastleWarsConstants.ATTR_LOGOUT_TIME_MILLIS, 0L) @@ -372,7 +369,7 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener, LoginListener { return } if (ticksLeftInGame > 0 && elapsedMillis < CastleWarsConstants.LOGOUT_GRACE_PERIOD_MILLIS) { - player.location = Location.create(logoutLocation) + relocateImmediately(player, Location.create(logoutLocation)) setTeam(player, team) clearLogoutAttributes(player) return @@ -384,7 +381,7 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener, LoginListener { } } - object SaradominInterfaceCloseHook : EventHook { // TODO: this might be useless. + object SaradominInterfaceCloseHook : EventHook { override fun process(entity: Entity, event: InterfaceCloseEvent) { val player = entity as? Player ?: return // Only restore if player is still in the game @@ -398,7 +395,7 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener, LoginListener { } } - object ZamorakInterfaceCloseHook : EventHook { // TODO: this might be useless. + object ZamorakInterfaceCloseHook : EventHook { override fun process(entity: Entity, event: InterfaceCloseEvent) { val player = entity as? Player ?: return if (!zamorakPlayers.contains(player)) return @@ -453,29 +450,36 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener, LoginListener { override fun areaEnter(entity: Entity) { val player = entity as? Player ?: return + restoreActiveLogoutOnLogin(player) + if (areaBorders.none { it.insideBorder(player.location) }) { + return + } + val team = CastleWarsGameState.getActiveGameTeam(player) + if (team == null || ticksLeftInGame <= 0) { + log(this::class.java, Log.WARN, "${player.name} entered Castle Wars without an active match membership") + player.properties.teleportLocation = CastleWars.lobbyBankArea.randomWalkableLoc + return + } + super.areaEnter(player) zone.zoneType = ZoneType.CASTLE_WARS.id registerTimer( player, spawnTimer("teleblock", CastleWars.gameTimeMinutes * CastleWarsConstants.TICKS_PER_MINUTE, false) ) - val team = CastleWarsGameState.getPlayerTeam(player) when (team) { CastleWarsTeam.SARADOMIN -> { player.interfaceManager.openOverlay(Component(Components.CASTLEWARS_STATUS_OVERLAY_SARADOMIN_58)) - player.equipment.replace(Item(CastleWars.saradominTeamHoodedCloak), 1) + player.equipment.replace(Item(CastleWars.saradominTeamHoodedCloak), EquipmentContainer.SLOT_CAPE) player.hook(Event.InterfaceClosed, SaradominInterfaceCloseHook) } CastleWarsTeam.ZAMORAK -> { player.interfaceManager.openOverlay(Component(Components.CASTLEWARS_STATUS_OVERLAY_ZAMORAK_59)) - player.equipment.replace(Item(CastleWars.zamorakTeamHoodedCloak), 1) + player.equipment.replace(Item(CastleWars.zamorakTeamHoodedCloak), EquipmentContainer.SLOT_CAPE) player.hook(Event.InterfaceClosed, ZamorakInterfaceCloseHook) } - null -> { - log(this::class.java, Log.WARN, "${player.name} entered Castle Wars but is NOT on a team?!") - } } CastleWarsGameState.setActiveGamePlayerOptions(player) } @@ -546,22 +550,8 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener, LoginListener { } CastleWarsGameState.checkFlagCarriers() CastleWarsGameState.checkDroppedFlagTimeouts() - for (player in saradominPlayers) { - val currentOverlayId = player.interfaceManager.overlay?.id - if (currentOverlayId == null || currentOverlayId != 58) { - player.interfaceManager.openOverlay(Component(Components.CASTLEWARS_STATUS_OVERLAY_SARADOMIN_58)) - } - // TODO: this... should not be done lmfao CRITICAL TODO: PROPER CW MATCH UI UPDATING THATS NOT SHIT - CastleWarsGameState.updateOverlay(player) - } - for (player in zamorakPlayers) { - val currentOverlayId = player.interfaceManager.overlay?.id - if (currentOverlayId == null || currentOverlayId != 59) { - player.interfaceManager.openOverlay(Component(Components.CASTLEWARS_STATUS_OVERLAY_ZAMORAK_59)) - } - // TODO: this... should not be done lmfao CRITICAL TODO: PROPER CW MATCH UI UPDATING THATS NOT SHIT - CastleWarsGameState.updateOverlay(player) - } + saradominPlayers.forEach { ensureGameOverlay(it, CastleWarsTeam.SARADOMIN) } + zamorakPlayers.forEach { ensureGameOverlay(it, CastleWarsTeam.ZAMORAK) } if (ticksLeftInGame % PLAYER_CLEANUP_INTERVAL_TICKS == 0) { cleanupInactivePlayers() } @@ -609,12 +599,15 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener, LoginListener { CastleWarsGameState.cleanupInactiveHintIcons() } - /** - * Handle death in Castle Wars - respawn at team base instead of normal death - */ - fun death(entity: Entity, killer: Entity?): Boolean { - if (entity !is Player) return false - return handleDeath(entity, killer) + private fun ensureGameOverlay(player: Player, team: CastleWarsTeam) { + val overlayId = when (team) { + CastleWarsTeam.SARADOMIN -> Components.CASTLEWARS_STATUS_OVERLAY_SARADOMIN_58 + CastleWarsTeam.ZAMORAK -> Components.CASTLEWARS_STATUS_OVERLAY_ZAMORAK_59 + } + if (player.interfaceManager.overlay?.id != overlayId) { + player.interfaceManager.openOverlay(Component(overlayId)) + CastleWarsGameState.updateOverlay(player) + } } override fun isPvpAllowed(entity: Entity, target: Node?, style: CombatStyle?, message: Boolean): Boolean? { @@ -623,8 +616,8 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener, LoginListener { if (ticksLeftInGame <= 0) { return false } - val attackerTeam = CastleWarsGameState.getPlayerTeam(attacker) ?: return false - val victimTeam = CastleWarsGameState.getPlayerTeam(victim) ?: return false + val attackerTeam = CastleWarsGameState.getActiveGameTeam(attacker) ?: return false + val victimTeam = CastleWarsGameState.getActiveGameTeam(victim) ?: return false if (attackerTeam == victimTeam) { if (message) { sendMessage(attacker, "You can only attack players on the opposite team!") @@ -634,4 +627,15 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener, LoginListener { return true } + override fun modifyOutgoingHitInArea(attacker: Entity, target: Entity, hit: Int): Int? { + val attackingPlayer = attacker as? Player ?: return null + val victim = target as? Player ?: return null + if (!CastleWarsGameState.isGameActive || CastleWarsGameState.getActiveGameTeam(attackingPlayer) == null || + CastleWarsGameState.getActiveGameTeam(victim) == null + ) { + return null + } + return CastleWarsGameState.applyBraceletDamageBoost(attackingPlayer, victim, hit) + } + } diff --git a/Server/src/main/content/minigame/castlewars/areas/CastleWarsRespawnArea.kt b/Server/src/main/content/minigame/castlewars/areas/CastleWarsRespawnArea.kt index 2ef507f55..84001273a 100644 --- a/Server/src/main/content/minigame/castlewars/areas/CastleWarsRespawnArea.kt +++ b/Server/src/main/content/minigame/castlewars/areas/CastleWarsRespawnArea.kt @@ -3,11 +3,13 @@ package content.minigame.castlewars.areas import content.minigame.castlewars.CastleWars import content.minigame.castlewars.CastleWarsConstants import content.minigame.castlewars.CastleWarsGameState +import content.minigame.castlewars.CastleWarsTeam import core.api.TickListener import core.api.getWorldTicks import core.api.sendMessage import core.game.node.entity.Entity import core.game.node.entity.player.Player +import core.game.container.impl.EquipmentContainer import core.game.node.item.Item import core.game.world.map.Location import core.game.world.map.zone.ZoneBorders @@ -33,6 +35,11 @@ class CastleWarsRespawnArea : CastleWarsArea(), TickListener { override fun areaEnter(entity: Entity) { val player = entity as? Player ?: return + val team = CastleWarsGameState.getActiveGameTeam(player) + if (team == null || CastleWarsGameArea.ticksLeftInGame <= 0) { + player.properties.teleportLocation = CastleWars.lobbyBankArea.randomWalkableLoc + return + } super.areaEnter(player) if (CastleWarsGameState.isCarryingAnyFlag(player)) { if (CastleWarsGameState.isCarryingFlag(player)) { @@ -42,12 +49,24 @@ class CastleWarsRespawnArea : CastleWarsArea(), TickListener { } sendMessage(player, "You cannot take the flag into your respawn room!") } - 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() + when (team) { + CastleWarsTeam.SARADOMIN -> { + if (!saradominRespawnRoom.insideBorder(player.location)) { + player.properties.teleportLocation = saradominRespawnRoom.randomWalkableLoc + return + } + player.equipment.replace(Item(team.cloakId), EquipmentContainer.SLOT_CAPE) + saradominPlayersInRespawnRoom[player] = getWorldTicks() + } + + CastleWarsTeam.ZAMORAK -> { + if (!zamorakRespawnRoom.insideBorder(player.location)) { + player.properties.teleportLocation = zamorakRespawnRoom.randomWalkableLoc + return + } + player.equipment.replace(Item(team.cloakId), EquipmentContainer.SLOT_CAPE) + zamorakPlayersInRespawnRoom[player] = getWorldTicks() + } } CastleWarsGameState.setActiveGamePlayerOptions(player) } @@ -82,7 +101,7 @@ class CastleWarsRespawnArea : CastleWarsArea(), TickListener { } saradominPlayersInRespawnRoom.remove(player) zamorakPlayersInRespawnRoom.remove(player) - if (CastleWarsGameArea.ticksLeftInGame > 0 && CastleWarsGameState.getPlayerTeam(player) != null) { + if (CastleWarsGameArea.ticksLeftInGame > 0 && CastleWarsGameState.getActiveGameTeam(player) != null) { CastleWarsGameArea().logout(player) return } diff --git a/Server/src/main/content/minigame/castlewars/areas/CastleWarsWaitingArea.kt b/Server/src/main/content/minigame/castlewars/areas/CastleWarsWaitingArea.kt index e66a93747..77bf1e878 100644 --- a/Server/src/main/content/minigame/castlewars/areas/CastleWarsWaitingArea.kt +++ b/Server/src/main/content/minigame/castlewars/areas/CastleWarsWaitingArea.kt @@ -8,6 +8,7 @@ import core.api.* import core.game.component.Component import core.game.node.entity.Entity import core.game.node.entity.player.Player +import core.game.container.impl.EquipmentContainer import core.game.node.item.Item import core.game.world.map.zone.ZoneBorders import org.rs09.consts.Components @@ -50,11 +51,11 @@ class CastleWarsWaitingArea : CastleWarsArea(), TickListener { // Set team attribute and equip the hooded cloak on the entity based on which waiting room they're in val joinedTeam = if (zamorakWaitingRoom.insideBorder(player.location)) { - player.equipment.replace(Item(CastleWars.zamorakTeamHoodedCloak), 1) + player.equipment.replace(Item(CastleWars.zamorakTeamHoodedCloak), EquipmentContainer.SLOT_CAPE) waitingZamorakPlayers.add(player) CastleWarsTeam.ZAMORAK } else if (saradominWaitingRoom.insideBorder(player.location)) { - player.equipment.replace(Item(CastleWars.saradominTeamHoodedCloak), 1) + player.equipment.replace(Item(CastleWars.saradominTeamHoodedCloak), EquipmentContainer.SLOT_CAPE) waitingSaradominPlayers.add(player) CastleWarsTeam.SARADOMIN } else { diff --git a/Server/src/main/core/api/MapArea.kt b/Server/src/main/core/api/MapArea.kt index 6b72737ef..f8b4e29a0 100644 --- a/Server/src/main/core/api/MapArea.kt +++ b/Server/src/main/core/api/MapArea.kt @@ -3,6 +3,8 @@ package core.api import core.game.node.Node import core.game.node.entity.Entity import core.game.node.entity.combat.CombatStyle +import core.game.node.entity.player.Player +import core.game.node.item.Item import core.game.world.map.Location import core.game.world.map.zone.MapZone import core.game.world.map.zone.ZoneBorders @@ -32,12 +34,17 @@ interface MapArea : ContentInterface { * Return null if you want the default check in MapZone. (default) **/ fun isPvpAllowed(entity: Entity, target: Node?, style: CombatStyle?, message: Boolean): Boolean? { return null } + /** - * Extends MapZone to cater to pvp, team games and all other unique targeting systems. - * Return true/false if you want to allow the deaths, especially false for "safe" areas. - * Return null if you want the default check in MapZone. (default) + * Allows an area to reject equipment changes without coupling core equipment code + * to a particular piece of content. **/ - fun canStartDeath(entity: Entity, killer: Entity?): Boolean? { return null } + fun canEquipItem(player: Player, item: Item, equipmentSlot: Int): Boolean? { return null } + + /** + * Allows an area to modify damage dealt by an entity inside it. + */ + fun modifyOutgoingHitInArea(attacker: Entity, target: Entity, hit: Int): Int? { return null } companion object { val zoneMaps = HashMap() diff --git a/Server/src/main/core/game/container/impl/EquipmentContainer.java b/Server/src/main/core/game/container/impl/EquipmentContainer.java index a524534e8..5964ecd5f 100644 --- a/Server/src/main/core/game/container/impl/EquipmentContainer.java +++ b/Server/src/main/core/game/container/impl/EquipmentContainer.java @@ -2,7 +2,6 @@ package core.game.container.impl; import content.global.skill.skillcapeperks.SkillcapeEquipmentPlugin; import content.global.skill.skillcapeperks.SkillcapePerks; -import content.minigame.castlewars.CastleWars; import core.game.container.Container; import core.game.container.ContainerEvent; import core.game.container.ContainerListener; @@ -151,7 +150,7 @@ public final class EquipmentContainer extends Container { return false; } ArrayList itemsToMoveToInventory = new ArrayList<>(current); - itemsToMoveToInventory.removeIf(item -> isCastleWarsFlag(item) && !containsItem(item)); + itemsToMoveToInventory.removeIf(item -> canUnequipWithoutInventorySpace(item) && !containsItem(item)); if(itemsToMoveToInventory.isEmpty()) { return true; } @@ -191,7 +190,7 @@ public final class EquipmentContainer extends Container { int neededSlots = 0; for(Item item : current) { - if(isCastleWarsFlag(item)) { + if(canUnequipWithoutInventorySpace(item)) { continue; } if(!item.getDefinition().isStackable()) { @@ -205,8 +204,8 @@ public final class EquipmentContainer extends Container { return neededSlots; } - private boolean isCastleWarsFlag(Item item) { - return item.getId() == CastleWars.saradominFlag || item.getId() == CastleWars.zamorakFlag; + private boolean canUnequipWithoutInventorySpace(Item item) { + return item.getDefinition().getConfiguration(ItemConfigParser.UNEQUIP_WITHOUT_INVENTORY_SPACE, false); } @Nullable @@ -230,7 +229,7 @@ public final class EquipmentContainer extends Container { if (plugin != null) { Object object = plugin.fireEvent("unequip", player, currentItem); if (object != null && !((Boolean) object)) { - if (isCastleWarsFlag(currentItem) && !containsItem(currentItem)) { + if (canUnequipWithoutInventorySpace(currentItem) && !containsItem(currentItem)) { continue; } canContinue = false; @@ -241,7 +240,7 @@ public final class EquipmentContainer extends Container { canContinue = InteractionListeners.run(currentItem.getId(), player, currentItem, false); if(!canContinue) { - if (isCastleWarsFlag(currentItem) && !containsItem(currentItem)) { + if (canUnequipWithoutInventorySpace(currentItem) && !containsItem(currentItem)) { canContinue = true; continue; } diff --git a/Server/src/main/core/game/global/action/EquipHandler.kt b/Server/src/main/core/game/global/action/EquipHandler.kt index 4b44e8e74..b2dc7586d 100644 --- a/Server/src/main/core/game/global/action/EquipHandler.kt +++ b/Server/src/main/core/game/global/action/EquipHandler.kt @@ -1,7 +1,5 @@ package core.game.global.action -import content.minigame.castlewars.CastleWars -import content.minigame.castlewars.CastleWarsEquipmentRestrictions import core.game.event.ItemEquipEvent import core.game.event.ItemUnequipEvent import core.game.container.impl.EquipmentContainer @@ -12,12 +10,14 @@ import core.game.node.Node import core.game.node.entity.combat.equipment.WeaponInterface import core.game.node.entity.player.Player import core.game.node.entity.player.link.audio.Audio +import core.game.node.item.Item import core.plugin.Plugin import core.game.interaction.InteractionListener import core.game.interaction.IntType import core.game.interaction.InteractionListeners import core.game.system.config.ItemConfigParser import org.rs09.consts.Sounds +import java.util.Locale /** * Represents the equipment equipping handler plugin. @@ -37,20 +37,20 @@ class EquipHandler : InteractionListener { val item = node.asItem() val itemEquipmentSlot = item.definition.getConfiguration(ItemConfigParser.EQUIP_SLOT, -1) - if (CastleWarsEquipmentRestrictions.blocksEquip(player, itemEquipmentSlot)) { + if (!player.zoneMonitor.canEquip(player, item, itemEquipmentSlot)) { return } val currentEquippedItem = player.equipment[itemEquipmentSlot] - if (item == null || currentEquippedItem == item || item.name.toLowerCase().contains("goblin mail")) { + if (currentEquippedItem == item || item.name.lowercase(Locale.ROOT).contains("goblin mail")) { return } if(currentEquippedItem != null){ if(!InteractionListeners.run(currentEquippedItem.id, player, currentEquippedItem, false)){ - val droppedCastleWarsFlag = - isCastleWarsFlag(currentEquippedItem.id) && player.equipment[itemEquipmentSlot] == null - if (!droppedCastleWarsFlag) { + val listenerRemovedItem = + canUnequipWithoutInventorySpace(currentEquippedItem) && player.equipment[itemEquipmentSlot] == null + if (!listenerRemovedItem) { return } } @@ -128,7 +128,7 @@ class EquipHandler : InteractionListener { if (slot == EquipmentContainer.SLOT_WEAPON) { player.packetDispatch.sendString("", 92, 0) } - if (!isCastleWarsFlag(item.id)) { + if (!canUnequipWithoutInventorySpace(item)) { val maximumAdd = player.inventory.getMaximumAdd(item) if (maximumAdd < item.amount) { player.packetDispatch.sendMessage("Not enough free space in your inventory.") @@ -138,10 +138,16 @@ class EquipHandler : InteractionListener { val plugin = item.definition.getConfiguration>("equipment", null) if (plugin != null) { if (!(plugin.fireEvent("unequip", player, item) as Boolean)) { + if (canUnequipWithoutInventorySpace(item) && player.equipment[slot] == null) { + return + } return } } if (!InteractionListeners.run(itemId, player, item, false)) { + if (canUnequipWithoutInventorySpace(item) && player.equipment[slot] == null) { + return + } return } val maximumAdd = player.inventory.getMaximumAdd(item) @@ -163,8 +169,8 @@ class EquipHandler : InteractionListener { } } - private fun isCastleWarsFlag(itemId: Int): Boolean { - return itemId == CastleWars.saradominFlag || itemId == CastleWars.zamorakFlag + private fun canUnequipWithoutInventorySpace(item: Item): Boolean { + return item.definition.getConfiguration(ItemConfigParser.UNEQUIP_WITHOUT_INVENTORY_SPACE, false) } } } diff --git a/Server/src/main/core/game/node/entity/combat/CombatSwingHandler.kt b/Server/src/main/core/game/node/entity/combat/CombatSwingHandler.kt index 908a07394..2aee6a2b8 100644 --- a/Server/src/main/core/game/node/entity/combat/CombatSwingHandler.kt +++ b/Server/src/main/core/game/node/entity/combat/CombatSwingHandler.kt @@ -23,7 +23,6 @@ import core.game.world.update.flag.context.Animation import core.tools.RandomFunction import core.game.system.config.ItemConfigParser import core.tools.Log -import content.minigame.castlewars.CastleWarsGameState import org.rs09.consts.Sounds import java.util.* import kotlin.math.floor @@ -587,7 +586,7 @@ abstract class CombatSwingHandler(var type: CombatStyle?) { hit += (hit.toDouble() * 0.15).toInt() } if (victim is Player) { - hit = CastleWarsGameState.applyBraceletDamageBoost(player, victim.asPlayer(), hit) + hit = player.zoneMonitor.modifyOutgoingHit(player, victim, hit) } } if (attacker is Familiar && victim is Player) { diff --git a/Server/src/main/core/game/node/entity/combat/DeathContext.kt b/Server/src/main/core/game/node/entity/combat/DeathContext.kt index f81f6887b..26192117d 100644 --- a/Server/src/main/core/game/node/entity/combat/DeathContext.kt +++ b/Server/src/main/core/game/node/entity/combat/DeathContext.kt @@ -1,7 +1,7 @@ package core.game.node.entity.combat -import core.game.node.entity.Entity import core.game.world.map.Location +import core.game.world.map.zone.MapZone /** * Stores the state at the moment death commenced. @@ -16,18 +16,11 @@ import core.game.world.map.Location * @param deathLocation The location where death commenced * @param zoneType The zone type ID at death time (from ZoneMonitor.getType()) * @param isSafeZone Whether the player was in a safe zone at death time - * @param wasInWilderness Whether the player was in wilderness at death time - * @param wasSkulled Whether the player was skulled at death time - * @param killer The entity that killed the player (may be null for environmental deaths) - * @param deathTick The game tick when death commenced + * @param deathZones Snapshot of the zone handlers active when death commenced */ data class DeathContext( val deathLocation: Location, val zoneType: Int, val isSafeZone: Boolean, - val wasInWilderness: Boolean, - val wasSkulled: Boolean, - val killer: Entity?, - val deathTick: Int + val deathZones: List ) - diff --git a/Server/src/main/core/game/node/entity/player/Player.java b/Server/src/main/core/game/node/entity/player/Player.java index 23abb9281..9ffb8d4ca 100644 --- a/Server/src/main/core/game/node/entity/player/Player.java +++ b/Server/src/main/core/game/node/entity/player/Player.java @@ -49,6 +49,8 @@ import core.game.world.map.build.DynamicRegion; import core.game.world.map.path.Pathfinder; import core.game.world.map.zone.ZoneRestriction; import core.game.world.map.zone.ZoneType; +import core.game.world.map.zone.MapZone; +import core.game.world.map.zone.RegionZone; import core.game.node.entity.combat.DeathContext; import core.game.world.update.flag.PlayerFlags; import core.game.world.update.flag.*; @@ -87,6 +89,7 @@ import core.worker.ManagementEvents; import core.game.world.update.flag.context.*; import java.util.*; +import java.util.stream.Collectors; import java.util.concurrent.TimeUnit; import static core.api.ContentAPIKt.*; @@ -612,13 +615,13 @@ public class Player extends Entity { public void commenceDeath(Entity killer) { if (!isPlaying()) return; DeathContext context = new DeathContext( - getLocation(), + Location.create(getLocation()), getZoneMonitor().getType(), getProperties().isSafeZone(), - getSkullManager().isWilderness(), - getSkullManager().isSkulled(), - killer, - GameWorld.getTicks() + getZoneMonitor().getZones().stream() + .map(RegionZone::getZone) + .distinct() + .collect(Collectors.toList()) ); setAttribute("death-context", context); super.commenceDeath(killer); @@ -655,7 +658,9 @@ public class Player extends Entity { incrementAttribute("/save:"+STATS_BASE+":"+STATS_DEATHS); packetDispatch.sendTempMusic(90); - boolean zoneHandledDeath = getZoneMonitor().handleDeath(killer); + boolean zoneHandledDeath = deathContext != null + ? deathContext.getDeathZones().stream().anyMatch(zone -> zone.death(this, killer)) + : getZoneMonitor().handleDeath(killer); if (!zoneHandledDeath && (!wasSafeZone && deathZoneType != ZoneType.SAFE.getId()) && getDetails().getRights() != Rights.ADMINISTRATOR) { //If player was a Hardcore Ironman, announce that they died if (this.getIronmanManager().getMode().equals(IronmanMode.HARDCORE)) { //if this was checkRestriction, ultimate irons would be moved to HARDCORE_DEAD as well diff --git a/Server/src/main/core/game/system/config/ItemConfigParser.kt b/Server/src/main/core/game/system/config/ItemConfigParser.kt index 39645961d..962f2c83c 100644 --- a/Server/src/main/core/game/system/config/ItemConfigParser.kt +++ b/Server/src/main/core/game/system/config/ItemConfigParser.kt @@ -227,6 +227,12 @@ class ItemConfigParser { */ const val CASTLE_WARS_TICKET_PRICE = "castle_wars_ticket_price" + /** + * Whether an unequip listener may remove the item without first requiring + * enough inventory space to hold it. + */ + const val UNEQUIP_WITHOUT_INVENTORY_SPACE = "unequip_without_inventory_space" + } val parser = JSONParser() @@ -264,6 +270,7 @@ class ItemConfigParser { "fun_weapon", "rare_item", "bankable", + UNEQUIP_WITHOUT_INVENTORY_SPACE, "two_handed", "has_special", "remove_sleeves", @@ -311,4 +318,4 @@ class ItemConfigParser { } log(this::class.java, Log.FINE, "Parsed $count item configs.") } -} \ No newline at end of file +} diff --git a/Server/src/main/core/game/world/map/zone/MapZone.java b/Server/src/main/core/game/world/map/zone/MapZone.java index 752598f6f..38290e828 100644 --- a/Server/src/main/core/game/world/map/zone/MapZone.java +++ b/Server/src/main/core/game/world/map/zone/MapZone.java @@ -168,6 +168,22 @@ public abstract class MapZone implements Zone { return true; } + /** + * Checks whether an item may be equipped in this zone. + */ + public boolean canEquip(Player player, Item item, int equipmentSlot) { + return true; + } + + /** + * Applies zone-specific outgoing damage modifiers. + * + * @return the modified hit, or {@code null} when this zone has no modifier. + */ + public Integer modifyOutgoingHit(Entity attacker, Entity target, int hit) { + return null; + } + /** * If multi-zone boundaries should be ignored. * @param attacker The attacker. @@ -514,4 +530,4 @@ public abstract class MapZone implements Zone { public int hashCode() { return Objects.hash(uid, name, overlappable, fireRandomEvents, restriction, zoneType); } -} \ No newline at end of file +} diff --git a/Server/src/main/core/game/world/map/zone/ZoneMonitor.java b/Server/src/main/core/game/world/map/zone/ZoneMonitor.java index 4fabe5995..3e2af41c3 100644 --- a/Server/src/main/core/game/world/map/zone/ZoneMonitor.java +++ b/Server/src/main/core/game/world/map/zone/ZoneMonitor.java @@ -53,7 +53,7 @@ public final class ZoneMonitor { Items.RING_OF_WEALTH2_14642, Items.RING_OF_WEALTH3_14644, Items.RING_OF_LIFE_2570 - ); + ); /** * The entity. @@ -187,6 +187,32 @@ public final class ZoneMonitor { } return true; } + + /** + * Checks whether all current zones permit an equipment change. + */ + public boolean canEquip(Player player, Item item, int equipmentSlot) { + for (RegionZone z : zones) { + if (!z.getZone().canEquip(player, item, equipmentSlot)) { + return false; + } + } + return true; + } + + /** + * Applies outgoing damage modifiers from the entity's current zones. + */ + public int modifyOutgoingHit(Entity attacker, Entity target, int hit) { + int modifiedHit = hit; + for (RegionZone z : zones) { + Integer zoneHit = z.getZone().modifyOutgoingHit(attacker, target, modifiedHit); + if (zoneHit != null) { + modifiedHit = zoneHit; + } + } + return modifiedHit; + } /** * Checks if the entity can interact with the target. diff --git a/Server/src/main/core/plugin/ClassScanner.kt b/Server/src/main/core/plugin/ClassScanner.kt index 725a5427c..9620f2929 100644 --- a/Server/src/main/core/plugin/ClassScanner.kt +++ b/Server/src/main/core/plugin/ClassScanner.kt @@ -11,11 +11,13 @@ import core.game.node.Node import core.game.node.entity.Entity import core.game.node.entity.combat.CombatStyle import core.game.node.entity.npc.NPCBehavior +import core.game.node.entity.player.Player import core.game.node.entity.player.info.login.LoginConfiguration import core.game.node.entity.player.info.login.PlayerSaveParser import core.game.node.entity.player.info.login.PlayerSaver import core.game.node.entity.player.link.quest.Quest import core.game.node.entity.player.link.quest.QuestRepository +import core.game.node.item.Item import core.game.system.timer.RSTimer import core.game.system.timer.TimerRegistry import core.game.world.GameWorld @@ -141,17 +143,13 @@ object ClassScanner { } } - // Additional hook for startDeath for PVP area controls - override fun startDeath(e: Entity?, killer: Entity?): Boolean { - if (e == null) { - return super.startDeath(null, killer) - } - val clazzAttack = clazz.canStartDeath(e, killer) - if (clazzAttack == null) { // If clazz returns null, continue the usual check. - return super.startDeath(e, killer) - } else { - return clazzAttack // Otherwise return whatever function. - } + override fun canEquip(player: Player, item: Item, equipmentSlot: Int): Boolean { + return clazz.canEquipItem(player, item, equipmentSlot) + ?: super.canEquip(player, item, equipmentSlot) + } + + override fun modifyOutgoingHit(attacker: Entity, target: Entity, hit: Int): Int? { + return clazz.modifyOutgoingHitInArea(attacker, target, hit) } } for(border in clazz.defineAreaBorders()) zone.register(border) diff --git a/Server/src/test/kotlin/content/minigame/castlewars/CastleWarsTests.kt b/Server/src/test/kotlin/content/minigame/castlewars/CastleWarsTests.kt index a7fac1c40..a162bf685 100644 --- a/Server/src/test/kotlin/content/minigame/castlewars/CastleWarsTests.kt +++ b/Server/src/test/kotlin/content/minigame/castlewars/CastleWarsTests.kt @@ -21,6 +21,9 @@ import core.game.system.timer.impl.Teleblock import core.game.world.GameWorld import core.game.world.map.Location import core.game.world.map.RegionManager +import core.game.world.map.zone.MapZone +import core.game.world.map.zone.RegionZone +import core.game.world.map.zone.ZoneBorders import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Test @@ -28,6 +31,16 @@ import org.rs09.consts.Items class CastleWarsTests { + private fun addCastleWarsEquipmentZone(player: Player) { + val area = CastleWarsGameArea() + val zone = object : MapZone("Castle Wars equipment test", true) { + override fun canEquip(player: Player, item: Item, equipmentSlot: Int): Boolean { + return area.canEquipItem(player, item, equipmentSlot) ?: true + } + } + player.zoneMonitor.zones.add(RegionZone(zone, ZoneBorders(player.location, player.location))) + } + companion object { init { TestUtils.preTestSetup() @@ -82,6 +95,26 @@ class CastleWarsTests { } } + @Test + fun waitingRoomMembershipDoesNotGrantActiveGamePrivileges() { + TestUtils.getMockPlayer("cwwaitingonly").use { player -> + CastleWarsGameArea.ticksLeftInGame = 100 + CastleWarsWaitingArea.waitingSaradominPlayers.add(player) + + assertTrue(CastleWarsWaitingArea.waitingSaradominPlayers.contains(player)) + assertNull(CastleWarsGameState.getActiveGameTeam(player)) + + player.inventory.add(Item(CastleWars.cwBarricade)) + val setUp = requireNotNull( + InteractionListeners.get(CastleWars.cwBarricade, IntType.ITEM.ordinal, "set-up") + ) + setUp.invoke(player, player.inventory[0]) + + assertEquals(0, CastleWarsItemHandler.getBarricadeCount(CastleWarsTeam.SARADOMIN)) + assertTrue(player.inventory.contains(CastleWars.cwBarricade, 1)) + } + } + @Test fun spawnRoomPortalOffersVacancyAndFirstAcceptanceWins() { TestUtils.getMockPlayer("cwleaver").use { leaver -> @@ -802,6 +835,7 @@ class CastleWarsTests { fun castleWarsPlayersCannotEquipHelmsAfterEntry() { TestUtils.getMockPlayer("cwhelm").use { player -> player.location = Location.create(2380, 9490, 0) + addCastleWarsEquipmentZone(player) player.inventory.add(Item(Items.BRONZE_FULL_HELM_1155)) InteractionListeners.run( @@ -817,6 +851,7 @@ class CastleWarsTests { fun castleWarsPlayersCannotManuallyEquipTeamCloaksAfterEntry() { TestUtils.getMockPlayer("cwcloak").use { player -> player.location = Location.create(2380, 9490, 0) + addCastleWarsEquipmentZone(player) player.inventory.add(Item(Items.HOODED_CLOAK_4041)) InteractionListeners.run( @@ -980,6 +1015,16 @@ class CastleWarsTests { assertEquals(-2, zamorakNorth.y - zamorakBase.y) } + @Test + fun catapultMarkerUsesEntireInterfaceMap() { + val renderer = CatapultInterfaceRenderer() + + assertEquals(CatapultConfig.MAP_BASE_X, renderer.calculateScreenX(0)) + assertEquals(CatapultConfig.MAP_BASE_X + CatapultConfig.MAP_WIDTH, renderer.calculateScreenX(30)) + assertEquals(CatapultConfig.MAP_BASE_Y + CatapultConfig.MAP_HEIGHT, renderer.calculateScreenY(0)) + assertEquals(CatapultConfig.MAP_BASE_Y, renderer.calculateScreenY(30)) + } + @Test fun catapultShotInFlightBlocksOnlyShooterUntilLanding() { TestUtils.getMockPlayer("cwcatlock1").use { shooter -> @@ -996,6 +1041,9 @@ class CastleWarsTests { )!! CastleWarsGameArea.saradominPlayers.addAll(listOf(shooter, other)) + CastleWarsGameArea.ticksLeftInGame = 100 + shooter.location = catapult.location + other.location = catapult.location shooter.inventory.add(Item(CastleWars.cwRock, 2)) other.inventory.add(Item(CastleWars.cwRock)) @@ -1048,6 +1096,8 @@ class CastleWarsTests { catapult.id, IntType.SCENERY.ordinal, "operate" )!! CastleWarsGameArea.saradominPlayers.add(shooter) + CastleWarsGameArea.ticksLeftInGame = 100 + shooter.location = catapult.location shooter.inventory.add(Item(CastleWars.cwRock)) operateCatapult(shooter, catapult) @@ -1064,10 +1114,39 @@ class CastleWarsTests { } } + @Test + fun staleCatapultInterfaceCannotFireAfterPlayerMovesAway() { + TestUtils.getMockPlayer("cwcatmoved").use { shooter -> + val catapult = Scenery( + CastleWars.getOperationalCatapultId(CastleWarsTeam.SARADOMIN), + CastleWarsGameState.saradominCatapultLocation, + 10, + 0 + ) + ensureCatapultObject(catapult) + CastleWarsGameArea.saradominPlayers.add(shooter) + CastleWarsGameArea.ticksLeftInGame = 100 + shooter.location = catapult.location + shooter.inventory.add(Item(CastleWars.cwRock)) + shooter.setAttribute(CatapultConfig.ATTR_LOCATION, catapult.location) + shooter.interfaceManager.open(Component(CatapultConfig.INTERFACE_ID)) + shooter.location = Location.create(catapult.location.x + 10, catapult.location.y, catapult.location.z) + + InterfaceListeners.run( + shooter, Component(CatapultConfig.INTERFACE_ID), 0, CatapultConfig.BUTTON_FIRE, 0, 0 + ) + + assertEquals(1, shooter.inventory.getAmount(CastleWars.cwRock)) + assertFalse(CatapultFiringHandler.hasShotInFlight(shooter)) + assertFalse(shooter.interfaceManager.hasMainComponent(CatapultConfig.INTERFACE_ID)) + } + } + @Test fun staleCatapultInterfaceCannotFireWhenCatapultObjectIsMissing() { TestUtils.getMockPlayer("cwcatmissing").use { shooter -> CastleWarsGameArea.saradominPlayers.add(shooter) + CastleWarsGameArea.ticksLeftInGame = 100 shooter.inventory.add(Item(CastleWars.cwRock)) shooter.setAttribute(CatapultConfig.ATTR_LOCATION, Location.create(2400, 3090, 0)) shooter.interfaceManager.open(Component(CatapultConfig.INTERFACE_ID)) @@ -1120,6 +1199,13 @@ class CastleWarsTests { assertTrue(canClimbUpClimbingRope(rope.location, rope)) } + @Test + fun invalidBoulderKeysFailFast() { + assertThrows(IllegalArgumentException::class.java) { + CastleWarsGameState.getBoulderState("not-a-boulder") + } + } + @Test fun respawnEnergyBarrierPassesAcrossOneWallTile() { val barrierTile = Location.create(2426, 3080, 1) @@ -1171,6 +1257,7 @@ class CastleWarsTests { TestUtils.getMockPlayer("cwbarrfire").use { player -> val barricade = CastleWarsBarricadeNPC.spawn(Location.create(2400, 3100, 0), CastleWarsTeam.SARADOMIN) CastleWarsGameArea.ticksLeftInGame = 100 + CastleWarsGameArea.saradominPlayers.add(player) CastleWarsItemHandler.saradominBarricades.add(barricade) player.inventory.add(Item(Items.BUCKET_OF_WATER_1929))