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