Merge branch 'castle-wars' into 'master'

Castle Wars

See merge request 2009scape/2009scape!2267
This commit is contained in:
dam 2026-07-31 21:52:33 +03:00
commit 161ca91d2b
49 changed files with 7737 additions and 395 deletions

View file

@ -3080,5 +3080,29 @@
"replaceId": "38142",
"fence": "false",
"metal": "false"
},
{
"id": "4465",
"replaceId": "4466",
"fence": "false",
"metal": "false"
},
{
"id": "4466",
"replaceId": "4465",
"fence": "false",
"metal": "false"
},
{
"id": "4467",
"replaceId": "4468",
"fence": "false",
"metal": "false"
},
{
"id": "4468",
"replaceId": "4467",
"fence": "false",
"metal": "false"
}
]

View file

@ -34815,8 +34815,12 @@
"name": "Saradomin banner",
"examine": "The Holy Team Standard",
"archery_ticket_price": "0",
"attack_anims": "419,419,419,419",
"attack_speed": "4",
"defence_anim": "420",
"durability": null,
"equipment_slot": "3",
"unequip_without_inventory_space": "true",
"render_anim": "131",
"run_anim": "1427",
"stand_anim": "1421",
@ -34827,7 +34831,7 @@
"turn90cw_anim": "1424",
"two_handed": "true",
"walk_anim": "1422",
"weapon_interface": "14",
"weapon_interface": "1",
"weight": "2"
},
{
@ -34842,8 +34846,12 @@
"name": "Zamorak banner",
"examine": "The Zamorak team's flag.",
"archery_ticket_price": "0",
"attack_anims": "419,419,419,419",
"attack_speed": "4",
"defence_anim": "420",
"durability": null,
"equipment_slot": "3",
"unequip_without_inventory_space": "true",
"render_anim": "131",
"run_anim": "1427",
"stand_anim": "1421",
@ -34854,7 +34862,7 @@
"turn90cw_anim": "1424",
"two_handed": "true",
"walk_anim": "1422",
"weapon_interface": "14"
"weapon_interface": "1"
},
{
"id": "4040",
@ -125297,4 +125305,4 @@
"tradeable": "false",
"weight": "0.2"
}
]
]

View file

@ -2196,4 +2196,4 @@
"title": "Uglug's Stuffsies",
"stock": "{4844,100,10}-{10927,0,100}-{2862,100,100}-{1777,10,200}-{2876,0,500}-{2878,10,100}-{4850,0,100}-{946,5,100}-{4773,0,1000}-{4778,0,1000}-{4783,0,1000}-{4788,0,1500}-{4793,0,2000}-{4798,0,3000}-{4803,0,4000}-{4827,0,2000}"
}
]
]

View file

@ -78,6 +78,10 @@ class LunarListeners : SpellListener("lunar"), Commands {
// Level 68
onCast(Lunar.CURE_OTHER, PLAYER) { player, node ->
requires(player, 68, arrayOf(Item(Items.ASTRAL_RUNE_9075, 1), Item(Items.LAW_RUNE_563), Item(Items.EARTH_RUNE_557, 10)))
if (player.zoneMonitor.isInZone("CastleWarsGame")) {
player.sendMessage("You can't use Cure Other in Castle Wars!")
return@onCast
}
node?.let { cureOther(player, node) }
}
@ -138,6 +142,10 @@ class LunarListeners : SpellListener("lunar"), Commands {
// Level 74
onCast(Lunar.CURE_GROUP, NONE) { player, _ ->
requires(player, 74, arrayOf(Item(Items.ASTRAL_RUNE_9075, 2), Item(Items.LAW_RUNE_563, 2), Item(Items.COSMIC_RUNE_564, 2)))
if (player.zoneMonitor.isInZone("CastleWarsGame")) {
player.sendMessage("You can't use Cure Group in Castle Wars!")
return@onCast
}
cureGroup(player)
}

View file

@ -234,6 +234,10 @@ class ModernListeners : SpellListener("modern"){
player.sendMessage("You can only alch items from the cupboards!")
return false
}
if (player.zoneMonitor.isInZone("CastleWarsGame")) {
player.sendMessage("You can't use alchemy spells in Castle Wars!")
return false
}
val coins = Item(995, item.definition.getAlchemyValue(high))
if (item.amount > 1 && coins.amount > 0 && !player.inventory.hasSpaceFor(coins)) {

View file

@ -1,11 +1,17 @@
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.RegionManager
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 +22,115 @@ object CastleWars {
const val castleWaterTap: Int = Scenery.TAP_4482
const val cwSteppingStones: Int = Scenery.STEPPING_STONE_4411
private val steppingStoneLocations: Set<Location> by lazy(LazyThreadSafetyMode.NONE) {
val locations = mutableSetOf<Location>()
for (x in 2368..2431) {
for (y in 3072..3135) {
val location = Location.create(x, y, 0)
if (RegionManager.getObject(location)?.id == cwSteppingStones) {
locations += location
}
}
}
locations
}
// Stepping stone tile locations are cached so dropped flag scenery cannot mask the underlying stone tile.
fun isSteppingStoneTile(location: Location): Boolean {
return steppingStoneLocations.contains(Location.create(location))
}
// 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, set on fire, destroyed, and repaired.
// 4381/4382 are the operational variants, 4904/4905 are the burning variants, 4385/4386 are damaged.
const val catapult: Int = 4381 // Operational catapult variant 1
const val catapultAlt: Int = 4382 // Operational catapult variant 2 (different orientation)
const val catapultBurning1: Int = 4904 // Burning catapult variant 1
const val catapultBurning2: Int = 4905 // Burning catapult variant 2
const val catapultDamaged1: Int = 4385 // Damaged catapult variant 1
const val catapultDamaged2: Int = 4386 // Damaged catapult variant 2
val burningCatapultIds: IntArray = intArrayOf(catapultBurning1, catapultBurning2)
val damagedCatapultIds: IntArray = intArrayOf(catapultDamaged1, catapultDamaged2)
val allCatapultIds: IntArray = intArrayOf(
catapult, catapultAlt, catapultBurning1, catapultBurning2, catapultDamaged1, catapultDamaged2
)
fun getOperationalCatapultId(team: CastleWarsTeam): Int = when (team) {
CastleWarsTeam.SARADOMIN -> catapultAlt
CastleWarsTeam.ZAMORAK -> catapult
}
fun getDamagedCatapultId(team: CastleWarsTeam): Int = when (team) {
CastleWarsTeam.SARADOMIN -> catapultDamaged2
CastleWarsTeam.ZAMORAK -> catapultDamaged1
}
fun getBurningCatapultId(team: CastleWarsTeam): Int = when (team) {
CastleWarsTeam.SARADOMIN -> catapultBurning2
CastleWarsTeam.ZAMORAK -> catapultBurning1
}
// Trapdoors - allow going down from 2nd floor to respawn room
const val saradominTrapdoor: Int = 4471 // Saradomin castle trapdoor
const val zamorakTrapdoor: Int = 4472 // Zamorak castle trapdoor
// 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,10 +148,8 @@ 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
Scenery.BATTLEMENTS_4446 to Scenery.BATTLEMENTS_36313, Scenery.BATTLEMENTS_4447 to Scenery.BATTLEMENTS_36314
)
// Item IDs
@ -54,14 +167,33 @@ object CastleWars {
const val cwManualBook: Int = Items.CASTLEWARS_MANUAL_4055
const val cwTicketRewardCurrency: Int = Items.CASTLE_WARS_TICKET_4067
// Authoritative list of items to remove from players on game end or area exit.
// 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 +206,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
}

View file

@ -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
}
}

View file

@ -0,0 +1,87 @@
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)
* - Never retaliate when attacked
* - 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?) {}
override fun isPoisonImmune(): Boolean {
return true
}
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
}
}
}

View file

@ -0,0 +1,166 @@
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.system.task.Pulse
import core.game.world.map.Location
import core.game.world.map.RegionManager
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, _ ->
renderCurrentCoordinate(player)
submitWorldPulse(object : Pulse(1) {
override fun pulse(): Boolean {
renderCurrentCoordinate(player)
return true
}
})
true
}
onClose(INTERFACE_ID) { player, _ ->
player.removeAttribute(ATTR_LOCATION)
true
}
}
private fun handleButtonClick(player: Player, buttonId: Int): Boolean {
val team = CastleWarsGameState.getActiveGameTeam(player) ?: return closeAndReturn(player, true)
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 (CatapultFiringHandler.hasShotInFlight(player)) {
CatapultFiringHandler.sendShotInFlightMessage(player)
return closeAndReturn(player, true)
}
if (!hasRocks(player)) {
sendMessage(player, "You don't have any rocks to fire!")
return true
}
val catapultLocation = getCatapultLocation(player) ?: run {
sendMessage(player, "Error: Catapult location not found.")
return true
}
val catapultTeam = getCatapultTeam(catapultLocation)
if (!canFireCatapult(player, team, catapultTeam, catapultLocation)) {
return closeAndReturn(player, true)
}
if (!removeItem(player, CastleWars.cwRock)) {
sendMessage(player, "You don't have any rocks to fire!")
return true
}
firingHandler.fire(player, coord, CatapultTeam.fromCastleWarsTeam(catapultTeam), catapultLocation)
closeInterface(player)
return true
}
private fun canFireCatapult(
player: Player,
playerTeam: CastleWarsTeam,
catapultTeam: CastleWarsTeam,
catapultLocation: Location
): Boolean {
if (!CastleWarsGameState.isGameActive || CastleWarsGameState.getActiveGameTeam(player) != playerTeam) {
sendMessage(player, "The Castle Wars game is no longer active.")
return false
}
if (playerTeam != catapultTeam) {
sendMessage(player, "You can't use the enemy's catapult!")
return false
}
val currentCatapult = RegionManager.getObject(catapultLocation)
if (currentCatapult == null || !currentCatapult.isActive || currentCatapult.id !in CastleWars.allCatapultIds) {
sendMessage(player, "The catapult is no longer there.")
return false
}
if (player.location.z != catapultLocation.z || player.location.getDistance(catapultLocation) > 2.0) {
sendMessage(player, "You have moved too far away from the catapult.")
return false
}
if (currentCatapult.id in CastleWars.damagedCatapultIds || !CastleWarsGameState.isCatapultOperational(
catapultTeam
)
) {
sendMessage(player, "The catapult is destroyed! Use a toolkit to repair it.")
return false
}
if (currentCatapult.id in CastleWars.burningCatapultIds || CastleWarsGameState.isCatapultOnFire(
catapultTeam
)
) {
sendMessage(player, "The catapult is on fire! Use a bucket of water to extinguish it.")
return false
}
return true
}
private fun getCatapultTeam(location: Location): CastleWarsTeam {
return if (location.y < 3100) CastleWarsTeam.SARADOMIN else CastleWarsTeam.ZAMORAK
}
private fun handleCoordinateChange(
player: Player, currentCoord: Coordinate, button: CatapultButton
): Boolean {
val newCoord = currentCoord.withDelta(button.deltaX, button.deltaY).clamp()
setVarbit(player, VARBIT_CATAPULT_X, newCoord.x)
setVarbit(player, VARBIT_CATAPULT_Y, newCoord.y)
renderer.renderCoordinate(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 renderCurrentCoordinate(player: Player) {
renderer.renderCoordinate(player, getCurrentCoordinate(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
}
}

View file

@ -0,0 +1,58 @@
package content.minigame.castlewars
import content.minigame.castlewars.areas.CastleWarsGameArea
import content.minigame.castlewars.areas.CastleWarsWaitingArea
import core.api.Commands
import core.api.sendMessage
import core.game.system.command.Privilege
/**
* 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
*/
class CastleWarsCommands : Commands {
override fun defineCommands() {
define("cw", Privilege.ADMIN, "::cw <start|end>", "Castle Wars admin commands") { player, args ->
if (args.size < 2) {
sendMessage(player, "Usage: ::cw <start|end>")
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 < CastleWarsConstants.MIN_PLAYERS_PER_TEAM || zamCount < CastleWarsConstants.MIN_PLAYERS_PER_TEAM) {
sendMessage(
player,
"Both teams need at least ${CastleWarsConstants.MIN_PLAYERS_PER_TEAM} players. Saradomin: $saraCount, Zamorak: $zamCount"
)
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
}
CastleWarsGameArea.endCurrentGame()
sendMessage(player, "Castle Wars game ended by admin command.")
}
else -> {
sendMessage(player, "Unknown subcommand. Use: start or end")
}
}
}
}
}

View file

@ -0,0 +1,158 @@
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 */
const val FLAG_AUTO_RETURN_TICKS = 150
/** Duration of climbing ropes before despawning */
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
/** Minimum players per team required to start a game */
const val MIN_PLAYERS_PER_TEAM = 2
/** Castle Wars timing uses the server's 600ms game tick: 100 ticks = 1 minute */
const val TICKS_PER_MINUTE = 100
/** Catapult targeting inaccuracy radius */
const val CATAPULT_INACCURACY_RADIUS = 2
/** Catapult impact damage radius */
const val CATAPULT_IMPACT_RADIUS = 3
/** Catapult minimum damage */
const val CATAPULT_MIN_DAMAGE = 10
/** Catapult maximum damage */
const val CATAPULT_MAX_DAMAGE = 20
// ==================== Coordinate Defaults ====================
/** Default X coordinate for catapult targeting */
const val CATAPULT_DEFAULT_X = 8
/** Default Y coordinate for catapult targeting */
const val CATAPULT_DEFAULT_Y = 13
/** Maximum catapult coordinate value */
const val CATAPULT_MAX_COORDINATE = 30
// ==================== 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
// ==================== 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 ====================
/** Backdoor pick-lock success at Thieving level 1 */
const val BACKDOOR_PICKLOCK_LEVEL_ONE_SUCCESS = 6.3
/** Backdoor pick-lock success at Thieving level 99 */
const val BACKDOOR_PICKLOCK_LEVEL_NINETY_NINE_SUCCESS = 100.0
/** Highest Thieving level used by the linear backdoor pick-lock curve */
const val BACKDOOR_PICKLOCK_MAX_LEVEL = 99
// ==================== Cleanup Intervals ====================
/** How often to run inactive player cleanup (every 10 ticks = 6 seconds) */
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 ====================
/** Waiting-room cycle while no game is active: 2 minutes = 200 ticks */
const val WAITING_ROOM_COUNTDOWN_TICKS = 2 * TICKS_PER_MINUTE
/** How long a substitution offer can block later vacancy offers */
const val VACANCY_OFFER_TIMEOUT_TICKS = TICKS_PER_MINUTE
// ==================== Player Attribute Keys ====================
/** Attribute key: world tick when player joined the current game */
const val ATTR_JOIN_TICK = "cw:join_tick"
/** 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 = 2 * TICKS_PER_MINUTE
/** Wall-clock logout grace for saved login state (2 minutes) */
const val LOGOUT_GRACE_PERIOD_MILLIS = 120_000L
/** Saved attribute key: wall-clock millis when player logged out of an active match */
const val ATTR_LOGOUT_TIME_MILLIS = "/save:cw:logout_time_millis"
/** Saved attribute key: team name for an active-match logout */
const val ATTR_LOGOUT_TEAM = "/save:cw:logout_team"
/** Saved attribute key: exact active-match logout location */
const val ATTR_LOGOUT_LOCATION = "/save:cw:logout_location"
// ==================== Flag Steal Timer ====================
/** Time in ticks before teammates can steal the flag from a carrier (3 minutes = 300 ticks) */
const val FLAG_STEAL_TIMER_TICKS = 3 * TICKS_PER_MINUTE
/** Player attribute key for tracking when enemy flag was picked up */
const val ATTR_FLAG_PICKUP_TICK = "cw:flag_pickup_tick"
/** Player attribute key: team whose bracelet effect was activated when the match started */
const val ATTR_ACTIVE_BRACELET_TEAM = "cw:active_bracelet_team"
}

View file

