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] 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