ADR-048 - Move menu page builders into rtp-api behind RTPServerAccessor¶
- Status: Proposed (2026-05-23)
- Scope:
rtp-api(menu/,server/RTPServerAccessor),rtp-core(common/commands/menu/),rtp-bukkit-common,rtp-fabric-common(+-common-unobf),rtp-plugin(Paper + Fabric command roots). - Supersedes: none. Amends the platform-shaped wiring contract assumed by ADR-035, ADR-044, ADR-045, rtp-fabric-ADR-012.
- Related: ADR-035 (menu rollout - Paper book renderer + Fabric chat renderer), ADR-044 (
MenuModelreflector), rtp-fabric-ADR-011 (Fabric effective-permission resolver, feeds the accessor), rtp-fabric-ADR-012 (Fabric chat-first renderer; ADR-012 will be amended to consume the accessor introduced here).
Context¶
The interactive-menu rollout is split across two layers today:
- Platform-neutral SPI in
rtp-api/.../menu/:MenuModel,MenuPage,MenuLine,MenuFragment,MenuAction,MenuRenderer,MenuTokenRegistry,MenuConsumerProfile,MenuOpenRequest,YamlCommentLookup. These are pure value types and SPIs and are correctly factored - third-party-renderer-friendly, no platform imports. - Page builders in
rtp-core/.../common/commands/menu/:FrontPageBuilder,AdminPanelBuilder,CommandTreeMenuBuilder,MultiConfigMenuBuilder,PrefabConfirmationMenuBuilder. These buildMenuModelinstances from the live command tree, configuration files, and per-player state.
The builders are nominally platform-neutral (they live in rtp-core, which forbids org.bukkit.* imports) but in practice they couple to platform-shaped data through ad-hoc seams:
- Permission visibility filter. Every builder filters command-tree rows by a
Predicate<String>permission probe. Today the probe is constructed inline at each command-root call site (Paper:RTPCmdBukkitlines 213 / 239 / 246 / 259 / 303; Fabric per rtp-fabric-ADR-012 plan). The construction logic differs per platform (Paper delegates toPlayer#hasPermission; Fabric routes through the three-tier chain from rtp-fabric-ADR-011). - Effective-permission enumeration.
AdminPanelBuilderand the param-picker page need the granted-node set to decide what config rows / param values to expose. Available onRTPPlayer#getEffectivePermissions()today, but each builder reaches for the player viaRTP.serverAccessor.getPlayer(uuid)ad-hoc and assumes the result is non-empty - a Fabric pre-rtp-fabric-ADR-011 regression that the ADR-011 resolver fixed but did not lift to a stable accessor surface. - Player locale. Builders embed strings through
LangParser/messages.yml. The lookup is platform-neutral, but the fallback chain (client locale -> world default -> server default) is not - Paper readsPlayer#getLocale(), Fabric resolves through the carrier split. - Region / world descriptor.
FrontPageBuildershows a "you are here" line. On a single-server backend this readsPlayer#getWorld(); on a network-mode backend (ADR-036) the answer is proxy-routed. Each builder currently reaches for whatever it can find.
The result is that each platform's command root re-wires the same seven things inline: build a LocalMenuTokenRegistry, construct a menuPermissionProbe, attach MenuRedeemSubcommand, instantiate each builder, and connect them to the renderer. Adding a new platform (the Fabric work in rtp-fabric-ADR-012, or any future Forge / Sponge port) means re-implementing that wiring from scratch. The five builders themselves are mostly platform-neutral, but the platform-shaped seams keep them from being directly reusable.
The user-raised question - "can we extract this into a menu-api module and use the RTPServerAccessor / effects-api model?" - distils to: where do the platform-shaped seams belong, and is a new Gradle module warranted? This ADR answers both.
Decision¶
Adopt the accessor-extension path. No new Gradle module.
1. Fold the menu-platform seams into RTPServerAccessor¶
Add four default methods to RTPServerAccessor (in rtp-api/.../server/RTPServerAccessor.java). They have safe defaults so the existing Bukkit and Fabric implementations compile without immediate changes; platforms override progressively.
// In RTPServerAccessor
/**
* Returns a permission probe scoped to the given player. The probe answers
* {@code hasPermission(node)} for menu-visibility filtering. Implementations
* should respect the same three-tier resolution as
* {@link RTPPlayer#hasPermission(String)}.
*
* @param player the player UUID; the probe is bound to this player's view
* @return a non-null predicate; default delegates to
* {@code getPlayer(player).hasPermission(node)} and falls back to
* {@code n -> false} if the player is offline / unresolved
*/
default Predicate<String> menuPermissionProbe(UUID player) { ... }
/**
* Returns the set of effective permissions granted to {@code player} for
* the menu-relevant {@code rtp.*} namespaces (effects, on-event, numeric
* tails where enumerable). Delegates to
* {@link RTPPlayer#getEffectivePermissions()} by default.
*
* @param player the player UUID
* @return an immutable snapshot; empty if unresolved
*/
default Set<String> menuEffectivePermissions(UUID player) { ... }
/**
* Returns the BCP-47 locale tag the menu should render in for the given
* player (e.g. {@code "en_us"}, {@code "es_es"}). Default returns the
* server's configured default locale.
*
* @param player the player UUID; may be {@link RTPPlayer#consoleUuid()}
* for console-issued menu commands
* @return a non-null locale tag
*/
default String menuLocale(UUID player) { ... }
/**
* Returns a short descriptor of where the player currently is, for the
* "you are here" line in {@code FrontPageBuilder}. The descriptor is
* intentionally string-shaped (not a {@code World} reference) so that
* network-mode backends (ADR-036) can answer with a proxy-routed server
* id or region name.
*
* @param player the player UUID
* @return a non-null short descriptor; default is the empty string,
* which causes the "you are here" line to be omitted
*/
default String menuRegionDescriptor(UUID player) { ... }
These methods live on RTPServerAccessor (not a new sibling accessor) because:
- The five builders already depend on
RTPServerAccessortransitively throughRTP.serverAccessor. Adding methods on the existing accessor avoids a second injection point. - Each method is the menu-shaped projection of an existing accessor /
RTPPlayercapability. They are not a new subsystem, they are convenience views. effects-apihas a separateEffectFactorybecause effects are third-party-registerable. Menu builders are not third-party-registerable (per ADR-044 the model reflects the command tree, not user-registered pages), so the symmetry does not apply.
If a fifth or sixth method joins the list (e.g. boolean isViewingMenu(UUID) for rtp-fabric-ADR-012's chat-prompt callback, or void runOnPlayerThread(UUID, Runnable) for Folia region-affinity actions), this ADR is amended to split the menu surface into a sibling RTPMenuAccessor retrieved via RTPServerAccessor#menuAccessor(). The split threshold is documented as >=5 menu-specific methods on RTPServerAccessor; today's four sit below it.
2. Move the five builders into rtp-api/.../menu/builders/¶
Relocate (not rewrite):
From (rtp-core/.../common/commands/menu/) |
To (rtp-api/.../menu/builders/) |
|---|---|
FrontPageBuilder |
FrontPageBuilder |
AdminPanelBuilder |
AdminPanelBuilder |
CommandTreeMenuBuilder |
CommandTreeMenuBuilder |
MultiConfigMenuBuilder |
MultiConfigMenuBuilder |
PrefabConfirmationMenuBuilder |
PrefabConfirmationMenuBuilder |
Constructor signatures change to take the platform seams from the accessor rather than reaching for RTP.serverAccessor ad-hoc:
public FrontPageBuilder(MenuPlatformView view, MenuTokenRegistry tokens) { ... }
where MenuPlatformView is a tiny record assembled from the accessor at call time:
public record MenuPlatformView(
Predicate<String> hasPermission,
Set<String> effectivePermissions,
String locale,
String regionDescriptor) {
public static MenuPlatformView of(RTPServerAccessor accessor, UUID player) {
return new MenuPlatformView(
accessor.menuPermissionProbe(player),
accessor.menuEffectivePermissions(player),
accessor.menuLocale(player),
accessor.menuRegionDescriptor(player));
}
}
The MenuPlatformView lives in rtp-api/.../menu/. It is a snapshot per build call, not a long-lived reference - this keeps builders pure (same input -> same MenuModel) and avoids cross-thread races on permission / locale changes mid-render.
Why rtp-api rather than leaving in rtp-core: rtp-api is the only module both rtp-core and platform adapters depend on. Builders in rtp-api can be invoked from the platform-side command roots without a rtp-core round-trip, and the MenuPlatformView record sits next to MenuModel / MenuPage where it naturally belongs.
3. Collapse the per-platform command-root wiring¶
After the move, RTPCmdBukkit and RTPCmdFabricRoot shrink to:
// Once at boot
LocalMenuTokenRegistry tokens = new LocalMenuTokenRegistry();
MenuRendererRegistry.register("book", new BookMenuRenderer(...)); // Paper
// or new ChatMenuRenderer(...) on Fabric per rtp-fabric-ADR-012
attachSubcommand(new MenuRedeemSubcommand(tokens));
// Per /rtp invocation
MenuPlatformView view = MenuPlatformView.of(RTP.serverAccessor, callerUuid);
MenuModel model = new FrontPageBuilder(view, tokens).build();
RTP.serverAccessor.openMenu(callerUuid, model); // existing dispatch path
The seven inline construction sites in RTPCmdBukkit (token registry + permission probe + redeem subcommand + four builder call sites) collapse to one accessor lookup per build call. The same pattern applies to Fabric, which lets rtp-fabric-ADR-012's RTPCmdFabricRoot wiring section be drastically simplified - the ADR will be amended to delete its menuPermissionProbe sub-item (now subsumed) and reference this ADR for the builder wiring.
4. Platform adapter changes¶
| Platform | Adapter delta |
|---|---|
BukkitServerAccessor (rtp-bukkit-common) |
Override the four default methods. menuPermissionProbe(uuid) -> node -> Bukkit.getPlayer(uuid).hasPermission(node). menuEffectivePermissions(uuid) -> walks Player#getEffectivePermissions(). menuLocale(uuid) -> Player#getLocale(). menuRegionDescriptor(uuid) -> Player#getWorld().getName() (or empty string when network mode is active and the player is on a different backend). |
FabricServerAccessor (rtp-fabric-common + -common-unobf) |
Override the four default methods. menuPermissionProbe(uuid) and menuEffectivePermissions(uuid) delegate to FabricEffectivePermissionsResolver from rtp-fabric-ADR-011. menuLocale(uuid) reads ServerPlayer#clientInformation().language() through the carrier split. menuRegionDescriptor(uuid) reads ServerPlayer#serverLevel().dimension().location().toString(). |
| Folia | Inherits BukkitServerAccessor. No delta in this ADR; a follow-up may add runOnPlayerThread(UUID, Runnable) for region-affinity click actions, but the four methods above are platform-thread-agnostic. |
rtp-proxy-* |
Out of scope. The proxy SPI in rtp-proxy-common does not implement RTPServerAccessor and does not render menus directly - menus on proxy-routed players are still rendered backend-side per the 2026-05-15 ADR-035 amendment. |
5. Migration mechanics¶
The move is mechanical but non-trivial because the builders are referenced from RTPCmdBukkit, MenuRedeemSubcommand, and (planned) RTPCmdFabricRoot. Sequence:
- Land the accessor methods first (default implementations + Bukkit / Fabric overrides). No behavioural change yet - existing call sites still work because the inline
menuPermissionProbeconstructions are byte-equivalent to the new defaults. - Add
MenuPlatformViewas a record inrtp-api/.../menu/. - Copy the five builders into
rtp-api/.../menu/builders/with the new constructor signature, alongside the existing copies inrtp-core. Both work for one cycle. - Switch call sites in
RTPCmdBukkitto the new builders +MenuPlatformView.of(...). - Delete the old builders from
rtp-core. ArchUnit guard (MULTI_PLATFORM_PLAN.md Step H) is unchanged - the new package is inrtp-api, which is under neitherbukkit/norfabric/. - Wire Fabric in
RTPCmdFabricRootper the amended rtp-fabric-ADR-012.
Steps 1-2 are independently committable; steps 3-6 land together to avoid split-brain builders.
Consequences¶
Positive¶
- Each new platform adds one accessor subclass + four overrides to gain full menu support. Forge / Sponge / hypothetical Mod Loader X get the menu surface for free as long as they ship a
RTPServerAccessor. RTPCmdBukkitandRTPCmdFabricRootshrink substantially: seven inline constructions per root collapse to one accessor lookup per build call.- Cross-cutting menu concerns funnel through one surface. Any future "show 'you are here' as proxy-routed server id" change is a single override in the network-mode-aware accessor variant, not five builders.
- Aligns with the existing
RTPServerAccessorconvention. No new accessor pattern for contributors to learn. - No new Gradle module. Build times, publish coordinates, and ArchUnit guards unchanged.
- Retroactively cleans Paper's
menuPermissionProbead-hoc field (the implicit pattern that rtp-fabric-ADR-012 inherits).
Negative¶
rtp-apigrows. Five builder classes (~600-1200 LOC each in their current form) move fromrtp-coretortp-api. This is acceptable because they are leaf consumers ofrtp-apitypes and have no dependency onrtp-corebusiness logic that would force a circular dependency - confirmed by inspection (the builders consumeMenuModel,MenuTokenRegistry,LangParser,YamlCommentLookup, all of which are inrtp-api). If a circular dep is uncovered during step 3 of the migration, the offending dep is pulled up tortp-api(LangParseris the most likely candidate; it is already a viablertp-apiresident).RTPServerAccessorinterface widens by four methods. Each has a safe default, so out-of-tree implementers (addons that ship a stub accessor for tests) are not broken. The widening is documented in CHANGELOG under[3.0.0-beta.4]when the implementation lands.- The four default implementations need careful documentation. Default
menuPermissionProbereturningn -> falsefor offline players is a subtle behavioural choice (no rows visible vs all rows visible). The chosen default (no rows visible) is conservative; documented in the method Javadoc.
Limitations¶
MenuPlatformViewis a snapshot. Permission or locale changes during a long-rendering page do not propagate. Acceptable because pages render in milliseconds; documented as a Javadoc note on the record.- No third-party builder registration. This ADR keeps builders project-internal. Per ADR-044 the model reflects the command tree, not user-registered content, so third-party builders are out of scope. If that requirement materializes, the migration target (
rtp-api/.../menu/builders/) is already in a publishable module. - No coverage for proxy-side menu rendering. Proxy JVMs (
rtp-proxy-velocity,rtp-proxy-bungee) do not hostRTPServerAccessor. Menus on proxy-routed players continue to render on the destination backend per ADR-035's 2026-05-15 amendment. A future "proxy-rendered menu" ADR would need its own seam. menuRegionDescriptoris intentionally aString. Avoids leaking aWorldreference intortp-api. Callers that want richer structure can parse the descriptor or call a future typed method.
Implementation plan (post-acceptance)¶
The implementation is a separate change after this ADR is accepted. Sketch:
- Add four
defaultmethods toRTPServerAccessor. AddMenuPlatformViewrecord underrtp-api/.../menu/. - Override the four methods in
BukkitServerAccessorandFabricServerAccessor(+-common-unobfvariant). Wire Fabric overrides to the existingFabricEffectivePermissionsResolver. - Move the five builders from
rtp-core/.../common/commands/menu/tortp-api/.../menu/builders/. Update constructors to takeMenuPlatformView+MenuTokenRegistry. - Update
RTPCmdBukkitcall sites toMenuPlatformView.of(...)+ new builder package. - Amend rtp-fabric-ADR-012: delete the
menuPermissionProbesub-item, simplify theRTPCmdFabricRootwiring section to reference ADR-048, keep the chat-renderer decision intact. - New
ServerAccessorMenuSurfaceTestinrtp-api/src/test/: pins the four-method contract against a mock accessor; assertsMenuPlatformView.ofsnapshots correctly; asserts defaultmenuPermissionProbeis conservative for unresolved players. - New
BukkitMenuPlatformViewParityTestandFabricMenuPlatformViewParityTest: each builds a knownMenuModelagainst a real platform accessor and asserts the rendered page set matches an oracle (covers the regression where Fabric's permission probe was always-true pre-rtp-fabric-ADR-011). - CHANGELOG entry under
[3.0.0-beta.4] - Unreleased: "Menu page builders moved fromrtp-coretortp-api; platform-shaped seams (permission probe, effective permissions, locale, region descriptor) lifted toRTPServerAccessordefault methods. Out-of-tree accessor implementations gain the new surface via defaults; explicit overrides recommended for production platforms." TRACEABILITY.mdrows added if the new tests target a REQ-* (likely a newREQ-RTP-MENU-NNNfor "menu visibility filter shall use the platform's effective-permission view"; needs a REQ wording pass first).
Alternatives Considered¶
| Alternative | Why rejected |
|---|---|
New menu-api Gradle module (the user's original framing) |
No third-party menu registration on the roadmap; the effects-api symmetry is superficial (effects are third-party-registerable, menus are not per ADR-044). Gradle module overhead (publish coordinates, ArchUnit updates, transitive-dep wiring on five other modules) without an API-surface win. Can be split out later if requirements change; the migration target (rtp-api/.../menu/builders/) is already publishable. |
Separate RTPMenuAccessor sibling, retrieved via RTPServerAccessor#menuAccessor() |
Premature with only four menu-shaped methods. Adds an indirection (accessor.menuAccessor().permissionProbe(uuid)) that buys no clarity at this size. Documented threshold for revisiting: >=5 menu-specific methods on RTPServerAccessor. |
Keep builders in rtp-core, pass RTPServerAccessor directly |
Builders would still reach into the accessor for four different concerns each, scattering the call sites and forcing every platform to implement the four methods even if they are not menu-rendering yet. The MenuPlatformView snapshot record is the right intermediate object. |
Pass a RTPPlayer to builders instead of MenuPlatformView |
RTPPlayer is too coarse - locale and region descriptor are not on it today, and adding them re-creates the same widening problem one level deeper. MenuPlatformView is a menu-shaped projection, not a player abstraction. |
Refactor RTPServerAccessor into a ServiceLocator keyed by capability |
Out of scope and far heavier than required. The four methods do not warrant a service-locator pattern; the existing default-method convention on the interface is sufficient. |
References¶
- ADR-035 - Interactive Menus via Written Book
- ADR-044 - Command-Tree Menu Reflector
- ADR-045 -
rtp-docsMenu Consumer - rtp-fabric-ADR-011 - Effective-permission enumeration on Fabric
- rtp-fabric-ADR-012 - Menu renderer parity on Fabric
MULTI_PLATFORM_PLAN.md(Step I gating context)RTPServerAccessor.java(the interface being extended)