@ -0,0 +1,84 @@
package content.minigame.castlewars
import core.api.animate
import core.api.playAudio
import core.game.container.impl.EquipmentContainer
import core.game.node.entity.combat.equipment.WeaponInterface
import core.game.node.entity.player.Player
import core.game.node.entity.skill.Skills
import core.game.node.scenery.Scenery
import core.game.system.task.Pulse
import core.tools.RandomFunction
import org.rs09.consts.Sounds
import kotlin.math.floor
/**
* Handles attacking the Castle Wars front doors.
* - Uses player's attack speed based on weapon
* - Calculates damage based on combat stats
* - Damages door health until destroyed or player stops
*/
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.getActiveGameTeam(
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 {
var effectiveStrength = player.skills.getLevel(Skills.STRENGTH).toDouble()
when (player.properties.attackStyle.style) {
WeaponInterface.STYLE_AGGRESSIVE -> effectiveStrength += 3.0
WeaponInterface.STYLE_CONTROLLED -> effectiveStrength += 1.0
}
effectiveStrength += 8.0
val strengthBonus = player.properties.bonuses[11] + 64
val maxHit = floor(0.5 + (effectiveStrength * strengthBonus / 640.0)).toInt().coerceAtLeast(1)
val bonusType = player.properties.attackStyle.bonusType
val styleMultiplier = when (bonusType) {
WeaponInterface.BONUS_CRUSH -> 1.25
WeaponInterface.BONUS_SLASH -> 1.0
WeaponInterface.BONUS_STAB -> 0.85
else -> 0.75
}
val styleAdjustedMax = floor(maxHit * styleMultiplier).toInt().coerceAtLeast(1)
return RandomFunction.random(styleAdjustedMax + 1).coerceIn(0, 25)
}
private fun getAttackAnimation(): Int {
val attackAnim = player.properties.attackAnimation
return if (attackAnim != null && attackAnim.id > 0) attackAnim.id else 422
}
}

View file

@ -0,0 +1,55 @@
package content.minigame.castlewars
import content.minigame.castlewars.areas.CastleWarsGameArea
import content.minigame.castlewars.areas.CastleWarsRespawnArea
import content.minigame.castlewars.areas.CastleWarsWaitingArea
import core.api.EquipmentSlot
import core.api.sendMessage
import core.game.node.entity.player.Player
import core.game.node.item.GroundItemManager
object CastleWarsEquipmentRestrictions {
private val restrictedSlots = intArrayOf(
EquipmentSlot.HEAD.ordinal, EquipmentSlot.CAPE.ordinal
)
private val castleWarsTeamCapeIds = intArrayOf(
CastleWars.saradominTeamHoodedCloak, CastleWars.zamorakTeamHoodedCloak
)
private val castleWarsBorders by lazy {
CastleWarsGameArea.areaBorders.toList() + CastleWarsWaitingArea.areaBorders.toList() + listOf(
CastleWarsRespawnArea.saradominRespawnRoom,
CastleWarsRespawnArea.zamorakRespawnRoom
)
}
fun blocksEquip(player: Player, equipmentSlot: Int): Boolean {
if (equipmentSlot !in restrictedSlots) {
return false
}
sendMessage(player, "You can't wear hats, capes, or helms in the arena.")
return true
}
fun removeInvalidEquipment(player: Player) {
removeEquipment(player, EquipmentSlot.HEAD.ordinal)
val cape = player.equipment[EquipmentSlot.CAPE.ordinal] ?: return
if (cape.id !in castleWarsTeamCapeIds) {
removeEquipment(player, EquipmentSlot.CAPE.ordinal)
}
}
fun isInCastleWarsArea(player: Player): Boolean {
return castleWarsBorders.any { it.insideBorder(player.location) }
}
private fun removeEquipment(player: Player, slot: Int) {
val item = player.equipment[slot] ?: return
player.equipment.remove(item)
if (!player.inventory.add(item)) {
GroundItemManager.create(item.copy(), player.location, player)
}
}
}

View file

@ -0,0 +1,160 @@
package content.minigame.castlewars
import core.api.sendMessage
import core.game.interaction.IntType
import core.game.interaction.InteractionListener
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.getActiveGameTeam(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)
return@onUnequip false
} else if (CastleWarsGameState.isCarryingOwnFlag(player)) {
CastleWarsGameState.dropOwnFlag(player)
return@onUnequip false
}
return@onUnequip true
}
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.getActiveGameTeam(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) {
CastleWarsGameState.pickUpOwnFlag(player, flagTeam)
} else {
CastleWarsGameState.pickUpFlag(player, flagTeam)
}
return@on true
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,114 @@
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.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.
*/
class CastleWarsGameZone : MapZone("CastleWarsGame", true, ZoneRestriction.RANDOM_EVENTS), StartupListener {
companion object {
/**
* Handle Castle Wars death with poison preservation.
* 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.getActiveGameTeam(player) ?: return false
if (CastleWarsGameState.isCarryingFlag(player)) {
CastleWarsGameState.dropFlag(player)
} else if (CastleWarsGameState.isCarryingOwnFlag(player)) {
CastleWarsGameState.dropOwnFlag(player)
}
PulseManager.cancelDeathTask(player)
val poisonTimer = getTimer<Poison>(player)
val poisonSeverity: Int? = poisonTimer?.severity
val poisonSource: Entity? = poisonTimer?.damageSource
player.fullRestore()
if (poisonSeverity != null && poisonSeverity > 0 && poisonSource != null) {
registerTimer(player, spawnTimer<Poison>(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 CastleWarsGameArea.areaBorders) {
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 findLocalNPCs(e.location, 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
}
}

View file

@ -0,0 +1,342 @@
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 50 HP, max 10 per team
* - Explosive Potions: Instant-destroy barricades, 15% max HP self-damage on drop
*/
class CastleWarsItemHandler : InteractionListener {
companion object {
val saradominBarricades = mutableListOf<NPC>()
val zamorakBarricades = mutableListOf<NPC>()
val burningBarricadePulses = mutableMapOf<NPC, core.game.system.task.Pulse>()
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.getActiveGameTeam(attacker) ?: return
RegionManager.getLocalPlayers(location, radius).filter { target ->
val targetTeam = CastleWarsGameState.getActiveGameTeam(target)
target != attacker && target.isActive && targetTeam != null && targetTeam != attackerTeam
}.forEach { target ->
val damage = CastleWarsConstants.EXPLOSIVE_POTION_SELF_DAMAGE.coerceAtMost(target.skills.lifepoints)
target.impactHandler.manualHit(
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
}
onUseWithPlayer(CastleWars.cwBandages) { player, used, with ->
val target = with as? Player ?: return@onUseWithPlayer false
if (useBandage(player, target)) {
removeItem(player, used.asItem())
}
return@onUseWithPlayer 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.getActiveGameTeam(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 (!isActiveGamePlayer(player)) {
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 (!isActiveGamePlayer(player)) {
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()
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 (!isActiveGamePlayer(player)) {
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)
burningBarricadePulses.remove(barricade)?.stop()
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 (!CastleWarsGameState.isGameActive || !barricade.isActive ||
barricade.id != NPCs.BARRICADE_1533 || tickCount >= BURNING_BARRICADE_MAX_TICKS
) {
burningBarricadePulses.remove(barricade)
if (barricade.isActive && barricade.id == NPCs.BARRICADE_1533) {
barricade.skills.lifepoints = 0
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 (!isActiveGamePlayer(player)) {
return@onUseWith false
}
val originalId = if (saradominBarricades.contains(barricade)) {
NPCs.BARRICADE_1532
} else {
NPCs.BARRICADE_1534
}
barricade.transform(originalId)
burningBarricadePulses.remove(barricade)?.stop()
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.isGameActive) {
sendMessage(user, "You can only use bandages during a Castle Wars game.")
return false
}
val userTeam = CastleWarsGameState.getActiveGameTeam(user)
val targetTeam = CastleWarsGameState.getActiveGameTeam(target)
if (userTeam == null || targetTeam == null) {
sendMessage(user, "You can only use bandages during a Castle Wars game.")
return false
}
if (userTeam != targetTeam) {
sendMessage(user, "You can only heal your teammates!")
return false
}
if (CastleWarsGameState.isCarryingOwnFlag(target)) {
if (user == target) {
sendMessage(user, "You can't heal while carrying your team's flag!")
} 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.hasActiveCastleWarsBracelet()) {
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 isActiveGamePlayer(player: Player): Boolean {
return CastleWarsGameState.isGameActive && CastleWarsGameState.getActiveGameTeam(player) != null
}
private fun getLocalBarricades(location: Location, radius: Int): List<NPC> {
val barricadeIds = intArrayOf(
NPCs.BARRICADE_1532, NPCs.BARRICADE_1533, NPCs.BARRICADE_1534, NPCs.BARRICADE_1535
)
return findLocalNPCs(location, radius).filter { it.id in barricadeIds && it.isActive }
}
}

View file

@ -1,4 +1,6 @@
import core.api.*
package content.minigame.castlewars
import core.api.setVarp
import core.game.node.entity.player.Player
/**
@ -7,16 +9,7 @@ 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, gameStartMinutes: Int) {
setVarp(player, 380, gameStartMinutes)
}
}

View file

@ -0,0 +1,95 @@
package content.minigame.castlewars
import content.minigame.castlewars.areas.CastleWarsGameArea
import content.minigame.castlewars.areas.CastleWarsRespawnArea
import content.minigame.castlewars.areas.CastleWarsWaitingArea
import core.api.ShutdownListener
import core.api.removeAttributes
import core.game.node.entity.player.Player
import core.game.world.map.Location
import core.game.world.map.RegionManager
/**
* 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<Player>()
allTrackedPlayers += CastleWarsGameArea.saradominPlayers
allTrackedPlayers += CastleWarsGameArea.zamorakPlayers
allTrackedPlayers += CastleWarsWaitingArea.waitingSaradominPlayers
allTrackedPlayers += CastleWarsWaitingArea.waitingZamorakPlayers
allTrackedPlayers += CastleWarsRespawnArea.saradominPlayersInRespawnRoom.keys
allTrackedPlayers += CastleWarsRespawnArea.zamorakPlayersInRespawnRoom.keys
allTrackedPlayers += CastleWarsGameArea.loggedOutPlayers.values.map { it.player }
val respawnBorders = arrayOf(
CastleWarsRespawnArea.saradominRespawnRoom, CastleWarsRespawnArea.zamorakRespawnRoom
)
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, broadcast = false)
} else if (CastleWarsGameState.isCarryingOwnFlag(player)) {
CastleWarsGameState.dropOwnFlag(player, broadcast = false)
}
CastleWarsGameState.restoreAndCleanupPlayer(player)
val lobbyLocation = CastleWars.lobbyBankArea.randomWalkableLoc
player.location = lobbyLocation
player.properties.teleportLocation = lobbyLocation
removeAttributes(
player,
CastleWarsConstants.ATTR_LOGOUT_TICK,
CastleWarsConstants.ATTR_LOGOUT_TIME_MILLIS,
CastleWarsConstants.ATTR_LOGOUT_TEAM,
CastleWarsConstants.ATTR_LOGOUT_LOCATION,
CastleWarsConstants.ATTR_JOIN_TICK
)
}
CastleWarsItemHandler.clearAllBarricades()
CastleWarsGameState.clearCastleWarsGroundItems()
CastleWarsGameState.resetGame()
CastleWarsGameArea.clearSubstitutionState()
CastleWarsGameArea.saradominPlayers.clear()
CastleWarsGameArea.zamorakPlayers.clear()
CastleWarsGameArea.loggedOutPlayers.clear()
CastleWarsWaitingArea.waitingSaradominPlayers.clear()
CastleWarsWaitingArea.waitingZamorakPlayers.clear()
CastleWarsRespawnArea.saradominPlayersInRespawnRoom.clear()
CastleWarsRespawnArea.zamorakPlayersInRespawnRoom.clear()
CastleWarsGameArea.ticksLeftInGame = -1
CastleWarsWaitingArea.gameStartCountdown = -1
}
}

View file

@ -0,0 +1,39 @@
package content.minigame.castlewars
import content.minigame.castlewars.areas.CastleWarsGameArea
import core.api.sendMessage
import core.game.dialogue.DialogueFile
import core.game.dialogue.Topic
import core.tools.END_DIALOGUE
import core.tools.START_DIALOGUE
class CastleWarsSubstitutionDialogue(private val team: CastleWarsTeam) : DialogueFile() {
override fun handle(componentID: Int, buttonID: Int) {
val currentPlayer = player ?: return
when (stage) {
START_DIALOGUE -> showTopics(
Topic("Yes.", ACCEPT, true),
Topic("No.", DECLINE, true),
title = "Do you wish to join the current game?"
)
ACCEPT -> {
if (!CastleWarsGameArea.claimVacancy(currentPlayer, team)) {
sendMessage(currentPlayer, "That Castle Wars vacancy is no longer available.")
}
stage = END_DIALOGUE
}
DECLINE -> {
CastleWarsGameArea.declineVacancyOffer(currentPlayer, team)
stage = END_DIALOGUE
}
}
}
companion object {
private const val ACCEPT = 10
private const val DECLINE = 20
}
}

View file

@ -0,0 +1,213 @@
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(
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 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 {
if (!canContinueCastleWarsMining(player, rocks)) {
return false
}
val expectedState = if (isWeakened) BoulderState.WEAKENED else BoulderState.COLLAPSED
if (CastleWarsGameState.getBoulderState(boulderKey) != expectedState) {
return false
}
val pickaxe = SkillingTool.getPickaxe(player)
if (pickaxe == null) {
sendMessage(player, "You need a pickaxe to mine these rocks.")
return false
}
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 {
if (!canContinueCastleWarsMining(player, caveWall)) {
return false
}
if (!CastleWarsGameState.isBoulderCleared(boulderKey)) {
return false
}
val pickaxe = SkillingTool.getPickaxe(player)
if (pickaxe == null) {
sendMessage(player, "You need a pickaxe to mine the cave wall.")
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!")
}
}
private fun canContinueCastleWarsMining(player: Player, scenery: Scenery): Boolean {
return player.isActive && CastleWarsGameState.isGameActive &&
CastleWarsGameState.getActiveGameTeam(player) != null && scenery.isActive &&
player.location.z == scenery.location.z && player.location.getDistance(scenery.location) <= 2.0
}

View file

@ -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
}
}

View file

@ -0,0 +1,124 @@
package content.minigame.castlewars
import org.rs09.consts.Components
/**
* Configuration constants for the Castle Wars catapult interface.
* All magic numbers and hardcoded values are centralized here.
*/
object CatapultConfig {
/** 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
/** Native Castle Wars dummy items used to render digits on interface 54 */
val DIGIT_ITEM_IDS = intArrayOf(4056, 4057, 4058, 4059, 4060, 4061, 4062, 4063, 4064, 4065)
const val BUTTON_FIRE = 51
const val BUTTON_CLOSE = 56
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(61, 62, 63, 64)
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_X = CastleWarsConstants.CATAPULT_DEFAULT_X
const val DEFAULT_Y = CastleWarsConstants.CATAPULT_DEFAULT_Y
const val CENTER_OFFSET = 15
const val TILE_SCALE = 2
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"
const val ATTR_SHOT_IN_FLIGHT = "catapult:shot-in-flight"
}
/**
* 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 {
SARADOMIN {
override fun calculateTargetX(catapultX: Int, interfaceX: Int): Int {
return catapultX + ((interfaceX - CatapultConfig.CENTER_OFFSET) * CatapultConfig.TILE_SCALE)
}
override fun calculateTargetY(catapultY: Int, interfaceY: Int): Int {
return catapultY + (interfaceY * CatapultConfig.TILE_SCALE)
}
},
ZAMORAK {
override fun calculateTargetX(catapultX: Int, interfaceX: Int): Int {
return catapultX - ((interfaceX - CatapultConfig.CENTER_OFFSET) * CatapultConfig.TILE_SCALE)
}
override fun calculateTargetY(catapultY: Int, interfaceY: Int): Int {
return catapultY - (interfaceY * CatapultConfig.TILE_SCALE)
}
};
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)
}

View file

@ -0,0 +1,142 @@
package content.minigame.castlewars
import content.minigame.castlewars.CatapultConfig.ATTR_SHOT_IN_FLIGHT
import content.minigame.castlewars.CatapultConfig.GFX_BOULDER_PROJECTILE
import content.minigame.castlewars.CatapultConfig.GFX_IMPACT
import content.minigame.castlewars.CatapultConfig.IMPACT_RADIUS
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 {
companion object {
internal fun hasShotInFlight(player: Player): Boolean {
return getAttribute(player, ATTR_SHOT_IN_FLIGHT, false)
}
internal fun markShotInFlight(player: Player) {
setAttribute(player, ATTR_SHOT_IN_FLIGHT, true)
}
internal fun clearShotInFlight(player: Player) {
removeAttribute(player, ATTR_SHOT_IN_FLIGHT)
}
internal fun sendShotInFlightMessage(player: Player) {
sendMessage(player, "You need to wait for your previous catapult shot to land.")
}
}
/**
* Fires the catapult at the specified coordinates.
* @param player The player firing the catapult
* @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) {
if (hasShotInFlight(player)) {
sendShotInFlightMessage(player)
return
}
markShotInFlight(player)
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, distance, speed)
playFireEffects(player, finalLocation, impactDelayTicks)
scheduleImpact(player, finalLocation, impactDelayTicks)
sendMessage(player, "You fire the catapult!")
}
internal fun calculateTargetLocation(team: CatapultTeam, catapultLocation: Location, coord: Coordinate): Location {
val targetX = team.calculateTargetX(catapultLocation.x, coord.x)
val targetY = team.calculateTargetY(catapultLocation.y, coord.y)
return Location.create(targetX, targetY, 0)
}
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, distance: Int, speed: Int) {
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 {
try {
dealImpactDamage(player, targetLocation)
} finally {
clearShotInFlight(player)
}
return true
}
})
}
private fun dealImpactDamage(attacker: Player, targetLocation: Location) {
RegionManager.getLocalPlayers(targetLocation, IMPACT_RADIUS).filter { isValidTarget(it) }.forEach { target ->
val damage = RandomFunction.random(MIN_DAMAGE, MAX_DAMAGE)
target.impactHandler.manualHit(attacker, damage, ImpactHandler.HitsplatType.NORMAL)
sendMessage(target, "You are hit by a catapult rock!")
}
}
private fun isValidTarget(target: Player): Boolean {
return CastleWarsGameState.isGameActive && target.isActive &&
CastleWarsGameState.getActiveGameTeam(target) != null
}
}

View file

@ -0,0 +1,97 @@
package content.minigame.castlewars
import content.minigame.castlewars.CatapultConfig.DIGIT_CHILD_IDS
import content.minigame.castlewars.CatapultConfig.DIGIT_ITEM_IDS
import content.minigame.castlewars.CatapultConfig.INTERFACE_ID
import content.minigame.castlewars.CatapultConfig.MAP_BASE_X
import content.minigame.castlewars.CatapultConfig.MAP_BASE_Y
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.ComponentType
import core.cache.def.impl.IfaceDefinition
import core.game.node.entity.player.Player
import core.net.packet.PacketRepository
import core.net.packet.context.ChildPositionContext
import core.net.packet.out.RepositionChild
import kotlin.math.roundToInt
/**
* Handles rendering the catapult interface including marker position and digit displays.
*/
class CatapultInterfaceRenderer {
private val interfaceDefinition: IfaceDefinition by lazy { IfaceDefinition.forId(INTERFACE_ID) }
fun renderCoordinate(player: Player, coord: Coordinate) {
updateMarkerPosition(player, coord)
updateDigitDisplay(player, coord)
}
/**
* Updates the position of the red X marker on the interface map.
* @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)
}
internal fun calculateScreenX(coordX: Int): Int {
return MAP_BASE_X + (coordX.toDouble() / MAX_COORDINATE * MAP_WIDTH).roundToInt()
}
internal fun calculateScreenY(coordY: Int): Int {
// Y is inverted - 0 is at the bottom, MAX is at the top
return MAP_BASE_Y + ((MAX_COORDINATE - coordY).toDouble() / MAX_COORDINATE * MAP_HEIGHT).roundToInt()
}
private fun sendDigitModel(player: Player, childId: Int, digit: Int) {
if (!isValidDigitChild(childId)) {
return
}
val child = interfaceDefinition.children[childId] ?: return
val itemId = getDigitItemId(digit)
if (itemId < 0) {
return
}
player.packetDispatch.sendItemOnInterface(itemId, 1, INTERFACE_ID, childId)
player.packetDispatch.sendAngleOnInterface(
INTERFACE_ID, childId, child.modelZoom, child.modelXAngle, child.modelYAngle
)
}
internal fun isValidDigitChild(childId: Int): Boolean {
if (childId !in interfaceDefinition.children.indices) {
return false
}
return interfaceDefinition.children[childId]?.type == ComponentType.MODEL
}
internal fun getDigitItemId(digit: Int): Int {
return DIGIT_ITEM_IDS.getOrNull(digit) ?: -1
}
}

View file

@ -1,60 +1,64 @@
package rs09.game.content.activity.castlewars
package content.minigame.castlewars
import core.api.TickListener
import core.api.openDialogue
import core.api.sendMessage
import content.minigame.castlewars.areas.CastleWarsGameArea
import content.minigame.castlewars.areas.CastleWarsWaitingArea
import core.api.*
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 org.rs09.consts.Items
import org.rs09.consts.NPCs
class Lanthus: InteractionListener, TickListener {
class Lanthus : InteractionListener, TickListener {
override fun defineListeners() {
on(NPCs.LANTHUS_1526, IntType.NPC, "talk-to") { player, npcNode ->
val lanthusNpc = npcNode.asNpc()
openDialogue(player, object : DialogueFile(){
openDialogue(player, object : DialogueFile() {
override fun handle(componentID: Int, buttonID: Int) {
when(stage) {
when (stage) {
0 -> npcl(FacialExpression.FRIENDLY, "Good day, how may I help you?").also { stage = 2 }
2 -> options(
"What is this place?",
"What do you have for trade?",
"Do you have a manual? I'd like to learn how to play!"
).also { stage++ }
3 -> when (buttonID) {
1 -> player("What is this place?").also { stage = 4 }
2 -> lanthusNpc.openShop(player).also { stage = END_DIALOGUE }
3 -> player("Do you have a manual? I'd like to learn how to play!").also { stage = 50 }
}
4 -> npcl(
FacialExpression.FRIENDLY,
"This is the great Castle Wars arena! Here you can fight for the glory of Saradomin or Zamorak."
).also { stage++ }
5 -> options(
"Really, how do I do that?",
"Are there any rules?",
"What can I win?"
"Really, how do I do that?", "Are there any rules?", "What can I win?"
).also { stage++ }
6 -> when (buttonID) {
1 -> player("Really, how do I do that?").also { stage = 7 }
2 -> player("Are there any rules?").also { stage = 80 }
3 -> player("What can I win?").also { stage = 90 }
}
7 -> npcl(
FacialExpression.FRIENDLY,
"Easy, you just step through one of the three portals. To join Zamorak, pass through the red portal. To join Saradomin, pass through the blue portal. If you don't mind then pass through the green portal.",
).also { stage ++ }
).also { stage++ }
8 -> options(
"Are there any rules?",
"What can I win?",
"What do you have for trade?",
"Do you have a manual? I'd like to learn how to play!"
).also { stage++ }
9 -> when (buttonID) {
1 -> player("Are there any rules?").also { stage = 80 }
2 -> player("What can I win?").also { stage = 90 }
@ -63,31 +67,40 @@ class Lanthus: InteractionListener, TickListener {
}
80 -> npcl(
FacialExpression.FRIENDLY, "Of course, there are always rules. Firstly you can't wear a cape as you enter the portal, you'll be given your team colours to wear while in the arena. You're also prohibited from taking non-combat related items in")
.also { stage ++ }
FacialExpression.FRIENDLY,
"Of course, there are always rules. Firstly you can't wear a cape as you enter the portal, you'll be given your team colours to wear while in the arena. You're also prohibited from taking non-combat related items in"
).also { stage++ }
81 -> npcl(
FacialExpression.FRIENDLY, " with you. So you should only have equipment, potions, and runes on you. Secondly, attacking your own team or your team's defences isn't allowed. You don't want to be angering"
).also { stage ++ }
FacialExpression.FRIENDLY,
" with you. So you should only have equipment, potions, and runes on you. Secondly, attacking your own team or your team's defences isn't allowed. You don't want to be angering"
).also { stage++ }
82 -> npcl(
FacialExpression.FRIENDLY, "your patron god, do you? Other than that, just have fun and enjoy it!"
).also { stage ++ }
50 -> npcl(
FacialExpression.FRIENDLY,
"Sure, here you go.").also { stage = END_DIALOGUE }.also { player.inventory.add(Item(Items.CASTLEWARS_MANUAL_4055)) }
"your patron god, do you? Other than that, just have fun and enjoy it!"
).also { stage++ }
83 -> player("Great! Oh, how do I win the game?").also { stage ++ }
50 -> {
if (addItem(player, Items.CASTLEWARS_MANUAL_4055)) {
npcl(FacialExpression.FRIENDLY, "Sure, here you go.")
} else {
npcl(FacialExpression.FRIENDLY, "You don't have enough inventory space.")
}.also { stage = END_DIALOGUE }
}
83 -> player("Great! Oh, how do I win the game?").also { stage++ }
84 -> npcl(
FacialExpression.FRIENDLY,
"The aim is to get into your opponents' castle and take their team standard. Then bring that back and capture it on your team's standard."
).also { stage ++ }
).also { stage++ }
85 -> options(
"What can I win?",
"What do you have to trade?",
"Do you have a manual? I'd like to learn how to play!"
).also { stage ++ }
).also { stage++ }
86 -> when (buttonID) {
1 -> player("What can I win?").also { stage = 90 }
2 -> lanthusNpc.openShop(player).also { stage = END_DIALOGUE }
@ -96,13 +109,15 @@ 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."
).also { stage ++ }
"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?",
"What do you have to trade?",
"Do you have a manual? I'd like to learn how to play!"
).also { stage ++ }
).also { stage++ }
92 -> when (buttonID) {
1 -> player("Are there any rules?").also { stage = 80 }
2 -> lanthusNpc.openShop(player).also { stage = END_DIALOGUE }
@ -122,10 +137,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 + CastleWarsConstants.TICKS_PER_MINUTE - 1) / CastleWarsConstants.TICKS_PER_MINUTE
// Announce at minute boundaries, only once per distinct minute value
if (minutesLeft != lastAnnouncedMinute && minutesLeft in 1..5) {
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!")
}
}
}
}

View file

@ -1,14 +1,28 @@
package rs09.game.content.activity.castlewars.areas
package content.minigame.castlewars.areas
import content.global.skill.summoning.familiar.BurdenBeast
import core.api.*
import content.minigame.castlewars.CastleWars
import content.minigame.castlewars.CastleWarsEquipmentRestrictions
import core.api.LogoutListener
import core.api.MapArea
import core.api.removeTimer
import core.api.sendMessage
import core.game.interaction.InteractionListener
import core.game.node.entity.Entity
import core.game.node.entity.player.Player
import rs09.game.content.activity.castlewars.CastleWars
import core.game.node.item.Item
abstract class CastleWarsArea : MapArea, LogoutListener, InteractionListener {
override fun canEquipItem(player: Player, item: Item, equipmentSlot: Int): Boolean? {
return !CastleWarsEquipmentRestrictions.blocksEquip(player, equipmentSlot)
}
override fun areaEnter(entity: Entity) {
val player = entity as? Player ?: return
CastleWarsEquipmentRestrictions.removeInvalidEquipment(player)
}
override fun areaLeave(entity: Entity, logout: Boolean) {
super.areaLeave(entity, logout)
exitArea(entity as? Player ?: return)
@ -48,27 +62,18 @@ abstract class CastleWarsArea : MapArea, LogoutListener, InteractionListener {
// Remove teleblock
removeTimer(player, "teleblock")
// Remove any Castle Wars items
// Todo Remove any tinderboxes or other castle wars items - See Jan 2018 update: https://oldschool.runescape.wiki/w/Castle_Wars
val cwarsItems = intArrayOf(CastleWars.saradominTeamHoodedCloak,
CastleWars.zamorakTeamHoodedCloak,
CastleWars.saradominFlag,
CastleWars.zamorakFlag)
player.equipment.removeAll(cwarsItems)
player.inventory.removeAll(cwarsItems)
(player.familiarManager.familiar as? BurdenBeast)?.container?.removeAll(cwarsItems)
player.equipment.removeAll(CastleWars.CW_ITEMS_TO_REMOVE)
player.inventory.removeAll(CastleWars.CW_ITEMS_TO_REMOVE)
(player.familiarManager.familiar as? BurdenBeast)?.container?.removeAll(CastleWars.CW_ITEMS_TO_REMOVE)
}
override fun defineListeners() {
onUnequip(intArrayOf(CastleWars.saradominTeamHoodedCloak, CastleWars.zamorakTeamHoodedCloak)) { player, _ ->
defineAreaBorders().forEach { border ->
if (border.insideBorder(player)) {
sendMessage(player, "You can't remove your team's colours")
return@onUnequip false
}
if (CastleWarsEquipmentRestrictions.isInCastleWarsArea(player)) {
sendMessage(player, "You can't remove your team's colours")
return@onUnequip false
}
return@onUnequip true
true
}
}

View file

@ -1,67 +1,446 @@
package rs09.game.content.activity.castlewars.areas
package content.minigame.castlewars.areas
import content.minigame.castlewars.*
import content.minigame.castlewars.CastleWarsConstants.PLAYER_CLEANUP_INTERVAL_TICKS
import core.api.*
import core.game.component.Component
import core.game.container.impl.EquipmentContainer
import core.game.event.EventHook
import core.game.event.InterfaceCloseEvent
import core.game.interaction.Option
import core.game.node.Node
import core.game.node.entity.Entity
import core.game.node.entity.combat.CombatStyle
import core.game.node.entity.player.Player
import core.game.node.entity.player.info.login.PlayerParser
import core.game.node.item.Item
import core.game.world.map.Location
import core.game.world.map.zone.ZoneBorders
import core.game.world.map.zone.ZoneType
import core.tools.Log
import core.tools.ticksPerMinute
import org.rs09.consts.Components
import rs09.game.content.activity.castlewars.CastleWars
import java.util.Locale
/**
* Handles the Castle Wars game map
*/
class CastleWarsGameArea : CastleWarsArea(), TickListener {
class CastleWarsGameArea : CastleWarsArea(), TickListener, LoginListener {
companion object {
private val saradominStandardFloor: ZoneBorders = ZoneBorders(Location.create(2426, 3073, 3), Location.create(2430, 3077, 3))
private val zamorakStandardFloor: ZoneBorders = ZoneBorders(Location.create(2373, 3134, 3), Location.create(2369, 3130, 3))
private val saradominUpperFloor: ZoneBorders = ZoneBorders(Location.create(2431, 3080, 2), Location.create(2423, 3072, 2))
private val zamorakUpperFloor: ZoneBorders = ZoneBorders(Location.create(2368, 3127, 2), Location.create(2376, 3135, 2))
private val saradominFloor: ZoneBorders = ZoneBorders(Location.create(2420, 3072, 1), Location.create(2431, 3083, 1))
private val zamorakFloor: ZoneBorders = ZoneBorders(Location.create(2379, 3135, 1), Location.create(2368, 3124, 1))
private val battleField: ZoneBorders = ZoneBorders(Location.create(2368, 3135, 0), Location.create(2431, 3072, 0))
private val saradominTunnels: ZoneBorders = ZoneBorders(Location.create(2430, 9481, 0), Location.create(2400, 9504, 0))
private val zamorakTunnels: ZoneBorders = ZoneBorders(Location.create(2401, 9503, 0), Location.create(2366, 9529, 0))
private val saradominStandardFloor: ZoneBorders =
ZoneBorders(Location.create(2426, 3073, 3), Location.create(2430, 3077, 3))
private val zamorakStandardFloor: ZoneBorders =
ZoneBorders(Location.create(2373, 3134, 3), Location.create(2369, 3130, 3))
private val saradominUpperFloor: ZoneBorders =
ZoneBorders(Location.create(2431, 3080, 2), Location.create(2423, 3072, 2))
private val zamorakUpperFloor: ZoneBorders =
ZoneBorders(Location.create(2368, 3127, 2), Location.create(2376, 3135, 2))
private val saradominFloor: ZoneBorders =
ZoneBorders(Location.create(2420, 3072, 1), Location.create(2431, 3083, 1))
private val zamorakFloor: ZoneBorders =
ZoneBorders(Location.create(2379, 3135, 1), Location.create(2368, 3124, 1))
private val battleField: ZoneBorders =
ZoneBorders(Location.create(2368, 3135, 0), Location.create(2431, 3072, 0))
private val saradominTunnels: ZoneBorders =
ZoneBorders(Location.create(2430, 9481, 0), Location.create(2385, 9504, 0))
private val zamorakTunnels: ZoneBorders =
ZoneBorders(Location.create(2401, 9503, 0), Location.create(2366, 9529, 0))
val areaBorders = arrayOf(saradominStandardFloor, zamorakStandardFloor, saradominUpperFloor, zamorakUpperFloor, saradominFloor, zamorakFloor, battleField, saradominTunnels, zamorakTunnels)
// Center tunnel zone to cover the gap where ladders are located (2399, 9499) and (2400, 9508)
private val centerTunnels: ZoneBorders =
ZoneBorders(Location.create(2396, 9493, 0), Location.create(2405, 9515, 0))
val areaBorders = arrayOf(
saradominStandardFloor,
zamorakStandardFloor,
saradominUpperFloor,
zamorakUpperFloor,
saradominFloor,
zamorakFloor,
battleField,
saradominTunnels,
zamorakTunnels,
centerTunnels
)
val saradominPlayers = mutableSetOf<Player>()
val zamorakPlayers = mutableSetOf<Player>()
var ticksLeftInGame = 0
data class LogoutRecord(
val player: Player, val team: CastleWarsTeam, val logoutTick: Int, val logoutLocation: Location
)
/** Players who have logged out but are within the grace period. Maps normalized username -> logout record. */
val loggedOutPlayers = mutableMapOf<String, LogoutRecord>()
var ticksLeftInGame = -1
private val activeVacancyOffers = mutableMapOf<CastleWarsTeam, MutableSet<Player>>()
private val activeVacancyOfferTicks = mutableMapOf<CastleWarsTeam, Int>()
private val declinedSubstitutionOffers = mutableMapOf<CastleWarsTeam, MutableSet<Player>>()
private val openVacancies = mutableMapOf<CastleWarsTeam, Int>()
fun startGame() {
if (!CastleWarsWaitingArea.hasMinimumPlayers()) {
CastleWarsWaitingArea.gameStartCountdown = CastleWarsConstants.WAITING_ROOM_COUNTDOWN_TICKS
return
}
clearSubstitutionState()
CastleWarsGameState.resetGame()
CastleWarsItemHandler.clearAllBarricades()
CastleWarsWaitingArea.gameStartCountdown = -1
saradominPlayers.addAll(CastleWarsWaitingArea.waitingSaradominPlayers)
zamorakPlayers.addAll(CastleWarsWaitingArea.waitingZamorakPlayers)
CastleWarsWaitingArea.waitingSaradominPlayers.clear()
CastleWarsWaitingArea.waitingZamorakPlayers.clear()
ticksLeftInGame = CastleWars.gameTimeMinutes * ticksPerMinute
// Put all the players in their respawn area
ticksLeftInGame = CastleWars.gameTimeMinutes * CastleWarsConstants.TICKS_PER_MINUTE
saradominPlayers.forEach { player ->
player.properties.teleportLocation = CastleWarsRespawnArea.saradominRespawnRoom.randomWalkableLoc
CastleWarsGameState.activateBraceletIfEquipped(player, CastleWarsTeam.SARADOMIN)
CastleWarsGameState.setActiveGamePlayerOptions(player)
setAttribute(player, CastleWarsConstants.ATTR_JOIN_TICK, getWorldTicks())
}
zamorakPlayers.forEach { player ->
player.properties.teleportLocation = CastleWarsRespawnArea.zamorakRespawnRoom.randomWalkableLoc
CastleWarsGameState.activateBraceletIfEquipped(player, CastleWarsTeam.ZAMORAK)
CastleWarsGameState.setActiveGamePlayerOptions(player)
setAttribute(player, CastleWarsConstants.ATTR_JOIN_TICK, getWorldTicks())
}
val allPlayers = saradominPlayers + zamorakPlayers
allPlayers.forEach { player ->
sendMessage(player, "The Castle Wars game has begun!")
CastleWarsGameState.updateOverlay(player)
}
}
}
private fun endGame() {
saradominPlayers.forEach { player ->
player.properties.teleportLocation = CastleWars.lobbyBankArea.randomWalkableLoc
fun clearSubstitutionState() {
activeVacancyOffers.clear()
activeVacancyOfferTicks.clear()
declinedSubstitutionOffers.clear()
openVacancies.clear()
}
zamorakPlayers.forEach { player ->
player.properties.teleportLocation = CastleWars.lobbyBankArea.randomWalkableLoc
fun selectGuthixTeam(): CastleWarsTeam {
val saraTotal = CastleWarsWaitingArea.waitingSaradominPlayers.size + saradominPlayers.size
val zamTotal = CastleWarsWaitingArea.waitingZamorakPlayers.size + zamorakPlayers.size
return when {
saraTotal < zamTotal -> CastleWarsTeam.SARADOMIN
saraTotal > zamTotal -> CastleWarsTeam.ZAMORAK
Math.random() < 0.5 -> CastleWarsTeam.SARADOMIN
else -> CastleWarsTeam.ZAMORAK
}
}
/**
* Offer a vacancy on the given team to every eligible player in that team's waiting room.
* Called when a player leaves an active game.
* @param team The team that lost a player
*/
fun fillVacancy(team: CastleWarsTeam) {
if (ticksLeftInGame <= 0) return
if ((openVacancies[team] ?: 0) <= 0) return
val waitingSet = when (team) {
CastleWarsTeam.SARADOMIN -> CastleWarsWaitingArea.waitingSaradominPlayers
CastleWarsTeam.ZAMORAK -> CastleWarsWaitingArea.waitingZamorakPlayers
}
pruneVacancyOffer(team, waitingSet)
if (activeVacancyOffers[team]?.isNotEmpty() == true) return
val declined = declinedSubstitutionOffers[team].orEmpty()
val candidates = waitingSet.filter { it !in declined }
if (candidates.isEmpty()) return
activeVacancyOffers[team] = candidates.toMutableSet()
activeVacancyOfferTicks[team] = getWorldTicks()
for (candidate in candidates) {
openDialogue(candidate, CastleWarsSubstitutionDialogue(team))
}
}
fun claimVacancy(player: Player, team: CastleWarsTeam): Boolean {
val offer = activeVacancyOffers[team] ?: return false
if (!offer.contains(player)) return false
val waitingSet = when (team) {
CastleWarsTeam.SARADOMIN -> CastleWarsWaitingArea.waitingSaradominPlayers
CastleWarsTeam.ZAMORAK -> CastleWarsWaitingArea.waitingZamorakPlayers
}
if (!waitingSet.remove(player)) {
offer.remove(player)
if (offer.isEmpty()) {
activeVacancyOffers.remove(team)
activeVacancyOfferTicks.remove(team)
}
return false
}
activeVacancyOffers.remove(team)
activeVacancyOfferTicks.remove(team)
declinedSubstitutionOffers[team]?.remove(player)
val remainingVacancies = ((openVacancies[team] ?: 0) - 1).coerceAtLeast(0)
if (remainingVacancies > 0) {
openVacancies[team] = remainingVacancies
} else {
openVacancies.remove(team)
}
val gameSet = when (team) {
CastleWarsTeam.SARADOMIN -> saradominPlayers
CastleWarsTeam.ZAMORAK -> zamorakPlayers
}
gameSet.add(player)
CastleWarsGameState.activateBraceletIfEquipped(player, team)
CastleWarsGameState.setActiveGamePlayerOptions(player)
setAttribute(player, CastleWarsConstants.ATTR_JOIN_TICK, getWorldTicks())
val respawnLoc = when (team) {
CastleWarsTeam.SARADOMIN -> CastleWarsRespawnArea.saradominRespawnRoom.randomWalkableLoc
CastleWarsTeam.ZAMORAK -> CastleWarsRespawnArea.zamorakRespawnRoom.randomWalkableLoc
}
player.properties.teleportLocation = respawnLoc
sendMessage(player, "A spot has opened up! You have been moved into the Castle Wars game.")
if ((openVacancies[team] ?: 0) > 0) {
fillVacancy(team)
}
return true
}
fun declineVacancyOffer(player: Player, team: CastleWarsTeam) {
activeVacancyOffers[team]?.remove(player)
declinedSubstitutionOffers.getOrPut(team) { mutableSetOf() }.add(player)
if (activeVacancyOffers[team]?.isEmpty() == true) {
activeVacancyOffers.remove(team)
activeVacancyOfferTicks.remove(team)
fillVacancy(team)
}
}
fun removeVacancyOffer(player: Player) {
val teamsToRefill = mutableSetOf<CastleWarsTeam>()
for (team in CastleWarsTeam.values()) {
activeVacancyOffers[team]?.remove(player)
declinedSubstitutionOffers[team]?.remove(player)
if (activeVacancyOffers[team]?.isEmpty() == true) {
activeVacancyOffers.remove(team)
activeVacancyOfferTicks.remove(team)
teamsToRefill.add(team)
}
if (declinedSubstitutionOffers[team]?.isEmpty() == true) {
declinedSubstitutionOffers.remove(team)
}
}
for (team in teamsToRefill) {
fillVacancy(team)
}
}
private fun pruneVacancyOffer(team: CastleWarsTeam, waitingSet: Set<Player>) {
val offer = activeVacancyOffers[team] ?: return
val createdTick = activeVacancyOfferTicks[team] ?: getWorldTicks()
if (getWorldTicks() - createdTick >= CastleWarsConstants.VACANCY_OFFER_TIMEOUT_TICKS) {
activeVacancyOffers.remove(team)
activeVacancyOfferTicks.remove(team)
return
}
offer.removeAll { it !in waitingSet || !it.isActive }
if (offer.isEmpty()) {
activeVacancyOffers.remove(team)
activeVacancyOfferTicks.remove(team)
}
}
fun removePlayerFromGame(player: Player, offerReplacement: Boolean = true): CastleWarsTeam? {
val leavingTeam = when {
saradominPlayers.contains(player) -> CastleWarsTeam.SARADOMIN
zamorakPlayers.contains(player) -> CastleWarsTeam.ZAMORAK
else -> null
}
if (leavingTeam == null) return null
CastleWarsGameState.handlePlayerLeave(player)
player.unhook(SaradominInterfaceCloseHook)
player.unhook(ZamorakInterfaceCloseHook)
clearLogoutState(player)
saradominPlayers.remove(player)
zamorakPlayers.remove(player)
if (offerReplacement) {
openVacancy(leavingTeam)
fillVacancy(leavingTeam)
}
return leavingTeam
}
private fun openVacancy(team: CastleWarsTeam) {
openVacancies[team] = (openVacancies[team] ?: 0) + 1
}
fun playerKey(player: Player): String {
return player.username.lowercase(Locale.ROOT)
}
private fun setTeam(player: Player, team: CastleWarsTeam) {
when (team) {
CastleWarsTeam.SARADOMIN -> {
zamorakPlayers.remove(player)
saradominPlayers.add(player)
}
CastleWarsTeam.ZAMORAK -> {
saradominPlayers.remove(player)
zamorakPlayers.add(player)
}
}
}
private fun removeFromTeam(player: Player) {
saradominPlayers.remove(player)
zamorakPlayers.remove(player)
}
private fun clearLogoutAttributes(player: Player) {
removeAttributes(
player,
CastleWarsConstants.ATTR_LOGOUT_TICK,
CastleWarsConstants.ATTR_LOGOUT_TIME_MILLIS,
CastleWarsConstants.ATTR_LOGOUT_TEAM,
CastleWarsConstants.ATTR_LOGOUT_LOCATION
)
}
private fun clearLogoutState(player: Player) {
loggedOutPlayers.remove(playerKey(player))
clearLogoutAttributes(player)
}
private fun waitingRoomFor(team: CastleWarsTeam): ZoneBorders {
return when (team) {
CastleWarsTeam.SARADOMIN -> CastleWarsWaitingArea.saradominWaitingRoom
CastleWarsTeam.ZAMORAK -> CastleWarsWaitingArea.zamorakWaitingRoom
}
}
private fun waitingSetFor(team: CastleWarsTeam): MutableSet<Player> {
return when (team) {
CastleWarsTeam.SARADOMIN -> CastleWarsWaitingArea.waitingSaradominPlayers
CastleWarsTeam.ZAMORAK -> CastleWarsWaitingArea.waitingZamorakPlayers
}
}
private fun moveLoggedOutPlayerToWaitingRoom(player: Player, team: CastleWarsTeam) {
removeFromTeam(player)
CastleWarsGameState.clearHintIconForPlayer(player)
CastleWarsGameState.restoreAndCleanupPlayer(player)
player.interaction.remove(Option._P_ATTACK)
relocateImmediately(player, waitingRoomFor(team).randomWalkableLoc)
player.equipment.replace(Item(team.cloakId), EquipmentContainer.SLOT_CAPE)
waitingSetFor(team).add(player)
clearLogoutAttributes(player)
}
private fun moveLoggedOutPlayerToLobby(player: Player) {
removeFromTeam(player)
CastleWarsGameState.clearHintIconForPlayer(player)
CastleWarsGameState.restoreAndCleanupPlayer(player)
player.interaction.remove(Option._P_ATTACK)
player.interaction.remove(Option._P_STEAL_FROM)
val lobbyLocation = CastleWars.lobbyBankArea.randomWalkableLoc
relocateImmediately(player, lobbyLocation)
clearLogoutState(player)
}
@Suppress("DEPRECATION")
private fun relocateImmediately(player: Player, destination: Location) {
// Logout restoration may run before the player can process a normal teleport.
player.location = destination
player.properties.teleportLocation = destination
}
fun restoreActiveLogoutOnLogin(player: Player) {
val savedTeamName = getAttribute(player, CastleWarsConstants.ATTR_LOGOUT_TEAM, "")
val savedLogoutTime = getAttribute(player, CastleWarsConstants.ATTR_LOGOUT_TIME_MILLIS, 0L)
if (savedTeamName.isEmpty() || savedLogoutTime <= 0L) return
val team = CastleWarsTeam.values().firstOrNull { it.name == savedTeamName } ?: return
val key = playerKey(player)
val record = loggedOutPlayers.remove(key)
val previousPlayer = record?.player
if (previousPlayer != null && previousPlayer != player) {
removeFromTeam(previousPlayer)
}
val elapsedMillis = System.currentTimeMillis() - savedLogoutTime
val logoutLocation = getAttribute(
player, CastleWarsConstants.ATTR_LOGOUT_LOCATION, record?.logoutLocation ?: player.location
)
if (ticksLeftInGame <= 0) {
moveLoggedOutPlayerToLobby(player)
return
}
if (ticksLeftInGame > 0 && elapsedMillis < CastleWarsConstants.LOGOUT_GRACE_PERIOD_MILLIS) {
relocateImmediately(player, Location.create(logoutLocation))
setTeam(player, team)
clearLogoutAttributes(player)
return
}
moveLoggedOutPlayerToWaitingRoom(player, team)
if (ticksLeftInGame > 0) {
openVacancy(team)
fillVacancy(team)
}
}
object SaradominInterfaceCloseHook : EventHook<InterfaceCloseEvent> {
override fun process(entity: Entity, event: InterfaceCloseEvent) {
val player = entity as? Player ?: return
// Only restore if player is still in the game
if (!saradominPlayers.contains(player)) return
val currentOverlayId = player.interfaceManager.overlay?.id
if (currentOverlayId == null || currentOverlayId != 58) {
player.interfaceManager.openOverlay(Component(Components.CASTLEWARS_STATUS_OVERLAY_SARADOMIN_58))
CastleWarsGameState.updateOverlay(player)
}
}
}
object ZamorakInterfaceCloseHook : EventHook<InterfaceCloseEvent> {
override fun process(entity: Entity, event: InterfaceCloseEvent) {
val player = entity as? Player ?: return
if (!zamorakPlayers.contains(player)) return
val currentOverlayId = player.interfaceManager.overlay?.id
if (currentOverlayId == null || currentOverlayId != 59) {
player.interfaceManager.openOverlay(Component(Components.CASTLEWARS_STATUS_OVERLAY_ZAMORAK_59))
CastleWarsGameState.updateOverlay(player)
}
}
}
fun endCurrentGame(startCooldown: Boolean = true) {
val saraSnapshot = saradominPlayers.toSet()
val zamSnapshot = zamorakPlayers.toSet()
val loggedOutSnapshot = loggedOutPlayers.values.toList()
val loggedOutRewardRecipients = loggedOutSnapshot.associate { it.player to it.team }
CastleWarsGameState.endGame(loggedOutRewardRecipients)
for (record in loggedOutSnapshot) {
moveLoggedOutPlayerToLobby(record.player)
if (!record.player.isActive || record.player.session == null) {
PlayerParser.saveImmediately(record.player)
}
}
CastleWarsItemHandler.clearAllBarricades()
CastleWarsGameState.clearCastleWarsGroundItems()
clearSubstitutionState()
ticksLeftInGame = -1
CastleWarsWaitingArea.gameStartCountdown = if (startCooldown) {
CastleWars.gameCooldownMinutes * CastleWarsConstants.TICKS_PER_MINUTE
} else {
-1
}
saraSnapshot.forEach { player ->
player.unhook(SaradominInterfaceCloseHook)
player.properties.teleportLocation = CastleWars.lobbyBankArea.randomWalkableLoc
}
zamSnapshot.forEach { player ->
player.unhook(ZamorakInterfaceCloseHook)
player.properties.teleportLocation = CastleWars.lobbyBankArea.randomWalkableLoc
}
saradominPlayers.clear()
zamorakPlayers.clear()
CastleWarsRespawnArea.saradominPlayersInRespawnRoom.clear()
CastleWarsRespawnArea.zamorakPlayersInRespawnRoom.clear()
}
saradominPlayers.clear()
zamorakPlayers.clear()
}
override fun defineAreaBorders(): Array<ZoneBorders> {
@ -70,31 +449,193 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener {
override fun areaEnter(entity: Entity) {
val player = entity as? Player ?: return
restoreActiveLogoutOnLogin(player)
if (areaBorders.none { it.insideBorder(player.location) }) {
return
}
val team = CastleWarsGameState.getActiveGameTeam(player)
if (team == null || ticksLeftInGame <= 0) {
log(this::class.java, Log.WARN, "${player.name} entered Castle Wars without an active match membership")
player.properties.teleportLocation = CastleWars.lobbyBankArea.randomWalkableLoc
return
}
super.areaEnter(player)
zone.zoneType = ZoneType.CASTLE_WARS.id
registerTimer (player, spawnTimer("teleblock", (CastleWars.gameTimeMinutes)*60*2))
registerTimer(
player,
spawnTimer("teleblock", CastleWars.gameTimeMinutes * CastleWarsConstants.TICKS_PER_MINUTE, false)
)
when (team) {
CastleWarsTeam.SARADOMIN -> {
player.interfaceManager.openOverlay(Component(Components.CASTLEWARS_STATUS_OVERLAY_SARADOMIN_58))
player.equipment.replace(Item(CastleWars.saradominTeamHoodedCloak), EquipmentContainer.SLOT_CAPE)
player.hook(Event.InterfaceClosed, SaradominInterfaceCloseHook)
}
CastleWarsTeam.ZAMORAK -> {
player.interfaceManager.openOverlay(Component(Components.CASTLEWARS_STATUS_OVERLAY_ZAMORAK_59))
player.equipment.replace(Item(CastleWars.zamorakTeamHoodedCloak), EquipmentContainer.SLOT_CAPE)
player.hook(Event.InterfaceClosed, ZamorakInterfaceCloseHook)
}
if (saradominPlayers.contains(player)) {
player.interfaceManager.openOverlay(Component(Components.CASTLEWARS_STATUS_OVERLAY_SARADOMIN_58))
player.equipment.replace(Item(CastleWars.saradominTeamHoodedCloak), 1)
} else if (zamorakPlayers.contains(player)) {
player.interfaceManager.openOverlay(Component(Components.CASTLEWARS_STATUS_OVERLAY_ZAMORAK_59))
player.equipment.replace(Item(CastleWars.zamorakTeamHoodedCloak), 1)
}
CastleWarsGameState.setActiveGamePlayerOptions(player)
}
override fun exitArea(player: Player) {
super.exitArea(player)
// Remove player from the players set (whichever one that is)
saradominPlayers.remove(player)
zamorakPlayers.remove(player)
}
override fun tick() {
ticksLeftInGame--
if (ticksLeftInGame == 0) {
endGame()
val stillInCastleWars =
areaBorders.any { it.insideBorder(player.location) } || CastleWarsRespawnArea.saradominRespawnRoom.insideBorder(
player.location
) || CastleWarsRespawnArea.zamorakRespawnRoom.insideBorder(player.location)
if (!stillInCastleWars) {
removePlayerFromGame(player)
}
}
override fun logout(player: Player) {
val team = when {
saradominPlayers.contains(player) -> CastleWarsTeam.SARADOMIN
zamorakPlayers.contains(player) -> CastleWarsTeam.ZAMORAK
else -> null
}
if (ticksLeftInGame > 0 && team != null) {
val logoutTick = getWorldTicks()
val logoutLocation = Location.create(player.location)
setAttribute(player, CastleWarsConstants.ATTR_LOGOUT_TICK, logoutTick)
setAttribute(player, CastleWarsConstants.ATTR_LOGOUT_TIME_MILLIS, System.currentTimeMillis())
setAttribute(player, CastleWarsConstants.ATTR_LOGOUT_TEAM, team.name)
setAttribute(player, CastleWarsConstants.ATTR_LOGOUT_LOCATION, logoutLocation)
loggedOutPlayers[playerKey(player)] = LogoutRecord(player, team, logoutTick, logoutLocation)
if (CastleWarsGameState.isCarryingFlag(player)) {
CastleWarsGameState.dropFlag(player)
} else if (CastleWarsGameState.isCarryingOwnFlag(player)) {
CastleWarsGameState.dropOwnFlag(player)
}
CastleWarsGameState.clearHintIconForPlayer(player)
return
} else {
CastleWarsGameState.handlePlayerLeave(player)
CastleWarsGameState.clearHintIconForPlayer(player)
saradominPlayers.remove(player)
zamorakPlayers.remove(player)
}
super.logout(player)
}
override fun login(player: Player) {
restoreActiveLogoutOnLogin(player)
}
override fun tick() {
if (ticksLeftInGame < 0) {
return
}
if (ticksLeftInGame == 0) {
endCurrentGame()
return
}
val previousMinutes =
(ticksLeftInGame + CastleWarsConstants.TICKS_PER_MINUTE - 1) / CastleWarsConstants.TICKS_PER_MINUTE
ticksLeftInGame--
val currentMinutes =
(ticksLeftInGame + CastleWarsConstants.TICKS_PER_MINUTE - 1) / CastleWarsConstants.TICKS_PER_MINUTE
if (ticksLeftInGame == 0) {
endCurrentGame()
return
} else if (currentMinutes != previousMinutes) {
CastleWarsGameState.updateAllOverlays()
}
CastleWarsGameState.checkFlagCarriers()
CastleWarsGameState.checkDroppedFlagTimeouts()
saradominPlayers.forEach { ensureGameOverlay(it, CastleWarsTeam.SARADOMIN) }
zamorakPlayers.forEach { ensureGameOverlay(it, CastleWarsTeam.ZAMORAK) }
if (ticksLeftInGame % PLAYER_CLEANUP_INTERVAL_TICKS == 0) {
cleanupInactivePlayers()
}
}
/**
* Remove inactive players from team tracking sets.
*/
private fun cleanupInactivePlayers() {
val currentTick = getWorldTicks()
// Check logged-out players whose grace period has expired
val expiredLogouts = loggedOutPlayers.filter { (_, record) ->
currentTick - record.logoutTick >= CastleWarsConstants.LOGOUT_GRACE_PERIOD_TICKS
}
for ((key, record) in expiredLogouts) {
val player = record.player
loggedOutPlayers.remove(key)
clearLogoutAttributes(player)
removeFromTeam(player)
CastleWarsGameState.clearHintIconForPlayer(player)
CastleWarsGameState.restoreAndCleanupPlayer(player)
openVacancy(record.team)
fillVacancy(record.team)
}
// Remove truly inactive players (not in grace period)
val inactiveSara = saradominPlayers.filter { player ->
(!player.isActive || player.session == null) && !loggedOutPlayers.containsKey(playerKey(player))
}
val inactiveZam = zamorakPlayers.filter { player ->
(!player.isActive || player.session == null) && !loggedOutPlayers.containsKey(playerKey(player))
}
for (player in inactiveSara) {
saradominPlayers.remove(player)
CastleWarsGameState.clearHintIconForPlayer(player)
openVacancy(CastleWarsTeam.SARADOMIN)
fillVacancy(CastleWarsTeam.SARADOMIN)
}
for (player in inactiveZam) {
zamorakPlayers.remove(player)
CastleWarsGameState.clearHintIconForPlayer(player)
openVacancy(CastleWarsTeam.ZAMORAK)
fillVacancy(CastleWarsTeam.ZAMORAK)
}
// Also cleanup hint icons for any other inactive players
CastleWarsGameState.cleanupInactiveHintIcons()
}
private fun ensureGameOverlay(player: Player, team: CastleWarsTeam) {
val overlayId = when (team) {
CastleWarsTeam.SARADOMIN -> Components.CASTLEWARS_STATUS_OVERLAY_SARADOMIN_58
CastleWarsTeam.ZAMORAK -> Components.CASTLEWARS_STATUS_OVERLAY_ZAMORAK_59
}
if (player.interfaceManager.overlay?.id != overlayId) {
player.interfaceManager.openOverlay(Component(overlayId))
CastleWarsGameState.updateOverlay(player)
}
}
override fun isPvpAllowed(entity: Entity, target: Node?, style: CombatStyle?, message: Boolean): Boolean? {
val attacker = entity as? Player ?: return null
val victim = target as? Player ?: return null
if (ticksLeftInGame <= 0) {
return false
}
val attackerTeam = CastleWarsGameState.getActiveGameTeam(attacker) ?: return false
val victimTeam = CastleWarsGameState.getActiveGameTeam(victim) ?: return false
if (attackerTeam == victimTeam) {
if (message) {
sendMessage(attacker, "You can only attack players on the opposite team!")
}
return false
}
return true
}
override fun modifyOutgoingHitInArea(attacker: Entity, target: Entity, hit: Int): Int? {
val attackingPlayer = attacker as? Player ?: return null
val victim = target as? Player ?: return null
if (!CastleWarsGameState.isGameActive || CastleWarsGameState.getActiveGameTeam(attackingPlayer) == null ||
CastleWarsGameState.getActiveGameTeam(victim) == null
) {
return null
}
return CastleWarsGameState.applyBraceletDamageBoost(attackingPlayer, victim, hit)
}
}

View file

@ -1,12 +1,18 @@
package rs09.game.content.activity.castlewars.areas
package content.minigame.castlewars.areas
import content.minigame.castlewars.CastleWars
import content.minigame.castlewars.CastleWarsConstants
import content.minigame.castlewars.CastleWarsGameState
import content.minigame.castlewars.CastleWarsTeam
import core.api.TickListener
import core.api.getWorldTicks
import core.api.sendMessage
import core.game.node.entity.Entity
import core.game.node.entity.player.Player
import core.game.container.impl.EquipmentContainer
import core.game.node.item.Item
import core.game.world.map.Location
import core.game.world.map.zone.ZoneBorders
import rs09.game.content.activity.castlewars.CastleWars
/**
* Handles the Castle Wars respawn rooms
@ -14,11 +20,13 @@ import rs09.game.content.activity.castlewars.CastleWars
class CastleWarsRespawnArea : CastleWarsArea(), TickListener {
companion object {
val zamorakRespawnRoom: ZoneBorders = ZoneBorders(Location.create(2376, 3127, 1), Location.create(2368, 3135, 1))
val saradominRespawnRoom: ZoneBorders = ZoneBorders(Location.create(2423, 3080, 1), Location.create(2431, 3072, 1))
val zamorakRespawnRoom: ZoneBorders =
ZoneBorders(Location.create(2376, 3127, 1), Location.create(2368, 3135, 1))
val saradominRespawnRoom: ZoneBorders =
ZoneBorders(Location.create(2423, 3080, 1), Location.create(2431, 3072, 1))
val zamorakPlayersInRespawnRoom = mutableSetOf<Player>()
val saradominPlayersInRespawnRoom = mutableSetOf<Player>()
val zamorakPlayersInRespawnRoom = mutableMapOf<Player, Int>()
val saradominPlayersInRespawnRoom = mutableMapOf<Player, Int>()
}
override fun defineAreaBorders(): Array<ZoneBorders> {
@ -27,14 +35,84 @@ class CastleWarsRespawnArea : CastleWarsArea(), TickListener {
override fun areaEnter(entity: Entity) {
val player = entity as? Player ?: return
if (saradominRespawnRoom.insideBorder(player.location)) {
player.equipment.replace(Item(CastleWars.saradominTeamHoodedCloak), 1)
} else if (zamorakRespawnRoom.insideBorder(player.location)) {
player.equipment.replace(Item(CastleWars.zamorakTeamHoodedCloak), 1)
val team = CastleWarsGameState.getActiveGameTeam(player)
if (team == null || CastleWarsGameArea.ticksLeftInGame <= 0) {
player.properties.teleportLocation = CastleWars.lobbyBankArea.randomWalkableLoc
return
}
super.areaEnter(player)
if (CastleWarsGameState.isCarryingAnyFlag(player)) {
if (CastleWarsGameState.isCarryingFlag(player)) {
CastleWarsGameState.dropFlag(player)
} else if (CastleWarsGameState.isCarryingOwnFlag(player)) {
CastleWarsGameState.dropOwnFlag(player)
}
sendMessage(player, "You cannot take the flag into your respawn room!")
}
when (team) {
CastleWarsTeam.SARADOMIN -> {
if (!saradominRespawnRoom.insideBorder(player.location)) {
player.properties.teleportLocation = saradominRespawnRoom.randomWalkableLoc
return
}
player.equipment.replace(Item(team.cloakId), EquipmentContainer.SLOT_CAPE)
saradominPlayersInRespawnRoom[player] = getWorldTicks()
}
CastleWarsTeam.ZAMORAK -> {
if (!zamorakRespawnRoom.insideBorder(player.location)) {
player.properties.teleportLocation = zamorakRespawnRoom.randomWalkableLoc
return
}
player.equipment.replace(Item(team.cloakId), EquipmentContainer.SLOT_CAPE)
zamorakPlayersInRespawnRoom[player] = getWorldTicks()
}
}
CastleWarsGameState.setActiveGamePlayerOptions(player)
}
override fun tick() {
if (CastleWarsGameArea.ticksLeftInGame <= 0) {
saradominPlayersInRespawnRoom.clear()
zamorakPlayersInRespawnRoom.clear()
return
}
removePlayersOverSpawnLimit(saradominPlayersInRespawnRoom)
removePlayersOverSpawnLimit(zamorakPlayersInRespawnRoom)
}
private fun removePlayersOverSpawnLimit(players: MutableMap<Player, Int>) {
players.entries.removeIf { (player, _) -> !player.isActive || player.session == null }
val currentTick = getWorldTicks()
val expiredPlayers = players.filter { (player, entryTick) ->
player.isActive && player.session != null && currentTick - entryTick >= CastleWarsConstants.LOGOUT_GRACE_PERIOD_TICKS
}.keys
for (player in expiredPlayers) {
players.remove(player)
CastleWarsGameArea.removePlayerFromGame(player)
player.properties.teleportLocation = CastleWars.lobbyBankArea.randomWalkableLoc
sendMessage(player, "You have been removed from Castle Wars for staying in the spawn room too long.")
}
}
override fun logout(player: Player) {
if (!defineAreaBorders().any { it.insideBorder(player.location) }) {
return
}
saradominPlayersInRespawnRoom.remove(player)
zamorakPlayersInRespawnRoom.remove(player)
if (CastleWarsGameArea.ticksLeftInGame > 0 && CastleWarsGameState.getActiveGameTeam(player) != null) {
CastleWarsGameArea().logout(player)
return
}
super.logout(player)
}
override fun exitArea(player: Player) {
super.exitArea(player)
// Remove player from respawn room tracking
saradominPlayersInRespawnRoom.remove(player)
zamorakPlayersInRespawnRoom.remove(player)
}
}

View file

@ -1,15 +1,17 @@
package rs09.game.content.activity.castlewars.areas
package content.minigame.castlewars.areas
import CastleWarsOverlay
import content.minigame.castlewars.CastleWars
import content.minigame.castlewars.CastleWarsConstants
import content.minigame.castlewars.CastleWarsOverlay
import content.minigame.castlewars.CastleWarsTeam
import core.api.*
import core.game.component.Component
import core.game.node.entity.Entity
import core.game.node.entity.player.Player
import core.game.container.impl.EquipmentContainer
import core.game.node.item.Item
import core.game.world.map.zone.ZoneBorders
import core.tools.ticksPerMinute
import org.rs09.consts.Components
import rs09.game.content.activity.castlewars.CastleWars
/**
* Handles the Castle Wars "waiting room"
@ -24,6 +26,11 @@ class CastleWarsWaitingArea : CastleWarsArea(), TickListener {
val waitingSaradominPlayers = mutableSetOf<Player>()
val waitingZamorakPlayers = mutableSetOf<Player>()
internal var gameStartCountdown = -1
fun hasMinimumPlayers(): Boolean {
return waitingSaradominPlayers.size >= CastleWarsConstants.MIN_PLAYERS_PER_TEAM && waitingZamorakPlayers.size >= CastleWarsConstants.MIN_PLAYERS_PER_TEAM
}
}
override fun defineAreaBorders(): Array<ZoneBorders> {
@ -33,45 +40,75 @@ class CastleWarsWaitingArea : CastleWarsArea(), TickListener {
override fun areaEnter(entity: Entity) {
val player = entity as? Player ?: return
super.areaEnter(player)
registerTimer(player, spawnTimer("teleblock", (CastleWars.gameCooldownMinutes + CastleWars.gameTimeMinutes)*60*2))
registerTimer(
player,
spawnTimer(
"teleblock",
(CastleWars.gameCooldownMinutes + CastleWars.gameTimeMinutes) * CastleWarsConstants.TICKS_PER_MINUTE,
false
)
)
// Set team attribute and equip the hooded cloak on the entity based on which waiting room they're in
if (zamorakWaitingRoom.insideBorder(player.location)) {
player.equipment.replace(Item(CastleWars.zamorakTeamHoodedCloak), 1)
val joinedTeam = if (zamorakWaitingRoom.insideBorder(player.location)) {
player.equipment.replace(Item(CastleWars.zamorakTeamHoodedCloak), EquipmentContainer.SLOT_CAPE)
waitingZamorakPlayers.add(player)
CastleWarsTeam.ZAMORAK
} else if (saradominWaitingRoom.insideBorder(player.location)) {
player.equipment.replace(Item(CastleWars.saradominTeamHoodedCloak), 1)
player.equipment.replace(Item(CastleWars.saradominTeamHoodedCloak), EquipmentContainer.SLOT_CAPE)
waitingSaradominPlayers.add(player)
CastleWarsTeam.SARADOMIN
} else {
null
}
if (joinedTeam != null && CastleWarsGameArea.ticksLeftInGame > 0) {
CastleWarsGameArea.fillVacancy(joinedTeam)
}
var transformed = false
if (player.attributes[CastleWars.portalAttribute] == CastleWars.guthixName
&& (hasGodItem(player, God.ZAMORAK) || hasGodItem(player, God.SARADOMIN))) {
if (player.attributes[CastleWars.portalAttribute] == CastleWars.guthixName && (hasGodItem(
player,
God.ZAMORAK
) || hasGodItem(player, God.SARADOMIN))
) {
// https://www.youtube.com/watch?v=Vg3Om-jZNIY
// Turn the player into a sheep
player.appearance.transformNPC(CastleWars.sheep)
// Send the player a chat dialog
sendDialogue(player, "I pity your faith in my brothers. I shall bless you with some time in the most holy of forms; maybe its wisdom will rub off on you and you'll see the error of your ways.")
sendDialogue(
player,
"I pity your faith in my brothers. I shall bless you with some time in the most holy of forms; maybe its wisdom will rub off on you and you'll see the error of your ways."
)
transformed = true
}
if (player.attributes[CastleWars.portalAttribute] == CastleWars.zamorakName
&& hasGodItem(player, God.SARADOMIN) || hasGodItem(player, God.GUTHIX))
{
if (player.attributes[CastleWars.portalAttribute] == CastleWars.zamorakName && (hasGodItem(
player,
God.SARADOMIN
) || hasGodItem(player, God.GUTHIX))
) {
// https://youtu.be/hmF-CU9OEY4?t=63
// Turn the player into an imp!
player.appearance.transformNPC(CastleWars.imp)
// Send the player a chat dialog
sendDialogue(player, "You're wearing objects of my ignorant brothers and you come to me? Such treachery must be rewarded! Enjoy some time in the most mischievous of forms.")
sendDialogue(
player,
"You're wearing objects of my ignorant brothers and you come to me? Such treachery must be rewarded! Enjoy some time in the most mischievous of forms."
)
transformed = true
}
if (player.attributes[CastleWars.portalAttribute] == CastleWars.saradominName
&& hasGodItem(player, God.ZAMORAK) || hasGodItem(player, God.GUTHIX))
{
if (player.attributes[CastleWars.portalAttribute] == CastleWars.saradominName && (hasGodItem(
player,
God.ZAMORAK
) || hasGodItem(player, God.GUTHIX))
) {
// https://www.youtube.com/watch?v=Vg3Om-jZNIY
// Turn the player into a rabbit!
player.appearance.transformNPC(CastleWars.rabbit)
// Send the player a chat dialog
sendDialogue(player, "You wear objects of my foolish brothers? Perhaps some time spent as the lowliest of forms will help you appreciate the gifts that I can bestow upon my followers.")
sendDialogue(
player,
"You wear objects of my foolish brothers? Perhaps some time spent as the lowliest of forms will help you appreciate the gifts that I can bestow upon my followers."
)
transformed = true
}
@ -87,6 +124,7 @@ class CastleWarsWaitingArea : CastleWarsArea(), TickListener {
// Open the white text overlay
player.interfaceManager.openOverlay(Component(Components.CASTLEWARS_STATUS_OVERLAY_57))
}
override fun exitArea(player: Player) {
@ -94,35 +132,40 @@ class CastleWarsWaitingArea : CastleWarsArea(), TickListener {
// Remove player from waiting room set (whichever one that is)
waitingSaradominPlayers.remove(player)
waitingZamorakPlayers.remove(player)
CastleWarsGameArea.removeVacancyOffer(player)
}
override fun tick() {
var nextStart = Int.MAX_VALUE
// The game will start when there's enough players and CastleWarsGameArea.ticksLeftInGame = -5 minutes
if (CastleWarsGameArea.ticksLeftInGame >= 0) {
// A game is going on, display 5 minutes + remaining game time
nextStart = CastleWarsGameArea.ticksLeftInGame + CastleWars.gameCooldownMinutes * ticksPerMinute
val nextStart: Int
if (CastleWarsGameArea.ticksLeftInGame > 0) {
gameStartCountdown = -1
nextStart =
CastleWarsGameArea.ticksLeftInGame + CastleWars.gameCooldownMinutes * CastleWarsConstants.TICKS_PER_MINUTE
} else {
if (gameStartCountdown < 0) {
gameStartCountdown = CastleWarsConstants.WAITING_ROOM_COUNTDOWN_TICKS
}
nextStart = gameStartCountdown
gameStartCountdown--
}
else if (waitingSaradominPlayers.isEmpty() || waitingZamorakPlayers.isEmpty()) {
// A game is not going and there aren't enough players - Keep the game over time to the current value
CastleWarsGameArea.ticksLeftInGame = -1
}
else {
// There are enough players - Set nextStart to gameCooldownMinutes + ticksLeftInGame
nextStart = CastleWars.gameCooldownMinutes * ticksPerMinute + CastleWarsGameArea.ticksLeftInGame
}
for (player in waitingSaradominPlayers + waitingZamorakPlayers) {
CastleWarsOverlay.sendLobbyUpdate(
player,
waitingSaradominPlayers.isNotEmpty() && waitingZamorakPlayers.isNotEmpty() || CastleWarsGameArea.ticksLeftInGame >= 0,
(nextStart - 1) / ticksPerMinute + 1 // Displays max 5 minutes, min 1 minute, seems authentic
player, ticksToDisplayMinutes(nextStart)
)
}
if (nextStart <= 0) {
if (CastleWarsGameArea.ticksLeftInGame <= 0 && nextStart <= 0) {
gameStartCountdown = -1
if (!hasMinimumPlayers()) {
gameStartCountdown = CastleWarsConstants.WAITING_ROOM_COUNTDOWN_TICKS
return
}
CastleWarsGameArea.startGame()
}
}
private fun ticksToDisplayMinutes(ticks: Int): Int {
if (ticks <= 0) return 0
return (ticks - 1) / CastleWarsConstants.TICKS_PER_MINUTE + 1
}
}

View file

@ -5,7 +5,7 @@ import core.game.world.map.Location
import core.tools.LogLevel
import core.tools.mysql.Database
import core.tools.secondsToTicks
import rs09.game.content.activity.castlewars.CastleWars
import content.minigame.castlewars.CastleWars
import java.math.BigInteger
import java.util.*

View file

@ -1,9 +1,12 @@
package core.api
import core.game.node.Node
import core.game.node.entity.Entity
import core.game.node.entity.combat.CombatStyle
import core.game.node.entity.player.Player
import core.game.node.item.Item
import core.game.world.map.Location
import core.game.world.map.zone.MapZone
import core.game.world.map.zone.RegionZone
import core.game.world.map.zone.ZoneBorders
import core.game.world.map.zone.ZoneRestriction
@ -25,6 +28,23 @@ interface MapArea : ContentInterface {
fun areaEnter(entity: Entity) {}
fun areaLeave(entity: Entity, logout: Boolean) {}
fun entityStep(entity: Entity, location: Location, lastLocation: Location) {}
/**
* Extends MapZone to cater to pvp, team games and all other unique targeting systems.
* Return true/false if you want to override the controls.
* Return null if you want the default check in MapZone. (default)
**/
fun isPvpAllowed(entity: Entity, target: Node?, style: CombatStyle?, message: Boolean): Boolean? { return null }
/**
* Allows an area to reject equipment changes without coupling core equipment code
* to a particular piece of content.
**/
fun canEquipItem(player: Player, item: Item, equipmentSlot: Int): Boolean? { return null }
/**
* Allows an area to modify damage dealt by an entity inside it.
*/
fun modifyOutgoingHitInArea(attacker: Entity, target: Entity, hit: Int): Int? { return null }
companion object {
val zoneMaps = HashMap<String, MapZone>()

View file

@ -317,6 +317,7 @@ public class AIPlayer extends Player {
private ArrayList<Node> getNodeInRange(int range, int entry) {
int meX = this.getLocation().getX();
int meY = this.getLocation().getY();
int meZ = this.getLocation().getZ();
ArrayList<Node> nodes = new ArrayList<Node>();
for (NPC npc : RegionManager.getLocalNpcs(this, range)) {
if (npc.getId() == entry)
@ -324,19 +325,19 @@ public class AIPlayer extends Player {
}
for (int x = 0; x < range; x++) {
for (int y = 0; y < range - x; y++) {
Node node = RegionManager.getObject(0, meX + x, meY + y);
Node node = RegionManager.getObject(meZ, meX + x, meY + y);
if (node != null)
if (node.getId() == entry)
nodes.add(node);
Node node2 = RegionManager.getObject(0, meX + x, meY - y);
Node node2 = RegionManager.getObject(meZ, meX + x, meY - y);
if (node2 != null)
if (node2.getId() == entry)
nodes.add(node2);
Node node3 = RegionManager.getObject(0, meX - x, meY + y);
Node node3 = RegionManager.getObject(meZ, meX - x, meY + y);
if (node3 != null)
if (node3.getId() == entry)
nodes.add(node3);
Node node4 = RegionManager.getObject(0, meX - x, meY - y);
Node node4 = RegionManager.getObject(meZ, meX - x, meY - y);
if (node4 != null)
if (node4.getId() == entry)
nodes.add(node4);
@ -348,7 +349,7 @@ public class AIPlayer extends Player {
private ArrayList<Node> getNodeInRange(int range, List<Integer> entrys) {
int meX = this.getLocation().getX();
int meY = this.getLocation().getY();
//int meX2 = this.getLocation().getX();
int meZ = this.getLocation().getZ();
ArrayList<Node> nodes = new ArrayList<Node>();
for (NPC npc : RegionManager.getLocalNpcs(this, range)) {
@ -357,19 +358,19 @@ public class AIPlayer extends Player {
}
for (int x = 0; x < range; x++) {
for (int y = 0; y < range - x; y++) {
Node node = RegionManager.getObject(0, meX + x, meY + y);
Node node = RegionManager.getObject(meZ, meX + x, meY + y);
if (node != null)
if (entrys.contains(node.getId()))
nodes.add(node);
Node node2 = RegionManager.getObject(0, meX + x, meY - y);
Node node2 = RegionManager.getObject(meZ, meX + x, meY - y);
if (node2 != null)
if (entrys.contains(node2.getId()))
nodes.add(node2);
Node node3 = RegionManager.getObject(0, meX - x, meY + y);
Node node3 = RegionManager.getObject(meZ, meX - x, meY + y);
if (node3 != null)
if (entrys.contains(node3.getId()))
nodes.add(node3);
Node node4 = RegionManager.getObject(0, meX - x, meY - y);
Node node4 = RegionManager.getObject(meZ, meX - x, meY - y);
if (node4 != null)
if (entrys.contains(node4.getId()))
nodes.add(node4);

View file

@ -146,9 +146,17 @@ public final class EquipmentContainer extends Container {
}
boolean listenersSayWeCanUnequip = runUnequipHooks(current, newItem);
if(!listenersSayWeCanUnequip) {
return false;
}
ArrayList<Item> itemsToMoveToInventory = new ArrayList<>(current);
itemsToMoveToInventory.removeIf(item -> canUnequipWithoutInventorySpace(item) && !containsItem(item));
if(itemsToMoveToInventory.isEmpty()) {
return true;
}
boolean allRemoved = true;
for(Item item : current) {
for(Item item : itemsToMoveToInventory) {
if(!remove(item)) {
allRemoved = false;
break;
@ -157,7 +165,7 @@ public final class EquipmentContainer extends Container {
boolean allAdded = allRemoved;
if(allRemoved) {
for (Item item : current) {
for (Item item : itemsToMoveToInventory) {
if (!player.getInventory().add(item, true, preferredSlot)) {
allAdded = false;
break;
@ -168,7 +176,7 @@ public final class EquipmentContainer extends Container {
if (listenersSayWeCanUnequip && allRemoved && allAdded) return true;
else {
//put things back if we couldn't remove everything
for(Item item : current) {
for(Item item : itemsToMoveToInventory) {
if(!containsItem(item)) {
add(item);
}
@ -182,6 +190,9 @@ public final class EquipmentContainer extends Container {
int neededSlots = 0;
for(Item item : current) {
if(canUnequipWithoutInventorySpace(item)) {
continue;
}
if(!item.getDefinition().isStackable()) {
neededSlots++;
} else {
@ -193,6 +204,10 @@ public final class EquipmentContainer extends Container {
return neededSlots;
}
private boolean canUnequipWithoutInventorySpace(Item item) {
return item.getDefinition().getConfiguration(ItemConfigParser.UNEQUIP_WITHOUT_INVENTORY_SPACE, false);
}
@Nullable
private Item getSecondaryEquipIfApplicable(Item newItem, int equipmentSlot) {
Item secondaryEquipItem = null;
@ -211,19 +226,28 @@ public final class EquipmentContainer extends Container {
for(Item currentItem : currentItems) {
Plugin<Object> plugin = currentItem.getDefinition().getConfiguration("equipment", null);
if (plugin != null) {
Object object = plugin.fireEvent("unequip", player, currentItem);
if (object != null && !((Boolean) object)) {
canContinue = false;
if (plugin != null) {
Object object = plugin.fireEvent("unequip", player, currentItem);
if (object != null && !((Boolean) object)) {
if (canUnequipWithoutInventorySpace(currentItem) && !containsItem(currentItem)) {
continue;
}
canContinue = false;
break;
}
}
canContinue = InteractionListeners.run(currentItem.getId(), player, currentItem, false);
if(!canContinue) {
if (canUnequipWithoutInventorySpace(currentItem) && !containsItem(currentItem)) {
canContinue = true;
continue;
}
break;
}
}
canContinue = InteractionListeners.run(currentItem.getId(), player, currentItem, false);
if(!canContinue) break;
}
return canContinue;
}

View file

@ -10,12 +10,14 @@ import core.game.node.Node
import core.game.node.entity.combat.equipment.WeaponInterface
import core.game.node.entity.player.Player
import core.game.node.entity.player.link.audio.Audio
import core.game.node.item.Item
import core.plugin.Plugin
import core.game.interaction.InteractionListener
import core.game.interaction.IntType
import core.game.interaction.InteractionListeners
import core.game.system.config.ItemConfigParser
import org.rs09.consts.Sounds
import java.util.Locale
/**
* Represents the equipment equipping handler plugin.
@ -35,14 +37,22 @@ class EquipHandler : InteractionListener {
val item = node.asItem()
val itemEquipmentSlot = item.definition.getConfiguration<Int>(ItemConfigParser.EQUIP_SLOT, -1)
if (!player.zoneMonitor.canEquip(player, item, itemEquipmentSlot)) {
return
}
val currentEquippedItem = player.equipment[itemEquipmentSlot]
if (item == null || currentEquippedItem == item || item.name.toLowerCase().contains("goblin mail")) {
if (currentEquippedItem == item || item.name.lowercase(Locale.ROOT).contains("goblin mail")) {
return
}
if(currentEquippedItem != null){
if(!InteractionListeners.run(currentEquippedItem.id, player, currentEquippedItem, false)){
return
val listenerRemovedItem =
canUnequipWithoutInventorySpace(currentEquippedItem) && player.equipment[itemEquipmentSlot] == null
if (!listenerRemovedItem) {
return
}
}
}
@ -118,18 +128,31 @@ class EquipHandler : InteractionListener {
if (slot == EquipmentContainer.SLOT_WEAPON) {
player.packetDispatch.sendString("", 92, 0)
}
val maximumAdd = player.inventory.getMaximumAdd(item)
if (maximumAdd < item.amount) {
player.packetDispatch.sendMessage("Not enough free space in your inventory.")
return
if (!canUnequipWithoutInventorySpace(item)) {
val maximumAdd = player.inventory.getMaximumAdd(item)
if (maximumAdd < item.amount) {
player.packetDispatch.sendMessage("Not enough free space in your inventory.")
return
}
}
val plugin = item.definition.getConfiguration<Plugin<Any>>("equipment", null)
if (plugin != null) {
if (!(plugin.fireEvent("unequip", player, item) as Boolean)) {
if (canUnequipWithoutInventorySpace(item) && player.equipment[slot] == null) {
return
}
return
}
}
if (!InteractionListeners.run(itemId, player, item, false)) {
if (canUnequipWithoutInventorySpace(item) && player.equipment[slot] == null) {
return
}
return
}
val maximumAdd = player.inventory.getMaximumAdd(item)
if (maximumAdd < item.amount) {
player.packetDispatch.sendMessage("Not enough free space in your inventory.")
return
}
if (player.equipment.remove(item)) {
@ -145,5 +168,9 @@ class EquipHandler : InteractionListener {
SlayerEquipmentFlags.updateFlags(player)
}
}
private fun canUnequipWithoutInventorySpace(item: Item): Boolean {
return item.definition.getConfiguration(ItemConfigParser.UNEQUIP_WITHOUT_INVENTORY_SPACE, false)
}
}
}

View file

@ -65,6 +65,7 @@ public enum SpecialLadders implements LadderAchievementCheck {
CASTLEWARS_ZAMORAK_TOP_FLOOR_DOWN(Location.create(2374, 3133, 3), Location.create(2374, 3130, 2)),
CASTLEWARS_ZAMORAK_MAIN_FLOOR_STAIRS_UP(Location.create(2380, 3129, 0), Location.create(2379, 3127, 1)),
CASTLEWARS_ZAMORAK_MAIN_FLOOR_STAIRS_DOWN(Location.create(2379, 3127, 1), Location.create(2380, 3129, 0)),
CASTLEWARS_ZAMORAK_OUTERWALL_STAIRS_UP(Location.create(2382, 3130, 0), Location.create(2383, 3132, 0)),
CASTLEWARS_ZAMOUTER_WALL_STAIRS_DOWN(Location.create(2382, 3132, 0), Location.create(2382, 3129, 0)),

View file

@ -32,6 +32,8 @@ public final class Option {
public static final Option _P_PICKPOCKET = new Option("Pickpocket", 4);
public static final Option _P_STEAL_FROM = new Option("Steal from", 4);
public static final Option _P_EXAMINE = new Option("Examine", 7);

View file

@ -585,6 +585,9 @@ abstract class CombatSwingHandler(var type: CombatStyle?) {
if (player.equipment[3] != null && player.equipment[3].id == 14726 && state.style == CombatStyle.MAGIC) {
hit += (hit.toDouble() * 0.15).toInt()
}
if (victim is Player) {
hit = player.zoneMonitor.modifyOutgoingHit(player, victim, hit)
}
}
if (attacker is Familiar && victim is Player) {
if (victim.prayer[PrayerType.PROTECT_FROM_SUMMONING]) {

View file

@ -0,0 +1,26 @@
package core.game.node.entity.combat
import core.game.world.map.Location
import core.game.world.map.zone.MapZone
/**
* Stores the state at the moment death commenced.
* Used to prevent zone-transition exploits during death animation.
*
* When a player dies, there's a delay between death commencement (when HP reaches 0)
* and death finalization (after the death animation plays). During this time, a player
* could theoretically transition between zones. By capturing the state at death start,
* we ensure that item loss/keep decisions are made based on where the player actually died,
* not where they ended up after the animation.
*
* @param deathLocation The location where death commenced
* @param zoneType The zone type ID at death time (from ZoneMonitor.getType())
* @param isSafeZone Whether the player was in a safe zone at death time
* @param deathZones Snapshot of the zone handlers active when death commenced
*/
data class DeathContext(
val deathLocation: Location,
val zoneType: Int,
val isSafeZone: Boolean,
val deathZones: List<MapZone>
)

View file

@ -49,6 +49,9 @@ import core.game.world.map.build.DynamicRegion;
import core.game.world.map.path.Pathfinder;
import core.game.world.map.zone.ZoneRestriction;
import core.game.world.map.zone.ZoneType;
import core.game.world.map.zone.MapZone;
import core.game.world.map.zone.RegionZone;
import core.game.node.entity.combat.DeathContext;
import core.game.world.update.flag.PlayerFlags;
import core.game.world.update.flag.*;
import core.net.IoSession;
@ -86,6 +89,7 @@ import core.worker.ManagementEvents;
import core.game.world.update.flag.context.*;
import java.util.*;
import java.util.stream.Collectors;
import java.util.concurrent.TimeUnit;
import static core.api.ContentAPIKt.*;
@ -610,6 +614,16 @@ public class Player extends Entity {
@Override
public void commenceDeath(Entity killer) {
if (!isPlaying()) return;
DeathContext context = new DeathContext(
Location.create(getLocation()),
getZoneMonitor().getType(),
getProperties().isSafeZone(),
getZoneMonitor().getZones().stream()
.map(RegionZone::getZone)
.distinct()
.collect(Collectors.toList())
);
setAttribute("death-context", context);
super.commenceDeath(killer);
if (prayer.get(PrayerType.RETRIBUTION)) {
prayer.startRetribution(killer);
@ -619,6 +633,11 @@ public class Player extends Entity {
@Override
public void finalizeDeath(Entity killer) {
if (!isPlaying()) return; //if the player has already been full cleared, it has already disconnected. This code is probably getting called because something is maintaining a stale reference.
DeathContext deathContext = getAttribute("death-context", null);
removeAttribute("death-context");
boolean wasSafeZone = deathContext != null ? deathContext.isSafeZone() : getProperties().isSafeZone();
int deathZoneType = deathContext != null ? deathContext.getZoneType() : getZoneMonitor().getType();
Location deathLocation = deathContext != null ? deathContext.getDeathLocation() : getLocation();
GlobalStats.incrementDeathCount();
settings.setSpecialEnergy(100);
settings.updateRunEnergy(settings.getRunEnergy() - 100);
@ -639,7 +658,10 @@ public class Player extends Entity {
incrementAttribute("/save:"+STATS_BASE+":"+STATS_DEATHS);
packetDispatch.sendTempMusic(90);
if (!getZoneMonitor().handleDeath(killer) && (!getProperties().isSafeZone() && getZoneMonitor().getType() != ZoneType.SAFE.getId()) && getDetails().getRights() != Rights.ADMINISTRATOR) {
boolean zoneHandledDeath = deathContext != null
? deathContext.getDeathZones().stream().anyMatch(zone -> zone.death(this, killer))
: getZoneMonitor().handleDeath(killer);
if (!zoneHandledDeath && (!wasSafeZone && deathZoneType != ZoneType.SAFE.getId()) && getDetails().getRights() != Rights.ADMINISTRATOR) {
//If player was a Hardcore Ironman, announce that they died
if (this.getIronmanManager().getMode().equals(IronmanMode.HARDCORE)) { //if this was checkRestriction, ultimate irons would be moved to HARDCORE_DEAD as well
String gender = this.isMale() ? "man " : "woman ";
@ -649,7 +671,7 @@ public class Player extends Entity {
return;
}
}
GroundItemManager.create(new Item(BONES_526), this.getAttribute("/save:original-loc",location), k);
GroundItemManager.create(new Item(BONES_526), this.getAttribute("/save:original-loc", deathLocation), k);
final Container[] c = DeathTask.getContainers(this);
for (Item i : getEquipment().toArray()) {
@ -662,7 +684,7 @@ public class Player extends Entity {
boolean canCreateGrave = GraveController.allowGenerate(this);
if (canCreateGrave) {
Grave g = GraveController.produceGrave(GraveController.getGraveType(this));
g.initialize(this, location, Arrays.stream(c[1].toArray()).filter(Objects::nonNull).toArray(Item[]::new)); //note: the amount of code required to filter nulls from an array in Java is atrocious.
g.initialize(this, deathLocation, Arrays.stream(c[1].toArray()).filter(Objects::nonNull).toArray(Item[]::new)); //note: the amount of code required to filter nulls from an array in Java is atrocious.
} else {
StringBuilder itemsLost = new StringBuilder();
int coins = 0;
@ -693,11 +715,11 @@ public class Player extends Entity {
} else {
item = GraveController.checkTransform(item);
}
GroundItem gi = GroundItemManager.create(item, location, killer instanceof Player ? (Player) killer : this);
GroundItem gi = GroundItemManager.create(item, deathLocation, killer instanceof Player ? (Player) killer : this);
gi.setRemainPrivate(stayPrivate);
}
if (coins > 0) {
GroundItemManager.create(new Item(Items.COINS_995, coins), location, (Player) killer);
GroundItemManager.create(new Item(Items.COINS_995, coins), deathLocation, (Player) killer);
}
if (killer instanceof Player)
PlayerMonitor.log((Player) killer, LogType.PK, "Killed " + name + ", who dropped: " + itemsLost);
@ -719,7 +741,8 @@ public class Player extends Entity {
setComponentVisibility(this, 746, 12, false); //reenable the logout button (HD)
super.finalizeDeath(killer);
appearance.sync();
if (!getSavedData().getGlobalData().isDeathScreenDisabled()) {
// Don't show death tutorial in safe minigames (zone handled death) or if player disabled it
if (!zoneHandledDeath && !getSavedData().getGlobalData().isDeathScreenDisabled()) {
getInterfaceManager().open(new Component(153));
}
}
@ -771,16 +794,17 @@ public class Player extends Entity {
return false;
}
if (entity instanceof Player) {
Player p = (Player) entity;
if (p.getSkullManager().isWilderness() && skullManager.isWilderness()) {
Player attacker = (Player) entity;
if (attacker.getSkullManager().isWilderness() && skullManager.isWilderness()) {
if (!GameWorld.getSettings().getWild_pvp_enabled())
return false;
if (p.getSkullManager().hasWildernessProtection())
if (attacker.getSkullManager().hasWildernessProtection())
return false;
if (skullManager.hasWildernessProtection())
return false;
return super.isAttackable(entity, style, message);
} else return false;
} else if (!getZoneMonitor().isPvPable(entity, this, style, message)) {
return false;
}
}
return super.isAttackable(entity, style, message);
}
@ -806,7 +830,7 @@ public class Player extends Entity {
return false;
}
return !skullManager.hasWildernessProtection();
} else {
} else if (!getZoneMonitor().isPvPable(this, target, style, message)) {
return false;
}
}

View file

@ -227,6 +227,12 @@ class ItemConfigParser {
*/
const val CASTLE_WARS_TICKET_PRICE = "castle_wars_ticket_price"
/**
* Whether an unequip listener may remove the item without first requiring
* enough inventory space to hold it.
*/
const val UNEQUIP_WITHOUT_INVENTORY_SPACE = "unequip_without_inventory_space"
}
val parser = JSONParser()
@ -264,6 +270,7 @@ class ItemConfigParser {
"fun_weapon",
"rare_item",
"bankable",
UNEQUIP_WITHOUT_INVENTORY_SPACE,
"two_handed",
"has_special",
"remove_sleeves",
@ -311,4 +318,4 @@ class ItemConfigParser {
}
log(this::class.java, Log.FINE, "Parsed $count item configs.")
}
}
}

View file

@ -7,18 +7,20 @@ import core.game.system.timer.PersistTimer
import core.game.system.timer.RSTimer
import core.game.system.timer.TimerFlag
class Teleblock : PersistTimer (1, "teleblock", flags = arrayOf(TimerFlag.ClearOnDeath)) {
class Teleblock(private val sendChatboxMessage: Boolean = true) :
PersistTimer(1, "teleblock", flags = arrayOf(TimerFlag.ClearOnDeath)) {
override fun run (entity: Entity) : Boolean {
return false
}
override fun onRegister (entity: Entity) {
if (entity !is Player) return
if (!sendChatboxMessage) return
sendMessage (entity, "You have been teleblocked.")
}
override fun getTimer (vararg args: Any) : RSTimer {
val t = Teleblock()
val t = Teleblock(args.getOrNull(1) as? Boolean ?: true)
t.runInterval = args.getOrNull(0) as? Int ?: 100
return t
}

View file

@ -128,7 +128,21 @@ public abstract class MapZone implements Zone {
public boolean actionButton(Player player, int interfaceId, int buttonId, int slot, int itemId, int opcode) {
return false;
}
/**
* Checks if the entity is able to continue attacking another player within this zone.
* This is added like continueAttack for player vs player fighting.
*
* @param e the attacking entity
* @param target the target node being attacked
* @param style the combat style being used
* @param message whether to send a message to the player explaining why the attack cannot continue
* @return {@code true} if the zone allows the attack to continue
*/
public boolean continuePvp(Entity e, Node target, CombatStyle style, boolean message) {
return false;
}
/**
* Checks if the entity is able to continue attacking the target within this zone.
*
@ -154,6 +168,22 @@ public abstract class MapZone implements Zone {
return true;
}
/**
* Checks whether an item may be equipped in this zone.
*/
public boolean canEquip(Player player, Item item, int equipmentSlot) {
return true;
}
/**
* Applies zone-specific outgoing damage modifiers.
*
* @return the modified hit, or {@code null} when this zone has no modifier.
*/
public Integer modifyOutgoingHit(Entity attacker, Entity target, int hit) {
return null;
}
/**
* If multi-zone boundaries should be ignored.
* @param attacker The attacker.
@ -500,4 +530,4 @@ public abstract class MapZone implements Zone {
public int hashCode() {
return Objects.hash(uid, name, overlappable, fireRandomEvents, restriction, zoneType);
}
}
}

View file

@ -5,81 +5,83 @@ import core.game.node.Node;
import core.game.node.entity.Entity;
import core.game.node.entity.combat.CombatStyle;
import core.game.node.entity.player.Player;
import core.game.node.entity.player.link.music.MusicEntry;
import core.game.node.entity.player.link.music.MusicZone;
import core.game.node.entity.player.link.request.RequestType;
import core.game.node.item.Item;
import core.game.world.map.Location;
import core.game.world.map.Region;
import org.rs09.consts.Items;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Set;
import core.game.world.map.Region;
import org.rs09.consts.Items;
/**
* Handles the zones for an entity.
*
* @author Emperor
*/
public final class ZoneMonitor {
/**
* The set of jewellery which allow teleporting from up to and including level 30 wildy.
* Used to check if a player can teleport from 20 < level <= 30 wildy, see canTeleportByJewellery.
* Note: the check is based on the nextJewellery (see EnchantedJewellery.kt), so this list should not contain the (4) items, and should contain the empty ones.
*
* @author Player Name
*/
static final Set<Integer> MID_WILDY_TELEPORT_JEWELLERY = Set.of(
Items.AMULET_OF_GLORY_1704,
Items.AMULET_OF_GLORY1_1706,
Items.AMULET_OF_GLORY2_1708,
Items.AMULET_OF_GLORY3_1710,
Items.AMULET_OF_GLORYT_10362,
Items.AMULET_OF_GLORYT1_10360,
Items.AMULET_OF_GLORYT2_10358,
Items.AMULET_OF_GLORYT3_10356,
Items.SKILLS_NECKLACE_11113,
Items.SKILLS_NECKLACE1_11111,
Items.SKILLS_NECKLACE2_11109,
Items.SKILLS_NECKLACE3_11107,
Items.COMBAT_BRACELET_11126,
Items.COMBAT_BRACELET1_11124,
Items.COMBAT_BRACELET2_11122,
Items.COMBAT_BRACELET3_11120,
Items.RING_OF_WEALTH_14638,
Items.RING_OF_WEALTH1_14640,
Items.RING_OF_WEALTH2_14642,
Items.RING_OF_WEALTH3_14644,
Items.RING_OF_LIFE_2570
Items.AMULET_OF_GLORY_1704,
Items.AMULET_OF_GLORY1_1706,
Items.AMULET_OF_GLORY2_1708,
Items.AMULET_OF_GLORY3_1710,
Items.AMULET_OF_GLORYT_10362,
Items.AMULET_OF_GLORYT1_10360,
Items.AMULET_OF_GLORYT2_10358,
Items.AMULET_OF_GLORYT3_10356,
Items.SKILLS_NECKLACE_11113,
Items.SKILLS_NECKLACE1_11111,
Items.SKILLS_NECKLACE2_11109,
Items.SKILLS_NECKLACE3_11107,
Items.COMBAT_BRACELET_11126,
Items.COMBAT_BRACELET1_11124,
Items.COMBAT_BRACELET2_11122,
Items.COMBAT_BRACELET3_11120,
Items.RING_OF_WEALTH_14638,
Items.RING_OF_WEALTH1_14640,
Items.RING_OF_WEALTH2_14642,
Items.RING_OF_WEALTH3_14644,
Items.RING_OF_LIFE_2570
);
/**
* The entity.
*/
private final Entity entity;
/**
* The currently entered zones.
*/
private final List<RegionZone> zones = new ArrayList<>(20);
/**
* The currently entered music zones.
*/
private final List<MusicZone> musicZones = new ArrayList<>(20);
/**
* Constructs a new {@code ZoneMonitor} {@code Object}.
*
* @param entity The entity.
*/
public ZoneMonitor(Entity entity) {
this.entity = entity;
}
/**
* Gets the zone type.
*
* @return The zone type.
*/
public int getType() {
@ -90,9 +92,10 @@ public final class ZoneMonitor {
}
return 0;
}
/**
* Checks if the player can logout.
*
* @return {@code True} if so.
*/
public boolean canLogout() {
@ -103,18 +106,20 @@ public final class ZoneMonitor {
}
return true;
}
/**
* Checks if the restriction was flagged.
*
* @param restriction The restriction flag.
* @return {@code True} if so.
*/
public boolean isRestricted(ZoneRestriction restriction) {
return isRestricted(restriction.getFlag());
}
/**
* Checks if the restriction was flagged.
*
* @param flag The restriction flag.
* @return {@code True} if so.
*/
@ -126,9 +131,10 @@ public final class ZoneMonitor {
}
return false;
}
/**
* Handles a death.
*
* @param killer The killer.
* @return {@code True} if the death got handled.
*/
@ -140,13 +146,31 @@ public final class ZoneMonitor {
}
return false;
}
/**
* Checks if the entity is able to continue attacking the target.
* Checks whether any current zone explicitly allows PvP against the target.
*
* @param player The attacking entity.
* @param target The target.
* @param style The combat style used.
* @return {@code True} if so.
*/
public boolean isPvPable(Entity player, Node target, CombatStyle style, boolean message) {
for (RegionZone z : zones) {
if (z.getZone().continuePvp(player, target, style, message)) {
return true;
}
}
return false;
}
/**
* Checks if the entity is able to continue attacking the target.
*
* @param target The target.
* @param style The combat style used.
* @return {@code True} if so.
*/
public boolean continueAttack(Node target, CombatStyle style, boolean message) {
if (target instanceof Entity) {
if (!entity.continueAttack((Entity) target, style, message)) {
@ -158,22 +182,41 @@ public final class ZoneMonitor {
return false;
}
}
if (entity instanceof Player && target instanceof Player) {
if (!((Player) entity).getSkullManager().isWilderness() || !((Player) target).getSkullManager().isWilderness()) {
if(message) {
((Player) entity).getPacketDispatch().sendMessage("You can only attack other players in the wilderness.");
}
return false;
}
}
if (target instanceof Entity && !MapZone.checkMulti(entity, (Entity) target, message)) {
return false;
}
return true;
}
/**
* Checks whether all current zones permit an equipment change.
*/
public boolean canEquip(Player player, Item item, int equipmentSlot) {
for (RegionZone z : zones) {
if (!z.getZone().canEquip(player, item, equipmentSlot)) {
return false;
}
}
return true;
}
/**
* Applies outgoing damage modifiers from the entity's current zones.
*/
public int modifyOutgoingHit(Entity attacker, Entity target, int hit) {
int modifiedHit = hit;
for (RegionZone z : zones) {
Integer zoneHit = z.getZone().modifyOutgoingHit(attacker, target, modifiedHit);
if (zoneHit != null) {
modifiedHit = zoneHit;
}
}
return modifiedHit;
}
/**
* Checks if the entity can interact with the target.
*
* @param target The target to interact with.
* @param option The option.
* @return {@code True} if the option got handled.
@ -186,26 +229,27 @@ public final class ZoneMonitor {
}
return false;
}
/**
* Checks if a zone handles a useWith interaction
*/
public boolean useWith(Item used, Node with){
public boolean useWith(Item used, Node with) {
for (RegionZone z : zones) {
if (z.getZone().handleUseWith(entity.asPlayer(), used,with)) {
if (z.getZone().handleUseWith(entity.asPlayer(), used, with)) {
return true;
}
}
return false;
}
/**
* Checks if the player handled the reward button using a map zone.
*
* @param interfaceId The interface id.
* @param buttonId The button id.
* @param slot The slot.
* @param itemId The item id.
* @param opcode The packet opcode.
* @param buttonId The button id.
* @param slot The slot.
* @param itemId The item id.
* @param opcode The packet opcode.
* @return {@code True} if the button got handled.
*/
public boolean clickButton(int interfaceId, int buttonId, int slot, int itemId, int opcode) {
@ -216,9 +260,10 @@ public final class ZoneMonitor {
}
return false;
}
/**
* Checks if multiway combat zone rules should be ignored.
*
* @param victim The victim.
* @return {@code True} if this entity can attack regardless of multiway
* combat zone.
@ -231,9 +276,10 @@ public final class ZoneMonitor {
}
return false;
}
/**
* Checks if the entity can teleport.
*
* @param type The teleport type (0=spell, 1=item, 2=object, 3=npc -1= force)
* @return {@code True} if so.
*/
@ -251,9 +297,10 @@ public final class ZoneMonitor {
}
return true;
}
/**
* Checks if a player can teleport with a jewellery piece in >= 1 <= 30 wilderness level
*
* @return {@code True} if so.
*/
private boolean canTeleportByJewellery(int type, Node node) {
@ -262,11 +309,11 @@ public final class ZoneMonitor {
}
if (entity.timers.getTimer("teleblock") != null)
return false;
if (entity.getZoneMonitor().isRestricted(ZoneRestriction.TELEPORT)) {
return false;
}
if (entity.getLocks().isTeleportLocked()) {
if (entity.isPlayer()) {
Player p = entity.asPlayer();
@ -275,12 +322,13 @@ public final class ZoneMonitor {
}
}
}
return false;
}
/**
* Checks if the death should start for an entity.
*
* @param entity the entity.
* @param killer the killer.
* @return {@code True} if so.
@ -293,9 +341,10 @@ public final class ZoneMonitor {
}
return true;
}
/**
* Checks if the entity can fire a random event.
*
* @return {@code True} if so.
*/
public boolean canFireRandomEvent() {
@ -306,9 +355,10 @@ public final class ZoneMonitor {
}
return true;
}
/**
* Clears the zones.
*
* @return {@code True} if the entity successfully left all regions.
*/
public boolean clear() {
@ -324,10 +374,11 @@ public final class ZoneMonitor {
musicZones.clear();
return true;
}
/**
* Checks if the entity can move.
* @param location The destination location.
*
* @param location The destination location.
* @param destination The destination location.
* @return {@code True} if so.
*/
@ -339,18 +390,19 @@ public final class ZoneMonitor {
}
return true;
}
/**
* Handles a location update.
*
* @param last The last location of the entity.
* @return {@code false} If the entity could not enter/leave a region.
*/
public boolean updateLocation(Location last) {
if(entity instanceof Player && !entity.asPlayer().isArtificial()) {
if (entity instanceof Player && !entity.asPlayer().isArtificial()) {
checkMusicZones();
}
entity.updateLocation(last);
for (Iterator<RegionZone> it = zones.iterator(); it.hasNext();) {
for (Iterator<RegionZone> it = zones.iterator(); it.hasNext(); ) {
RegionZone zone = it.next();
if (!zone.getBorders().insideBorder(entity)) {
if (zone.getZone().isDynamicZone()) {
@ -385,7 +437,7 @@ public final class ZoneMonitor {
}
return true;
}
/**
* Checks the music zones.
*/
@ -395,7 +447,7 @@ public final class ZoneMonitor {
}
Player player = (Player) entity;
Location l = player.getLocation();
for (Iterator<MusicZone> it = musicZones.iterator(); it.hasNext();) {
for (Iterator<MusicZone> it = musicZones.iterator(); it.hasNext(); ) {
MusicZone zone = it.next();
if (!zone.getBorders().insideBorder(l.getX(), l.getY())) {
if (zone.leave(player, false)) {
@ -419,11 +471,12 @@ public final class ZoneMonitor {
player.getMusicPlayer().unlock(music, true);
}
}
/**
* Parses commands in a certain zone.
* @param player the player.
* @param name the name.
*
* @param player the player.
* @param name the name.
* @param arguments the arguments.
* @return {@code True} if parsed.
*/
@ -435,10 +488,11 @@ public final class ZoneMonitor {
}
return false;
}
/**
* Checks if a request can be made in this zone.
* @param type the type.
*
* @param type the type.
* @param target the target.
* @return {@code True} if so.
*/
@ -450,9 +504,10 @@ public final class ZoneMonitor {
}
return true;
}
/**
* Checks if the entity is in a zone.
*
* @param name The name of the zone.
* @return {@code True} if so.
*/
@ -465,34 +520,37 @@ public final class ZoneMonitor {
}
return false;
}
/**
* Removes the proper region zone for the given map zone.
*
* @param zone The map zone.
*/
public void remove(MapZone zone) {
for (Iterator<RegionZone> it = zones.iterator(); it.hasNext();) {
for (Iterator<RegionZone> it = zones.iterator(); it.hasNext(); ) {
if (it.next().getZone() == zone) {
it.remove();
break;
}
}
}
/**
* Gets the zones list.
*
* @return The list of region zones the entity is in.
*/
public List<RegionZone> getZones() {
return zones;
}
/**
* Gets the musicZones.
*
* @return The musicZones.
*/
public List<MusicZone> getMusicZones() {
return musicZones;
}
}

View file

@ -7,13 +7,17 @@ import core.game.activity.ActivityPlugin
import core.game.bots.PlayerScripts
import core.game.interaction.InteractionListener
import core.game.interaction.InterfaceListener
import core.game.node.Node
import core.game.node.entity.Entity
import core.game.node.entity.combat.CombatStyle
import core.game.node.entity.npc.NPCBehavior
import core.game.node.entity.player.Player
import core.game.node.entity.player.info.login.LoginConfiguration
import core.game.node.entity.player.info.login.PlayerSaveParser
import core.game.node.entity.player.info.login.PlayerSaver
import core.game.node.entity.player.link.quest.Quest
import core.game.node.entity.player.link.quest.QuestRepository
import core.game.node.item.Item
import core.game.system.timer.RSTimer
import core.game.system.timer.TimerRegistry
import core.game.world.GameWorld
@ -128,6 +132,25 @@ object ClassScanner {
if(e != null && from != null && to != null) clazz.entityStep(e, to, from)
return super.move(e, from, to)
}
// Additional hook for continueAttack for PVP area controls
override fun continuePvp(e: Entity, target: Node?, style: CombatStyle?, message: Boolean): Boolean {
val clazzAttack = clazz.isPvpAllowed(e, target, style, message)
if (clazzAttack == null) { // If clazz returns null, continue the usual check.
return super.continuePvp(e, target, style, message)
} else {
return clazzAttack // Otherwise return whatever function.
}
}
override fun canEquip(player: Player, item: Item, equipmentSlot: Int): Boolean {
return clazz.canEquipItem(player, item, equipmentSlot)
?: super.canEquip(player, item, equipmentSlot)
}
override fun modifyOutgoingHit(attacker: Entity, target: Entity, hit: Int): Int? {
return clazz.modifyOutgoingHitInArea(attacker, target, hit)
}
}
for(border in clazz.defineAreaBorders()) zone.register(border)
ZoneBuilder.configure(zone)

View file

@ -17,7 +17,12 @@ import core.game.node.entity.player.info.Rights
class EquipTests {
companion object {
init {TestUtils.preTestSetup(); EquipHandler().defineListeners()}
init {
TestUtils.preTestSetup()
if (InteractionListeners.get("equip", IntType.ITEM.ordinal) == null) {
EquipHandler().defineListeners()
}
}
}
@Test fun equipShouldFireEquipListeners() {
@ -52,6 +57,26 @@ class EquipTests {
Assertions.assertEquals(true, didRun)
}
@Test fun fullInventoryUnequipShouldNotFireUnequipListeners() {
var didRun = false
val listener = object : InteractionListener {
override fun defineListeners() {
onUnequip(Items.ABYSSAL_WHIP_4151) { _, _ -> didRun = true; return@onUnequip true }
}
}
listener.defineListeners()
TestUtils.getMockPlayer("fullunequip").use { player ->
player.equipment.replace(Item(Items.ABYSSAL_WHIP_4151), EquipmentSlot.WEAPON.ordinal)
player.inventory.add(Item(Items.CUP_OF_TEA_1978, player.inventory.capacity()))
EquipHandler.unequip(player, EquipmentSlot.WEAPON.ordinal, Items.ABYSSAL_WHIP_4151)
Assertions.assertEquals(false, didRun)
Assertions.assertEquals(Items.ABYSSAL_WHIP_4151, player.equipment[EquipmentSlot.WEAPON.ordinal].id)
}
}
@Test fun equippingItemThatReplacesAnotherItemShouldCallUnequipListenersForTheReplacedItem() {
var didRun = false
val listener = object : InteractionListener {

File diff suppressed because it is too large Load diff

View file

@ -92,7 +92,7 @@ jad_practice_enabled = false
enable_global_chat = false
#minimum HA value for announcements of bots selling on ge
ge_announcement_limit = 500
enable_castle_wars = false
enable_castle_wars = true
personalized_shops = false
bots_influence_ge_price = true
#verbose cutscene logging (for cutscenes in the new system)