ADR-055 - Optional PvP / combat-tag pre-flight gate¶
Status: Accepted Date: 2026-05-30 (wiring landed 2026-05-31)
Context¶
/rtp can be abused to escape mid-fight: a losing player teleports away the instant combat turns against them. Competing plugins address this; EzRTP 3.3.0 shipped "PvP-Tag Integration" as a soft-depend on external combat-tag plugins (PvPManager, SimpleCombatLog) with two knobs (cancel-countdown-on-pvp-tag, cancel-queued-on-pvp-tag) and locale messages. It performs no native damage tracking, so it does nothing without a third-party combat-tag plugin installed.
RTP's ROADMAP Tier 2 already scoped an "Optional PvP / combat-tag check": off by default, native damage tracking plus an external soft-depend path, gated at the /rtp pre-dispatch surface with an S-004 audit on refusal. This ADR records the design before implementation (Rule D-005), delivered incrementally - this increment is the platform-agnostic foundation (rtp-api SPI + rtp-core native fallback + gate evaluator + config); platform damage listeners, command/execution wiring, and the PvPManager/SimpleCombatLog adapters follow.
Decision¶
Introduce an optional combat gate built on the same replaceable-provider pattern as the biome/anvil pre-filter:
rtp-apiSPI.PvPCombatStateRegistry(single-bindingbind/current/clear, mirroringAnvilPrefilterRegistry) with aProvider#isInCombat(UUID)functional interface, reachable viaRTPAPI.hooks().pvpCombatState(). APvPCombatActionenum (ALLOW,DENY,DELAY,CANCEL) enumerates the response and parses case-insensitively, failing safe toDENY.- Native fallback (
rtp-core).NativePvPCombatTrackerkeepsuuid -> last-PvP-damage-msand answersisInCombatagainst a configurable window. It holds no platform types and is unit-testable with an injected clock. Platform damage listeners feed it via thePvPGate.nativeTracker()singleton, stamping both victim and aggressor as configured. PvPGateevaluator (rtp-core). Central decision point consulted at the/rtppre-dispatch surface (before queue enrolment) and immediately before the destination is applied (execution prefilter). It readssafety.yml, picks the combat-state authority perpvpSource(AUTO/NATIVE/EXTERNAL), and returns the configuredPvPCombatAction. A throwing external provider is audited at WARNING and treated as not-in-combat (REQ-RTP-S-004); the gate never blocks a teleport because an integration broke.- Config. New
safety.ymlkeys:pvpCheckEnabled(default false),pvpCombatTagSeconds(15),pvpOnCombat(DENY),pvpSource(AUTO),pvpTagVictim(true),pvpTagAggressor(true), mirrored into every shipped locale via the locale TSV pipeline. - External replacement. Combat-tag plugins (PvPManager, SimpleCombatLog, CombatLogX) bind their own
Provider, replacing the native check exactly as an external biome/anvil pre-filter replaces the built-in one - soft-depend adapters gated on plugin presence (catalog row inEXTERNAL_HOOKS.mdper ADR-026).
Alternatives Considered¶
| Alternative | Why Rejected |
|---|---|
| External-only (EzRTP's approach) | Does nothing without a third-party plugin; RTP's native fallback works out of the box. |
Region verifier (RegionVerifierRegistry) |
Verifiers are per-coordinate, not per-player; combat state is a property of the requester, not the destination. |
| Hard-coded "deny on combat" | Operators want graduated responses (allow-and-audit, delay, cancel); hence the enum. |
| Raw scheduled executor for tag expiry | Forbidden on backend JVMs; the tracker prunes lazily on read, no background thread needed. |
Consequences¶
- Positive: Feature-parity with EzRTP plus a zero-dependency native path; one replaceable seam consistent with the existing hook architecture; safe failure mode; off by default so no behavior change for existing servers.
- Negative / Trade-offs: Native tracking only sees damage the platform listener observes (direct melee/projectile PvP, incl. projectile shooter and primed-TNT igniter), not damage routed through other plugins - operators wanting authoritative combat state should bind an external provider. The pre-dispatch gate refuses a new
/rtpfor a combat-tagged player; aborting an already-running countdown/queued teleport mid-flight (the fullCANCEL/DELAYsemantics) is a later increment, so at the pre-dispatch surfaceDENY/CANCEL/DELAYall collapse to "refuse this request".
Implementation status (2026-05-31)¶
The gate is no longer inert. Wiring landed across all in-scope platforms:
- Command pre-dispatch (
rtp-core):RTPCmd.computeconsultsPvPGate.evaluate(senderId)before enrolling the requester, refusing with the configurablemessages.yml#pvpInCombatstring and a REQ-RTP-S-004 WARNING audit on a non-ALLOWaction;ALLOW-and-in-combat is logged for operator visibility. Skipped for the console sender and cross-player (player=) targeting. - Native damage listeners: Bukkit/Paper/Folia
OnPlayerCombatTag(EntityDamageByEntityEvent) and FabricFabricEventBridge(ServerLivingEntityEvents.AFTER_DAMAGE, reflection-guarded) stampPvPGate.nativeTracker()for player-vs-player damage perpvpTagVictim/pvpTagAggressor. Both short-circuit when the gate is disabled. - Session hygiene:
OnPlayerQuit(Bukkit) and the Fabric disconnect handler clear the tracker on disconnect. - Config / locale: new baseline
messages.yml#pvpInCombat, propagated to every shipped locale via the locale TSV pipeline. - Tests:
RTPCmdPvPGateTest(pre-dispatch wiring) on top of the existingPvPGateTest/NativePvPCombatTrackerTest.
References¶
- ROADMAP Tier 2 - Optional PvP / combat-tag check
- ADR-026 - external hook API surface, EXTERNAL_HOOKS.md
AnvilPrefilterRegistry(the replaceable-provider precedent), REQ-RTP-S-004, REQ-RTP-F-013- Code:
rtp-api/.../hooks/PvPCombatStateRegistry.java,PvPCombatAction.java;rtp-core/.../common/pvp/{NativePvPCombatTracker,PvPGate}.java