ADR-049 - Network-mode plumbing in rtp-core, platform-specific via PlayerLifecycleHook SPI¶
Status: Accepted
Date: 2026-05-23
Supersedes: rtp-fabric-ADR-013 (was Proposed 2026-05-22; the rejected-alternatives analysis in that ADR is based on an incorrect coupling estimate, see Context below)
Related: ADR-036 (umbrella network-mode ADR), rtp-proxy-ADR-014, rtp-proxy-ADR-015, rtp-fabric-ADR-002 (Fabric in scope), MULTI_SERVER_PLAN.md, MULTI_PLATFORM_PLAN.md Step J.
Context¶
Network-mode multi-server support landed end-to-end on Paper in this cycle (user-confirmed working 2026-05-22). The Phase 1 SPI in rtp-proxy-common is platform-agnostic and already reachable from any backend JVM, but the Phase 2 backend integration currently lives under rtp-plugin/src/main/java/io/github/dailystruggle/rtp/bukkit/network/ (NetworkModeBootstrap and twelve neighbouring classes). A Velocity-routed player arriving on a Fabric backend with a reservation token has no listener to redeem it: the lookup happens but the redeem-side handler is Bukkit-only. The cross-server flow therefore silently degrades on Fabric, violating the Phase 2 acceptance criterion in MULTI_SERVER_PLAN.md that says "a backend that advertises RtpTriggerSource MUST redeem reservation tokens on join".
rtp-fabric-ADR-013 proposed solving this by reimplementing NetworkModeBootstrap as a parallel FabricNetworkModeBootstrap. Its central rejection of the refactor path leaned on the claim that the Bukkit class has "eight independent coupling points to org.bukkit.* (Plugin, BukkitScheduler, PluginManager, Listener, PlayerJoinEvent, EventHandler, EventPriority, Bukkit.getOnlinePlayers())" and that a base-class refactor would produce "a near-equal amount of Fabric-side code anyway".
This claim was re-audited on 2026-05-23 and is incorrect. A precise count of org.bukkit.* imports in every file under rtp-plugin/.../bukkit/network/:
| Class | org.bukkit.* imports |
|---|---|
NetworkModeBootstrap (1108 lines) |
0 |
NetworkRouter |
0 |
NetworkStatusCache |
0 |
NetworkEnrolmentBuffer |
0 |
PeerRegionRegistry |
0 |
LobbyDispatchRetryQueue |
0 |
NetworkRegionCollisionWarner |
0 |
RoutingDecision |
0 |
BukkitNetworkCommandHook |
0 |
JoinTriggerSource |
7 (Bukkit, Player, EventHandler, EventPriority, Listener, PlayerJoinEvent, PlayerQuitEvent) |
NetworkWaitlistQuitListener |
4 (EventHandler, EventPriority, Listener, PlayerQuitEvent) |
NetworkWaitlistNotifier |
2 (Bukkit, Player) |
NetworkWaitlistGuard |
2 (CommandSender, Player) |
Of thirteen classes, nine have zero Bukkit imports. The four that do touch the Bukkit API touch it for one of two reasons:
- Subscribing to player join / quit events (
Listener,@EventHandler,PlayerJoinEvent,PlayerQuitEvent). - Looking up a player or sender by UUID (
Bukkit.getPlayer(uuid),org.bukkit.entity.Player,org.bukkit.command.CommandSender).
RTPServerAccessor already covers (2) end-to-end: getPlayer(UUID), getSender(UUID), sendMessage, getScheduler. The only primitive missing from the SPI is a player-join / player-quit lifecycle hook for (1).
The Bukkit class's "platform coupling surface" is therefore one missing SPI method, not eight, and the surface is shared with every other backend platform that already routes player operations through RTPServerAccessor. The cost calculus that drove ADR-013 toward reimplementation no longer holds.
Decision¶
Lift the network-mode plumbing into rtp-core and add a single new platform SPI primitive (PlayerLifecycleHook) accessed via RTPServerAccessor. Each backend platform supplies a shim that registers a join/quit subscription and routes the resulting UUIDs to the platform-neutral handler. There is no second backend bootstrap class.
1. New SPI: PlayerLifecycleHook¶
A new interface in rtp-api:
package io.github.dailystruggle.rtp.api.server;
public interface PlayerLifecycleHook {
AutoCloseable onPlayerJoin(java.util.function.Consumer<java.util.UUID> handler);
AutoCloseable onPlayerQuit(java.util.function.Consumer<java.util.UUID> handler);
}
RTPServerAccessor gains:
default PlayerLifecycleHook getPlayerLifecycleHook() {
return NoopPlayerLifecycleHook.INSTANCE;
}
The default returns a no-op that ignores subscriptions. Existing RTPServerAccessor implementations (AbstractServerAccessor for Bukkit/Paper/Folia, FabricServerAccessor, MockRTPServerAccessor) do not need to be touched to compile; each backend platform overrides only when it wants real lifecycle hooks (Bukkit and Fabric will; the mock does not).
2. Lifted classes in rtp-core¶
Moved verbatim (zero behavioural change) from rtp-plugin/.../bukkit/network/ to rtp-core/.../common/network/:
NetworkModeBootstrapNetworkRouterNetworkStatusCacheNetworkEnrolmentBufferPeerRegionRegistryLobbyDispatchRetryQueueNetworkRegionCollisionWarnerRoutingDecision
These nine classes (counting NetworkModeBootstrap) have no Bukkit imports today and require only a package rename plus updates to their import sites elsewhere in the codebase.
3. Lifted listener classes (signature change)¶
Moved to rtp-core/.../common/network/ with their event-registration surface replaced by PlayerLifecycleHook calls:
JoinTriggerSource. Removesimplements Listenerand the two@EventHandlermethods. Gainsregister(PlayerLifecycleHook hook)that callshook.onPlayerJoin(uuid -> handleJoin(uuid))andhook.onPlayerQuit(uuid -> handleQuit(uuid)). The body ofhandleJoin/handleQuitis the existing event-handler body withevent.getPlayer().getUniqueId()replaced by the inbounduuidandBukkit.getPlayer(uuid)replaced byRTP.serverAccessor.getPlayer(uuid).NetworkWaitlistQuitListener. Same treatment: loseListener+@EventHandler, gain aregister(PlayerLifecycleHook)that subscribes toonPlayerQuit.NetworkWaitlistNotifier. ReplaceBukkit.getPlayer(uuid)withRTP.serverAccessor.getPlayer(uuid); replacePlayer.isOnline()with anull-or-isOnlinecheck viaRTPPlayer.NetworkWaitlistGuard. ChangePredicate<CommandSender>toPredicate<RTPCommandSender>. The single caller isRTPCmdBukkit; that caller wraps its incomingCommandSenderwith the existingRTPCommandSenderadapter before invoking the predicate.
4. BukkitNetworkCommandHook and BukkitBackendStateSampler¶
Addendum (2026-05-23, post-acceptance audit): the original Decision text under-counted the platform coupling at this step. While neither class imports
org.bukkit.*, both are directly instantiated by name insideNetworkModeBootstrap(new BukkitBackendStateSampler(lobbyMode)at line ~234;new BukkitNetworkCommandHook(...)at line ~551, exposed viacommandHook()). A literal lift tortp-coreis therefore impossible without further SPI extraction. This addendum supersedes the original step 4 as the authoritative guidance.Resolution. Introduce two additional platform-injected types so the bootstrap depends only on
rtp-coreinterfaces:
BackendStateSamplerinterface inrtp-core(orrtp-api). Methods cover the small surface today'sBukkitBackendStateSamplerexposes (current player count, TPS, MSPT sample, world list, lobby-mode flag). Concrete implementations:BukkitBackendStateSampler(existing, moves only if its package changes) and a newFabricBackendStateSamplerthat pulls equivalent metrics viaRTPServerAccessor/FabricVersionAdapter.NetworkCommandHookinterface inrtp-core. ExistingBukkitNetworkCommandHookbecomes one implementation; newFabricNetworkCommandHookmounts the network sub-commands into the Fabric Brigadier tree (via the existingBrigadierCommandAdapterper commands-api-ADR-001).Accessed through new
RTPServerAccessorfactory / getter methods:default BackendStateSampler createBackendStateSampler(boolean lobbyMode) { throw new IllegalStateException( "Platform does not provide a BackendStateSampler; network mode requires one."); } default NetworkCommandHook getNetworkCommandHook() { return NoopNetworkCommandHook.INSTANCE; }
NetworkModeBootstrapreplaces its two directnewcalls withRTP.serverAccessor.createBackendStateSampler(lobbyMode)andRTP.serverAccessor.getNetworkCommandHook(). ThecommandHook()getter on the bootstrap stays but now returns the SPI type.Scope impact. This raises the lift estimate from "mechanical move + 1 new SPI" to "mechanical move + 3 new SPIs + 1 new Fabric metric sampler + 1 new Fabric command hook". The Fabric command hook in particular is non-trivial work that may overlap with other Fabric command-tree gaps; estimate it separately before scheduling. The session that authored this addendum did not start the lift — see Implementation plan below for the updated checklist.
5. Platform shims¶
BukkitPlayerLifecycleHookinrtp-bukkit-common. ImplementsPlayerLifecycleHook. Constructs aListenerwith@EventHandlermethods onPlayerJoinEventandPlayerQuitEvent, registers it viaBukkit.getPluginManager().registerEvents(...)on first subscription, and routes events to the registeredConsumer<UUID>handlers. EachAutoCloseablereturned unsubscribes the handler; closing the last handler unregisters the listener.AbstractServerAccessor.getPlayerLifecycleHook()returns a lazily-created singleton.FabricPlayerLifecycleHookinrtp-fabric-common. ImplementsPlayerLifecycleHookby hookingServerPlayConnectionEvents.JOINandServerPlayConnectionEvents.DISCONNECT. On the obf carrier the same events are reached through the intermediary surface per rtp-fabric-ADR-009; on the unobf carrier through the deobf API directly. The dispatch can go throughFabricVersionAdapterso the shim class itself does not need an obf / unobf split.FabricServerAccessor.getPlayerLifecycleHook()returns the singleton.
6. Wiring¶
RTPBukkitPlugin.onEnable: importNetworkModeBootstrapfrom its new package and callboot(networkYml). The bootstrap pulls its lifecycle hook fromRTP.serverAccessor.getPlayerLifecycleHook()internally. NoPluginreference passes through the bootstrap entry point.RTPCmdBukkit: update the waitlist guard call site to wrapCommandSenderinRTPCommandSenderonce at the predicate boundary.- Fabric mod entrypoint (
RTPFabricMod.onInitializeor equivalent): constructNetworkModeBootstrap, callboot(networkYml)after the version adapter installs and before the command tree wires.ServerLifecycleEvents.SERVER_STOPPINGcallsbootstrap.shutdown(). The path mirrors the Bukkit entrypoint line-for-line.
7. LIVE singleton¶
The LIVE static on NetworkModeBootstrap stays exactly as it is. It is a per-process singleton today; lifted to rtp-core it remains a per-process singleton, and works the same on either platform. Command-side code (RTPCmdBukkit, future RTPCmdFabricRoot) reads NetworkModeBootstrap.LIVE identically.
8. Test moves¶
These tests move with the code:
LobbyModeEarlyReadTestReqRtpNet015NetworkWaitlistTestNetworkRouterTestNetworkStatusCacheTest
They lose their Bukkit imports (where any exist) and exercise the lifted classes against MockRTPServerAccessor plus a synthetic PlayerLifecycleHook test fixture. All existing assertions remain.
Alternatives Considered¶
| Alternative | Why Rejected |
|---|---|
Reimplement NetworkModeBootstrap as a parallel FabricNetworkModeBootstrap (rtp-fabric-ADR-013) |
Predicated on an "8 Bukkit coupling points in the bootstrap" estimate that does not hold: the bootstrap has zero Bukkit imports, and the actual surface is one missing SPI primitive (player join / quit). The reimplement path would duplicate ~1108 lines of platform-neutral bootstrap plus ~300 lines of helpers, on a code path that will keep evolving through MULTI_SERVER_PLAN.md Phase 3 (Postgres LISTEN/NOTIFY, BungeeCord transport, multi-transport composition). Each future phase would land twice. |
Refactor NetworkModeBootstrap into a platform-neutral abstract base class with a Bukkit subclass and a Fabric subclass |
Abstract-base inheritance is heavier than this case warrants. The only platform-varying primitive is "subscribe to player join / quit"; a one-method interface plus composition is simpler than an abstract base with subclass-only overrides, and lets MockRTPServerAccessor plug a synthetic hook in for tests without subclassing. |
| Defer Fabric network mode until Phase 3 | Step J of MULTI_PLATFORM_PLAN.md is the missing acceptance criterion that downgrades Fabric from "first-class platform" to "single-server only" backend. Deferring violates rtp-fabric-ADR-002. |
Share JoinTriggerSource between platforms via a Listener shim on Fabric |
Forces a non-trivial event-system shim on Fabric (Fabric has no Listener concept). The PlayerLifecycleHook SPI is the cleaner inversion: platform owns event wiring, core owns the handler body. |
Place the lifted classes in rtp-network-common (new module) |
A new Gradle module costs setup overhead and adds a build edge for no benefit; rtp-core already depends on rtp-proxy-common (this is established by the existing Bukkit class's imports from io.github.dailystruggle.rtp.proxy.common.*). The classes belong in rtp-core/.../common/network/ next to the existing network/ subpackage. |
Defer the NetworkWaitlistGuard signature change |
Predicate<CommandSender> is unsafe to keep once the class is in rtp-core (which forbids org.bukkit.* imports). The single caller in RTPCmdBukkit is a 1-line adapter wrap. |
Consequences¶
Positive¶
- Closes Step J in
MULTI_PLATFORM_PLAN.md. Cross-server RTP arrival on a Fabric backend redeems reservation tokens, matching the documented Phase 2 contract, with zero duplicate code. - Future
MULTI_SERVER_PLAN.mdphases (BungeeCord adapter, PostgresLISTEN/NOTIFY, multi-transport composition) land once inrtp-coreand ship on every platform simultaneously. PlayerLifecycleHookis reusable for any future cross-cutting concern that needs "do X when a player joins / leaves" without leaking platform-specific event types into core (e.g.MULTI_SERVER_PLAN.mdlobby-side UX work,metrics-apiper-player heartbeats).- The Bukkit class moves verbatim apart from one line (
registerEventstolifecycleHook.onPlayerJoin), preserving the user-confirmed-working Paper code path. All existing Paper / Folia tests move with the code and stay green. MockRTPServerAccessorcan supply a syntheticPlayerLifecycleHookthat drives test-controlled join / quit events, replacing the per-platform listener test harnesses that exist today.
Negative / Trade-offs¶
NetworkWaitlistGuard's public predicate signature changes fromPredicate<CommandSender>toPredicate<RTPCommandSender>. Internal-only API, one caller (RTPCmdBukkit), one-line wrap adapter at the call site. Acceptable.RTPServerAccessorgains a new default method. Existing implementations compile without changes; platforms that want real lifecycle hooks override (Bukkit, Fabric). Mock / Folia / tests inherit the no-op.LIVEsingleton is now reachable from a wider set of call sites (any backend platform's command tree). This is intentional but means the existing volatile-publish contract is the only memory-visibility guarantee for cross-thread reads; no change from today's Bukkit behaviour.- Test fixtures that previously plugged into a Bukkit
Listenerregistry now plug intoPlayerLifecycleHook. New tests are needed for the platform shims (BukkitPlayerLifecycleHookTest,FabricPlayerLifecycleHookTest); these are straightforward harnesses and small.
Limitations¶
- No proxy adapter for Fabric. This ADR concerns the backend side only. Proxies remain Velocity (working) and BungeeCord (Phase 3). A Fabric process can be a backend; it cannot be a proxy.
- No Phase 3 hooks. Postgres
LISTEN/NOTIFY, BungeeCord transport, and theJoinTriggerSourcerework are explicitly out of scope. The lifted code implements Phase 2 only. - Single transport per process. Same constraint as today's Bukkit class. Multi-transport composition (
network.yml::transport.typelisted as a sequence) is aMULTI_SERVER_PLAN.mdPhase 3 follow-up. - No live
rtp test fullfor the network suite on Fabric in this ADR. A Fabric equivalent harness is a follow-up; the lift unblocks it but does not deliver it.
Implementation plan (post-acceptance)¶
The implementation is a separate change after this ADR is accepted. Updated 2026-05-23 after the step-4 addendum widened the SPI surface. Live progress tracking lives in docs/dev/scratch/CHECKLIST-adr049-network-mode-lift.md.
Done (prior sessions):
- ✅
PlayerLifecycleHookinterface inrtp-api;NoopPlayerLifecycleHook;DispatchingPlayerLifecycleHookskeleton;RTPServerAccessor.getPlayerLifecycleHook()default. - ✅
BukkitPlayerLifecycleHookinrtp-bukkit-common;AbstractServerAccessorowns and registers the singleton. - ✅
FabricPlayerLifecycleHookinrtp-fabric-common;FabricServerAccessor.getPlayerLifecycleHook()override;FabricEventBridgefans JOIN / DISCONNECT into the hook.
Open (in order):
- New SPI:
BackendStateSampler. Interface inrtp-core(orrtp-api) covering player count, TPS, MSPT, world list, lobby-mode flag.RTPServerAccessor.createBackendStateSampler(boolean lobbyMode)factory method (no default — throwIllegalStateExceptionuntil a platform provides one, per S-006).BukkitBackendStateSamplerbecomes an implementation; newFabricBackendStateSamplerreads fromMinecraftServerviaFabricVersionAdapter(TPS/MSPT may need a Fabric-side sampler that does not exist yet — scope this before estimating). - New SPI:
NetworkCommandHook. Interface inrtp-corecovering the surface today'sBukkitNetworkCommandHookexposes (mount/rtp server:<x>and network sub-verbs, exposecommandHook()getter on the bootstrap).RTPServerAccessor.getNetworkCommandHook()default returns a no-op.BukkitNetworkCommandHookbecomes an implementation. NewFabricNetworkCommandHookmounts into the Fabric Brigadier tree viaBrigadierCommandAdapter(commands-api-ADR-001). Estimate separately — this is the heaviest unknown in the lift. - Lift the nine zero-Bukkit-import classes (
NetworkModeBootstrap,NetworkRouter,NetworkStatusCache,NetworkEnrolmentBuffer,PeerRegionRegistry,LobbyDispatchRetryQueue,NetworkRegionCollisionWarner,RoutingDecision) fromrtp-plugin/.../bukkit/network/tortp-core/.../common/network/. Replace the twonew BukkitBackendStateSampler(...)/new BukkitNetworkCommandHook(...)call sites withRTP.serverAccessor.createBackendStateSampler(...)/getNetworkCommandHook(). Update import sites across the codebase. - Move and rewrite the 4 listener classes (
JoinTriggerSource,NetworkWaitlistQuitListener,NetworkWaitlistNotifier,NetworkWaitlistGuard) per Decision §3. UpdateRTPCmdBukkitwaitlist-guard call site (Predicate<CommandSender>->Predicate<RTPCommandSender>). - Move the 4 tests (
LobbyModeEarlyReadTest,ReqRtpNet015NetworkWaitlistTest,NetworkRouterTest,NetworkStatusCacheTest) and adjust toMockRTPServerAccessor+ syntheticPlayerLifecycleHookfixture. - Wire Fabric mod entrypoint (
RTPFabricMod.onInitializeor equivalent) to constructNetworkModeBootstrap, callboot(networkYml)after version-adapter install and before command-tree wiring, and callshutdown()onServerLifecycleEvents.SERVER_STOPPING. - New tests:
BukkitPlayerLifecycleHookTest,FabricPlayerLifecycleHookTest, Fabric bootstrap smoke test. - CHANGELOG entry under the current Unreleased cycle.
- Devstack acceptance (user-driven per session direction): cross-server round-trip with Fabric backend, reservation-token redeem, Fabric-lobby dispatch.
- Full
.\gradlew build.
References¶
- ADR-036 - Network mode (multi-server, multi-proxy)
- rtp-fabric-ADR-013 - Network-mode backend parity on Fabric (superseded by this ADR)
- rtp-proxy-ADR-014 - Backend-owned RTP with network queue
- rtp-proxy-ADR-015 - Shared network waitlist and dynamic batched dispatch
- rtp-fabric-ADR-002 - Platform in scope
- rtp-fabric-ADR-009 - Obf / unobf common split
MULTI_SERVER_PLAN.mdMULTI_PLATFORM_PLAN.mdStep J