Experimental PvP scaffolding

This commit is contained in:
dam 2026-02-18 22:50:10 +02:00
parent d427a9882f
commit 1f505efa26
No known key found for this signature in database
GPG key ID: 4AF4E722399663FB
8 changed files with 547 additions and 88 deletions

View 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)
}
}

View 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)."
}
}

View 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."
}
}

View 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
)

View file

@ -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) {

View file

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

View file

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

View file

@ -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,
;
/**