mirror of
https://gitlab.com/2009scape/2009scape.git
synced 2026-08-24 20:05:13 -06:00
Experimental PvP scaffolding
This commit is contained in:
parent
d427a9882f
commit
1f505efa26
8 changed files with 547 additions and 88 deletions
33
Server/src/main/content/global/handlers/PvPLogoutHandler.kt
Normal file
33
Server/src/main/content/global/handlers/PvPLogoutHandler.kt
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
package content.global.handlers
|
||||
|
||||
import core.api.LogoutListener
|
||||
import core.game.node.entity.player.Player
|
||||
import core.game.node.entity.player.link.PvPStateAudit
|
||||
|
||||
/**
|
||||
* Global logout handler that ensures PvP state is always cleaned up on logout.
|
||||
* This is a safety net to prevent PvP state leakage (Falador Massacre-style bugs).
|
||||
*
|
||||
* Even if a minigame's leave() or logout() method fails to clean up properly,
|
||||
* this handler ensures the player's PvP flags are reset before they rejoin.
|
||||
*
|
||||
* This works alongside PvPStateAudit (which runs periodically) to provide
|
||||
* comprehensive protection against PvP state leaking out of designated areas.
|
||||
*
|
||||
* @see core.game.node.entity.player.link.PvPStateAudit
|
||||
* @see core.api.PvPZone
|
||||
*/
|
||||
class PvPLogoutHandler : LogoutListener {
|
||||
|
||||
/**
|
||||
* Called when a player logs out.
|
||||
* Always cleans up PvP state as a final safety measure.
|
||||
*/
|
||||
override fun logout(player: Player) {
|
||||
// Always clean up PvP state on logout as final safety net.
|
||||
// This ensures that even if a minigame's leave() method fails,
|
||||
// the player won't have PvP flags enabled when they log back in.
|
||||
PvPStateAudit.cleanupPvPState(player)
|
||||
}
|
||||
}
|
||||
|
||||
76
Server/src/main/core/api/PvPCombatUtils.kt
Normal file
76
Server/src/main/core/api/PvPCombatUtils.kt
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
package core.api
|
||||
|
||||
import core.game.node.entity.player.Player
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* Utility object providing combat level range checking for PvP zones.
|
||||
* Used by zones that implement the PvPZone interface to determine
|
||||
* if two players are within the allowed combat level range for an area.
|
||||
*
|
||||
* @see PvPZone
|
||||
*/
|
||||
object PvPCombatUtils {
|
||||
/**
|
||||
* Check if two players are within a combat level range.
|
||||
* Used for wilderness-style level restrictions where the allowed
|
||||
* combat level difference is determined by the area's level.
|
||||
*
|
||||
* @param attacker The player initiating the attack
|
||||
* @param victim The player being attacked
|
||||
* @param range The maximum allowed combat level difference
|
||||
* @return true if within allowed range, false otherwise
|
||||
*/
|
||||
@JvmStatic
|
||||
fun withinCombatRange(attacker: Player, victim: Player, range: Int): Boolean {
|
||||
val attackerLevel = attacker.properties.currentCombatLevel
|
||||
val victimLevel = victim.properties.currentCombatLevel
|
||||
return abs(attackerLevel - victimLevel) <= range
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate wilderness-style combat range based on wilderness level.
|
||||
* In the wilderness, the combat level range equals the wilderness level.
|
||||
* For example, in level 5 wilderness, you can attack players within
|
||||
* 5 combat levels of your own level.
|
||||
*
|
||||
* @param wildernessLevel The wilderness level of the area
|
||||
* @return The combat level range (same as wilderness level)
|
||||
*/
|
||||
@JvmStatic
|
||||
fun getWildernessStyleRange(wildernessLevel: Int): Int {
|
||||
return wildernessLevel
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if combat between two players is allowed based on wilderness-style rules.
|
||||
* Combines the wilderness level lookup with combat range checking.
|
||||
*
|
||||
* @param attacker The player initiating the attack
|
||||
* @param victim The player being attacked
|
||||
* @param wildernessLevel The wilderness level of the area
|
||||
* @return true if combat is allowed, false otherwise
|
||||
*/
|
||||
@JvmStatic
|
||||
fun checkWildernessStyleCombat(attacker: Player, victim: Player, wildernessLevel: Int): Boolean {
|
||||
val range = getWildernessStyleRange(wildernessLevel)
|
||||
return withinCombatRange(attacker, victim, range)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a formatted message explaining why combat was blocked due to level difference.
|
||||
*
|
||||
* @param attacker The attacking player
|
||||
* @param victim The victim player
|
||||
* @param allowedRange The maximum allowed combat level difference
|
||||
* @return A descriptive message explaining the level restriction
|
||||
*/
|
||||
@JvmStatic
|
||||
fun getCombatLevelBlockedMessage(attacker: Player, victim: Player, allowedRange: Int): String {
|
||||
val attackerLevel = attacker.properties.currentCombatLevel
|
||||
val victimLevel = victim.properties.currentCombatLevel
|
||||
val actualDifference = abs(attackerLevel - victimLevel)
|
||||
return "The combat level difference ($actualDifference) exceeds the allowed range ($allowedRange)."
|
||||
}
|
||||
}
|
||||
|
||||
51
Server/src/main/core/api/PvPZone.kt
Normal file
51
Server/src/main/core/api/PvPZone.kt
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
package core.api
|
||||
|
||||
import core.game.node.entity.player.Player
|
||||
|
||||
/**
|
||||
* Interface for zones that allow player-vs-player combat.
|
||||
* Zones implementing this interface must explicitly define PvP rules.
|
||||
* This provides a centralized, explicit mechanism for PvP permission checking
|
||||
* to prevent combat state leakage (e.g., Falador Massacre-style bugs).
|
||||
*/
|
||||
interface PvPZone {
|
||||
/**
|
||||
* Check if attacker can attack victim in this zone.
|
||||
* This is the primary permission check for PvP combat.
|
||||
* @param attacker The player initiating the attack
|
||||
* @param victim The player being attacked
|
||||
* @return true if attack is permitted, false otherwise
|
||||
*/
|
||||
fun canAttackPlayer(attacker: Player, victim: Player): Boolean
|
||||
|
||||
/**
|
||||
* Check if combat level restrictions apply between two players.
|
||||
* Override this for zones with level-based restrictions (e.g., wilderness level range).
|
||||
* @param attacker The player initiating the attack
|
||||
* @param victim The player being attacked
|
||||
* @return true if within allowed combat level range, false otherwise
|
||||
*/
|
||||
fun checkCombatLevel(attacker: Player, victim: Player): Boolean
|
||||
|
||||
/**
|
||||
* Get the message to display when attack is blocked by canAttackPlayer().
|
||||
* @return The message string to send to the attacking player
|
||||
*/
|
||||
fun getBlockedAttackMessage(): String
|
||||
|
||||
/**
|
||||
* Get the message to display when attack is blocked by combat level check.
|
||||
* @return The message string to send to the attacking player
|
||||
*/
|
||||
fun getCombatLevelBlockedMessage(): String
|
||||
|
||||
/**
|
||||
* Companion object providing default values for the interface methods.
|
||||
* Use these in Java implementations to get default behavior.
|
||||
*/
|
||||
companion object {
|
||||
const val DEFAULT_BLOCKED_MESSAGE = "You cannot attack that player here." // TODO: correct
|
||||
const val DEFAULT_COMBAT_LEVEL_MESSAGE = "The level difference between you and your opponent is too great."
|
||||
}
|
||||
}
|
||||
|
||||
33
Server/src/main/core/game/node/entity/combat/DeathContext.kt
Normal file
33
Server/src/main/core/game/node/entity/combat/DeathContext.kt
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
package core.game.node.entity.combat
|
||||
|
||||
import core.game.node.entity.Entity
|
||||
import core.game.world.map.Location
|
||||
|
||||
/**
|
||||
* Stores the state at the moment death commenced.
|
||||
* Used to prevent zone-transition exploits during death animation.
|
||||
*
|
||||
* When a player dies, there's a delay between death commencement (when HP reaches 0)
|
||||
* and death finalization (after the death animation plays). During this time, a player
|
||||
* could theoretically transition between zones. By capturing the state at death start,
|
||||
* we ensure that item loss/keep decisions are made based on where the player actually died,
|
||||
* not where they ended up after the animation.
|
||||
*
|
||||
* @param deathLocation The location where death commenced
|
||||
* @param zoneType The zone type ID at death time (from ZoneMonitor.getType())
|
||||
* @param isSafeZone Whether the player was in a safe zone at death time
|
||||
* @param wasInWilderness Whether the player was in wilderness at death time
|
||||
* @param wasSkulled Whether the player was skulled at death time
|
||||
* @param killer The entity that killed the player (may be null for environmental deaths)
|
||||
* @param deathTick The game tick when death commenced
|
||||
*/
|
||||
data class DeathContext(
|
||||
val deathLocation: Location,
|
||||
val zoneType: Int,
|
||||
val isSafeZone: Boolean,
|
||||
val wasInWilderness: Boolean,
|
||||
val wasSkulled: Boolean,
|
||||
val killer: Entity?,
|
||||
val deathTick: Int
|
||||
)
|
||||
|
||||
|
|
@ -47,8 +47,12 @@ import core.game.system.task.Pulse;
|
|||
import core.game.world.map.*;
|
||||
import core.game.world.map.build.DynamicRegion;
|
||||
import core.game.world.map.path.Pathfinder;
|
||||
import core.game.world.map.zone.RegionZone;
|
||||
import core.game.world.map.zone.ZoneRestriction;
|
||||
import core.game.world.map.zone.ZoneType;
|
||||
import core.api.MapArea;
|
||||
import core.api.PvPZone;
|
||||
import core.game.node.entity.combat.DeathContext;
|
||||
import core.game.world.update.flag.PlayerFlags;
|
||||
import core.game.world.update.flag.*;
|
||||
import core.net.IoSession;
|
||||
|
|
@ -610,6 +614,17 @@ public class Player extends Entity {
|
|||
@Override
|
||||
public void commenceDeath(Entity killer) {
|
||||
if (!isPlaying()) return;
|
||||
// TODO: review DeathContext, PvPStateAudit, PvPZone and related components. theyre all cool but also... cant this break a lot of shit? 100%. does it? uhhh... lemme check (later
|
||||
DeathContext context = new DeathContext(
|
||||
getLocation(),
|
||||
getZoneMonitor().getType(),
|
||||
getProperties().isSafeZone(),
|
||||
getSkullManager().isWilderness(),
|
||||
getSkullManager().isSkulled(),
|
||||
killer,
|
||||
GameWorld.getTicks()
|
||||
);
|
||||
setAttribute("death-context", context);
|
||||
super.commenceDeath(killer);
|
||||
if (prayer.get(PrayerType.RETRIBUTION)) {
|
||||
prayer.startRetribution(killer);
|
||||
|
|
@ -619,6 +634,11 @@ public class Player extends Entity {
|
|||
@Override
|
||||
public void finalizeDeath(Entity killer) {
|
||||
if (!isPlaying()) return; //if the player has already been full cleared, it has already disconnected. This code is probably getting called because something is maintaining a stale reference.
|
||||
DeathContext deathContext = getAttribute("death-context", null);
|
||||
removeAttribute("death-context");
|
||||
boolean wasSafeZone = deathContext != null ? deathContext.isSafeZone() : getProperties().isSafeZone();
|
||||
int deathZoneType = deathContext != null ? deathContext.getZoneType() : getZoneMonitor().getType();
|
||||
Location deathLocation = deathContext != null ? deathContext.getDeathLocation() : getLocation();
|
||||
GlobalStats.incrementDeathCount();
|
||||
settings.setSpecialEnergy(100);
|
||||
settings.updateRunEnergy(settings.getRunEnergy() - 100);
|
||||
|
|
@ -639,7 +659,8 @@ public class Player extends Entity {
|
|||
incrementAttribute("/save:"+STATS_BASE+":"+STATS_DEATHS);
|
||||
|
||||
packetDispatch.sendTempMusic(90);
|
||||
if (!getZoneMonitor().handleDeath(killer) && (!getProperties().isSafeZone() && getZoneMonitor().getType() != ZoneType.SAFE.getId()) && getDetails().getRights() != Rights.ADMINISTRATOR) {
|
||||
boolean zoneHandledDeath = getZoneMonitor().handleDeath(killer);
|
||||
if (!zoneHandledDeath && (!wasSafeZone && deathZoneType != ZoneType.SAFE.getId()) && getDetails().getRights() != Rights.ADMINISTRATOR) {
|
||||
//If player was a Hardcore Ironman, announce that they died
|
||||
if (this.getIronmanManager().getMode().equals(IronmanMode.HARDCORE)) { //if this was checkRestriction, ultimate irons would be moved to HARDCORE_DEAD as well
|
||||
String gender = this.isMale() ? "man " : "woman ";
|
||||
|
|
@ -649,7 +670,7 @@ public class Player extends Entity {
|
|||
return;
|
||||
}
|
||||
}
|
||||
GroundItemManager.create(new Item(BONES_526), this.getAttribute("/save:original-loc",location), k);
|
||||
GroundItemManager.create(new Item(BONES_526), this.getAttribute("/save:original-loc", deathLocation), k);
|
||||
final Container[] c = DeathTask.getContainers(this);
|
||||
|
||||
for (Item i : getEquipment().toArray()) {
|
||||
|
|
@ -662,7 +683,7 @@ public class Player extends Entity {
|
|||
boolean canCreateGrave = GraveController.allowGenerate(this);
|
||||
if (canCreateGrave) {
|
||||
Grave g = GraveController.produceGrave(GraveController.getGraveType(this));
|
||||
g.initialize(this, location, Arrays.stream(c[1].toArray()).filter(Objects::nonNull).toArray(Item[]::new)); //note: the amount of code required to filter nulls from an array in Java is atrocious.
|
||||
g.initialize(this, deathLocation, Arrays.stream(c[1].toArray()).filter(Objects::nonNull).toArray(Item[]::new)); //note: the amount of code required to filter nulls from an array in Java is atrocious.
|
||||
} else {
|
||||
StringBuilder itemsLost = new StringBuilder();
|
||||
int coins = 0;
|
||||
|
|
@ -693,11 +714,11 @@ public class Player extends Entity {
|
|||
} else {
|
||||
item = GraveController.checkTransform(item);
|
||||
}
|
||||
GroundItem gi = GroundItemManager.create(item, location, killer instanceof Player ? (Player) killer : this);
|
||||
GroundItem gi = GroundItemManager.create(item, deathLocation, killer instanceof Player ? (Player) killer : this);
|
||||
gi.setRemainPrivate(stayPrivate);
|
||||
}
|
||||
if (coins > 0) {
|
||||
GroundItemManager.create(new Item(Items.COINS_995, coins), location, (Player) killer);
|
||||
GroundItemManager.create(new Item(Items.COINS_995, coins), deathLocation, (Player) killer);
|
||||
}
|
||||
if (killer instanceof Player)
|
||||
PlayerMonitor.log((Player) killer, LogType.PK, "Killed " + name + ", who dropped: " + itemsLost);
|
||||
|
|
@ -719,7 +740,8 @@ public class Player extends Entity {
|
|||
setComponentVisibility(this, 746, 12, false); //reenable the logout button (HD)
|
||||
super.finalizeDeath(killer);
|
||||
appearance.sync();
|
||||
if (!getSavedData().getGlobalData().isDeathScreenDisabled()) {
|
||||
// Don't show death tutorial in safe minigames (zone handled death) or if player disabled it
|
||||
if (!zoneHandledDeath && !getSavedData().getGlobalData().isDeathScreenDisabled()) {
|
||||
getInterfaceManager().open(new Component(153));
|
||||
}
|
||||
}
|
||||
|
|
@ -771,11 +793,31 @@ public class Player extends Entity {
|
|||
return false;
|
||||
}
|
||||
if (entity instanceof Player) {
|
||||
Player p = (Player) entity;
|
||||
if (p.getSkullManager().isWilderness() && skullManager.isWilderness()) {
|
||||
Player attacker = (Player) entity;
|
||||
|
||||
// NEW: Check for PvPZone-based permission (minigames, etc.)
|
||||
PvPZone pvpZone = findCommonPvPZone(attacker, this);
|
||||
if (pvpZone != null) {
|
||||
if (!pvpZone.canAttackPlayer(attacker, this)) {
|
||||
if (message) {
|
||||
attacker.getPacketDispatch().sendMessage(pvpZone.getBlockedAttackMessage());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (!pvpZone.checkCombatLevel(attacker, this)) {
|
||||
if (message) {
|
||||
attacker.getPacketDispatch().sendMessage(pvpZone.getCombatLevelBlockedMessage());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// LEGACY: Wilderness fallback (kept for backward compatibility)
|
||||
if (attacker.getSkullManager().isWilderness() && skullManager.isWilderness()) {
|
||||
if (!GameWorld.getSettings().getWild_pvp_enabled())
|
||||
return false;
|
||||
if (p.getSkullManager().hasWildernessProtection())
|
||||
if (attacker.getSkullManager().hasWildernessProtection())
|
||||
return false;
|
||||
if (skullManager.hasWildernessProtection())
|
||||
return false;
|
||||
|
|
@ -785,6 +827,71 @@ public class Player extends Entity {
|
|||
return super.isAttackable(entity, style, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a PvPZone that both the attacker and this player (victim) are in.
|
||||
* @param attacker The attacking player
|
||||
* @param victim The player being attacked (this)
|
||||
* @return The common PvPZone, or null if no common zone exists
|
||||
*/
|
||||
private PvPZone findCommonPvPZone(Player attacker, Player victim) {
|
||||
// First check: Direct PvPZone implementations (e.g., ActivityPlugin subclasses)
|
||||
for (RegionZone zone : attacker.getZoneMonitor().getZones()) {
|
||||
if (zone.getZone() instanceof PvPZone) {
|
||||
for (RegionZone victimZone : victim.getZoneMonitor().getZones()) {
|
||||
if (zone.getZone() == victimZone.getZone()) {
|
||||
return (PvPZone) zone.getZone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second check: MapArea implementations that implement PvPZone
|
||||
// ClassScanner wraps MapAreas in anonymous MapZone classes, so we need to
|
||||
// look up the original MapArea from MapArea.zoneMaps
|
||||
for (RegionZone zone : attacker.getZoneMonitor().getZones()) {
|
||||
String zoneName = zone.getZone().getName();
|
||||
// MapArea zones are named with "MapArea" suffix by ClassScanner
|
||||
if (zoneName != null && zoneName.endsWith("MapArea")) {
|
||||
// Look up the original MapArea class that implements PvPZone
|
||||
for (java.util.Map.Entry<String, core.game.world.map.zone.MapZone> entry : MapArea.Companion.getZoneMaps().entrySet()) {
|
||||
if (entry.getKey().equals(zoneName) && entry.getValue() == zone.getZone()) {
|
||||
// Find the MapArea instance - we need to check all loaded ContentInterfaces
|
||||
// The MapArea interface stores zones but not the instances themselves
|
||||
// We need to iterate GameWorld's content to find the matching MapArea
|
||||
PvPZone pvpZone = findMapAreaPvPZone(zoneName, attacker, victim);
|
||||
if (pvpZone != null) {
|
||||
return pvpZone;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a MapArea that implements PvPZone and matches the zone name.
|
||||
* This is needed because ClassScanner wraps MapAreas in anonymous classes.
|
||||
*/
|
||||
private PvPZone findMapAreaPvPZone(String zoneName, Player attacker, Player victim) {
|
||||
// Check TickListeners - MapAreas often implement TickListener
|
||||
for (Object listener : GameWorld.getTickListeners()) {
|
||||
if (listener instanceof MapArea && listener instanceof PvPZone) {
|
||||
String mapAreaZoneName = listener.getClass().getSimpleName() + "MapArea";
|
||||
if (mapAreaZoneName.equals(zoneName)) {
|
||||
// Verify victim is also in this zone
|
||||
for (RegionZone victimZone : victim.getZoneMonitor().getZones()) {
|
||||
if (victimZone.getZone().getName().equals(zoneName)) {
|
||||
return (PvPZone) listener;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean continueAttack(Entity target, CombatStyle style, boolean message) {
|
||||
if (target instanceof NPC) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
package core.game.node.entity.player.link
|
||||
|
||||
import core.api.PvPZone
|
||||
import core.api.TickListener
|
||||
import core.api.log
|
||||
import core.game.interaction.Option
|
||||
import core.game.node.entity.player.Player
|
||||
import core.game.world.map.zone.ZoneRestriction
|
||||
import core.game.world.map.zone.impl.WildernessZone
|
||||
import core.game.world.repository.Repository
|
||||
import core.tools.Log
|
||||
|
||||
/**
|
||||
* Periodic audit that detects and fixes PvP state leakage.
|
||||
* Runs every 10 ticks (6 seconds) to catch players who have
|
||||
* PvP flags enabled outside valid PvP zones.
|
||||
*/
|
||||
class PvPStateAudit : TickListener {
|
||||
private var tickCounter = 0
|
||||
|
||||
override fun tick() {
|
||||
tickCounter++
|
||||
if (tickCounter < 10) return
|
||||
tickCounter = 0
|
||||
|
||||
for (player in Repository.players) {
|
||||
if (player == null || !player.isActive || player.isArtificial) continue
|
||||
auditPlayer(player)
|
||||
}
|
||||
}
|
||||
|
||||
private fun auditPlayer(player: Player) {
|
||||
val hasPvPEnabled = player.skullManager.isWilderness
|
||||
val inPvPZone = isInValidPvPZone(player)
|
||||
if (hasPvPEnabled && !inPvPZone) {
|
||||
log(this::class.java, Log.WARN,
|
||||
"Player ${player.username} has PvP enabled but is not in a PvP zone! " +
|
||||
"Location: ${player.location}, Cleaning up state.")
|
||||
cleanupPvPState(player)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isInValidPvPZone(player: Player): Boolean {
|
||||
for (zone in player.zoneMonitor.zones) {
|
||||
if (zone.zone is PvPZone) return true
|
||||
}
|
||||
if (player.zoneMonitor.isRestricted(ZoneRestriction.PVP_ZONE)) return true
|
||||
return WildernessZone.isInZone(player)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Clean up all PvP-related state from a player.
|
||||
* Called when a player is found to have PvP flags enabled outside a PvP zone.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun cleanupPvPState(player: Player) {
|
||||
player.skullManager.isWilderness = false
|
||||
player.skullManager.isSkullCheckDisabled = false
|
||||
player.skullManager.level = 0
|
||||
player.properties.isSafeZone = false
|
||||
player.properties.isMultiZone = false
|
||||
player.interaction.remove(Option._P_ATTACK)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,85 +1,90 @@
|
|||
package core.game.world.map.zone;
|
||||
|
||||
import core.api.MapArea;
|
||||
import core.api.PvPZone;
|
||||
import core.game.interaction.Option;
|
||||
import core.game.node.Node;
|
||||
import core.game.node.entity.Entity;
|
||||
import core.game.node.entity.combat.CombatStyle;
|
||||
import core.game.node.entity.player.Player;
|
||||
import core.game.node.entity.player.link.music.MusicEntry;
|
||||
import core.game.node.entity.player.link.music.MusicZone;
|
||||
import core.game.node.entity.player.link.request.RequestType;
|
||||
import core.game.node.item.Item;
|
||||
import core.game.world.GameWorld;
|
||||
import core.game.world.map.Location;
|
||||
import core.game.world.map.Region;
|
||||
import org.rs09.consts.Items;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import core.game.world.map.Region;
|
||||
import org.rs09.consts.Items;
|
||||
|
||||
/**
|
||||
* Handles the zones for an entity.
|
||||
*
|
||||
* @author Emperor
|
||||
*/
|
||||
public final class ZoneMonitor {
|
||||
|
||||
|
||||
/**
|
||||
* The set of jewellery which allow teleporting from up to and including level 30 wildy.
|
||||
* Used to check if a player can teleport from 20 < level <= 30 wildy, see canTeleportByJewellery.
|
||||
* Note: the check is based on the nextJewellery (see EnchantedJewellery.kt), so this list should not contain the (4) items, and should contain the empty ones.
|
||||
*
|
||||
* @author Player Name
|
||||
*/
|
||||
static final Set<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 +95,10 @@ public final class ZoneMonitor {
|
|||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Checks if the player can logout.
|
||||
*
|
||||
* @return {@code True} if so.
|
||||
*/
|
||||
public boolean canLogout() {
|
||||
|
|
@ -103,18 +109,20 @@ public final class ZoneMonitor {
|
|||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Checks if the restriction was flagged.
|
||||
*
|
||||
* @param restriction The restriction flag.
|
||||
* @return {@code True} if so.
|
||||
*/
|
||||
public boolean isRestricted(ZoneRestriction restriction) {
|
||||
return isRestricted(restriction.getFlag());
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Checks if the restriction was flagged.
|
||||
*
|
||||
* @param flag The restriction flag.
|
||||
* @return {@code True} if so.
|
||||
*/
|
||||
|
|
@ -126,9 +134,10 @@ public final class ZoneMonitor {
|
|||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Handles a death.
|
||||
*
|
||||
* @param killer The killer.
|
||||
* @return {@code True} if the death got handled.
|
||||
*/
|
||||
|
|
@ -140,11 +149,12 @@ public final class ZoneMonitor {
|
|||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Checks if the entity is able to continue attacking the target.
|
||||
*
|
||||
* @param target The target.
|
||||
* @param style The combat style used.
|
||||
* @param style The combat style used.
|
||||
* @return {@code True} if so.
|
||||
*/
|
||||
public boolean continueAttack(Node target, CombatStyle style, boolean message) {
|
||||
|
|
@ -159,11 +169,17 @@ public final class ZoneMonitor {
|
|||
}
|
||||
}
|
||||
if (entity instanceof Player && target instanceof Player) {
|
||||
if (!((Player) entity).getSkullManager().isWilderness() || !((Player) target).getSkullManager().isWilderness()) {
|
||||
if(message) {
|
||||
((Player) entity).getPacketDispatch().sendMessage("You can only attack other players in the wilderness.");
|
||||
}
|
||||
return false;
|
||||
Player attacker = (Player) entity;
|
||||
Player victim = (Player) target;
|
||||
// Skip wilderness check if both players are in a PvPZone (e.g., Castle Wars, Duel Arena)
|
||||
// PvPZone handles its own attack permissions via canAttackPlayer()
|
||||
if (!isInCommonPvPZone(attacker, victim)) {
|
||||
if (!attacker.getSkullManager().isWilderness() || !victim.getSkullManager().isWilderness()) {
|
||||
if (message) {
|
||||
attacker.getPacketDispatch().sendMessage("You can only attack other players in the wilderness.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (target instanceof Entity && !MapZone.checkMulti(entity, (Entity) target, message)) {
|
||||
|
|
@ -171,9 +187,56 @@ public final class ZoneMonitor {
|
|||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check if both players are in a common PvPZone.
|
||||
* Used to bypass the wilderness check for minigames like Castle Wars and Duel Arena.
|
||||
*
|
||||
* @param attacker The attacking player
|
||||
* @param victim The player being attacked
|
||||
* @return true if both players share a PvPZone
|
||||
*/
|
||||
private boolean isInCommonPvPZone(Player attacker, Player victim) {
|
||||
// Check for direct PvPZone implementations (e.g., ActivityPlugin subclasses)
|
||||
for (RegionZone zone : attacker.getZoneMonitor().getZones()) {
|
||||
if (zone.getZone() instanceof PvPZone) {
|
||||
for (RegionZone victimZone : victim.getZoneMonitor().getZones()) {
|
||||
if (zone.getZone() == victimZone.getZone()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Check for MapArea-based PvPZones (ClassScanner wraps MapAreas in anonymous MapZone classes)
|
||||
for (RegionZone zone : attacker.getZoneMonitor().getZones()) {
|
||||
String zoneName = zone.getZone().getName();
|
||||
if (zoneName != null && zoneName.endsWith("MapArea")) {
|
||||
// Look up the original MapArea instance from TickListeners
|
||||
for (Object listener : GameWorld.getTickListeners()) {
|
||||
if (listener instanceof MapArea && listener instanceof PvPZone) {
|
||||
String mapAreaZoneName = listener.getClass().getSimpleName() + "MapArea";
|
||||
if (mapAreaZoneName.equals(zoneName)) {
|
||||
for (RegionZone victimZone : victim.getZoneMonitor().getZones()) {
|
||||
if (victimZone.getZone().getName().equals(zoneName)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Check for PVP_ZONE restriction flag
|
||||
if (attacker.getZoneMonitor().isRestricted(ZoneRestriction.PVP_ZONE) &&
|
||||
victim.getZoneMonitor().isRestricted(ZoneRestriction.PVP_ZONE)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the entity can interact with the target.
|
||||
*
|
||||
* @param target The target to interact with.
|
||||
* @param option The option.
|
||||
* @return {@code True} if the option got handled.
|
||||
|
|
@ -186,26 +249,27 @@ public final class ZoneMonitor {
|
|||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Checks if a zone handles a useWith interaction
|
||||
*/
|
||||
public boolean useWith(Item used, Node with){
|
||||
public boolean useWith(Item used, Node with) {
|
||||
for (RegionZone z : zones) {
|
||||
if (z.getZone().handleUseWith(entity.asPlayer(), used,with)) {
|
||||
if (z.getZone().handleUseWith(entity.asPlayer(), used, with)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Checks if the player handled the reward button using a map zone.
|
||||
*
|
||||
* @param interfaceId The interface id.
|
||||
* @param buttonId The button id.
|
||||
* @param slot The slot.
|
||||
* @param itemId The item id.
|
||||
* @param opcode The packet opcode.
|
||||
* @param buttonId The button id.
|
||||
* @param slot The slot.
|
||||
* @param itemId The item id.
|
||||
* @param opcode The packet opcode.
|
||||
* @return {@code True} if the button got handled.
|
||||
*/
|
||||
public boolean clickButton(int interfaceId, int buttonId, int slot, int itemId, int opcode) {
|
||||
|
|
@ -216,9 +280,10 @@ public final class ZoneMonitor {
|
|||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Checks if multiway combat zone rules should be ignored.
|
||||
*
|
||||
* @param victim The victim.
|
||||
* @return {@code True} if this entity can attack regardless of multiway
|
||||
* combat zone.
|
||||
|
|
@ -231,9 +296,10 @@ public final class ZoneMonitor {
|
|||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Checks if the entity can teleport.
|
||||
*
|
||||
* @param type The teleport type (0=spell, 1=item, 2=object, 3=npc -1= force)
|
||||
* @return {@code True} if so.
|
||||
*/
|
||||
|
|
@ -251,9 +317,10 @@ public final class ZoneMonitor {
|
|||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Checks if a player can teleport with a jewellery piece in >= 1 <= 30 wilderness level
|
||||
*
|
||||
* @return {@code True} if so.
|
||||
*/
|
||||
private boolean canTeleportByJewellery(int type, Node node) {
|
||||
|
|
@ -262,11 +329,11 @@ public final class ZoneMonitor {
|
|||
}
|
||||
if (entity.timers.getTimer("teleblock") != null)
|
||||
return false;
|
||||
|
||||
|
||||
if (entity.getZoneMonitor().isRestricted(ZoneRestriction.TELEPORT)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
if (entity.getLocks().isTeleportLocked()) {
|
||||
if (entity.isPlayer()) {
|
||||
Player p = entity.asPlayer();
|
||||
|
|
@ -275,12 +342,13 @@ public final class ZoneMonitor {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Checks if the death should start for an entity.
|
||||
*
|
||||
* @param entity the entity.
|
||||
* @param killer the killer.
|
||||
* @return {@code True} if so.
|
||||
|
|
@ -293,9 +361,10 @@ public final class ZoneMonitor {
|
|||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Checks if the entity can fire a random event.
|
||||
*
|
||||
* @return {@code True} if so.
|
||||
*/
|
||||
public boolean canFireRandomEvent() {
|
||||
|
|
@ -306,9 +375,10 @@ public final class ZoneMonitor {
|
|||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Clears the zones.
|
||||
*
|
||||
* @return {@code True} if the entity successfully left all regions.
|
||||
*/
|
||||
public boolean clear() {
|
||||
|
|
@ -324,10 +394,11 @@ public final class ZoneMonitor {
|
|||
musicZones.clear();
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Checks if the entity can move.
|
||||
* @param location The destination location.
|
||||
*
|
||||
* @param location The destination location.
|
||||
* @param destination The destination location.
|
||||
* @return {@code True} if so.
|
||||
*/
|
||||
|
|
@ -339,18 +410,19 @@ public final class ZoneMonitor {
|
|||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Handles a location update.
|
||||
*
|
||||
* @param last The last location of the entity.
|
||||
* @return {@code false} If the entity could not enter/leave a region.
|
||||
*/
|
||||
public boolean updateLocation(Location last) {
|
||||
if(entity instanceof Player && !entity.asPlayer().isArtificial()) {
|
||||
if (entity instanceof Player && !entity.asPlayer().isArtificial()) {
|
||||
checkMusicZones();
|
||||
}
|
||||
entity.updateLocation(last);
|
||||
for (Iterator<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 +457,7 @@ public final class ZoneMonitor {
|
|||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Checks the music zones.
|
||||
*/
|
||||
|
|
@ -395,7 +467,7 @@ public final class ZoneMonitor {
|
|||
}
|
||||
Player player = (Player) entity;
|
||||
Location l = player.getLocation();
|
||||
for (Iterator<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 +491,12 @@ public final class ZoneMonitor {
|
|||
player.getMusicPlayer().unlock(music, true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Parses commands in a certain zone.
|
||||
* @param player the player.
|
||||
* @param name the name.
|
||||
*
|
||||
* @param player the player.
|
||||
* @param name the name.
|
||||
* @param arguments the arguments.
|
||||
* @return {@code True} if parsed.
|
||||
*/
|
||||
|
|
@ -435,10 +508,11 @@ public final class ZoneMonitor {
|
|||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Checks if a request can be made in this zone.
|
||||
* @param type the type.
|
||||
*
|
||||
* @param type the type.
|
||||
* @param target the target.
|
||||
* @return {@code True} if so.
|
||||
*/
|
||||
|
|
@ -450,9 +524,10 @@ public final class ZoneMonitor {
|
|||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Checks if the entity is in a zone.
|
||||
*
|
||||
* @param name The name of the zone.
|
||||
* @return {@code True} if so.
|
||||
*/
|
||||
|
|
@ -465,34 +540,37 @@ public final class ZoneMonitor {
|
|||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Removes the proper region zone for the given map zone.
|
||||
*
|
||||
* @param zone The map zone.
|
||||
*/
|
||||
public void remove(MapZone zone) {
|
||||
for (Iterator<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;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,20 @@ public enum ZoneRestriction {
|
|||
* Dynamic regions are implicitly off-map and do not require this attribute.
|
||||
*/
|
||||
OFF_MAP,
|
||||
|
||||
/**
|
||||
* This zone allows player-vs-player combat.
|
||||
* Used to mark zones where PvP is explicitly permitted.
|
||||
* Zones with this restriction should implement the PvPZone interface
|
||||
* to define specific combat rules (who can attack whom, combat level ranges, etc.).
|
||||
*
|
||||
* This flag is used by PvPStateAudit as an additional validation mechanism
|
||||
* to ensure PvP combat state doesn't leak out of designated areas.
|
||||
*
|
||||
* @see core.api.PvPZone
|
||||
* @see core.game.node.entity.player.link.PvPStateAudit
|
||||
*/
|
||||
PVP_ZONE,
|
||||
;
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue