Replaced silly PvP scaffolding with OvenBread's actual engineer solution

This commit is contained in:
dam 2026-04-10 00:12:29 +03:00
parent dcc4fe2ccf
commit caa2d6dab6
No known key found for this signature in database
GPG key ID: 4AF4E722399663FB
12 changed files with 90 additions and 439 deletions

View file

@ -1,33 +0,0 @@
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

@ -51,10 +51,6 @@ abstract class CastleWarsArea : MapArea, LogoutListener, InteractionListener {
// Remove teleblock
removeTimer(player, "teleblock")
// TODO: PvPStateAudit blows, has gotta go and actual safe handling of PvP generalized to cover current inauthentic deep wildy,
// future authentic minigames and that wont result in 7000 exploits that ruin the server (yes. thats a good idea. who said that)
// PvPStateAudit.cleanupPvPState(player)
player.equipment.removeAll(CastleWars.CW_ITEMS_TO_REMOVE)
player.inventory.removeAll(CastleWars.CW_ITEMS_TO_REMOVE)
(player.familiarManager.familiar as? BurdenBeast)?.container?.removeAll(CastleWars.CW_ITEMS_TO_REMOVE)

View file

@ -7,7 +7,9 @@ import core.game.component.Component
import core.game.event.EventHook
import core.game.event.InterfaceCloseEvent
import core.game.interaction.Option
import core.game.node.Node
import core.game.node.entity.Entity
import core.game.node.entity.combat.CombatStyle
import core.game.node.entity.player.Player
import core.game.node.item.Item
import core.game.world.map.Location
@ -20,7 +22,7 @@ import org.rs09.consts.Components
/**
* Handles the Castle Wars game map
*/
class CastleWarsGameArea : CastleWarsArea(), TickListener, PvPZone {
class CastleWarsGameArea : CastleWarsArea(), TickListener {
companion object {
private val saradominStandardFloor: ZoneBorders =
@ -342,46 +344,21 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener, PvPZone {
return handleDeath(entity, killer)
}
// ==================== PvPZone Implementation ==================== TODO: PvPZone general review... it might be good but need to triple-check
/**
* Check if attacker can attack victim in this zone.
* Only allows attacks between players on opposite teams.
* Uses self-healing team detection based on equipped cloak to ensure
* players are properly tracked even if they weren't added to sets correctly.
* @param attacker The player initiating the attack
* @param victim The player being attacked
* @return true if players are on opposite teams, false otherwise
*/
override fun canAttackPlayer(attacker: Player, victim: Player): Boolean {
if (ticksLeftInGame <= 0) return false
override fun isPvpAllowed(entity: Entity, target: Node?, style: CombatStyle?, message: Boolean): Boolean? {
val attacker = entity as? Player ?: return null
val victim = target as? Player ?: return null
if (ticksLeftInGame <= 0) {
return false
}
val attackerTeam = CastleWarsGameState.getPlayerTeam(attacker) ?: return false
val victimTeam = CastleWarsGameState.getPlayerTeam(victim) ?: return false
return attackerTeam != victimTeam
}
/**
* Check combat level restrictions.
* Castle Wars has no combat level restrictions.
*/
override fun checkCombatLevel(attacker: Player, victim: Player): Boolean {
if (attackerTeam == victimTeam) {
if (message) {
sendMessage(attacker, "You can only attack players on the opposite team!")
}
return false
}
return true
}
/**
* Get the message to display when attack is blocked.
* @return The message string
*/
override fun getBlockedAttackMessage(): String {
return "You can only attack players on the opposite team!" // TODO: pretty sure this is inauthentic. seen the real one in vids, needs correction here (also does this even get called huh)
}
/**
* Get the message to display when attack is blocked by combat level check.
* @return The message string
*/
override fun getCombatLevelBlockedMessage(): String {
return PvPZone.DEFAULT_COMBAT_LEVEL_MESSAGE
}
}

View file

@ -1,6 +1,8 @@
package core.api
import core.game.node.Node
import core.game.node.entity.Entity
import core.game.node.entity.combat.CombatStyle
import core.game.world.map.Location
import core.game.world.map.zone.MapZone
import core.game.world.map.zone.RegionZone
@ -25,6 +27,18 @@ interface MapArea : ContentInterface {
fun areaEnter(entity: Entity) {}
fun areaLeave(entity: Entity, logout: Boolean) {}
fun entityStep(entity: Entity, location: Location, lastLocation: Location) {}
/**
* Extends MapZone to cater to pvp, team games and all other unique targeting systems.
* Return true/false if you want to override the controls.
* Return null if you want the default check in MapZone. (default)
**/
fun isPvpAllowed(entity: Entity, target: Node?, style: CombatStyle?, message: Boolean): Boolean? { return null }
/**
* Extends MapZone to cater to pvp, team games and all other unique targeting systems.
* Return true/false if you want to allow the deaths, especially false for "safe" areas.
* Return null if you want the default check in MapZone. (default)
**/
fun canStartDeath(entity: Entity, killer: Entity): Boolean? { return null }
companion object {
val zoneMaps = HashMap<String, MapZone>()

View file

@ -1,76 +0,0 @@
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

@ -1,51 +0,0 @@
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

@ -47,11 +47,8 @@ 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.*;
@ -614,7 +611,6 @@ 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(),
@ -794,26 +790,6 @@ public class Player extends Entity {
}
if (entity instanceof Player) {
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;
@ -821,77 +797,13 @@ public class Player extends Entity {
return false;
if (skullManager.hasWildernessProtection())
return false;
return super.isAttackable(entity, style, message);
} else return false;
} else if (!getZoneMonitor().isPvPable(entity, this, style, message)) {
return false;
}
}
return super.isAttackable(entity, style, message);
}
/**
* 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) {
@ -913,7 +825,7 @@ public class Player extends Entity {
return false;
}
return !skullManager.hasWildernessProtection();
} else {
} else if (!getZoneMonitor().isPvPable(this, target, style, message)) {
return false;
}
}

View file

@ -1,67 +0,0 @@
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

@ -128,7 +128,21 @@ public abstract class MapZone implements Zone {
public boolean actionButton(Player player, int interfaceId, int buttonId, int slot, int itemId, int opcode) {
return false;
}
/**
* Checks if the entity is able to continue attacking another player within this zone.
* This is added like continueAttack for player vs player fighting.
*
* @param e the attacking entity
* @param target the target node being attacked
* @param style the combat style being used
* @param message whether to send a message to the player explaining why the attack cannot continue
* @return {@code true} if the zone allows the attack to continue
*/
public boolean continuePvp(Entity e, Node target, CombatStyle style, boolean message) {
return false;
}
/**
* Checks if the entity is able to continue attacking the target within this zone.
*

View file

@ -1,7 +1,5 @@
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;
@ -10,7 +8,6 @@ import core.game.node.entity.player.Player;
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;
@ -150,6 +147,23 @@ public final class ZoneMonitor {
return false;
}
/**
* Checks whether any current zone explicitly allows PvP against the target.
*
* @param player The attacking entity.
* @param target The target.
* @param style The combat style used.
* @return {@code True} if so.
*/
public boolean isPvPable(Entity player, Node target, CombatStyle style, boolean message) {
for (RegionZone z : zones) {
if (z.getZone().continuePvp(player, target, style, message)) {
return true;
}
}
return false;
}
/**
* Checks if the entity is able to continue attacking the target.
*
@ -168,72 +182,12 @@ public final class ZoneMonitor {
return false;
}
}
if (entity instanceof Player && target instanceof Player) {
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)) {
return false;
}
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.
*

View file

@ -47,20 +47,6 @@ 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,
;
/**

View file

@ -7,7 +7,9 @@ import core.game.activity.ActivityPlugin
import core.game.bots.PlayerScripts
import core.game.interaction.InteractionListener
import core.game.interaction.InterfaceListener
import core.game.node.Node
import core.game.node.entity.Entity
import core.game.node.entity.combat.CombatStyle
import core.game.node.entity.npc.NPCBehavior
import core.game.node.entity.player.info.login.LoginConfiguration
import core.game.node.entity.player.info.login.PlayerSaveParser
@ -128,6 +130,29 @@ object ClassScanner {
if(e != null && from != null && to != null) clazz.entityStep(e, to, from)
return super.move(e, from, to)
}
// Additional hook for continueAttack for PVP area controls
override fun continuePvp(e: Entity, target: Node?, style: CombatStyle?, message: Boolean): Boolean {
val clazzAttack = clazz.isPvpAllowed(e, target, style, message)
if (clazzAttack == null) { // If clazz returns null, continue the usual check.
return super.continuePvp(e, target, style, message)
} else {
return clazzAttack // Otherwise return whatever function.
}
}
// Additional hook for startDeath for PVP area controls
override fun startDeath(e: Entity?, killer: Entity?): Boolean {
if (e == null) {
return super.startDeath(null, killer)
}
val clazzAttack = clazz.canStartDeath(e, killer)
if (clazzAttack == null) { // If clazz returns null, continue the usual check.
return super.startDeath(e, killer)
} else {
return clazzAttack // Otherwise return whatever function.
}
}
}
for(border in clazz.defineAreaBorders()) zone.register(border)
ZoneBuilder.configure(zone)