From decadbe51a13abc179e555ce2e829bed0e0982bd Mon Sep 17 00:00:00 2001 From: Andrew Wong <42793301+md5sha256@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:25:53 +1000 Subject: [PATCH 01/15] docs: spec and plan for reconciling notifications onto upstream events Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014kvZD6gWHjdMkN9Ebt3UbF --- .../2026-08-21-reconcile-notifications.md | 584 ++++++++++++++++++ ...cile-notifications-onto-upstream-events.md | 199 ++++++ 2 files changed, 783 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-21-reconcile-notifications.md create mode 100644 docs/superpowers/specs/2026-08-21-reconcile-notifications-onto-upstream-events.md diff --git a/docs/superpowers/plans/2026-08-21-reconcile-notifications.md b/docs/superpowers/plans/2026-08-21-reconcile-notifications.md new file mode 100644 index 0000000..859c078 --- /dev/null +++ b/docs/superpowers/plans/2026-08-21-reconcile-notifications.md @@ -0,0 +1,584 @@ +# Reconciled Notifications Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** On top of `origin/main`'s existing event system, make every Realty notification a fired `RealtyNotificationEvent` carrying pre-rendered text, delete `NotificationService` entirely, and move delivery into two adapter module jars. + +**Architecture:** One new standalone `RealtyNotificationEvent extends Event` in `realty-paper-api`, with its own `HandlerList`, carrying `List targets`, a rendered `Component`, and a nullable `WorldGuardRegion`. Every notification fire site renders as it does today and fires this event **alongside** the domain post-event it already fires. Upstream's 47 event classes are not modified. `NotificationService`, both implementations, `RegionNotificationListener` and the Essentials/transient branch in `onEnable` are deleted; `realty-paper-adapters/chat-adapter` and `.../essentials-adapter` deliver. + +**Tech Stack:** Java 21, Gradle Kotlin DSL, PaperMC 1.21.8, Adventure, Incendo Cloud, `com.minecraftcitiesnetwork:plugin-infrastructure`, EssentialsX 2.21.2 (adapter only), JUnit 5, Mockito. + +**Spec:** `docs/superpowers/specs/2026-08-21-reconcile-notifications-onto-upstream-events.md` + +**Prior work:** the abandoned branch is preserved at tag `pre-reconcile-event-driven-notifications`. Several tasks port files from it verbatim — read them there with `git show pre-reconcile-event-driven-notifications:`. + +## Global Constraints + +- **Java 21.** +- **No wildcard imports, no static imports.** Explicit single-class imports only. In tests, `Assertions.assertEquals(...)`, never static-imported. +- **No fully-qualified class names inline.** +- **Do not modify any existing class in `realty-paper-api/.../api/event/`.** Upstream's 47 events stay exactly as they are. This plan adds one file to that package and touches no other. +- **Do not change `messages.yml` or any `MessageKeys.NOTIFICATION_*` constant.** Rendered text must be byte-identical; message construction moves, verbatim, from a `queueNotification` argument into an event constructor. +- **`paper-plugin.yml` keeps the `Essentials` softdepend with `join-classpath: true`** even after core stops compiling against EssentialsX. Module jars load through a `URLClassLoader` parented to Realty's plugin class loader; that entry is the only reason EssX types resolve inside the adapter. Removing it compiles cleanly and fails at module load. +- **The module manifest file is `module-manifest.yml`** — `ModuleLoader.extractManifest` reads that exact name. +- **Adapter subprojects shade nothing and relocate nothing**, and need their own `compileOnly` on `plugin-infrastructure` (`realty-paper` exposes it as `implementation`, not `api`). +- **Do not add JUnit dependencies or `useJUnitPlatform()`** — `realty-conventions` in `buildSrc/` supplies both to every subproject. +- **Do not commit `CLAUDE.md` or anything under `memory-bank/`** — they are gitignored and deliberately untracked. Edit on disk only. +- Commit after every task. + +--- + +### Task 1: Branch from upstream and land the module system + +**Files:** +- Modify: `realty-paper/src/main/java/io/github/md5sha256/realty/Realty.java` (conflict) +- Modify: `realty-paper/src/main/resources/messages.yml` (conflict) + +**Interfaces:** +- Produces: a `ModuleLifecycleManager` over `plugins/Realty/modules`, `/realty module list|reload`, and `startModules()`/`stopModules()` in the plugin lifecycle. Tasks 6–8 depend on all of it. + +`eb9667f` ("Add module support via plugin-infrastructure") exists only on the local, unpushed `main`. It brings the `plugin-infrastructure` dependency, `ModuleCommandGroup`, and the module lifecycle wiring, and it replaces several local utility classes with library equivalents (`DateFormatter`, `DurationParserUtil`, `ComponentSerializer`, `SimpleDateFormatSerializer`). Upstream has moved since, so it conflicts in two files. + +- [ ] **Step 1: Create the reconciliation branch** + +```bash +git fetch origin +git checkout -b feature/reconcile-notifications origin/main +``` + +- [ ] **Step 2: Cherry-pick the module system** + +```bash +git cherry-pick eb9667f +``` + +Expect conflicts in `Realty.java` and `messages.yml`. Resolve by hand, keeping **both** sides: upstream's newer `onEnable` content (event dispatch, lease-lifecycle wiring, subregion work) **and** the module manager construction, `startModules()` last in `onEnable`, `stopModules()` first in `onDisable`, and the `ModuleCommandGroup` registration. In `messages.yml`, keep upstream's new lease-lifecycle keys and add the module command keys. + +- [ ] **Step 3: Verify** + +Run: `./gradlew shadowJar` — expected BUILD SUCCESSFUL. +Run: `./gradlew test` — expected BUILD SUCCESSFUL (Docker is running, so the Testcontainers suites execute). +Run: `grep -rn "ModuleLifecycleManager" --include=*.java realty-paper/src/main` — expected: hits in `Realty.java` and `ModuleCommandGroup.java`. + +- [ ] **Step 4: Commit** + +The cherry-pick is already staged; conclude it. + +```bash +git add -A +git commit -m "feat: add module support via plugin-infrastructure + +Cherry-picked from the unpushed local main; resolved against upstream's +event-dispatch and lease-lifecycle changes." +``` + +--- + +### Task 2: The notification event + +**Files:** +- Create: `realty-paper-api/src/main/java/io/github/md5sha256/realty/api/event/RealtyNotificationEvent.java` +- Test: `realty-paper-api/src/test/java/io/github/md5sha256/realty/api/event/RealtyNotificationEventTest.java` +- Modify: `realty-paper-api/build.gradle.kts` (test dependency, if absent) + +**Interfaces:** +- Produces: `RealtyNotificationEvent(List targets, Component message, @Nullable WorldGuardRegion region)` with `getTargets()`, `getMessage()`, `getRegion()`, `getHandlers()`, `getHandlerList()`. Tasks 4, 5, 6, 7 all use it. + +It extends `Event`, **not** `RealtyRegionEvent` — the latter requires a live `WorldGuardRegion`, and the payment-expiry sweeps in Task 5 cannot always produce one. The class is `final`: a subclass omitting `getHandlerList()` would silently share this handler list. + +`realty-paper-api` declares paper-api as `compileOnlyApi`, which does not reach the test compile classpath. If `testImplementation("io.papermc.paper:paper-api:1.21.8-R0.1-SNAPSHOT")` is not already present, add it. + +- [ ] **Step 1: Write the failing test** + +```java +package io.github.md5sha256.realty.api.event; + +import net.kyori.adventure.text.Component; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +class RealtyNotificationEventTest { + + private static final Component MESSAGE = Component.text("rendered"); + + @Test + void exposesTargetsAndMessage() { + UUID target = UUID.randomUUID(); + RealtyNotificationEvent event = + new RealtyNotificationEvent(List.of(target), MESSAGE, null); + + Assertions.assertEquals(List.of(target), event.getTargets()); + Assertions.assertEquals(MESSAGE, event.getMessage()); + Assertions.assertNull(event.getRegion()); + } + + @Test + void targetsAreDefensivelyCopiedAndImmutable() { + List mutable = new ArrayList<>(); + mutable.add(UUID.randomUUID()); + RealtyNotificationEvent event = + new RealtyNotificationEvent(mutable, MESSAGE, null); + + mutable.add(UUID.randomUUID()); + + Assertions.assertEquals(1, event.getTargets().size()); + Assertions.assertThrows(UnsupportedOperationException.class, + () -> event.getTargets().add(UUID.randomUUID())); + } + + @Test + void rejectsEmptyTargets() { + Assertions.assertThrows(IllegalArgumentException.class, + () -> new RealtyNotificationEvent(List.of(), MESSAGE, null)); + } + + @Test + void rejectsNulls() { + Assertions.assertThrows(NullPointerException.class, + () -> new RealtyNotificationEvent(null, MESSAGE, null)); + Assertions.assertThrows(NullPointerException.class, + () -> new RealtyNotificationEvent(List.of(UUID.randomUUID()), null, null)); + } + + @Test + void isSynchronous() { + RealtyNotificationEvent event = + new RealtyNotificationEvent(List.of(UUID.randomUUID()), MESSAGE, null); + + Assertions.assertFalse(event.isAsynchronous()); + } + + @Test + void handlerListIsShared() { + RealtyNotificationEvent event = + new RealtyNotificationEvent(List.of(UUID.randomUUID()), MESSAGE, null); + + Assertions.assertSame(RealtyNotificationEvent.getHandlerList(), event.getHandlers()); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :realty-paper-api:test --tests "*RealtyNotificationEventTest*"` +Expected: FAIL — `RealtyNotificationEvent` does not exist. + +- [ ] **Step 3: Write the implementation** + +```java +package io.github.md5sha256.realty.api.event; + +import io.github.md5sha256.realty.api.WorldGuardRegion; +import net.kyori.adventure.text.Component; +import org.bukkit.event.Event; +import org.bukkit.event.HandlerList; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +/** + * Fired whenever Realty has something to tell one or more players. The message is rendered by the + * fire site from {@code messages.yml}; this event only carries it. + * + *

Realty itself delivers nothing — adapter modules listen for this event and decide what reaches + * the target. It is fired alongside, not instead of, the domain event describing what happened.

+ * + *

Synchronous: fired through {@code RealtyEventDispatch.fireSync}, so handlers run on the main + * thread and may use the Bukkit API directly.

+ */ +public final class RealtyNotificationEvent extends Event { + + private static final HandlerList HANDLERS = new HandlerList(); + + private final List targets; + private final Component message; + private final WorldGuardRegion region; + + /** + * @param targets who should be told; never empty. Several targets means several people get the + * same message — different text per person is separate events. + * @param message the rendered message + * @param region the region this concerns, or null when it cannot be resolved — a refund is + * still announced when its region has already been deleted + */ + public RealtyNotificationEvent(@NotNull List targets, + @NotNull Component message, + @Nullable WorldGuardRegion region) { + this.targets = List.copyOf(Objects.requireNonNull(targets, "targets")); + if (this.targets.isEmpty()) { + throw new IllegalArgumentException("A notification needs at least one target"); + } + this.message = Objects.requireNonNull(message, "message"); + this.region = region; + } + + public @NotNull List getTargets() { + return this.targets; + } + + public @NotNull Component getMessage() { + return this.message; + } + + public @Nullable WorldGuardRegion getRegion() { + return this.region; + } + + @Override + public @NotNull HandlerList getHandlers() { + return HANDLERS; + } + + public static @NotNull HandlerList getHandlerList() { + return HANDLERS; + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :realty-paper-api:test --tests "*RealtyNotificationEventTest*"` — expected PASS, 6 tests. + +- [ ] **Step 5: Commit** + +```bash +git add realty-paper-api +git commit -m "feat(api): add RealtyNotificationEvent" +``` + +--- + +### Task 3: `worldId` on the two expiry records + +**Files:** +- Modify: `realty-backend-api/src/main/java/io/github/md5sha256/realty/api/RealtyBackend.java` +- Modify: `realty-backend/src/main/java/io/github/md5sha256/realty/database/RealtyBackendImpl.java` +- Modify: `realty-backend/src/test/java/io/github/md5sha256/realty/database/RealtyBackendImplTest.java` (if it constructs either record) + +**Interfaces:** +- Produces: `ExpiredBidPayment(UUID bidderId, double refundAmount, String regionId, @Nullable UUID worldId)` and `ExpiredOfferPayment(UUID offererId, double refundAmount, String regionId, @Nullable UUID worldId)`. Task 5 needs both. + +`ExpiredBiddingAuction` and `ExpiredLeasehold` already carry a `worldId` — **leave those two alone.** + +No SQL change and no migration are needed: `clearExpiredBidPayments()` and `clearExpiredOfferPayments()` already select a `RealtyRegionEntity`, which is `(int realtyRegionId, String worldGuardRegionId, UUID worldId)`. The world id is already in hand where the result record is built. + +Both methods already fall back to `String regionName = region != null ? region.worldGuardRegionId() : "unknown";`. There is no honest fallback for a UUID, so the new component is `@Nullable`, populated `region != null ? region.worldId() : null`. + +- [ ] **Step 1: Add the component to both records** + +Add `@Nullable UUID worldId` as a fourth component to each, with a Javadoc line saying it is null when the region row has already been deleted. + +- [ ] **Step 2: Populate it** + +In both methods, compute `UUID worldId = region != null ? region.worldId() : null;` beside the existing `regionName` line, and pass it. **Do not make the refund conditional on it** — the delete and commit already happen independently and must continue to. + +- [ ] **Step 3: Verify** + +Run: `./gradlew :realty-backend:test` — expected BUILD SUCCESSFUL (Docker is up, so these run for real). +Run: `./gradlew shadowJar` — expected BUILD SUCCESSFUL. + +- [ ] **Step 4: Commit** + +```bash +git add realty-backend realty-backend-api +git commit -m "feat(backend): carry worldId on the two payment-expiry records" +``` + +--- + +### Task 4: Fire notifications from the command call sites + +**Files:** +- Modify: `realty-paper/src/main/java/io/github/md5sha256/realty/command/AgentInviteCommand.java` +- Modify: `.../command/AgentInviteAcceptCommand.java`, `AgentInviteRejectCommand.java`, `AgentInviteWithdrawCommand.java`, `AgentRemoveCommand.java` +- Modify: `.../command/AuctionCommandGroup.java` +- Modify: `.../command/OfferCommandGroup.java` +- Modify: `realty-paper/src/main/java/io/github/md5sha256/realty/Realty.java` (constructor arguments) + +**Interfaces:** +- Consumes: `RealtyNotificationEvent` (Task 2), upstream's `RealtyEventDispatch`. +- Produces: command classes with no `NotificationService` parameter. Task 5 deletes the interface itself. + +Every direct `notificationService.queueNotification(target, component)` call in these files becomes: + +```java +eventDispatch.fireSync(new RealtyNotificationEvent(List.of(target), component, region)); +``` + +**Keep the `messages.messageFor(...)` call exactly as it is** — same key, same `Placeholder.unparsed(...)` arguments in the same order. It moves from being the second argument of `queueNotification` to being the second argument of the event constructor. + +**Do not touch the domain post-events these files already fire** (`AgentInvitedEvent`, `OfferPlacedEvent`, `AuctionBidPlacedEvent`, and so on). They keep firing exactly as they do now, unchanged. You are adding a notification fire beside them and removing the direct service call. + +Each command already has a `WorldGuardRegion region` local — pass it. Where the region is not in scope at the notification point, pass `null` and note it in your report. + +These classes take `NotificationService` as a constructor parameter or record component. Remove it, remove the import, and drop the argument at each construction site in `Realty.java`. They will need `RealtyEventDispatch` instead — check how upstream already passes it to `AuctionCommandGroup`/`OfferCommandGroup` and follow that exact pattern for the agent commands. + +The reject-all site in `OfferCommandGroup` currently loops one `queueNotification` per offerer with a single shared `Component`. Collapse it into **one** event carrying the whole list — that is what the multi-target list is for. Guard it with `if (!success.offererIds().isEmpty())`, since the event rejects an empty target list. + +- [ ] **Step 1: Migrate the five agent commands** +- [ ] **Step 2: Migrate `AuctionCommandGroup`** +- [ ] **Step 3: Migrate `OfferCommandGroup`, including the reject-all collapse** +- [ ] **Step 4: Update the construction sites in `Realty.java`** +- [ ] **Step 5: Verify** + +Run: `./gradlew :realty-paper:compileJava` — expected BUILD SUCCESSFUL. +Run: `grep -rn "queueNotification" --include=*.java realty-paper/src/main/java/io/github/md5sha256/realty/command` — expected: no output. +Run: `./gradlew :realty-paper:test` — expected BUILD SUCCESSFUL. + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "refactor: fire notification events from the command call sites" +``` + +--- + +### Task 5: Migrate the sweeps and delete NotificationService + +**Files:** +- Modify: `realty-paper/src/main/java/io/github/md5sha256/realty/Realty.java` +- Delete: `realty-paper-api/src/main/java/io/github/md5sha256/realty/api/NotificationService.java` +- Delete: `realty-paper/src/main/java/io/github/md5sha256/realty/util/TransientNotificationService.java` +- Delete: `realty-paper/src/main/java/io/github/md5sha256/realty/util/EssentialsNotificationService.java` +- Delete: `realty-paper/src/main/java/io/github/md5sha256/realty/listener/RegionNotificationListener.java` + +**Interfaces:** +- Consumes: `RealtyNotificationEvent`, the Task 3 records. +- Produces: a core with no notification delivery at all. `EssentialsSafeBlockPredicate` still exists in `util/` — Task 7 moves it. + +`RegionNotificationListener` renders and delivers nine events' worth of notifications. Its handlers move back to their fire sites as `RealtyNotificationEvent` fires. For each of its nine handlers, find where the corresponding domain event is fired and fire the notification there instead, carrying the same `MessageKeys` constant and the same placeholders. Its private `resolveName(UUID)` helper is needed wherever a name is interpolated — move it to the fire site's class or a shared utility rather than duplicating it. + +`Realty.scheduleTasks()` also calls `queueNotification` directly for auction end, expired bid payments, and expired offer payments. Migrate those too. For the two payment sweeps, build the `WorldGuardRegion` only if `payment.worldId()` is non-null and the world and WG region both resolve; otherwise pass `null` for the region. **The refund must not become conditional on any of that.** + +The leasehold-expiry sweep already hops to the main thread with `scheduler.runTask` because it calls `regionProfileService.applyFlags`. Keep that hop and fire from inside it. + +Then delete the four files, the `notificationService` field, the Essentials/transient selection branch in `onEnable` (leaving `SafeLocationFinder safeLocationFinder = new SafeLocationFinder();` — but **keep** the `EssentialsSafeBlockPredicate` line for now; Task 7 removes it), the `registerEvents(new RegionNotificationListener(...))` registration, and every `NotificationService` import and parameter. + +- [ ] **Step 1: Move the nine `RegionNotificationListener` handlers to their fire sites** +- [ ] **Step 2: Migrate the three `scheduleTasks()` call sites** +- [ ] **Step 3: Delete the four files and strip the wiring** + +```bash +git rm realty-paper-api/src/main/java/io/github/md5sha256/realty/api/NotificationService.java +git rm realty-paper/src/main/java/io/github/md5sha256/realty/util/TransientNotificationService.java +git rm realty-paper/src/main/java/io/github/md5sha256/realty/util/EssentialsNotificationService.java +git rm realty-paper/src/main/java/io/github/md5sha256/realty/listener/RegionNotificationListener.java +``` + +- [ ] **Step 4: Verify** + +Run: `./gradlew shadowJar` — expected BUILD SUCCESSFUL. +Run: `grep -rn "NotificationService\|queueNotification" --include=*.java . | grep -v /build/` — expected: no output. +Run: `./gradlew test` — expected BUILD SUCCESSFUL. + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "refactor!: delete NotificationService; notifications are events only + +Removes a published realty-paper-api type and RegionNotificationListener; +its nine handlers move back to their fire sites as notification events." +``` + +--- + +### Task 6: Expose the module seams + +**Files:** +- Modify: `realty-paper/src/main/java/io/github/md5sha256/realty/Realty.java` +- Modify: `realty-paper-api/src/main/java/io/github/md5sha256/realty/api/RealtyPaperApi.java` +- Modify: `realty-paper/src/main/java/io/github/md5sha256/realty/api/RealtyPaperApiImpl.java` +- Modify: `realty-paper/src/main/java/io/github/md5sha256/realty/command/util/SafeLocationFinder.java` +- Test: `realty-paper/src/test/java/io/github/md5sha256/realty/command/util/SafeLocationFinderTest.java` + +**Interfaces:** +- Produces: `Realty.executorState()`, `Realty.paperApi()`, `RealtyPaperApi.setSafeBlockPredicate(Predicate)`, `SafeLocationFinder.setSafetyPredicate(...)` / `safetyPredicate()`. Tasks 7 and 8 need them. + +Port this from the abandoned branch, where it was reviewed clean: + +```bash +git show pre-reconcile-event-driven-notifications:realty-paper/src/test/java/io/github/md5sha256/realty/command/util/SafeLocationFinderTest.java +``` + +`SafeLocationFinder`'s predicate field becomes `private volatile Predicate` with a setter and getter. It must be mutable because `registerCommands(...)` runs before `startModules()`, so by the time the Essentials adapter initialises, the finder instance is already captured inside `TeleportCommand`. `volatile` because modules start on the main thread while the finder is consulted from async chunk-load callbacks. + +`RealtyPaperApiImpl` gains the finder as a constructor parameter and delegates. **There must be exactly one `SafeLocationFinder` instance** — the one `RealtyPaperApiImpl` delegates to must be the same object `TeleportCommand` received, or `setSafeBlockPredicate` silently mutates a finder nobody consults. Construct it once in `onEnable`, before the API impl, and pass the same local to both. + +`SafeLocationFinder.defaultPredicate()` already exists as a `public static Predicate` — use that name. + +- [ ] **Step 1: Write the failing test** (port it from the tag) +- [ ] **Step 2: Run it and confirm it fails** — `setSafetyPredicate`/`safetyPredicate` do not exist +- [ ] **Step 3: Implement the accessors, the volatile field, and the API method** +- [ ] **Step 4: Verify** + +Run: `./gradlew :realty-paper:test --tests "*SafeLocationFinderTest*"` — expected PASS. +Run: `./gradlew shadowJar` — expected BUILD SUCCESSFUL. + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "feat: expose executorState, paperApi and a swappable safe-block predicate" +``` + +--- + +### Task 7: The chat-adapter module + +**Files:** +- Create: `realty-paper-adapters/chat-adapter/` — `build.gradle.kts`, `ChatAdapterModule.java`, `ChatNotificationListener.java`, `module-manifest.yml`, `ChatNotificationListenerTest.java` +- Modify: `settings.gradle.kts` + +**Interfaces:** +- Consumes: `RealtyNotificationEvent`, `Realty.executorState()`. +- Produces: a module jar. Task 9 bundles it. + +Port the whole subproject from the tag — it was reviewed clean: + +```bash +git show pre-reconcile-event-driven-notifications:realty-paper-adapters/chat-adapter/build.gradle.kts +``` + +Two changes from the ported version: + +1. The listener now handles the **standalone** `RealtyNotificationEvent`, whose accessors are `getTargets()` and `getMessage()` (upstream's convention), not `targetIds()`/`message()`. +2. **The event is synchronous**, so the listener no longer needs to marshal to the main thread — it is already there. Drop the `Executor` constructor parameter and the `mainThreadExec.execute(...)` wrapper. Keep the injected `Function playerLookup` seam, which is what makes it testable without a server; production passes `Bukkit::getPlayer`. + +Behaviour is otherwise the deleted `TransientNotificationService`: send to each online target, drop otherwise. + +The three tests that matter: an online target receives the message; an offline target is skipped without throwing; a multi-target event fans out once per online target. `Audience` has no abstract methods, so a recording fake just overrides `sendMessage(Component)`. + +Manifest is `module-manifest.yml` at `src/main/resources/`, with `entryClass` exactly the module class's FQN and `expectedPluginClass` exactly `io.github.md5sha256.realty.Realty`. + +- [ ] **Step 1: Port the subproject and wire `settings.gradle.kts`** +- [ ] **Step 2: Adapt the listener to the new event and drop the executor** +- [ ] **Step 3: Run the tests and confirm they pass** +- [ ] **Step 4: Verify** + +Run: `./gradlew :realty-paper-adapters:chat-adapter:test` — expected PASS. +Run: `./gradlew shadowJar` — expected BUILD SUCCESSFUL. + +- [ ] **Step 5: Commit** + +```bash +git add settings.gradle.kts realty-paper-adapters/chat-adapter +git commit -m "feat(chat-adapter): deliver Realty notifications to online players" +``` + +--- + +### Task 8: The essentials-adapter module, and Essentials leaves core + +**Files:** +- Create: `realty-paper-adapters/essentials-adapter/` — `build.gradle.kts`, `EssentialsAdapterModule.java`, `EssentialsMailListener.java`, `EssentialsSafeBlockPredicate.java`, `module-manifest.yml`, `EssentialsMailListenerTest.java` +- Delete: `realty-paper/src/main/java/io/github/md5sha256/realty/util/EssentialsSafeBlockPredicate.java` +- Modify: `realty-paper/build.gradle.kts` (drop the EssentialsX `compileOnly`) +- Modify: `realty-paper/src/main/java/io/github/md5sha256/realty/Realty.java` (drop the predicate line and import) +- Modify: `settings.gradle.kts` + +Port the subproject from the tag; it was reviewed clean. Same two adaptations as Task 7 — `getTargets()`/`getMessage()`, and no main-thread marshalling since the event is synchronous. + +Mail goes **only to offline targets** — chat-adapter has the online ones, and mailing both would double up. One target failing must not cost the others their mail: wrap each send so a throw is logged and the loop continues. + +`initialize` throws `IllegalStateException` (not `ModuleInitializationException`) when Essentials is absent or disabled — `SimplePluginModule.initialize` declares no `throws`, so an override cannot re-widen to a checked exception. `ModuleLifecycleManager` catches `ModuleInitializationException | RuntimeException` identically: it logs SEVERE, unloads the module, and leaves core running. + +`shutdown` unregisters listeners **and** resets the predicate to `SafeLocationFinder.defaultPredicate()`. + +Manifest `reloadable: false` — no configuration to refresh. + +Then remove Essentials from core: delete the predicate, delete the `compileOnly("net.essentialsx:EssentialsX:2.21.2")` block (**leave the `runServer` download URL**), and delete the predicate line and import from `onEnable`. + +- [ ] **Step 1: Port the subproject and wire `settings.gradle.kts`** +- [ ] **Step 2: Adapt the listener; move the predicate in** +- [ ] **Step 3: Remove Essentials from core** +- [ ] **Step 4: Verify** + +Run: `./gradlew :realty-paper-adapters:essentials-adapter:test` — expected PASS. +Run: `grep -rn "com.earth2me\|net.ess3" --include=*.java realty-paper/src` — expected: no output. +Run: `grep -n "essentialsx\|EssentialsX" realty-paper/build.gradle.kts` — expected: only the `runServer` URL. +Run: `git diff --stat` — `paper-plugin.yml` must **not** appear. +Run: `./gradlew shadowJar` — expected BUILD SUCCESSFUL. + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "feat(essentials-adapter): move Essentials mail and safe-block predicate out of core" +``` + +--- + +### Task 9: Bundle chat-adapter, and stage both for runServer + +**Files:** +- Create: `realty-paper/src/main/java/io/github/md5sha256/realty/BundledModuleExtractor.java` +- Test: `realty-paper/src/test/java/io/github/md5sha256/realty/BundledModuleExtractionTest.java` +- Modify: `realty-paper/build.gradle.kts`, `realty-paper/src/main/java/io/github/md5sha256/realty/Realty.java` + +Port both from the tag; reviewed clean. + +**Extraction must never overwrite.** An operator who deleted or replaced `chat-adapter.jar` must not find it restored. `Files.exists(target)` returns first, before the resource is even opened. A failed extraction logs a warning and must not fail plugin enable. + +Wire `shadowJar` so the chat-adapter jar lands at `modules/chat-adapter.jar` inside the plugin jar, and stage **both** adapters into `run/plugins/Realty/modules` for `runServer`. + +- [ ] **Step 1: Port the extractor and its test; confirm red then green** +- [ ] **Step 2: Wire shadowJar and runServer** +- [ ] **Step 3: Verify** + +Run: `./gradlew :realty-paper:test --tests "*BundledModuleExtractionTest*"` — expected PASS. +Run: `./gradlew shadowJar && unzip -l realty-paper/build/libs/*-all.jar | grep chat-adapter` — expected: exactly one `modules/chat-adapter.jar` entry. **Put this output in your report** — it is the only proof the Gradle wiring worked. + +- [ ] **Step 4: Commit** + +```bash +git add -A +git commit -m "feat: bundle chat-adapter in the plugin jar and extract it on first enable" +``` + +--- + +### Task 10: Warn when Essentials is present without its adapter, and document + +**Files:** +- Modify: `realty-paper/src/main/java/io/github/md5sha256/realty/Realty.java` +- Modify: `CLAUDE.md` (on disk only — gitignored, never `git add`) +- Delete: `memory-bank/player-notifications-integration-plan.md` (on disk) + +An Essentials server upgrading to this build silently loses offline mail and the EssX teleport predicate. After `moduleManager.start()`, warn when `isPluginEnabled("Essentials")` is true but `moduleManager.getActiveModules()` has no `essentials-adapter`. No `com.earth2me` import is needed. + +Update `CLAUDE.md` on disk to describe: the notification event model, that core delivers nothing, the two adapter subprojects, `module-manifest.yml` as the manifest name, and why the `Essentials` softdepend with `join-classpath: true` must survive. + +- [ ] **Step 1: Add the warning** +- [ ] **Step 2: Update the docs on disk** +- [ ] **Step 3: Verify** + +Run: `./gradlew test` — expected BUILD SUCCESSFUL. +Run: `git status --short` — expected: `CLAUDE.md` and `memory-bank/` must **not** appear (they are gitignored). + +- [ ] **Step 4: Commit** + +```bash +git add realty-paper +git commit -m "feat: warn when EssentialsX is installed without the essentials-adapter module" +``` + +--- + +## Manual verification (after Task 10) + +- [ ] `./gradlew runServer` — both modules appear in `/realty module list`. +- [ ] With two accounts: sell a region owned by account A, log A out, buy it as B. A has Essentials mail on next login and no chat message. +- [ ] Repeat with A online: chat message, no mail. +- [ ] `/realty offer rejectall` on a region with several offers notifies every offerer once. +- [ ] `/realty teleport` still lands somewhere safe. +- [ ] Delete `plugins/Realty/modules/chat-adapter.jar`, restart, confirm it returns. Put an empty file there, restart, confirm it is **not** overwritten. +- [ ] Remove EssentialsX, restart: plugin enables, logs the missing-adapter warning, chat notifications still work. diff --git a/docs/superpowers/specs/2026-08-21-reconcile-notifications-onto-upstream-events.md b/docs/superpowers/specs/2026-08-21-reconcile-notifications-onto-upstream-events.md new file mode 100644 index 0000000..72a594f --- /dev/null +++ b/docs/superpowers/specs/2026-08-21-reconcile-notifications-onto-upstream-events.md @@ -0,0 +1,199 @@ +# Reconciling event-driven notifications onto upstream's event system + +Date: 2026-08-21 +Status: approved +Supersedes: `2026-08-21-event-driven-notifications-design.md` + +## Why this document exists + +The branch `feature/event-driven-notifications` built a 21-class notification event layer, deleted +`NotificationService`, and moved delivery into two adapter module jars. While it was being built, +`origin/main` advanced 15 commits — six of which (`a32ef11`..`c2cc771`) introduced an independent +event system covering the same ground: 47 event classes in the same package, with 13 exact +class-name collisions. A merge of the branch as-is conflicts in 27 files. + +This document describes what to keep, what to discard, and what still has to be built on top of +upstream. + +## What upstream already does better + +- **`RealtyEventDispatch`** places events on the right thread in both directions, and throws rather + than silently deferring when a *cancellable* event would need a hop — a deferred cancellation + verdict cannot be reported to the caller. Strictly better than the branch's `NotificationDispatcher`. +- **A pre/post pattern**: cancellable `RegionBuyEvent` before the mutation, non-cancellable + `RegionBoughtEvent` after. The branch had no cancellable tier at all. +- **Events are synchronous.** Every production event passes `async = false`, so the branch's + Critical defect — async events fired from the primary thread — cannot occur, and its fix is moot. + +## What upstream has not done + +- **Notifications are not event-driven.** Only nine events route through `RegionNotificationListener` + (region buy/rent/unrent plus the lease lifecycle). Agent, offer and auction commands still call + `notificationService.queueNotification(...)` directly *and* fire a post-event nobody consumes. +- **Two sweeps fire nothing.** `clearExpiredBidPayments()` and `clearExpiredOfferPayments()` notify + directly with no event at all. +- **`NotificationService` still picks delivery at enable time**, `EssentialsSafeBlockPredicate` is + still in `realty-paper/util/`, and core still compiles against EssentialsX. +- **`ModuleLifecycleManager` does not exist upstream.** The module system is in the unpushed local + commit `eb9667f`, so the adapters depend on infrastructure that is not on `main` either. + +## Decisions + +| Question | Decision | +|---|---| +| Event catalogue | Upstream's 47 classes win, **unmodified**. The branch's 21 are deleted. | +| Where text is rendered | At the fire site. The notification event carries the rendered `Component`. | +| `NotificationService` | **Obsoleted and deleted**, with both implementations and `RegionNotificationListener`. | +| How adapters receive notifications | One **standalone** `RealtyNotificationEvent`, fired alongside the domain event. | +| Threading | Notification events are **synchronous**, fired via `eventDispatch.fireSync(...)`. | +| Dispatcher | Upstream's `RealtyEventDispatch`. The branch's `NotificationDispatcher` is deleted. | + +### Why standalone rather than a supertype + +An earlier draft reparented ~20 of upstream's post-events onto a shared base so one adapter handler +could catch them all. That was rejected. Verified against the Paper 1.21.8 sources: + +- `SimplePluginManager.fireEvent:645-647` consults **only** `event.getHandlers()` — no hierarchy walk. +- `getRegistrationClass:748-761` resolves a listener's handler list by walking up to the class + *declaring* `getHandlerList()`, found via `getDeclaredMethod`, so it is **not inherited**. +- The generated executor filters with `isAssignableFrom` (`JavaPluginLoader:297`), so a listener on a + specific event still receives only that event even when the list is shared. + +Bukkit's own `EntityDamageByEntityEvent` relies on exactly this: it declares no `HandlerList` and is +therefore delivered to listeners registered on `EntityDamageEvent`. + +The consequence is that reparenting requires deleting **both** the `HANDLERS` field and +`getHandlerList()` from every reparented class. Miss one and that event silently keeps its own list +and never reaches the adapters — no error, no warning. That is unacceptable risk on ~20 classes a +colleague merged days ago, and it also pushes presentation (`Component`) onto domain events that +upstream deliberately kept domain-only. + +A standalone event has its own `HandlerList`, touches none of their classes, and gives adapters a +single handler with no inheritance subtleties. + +## Architecture + +### 1. The notification event + +New in `realty-paper-api/.../api/event`: + +```java +public final class RealtyNotificationEvent extends Event { + + private static final HandlerList HANDLERS = new HandlerList(); + + private final List targets; + private final Component message; + private final WorldGuardRegion region; // nullable + + public RealtyNotificationEvent(@NotNull List targets, + @NotNull Component message, + @Nullable WorldGuardRegion region) { /* ... */ } + + public @NotNull List getTargets(); // List.copyOf, rejects empty + public @NotNull Component getMessage(); + public @Nullable WorldGuardRegion getRegion(); + + @Override public @NotNull HandlerList getHandlers(); + public static @NotNull HandlerList getHandlerList(); +} +``` + +Accessors use upstream's `getX()` convention. The class is `final`: nothing should subclass it, +because a subclass that omitted `getHandlerList()` would silently share this list. + +**It extends `Event`, not `RealtyRegionEvent`, and its region is nullable.** `RealtyRegionEvent` +requires a live `WorldGuardRegion`, and the two payment-expiry sweeps cannot always produce one — a +refund must still be announced even when the region row or the WorldGuard region has since been +deleted. Tying notification to region resolution would mean losing the notification exactly when +something has already gone wrong. Commands pass the region they already hold; the sweeps pass what +they can. + +### 2. Fire sites render and fire + +Every notification path fires this event, in addition to whatever domain post-event it already +fires. The existing `messages.messageFor(MessageKeys.NOTIFICATION_*, ...)` call is kept verbatim, +moved from the `queueNotification` argument into the event constructor, so rendered text does not +change. + +The direct `queueNotification` calls in `AgentInviteCommand`, `AgentRemoveCommand`, +`AuctionCommandGroup`, `OfferCommandGroup` and `Realty.scheduleTasks()` are deleted. Upstream's +domain post-events keep firing exactly as they do now — untouched. + +Where two parties get different messages (lease expiry notifies tenant and landlord with different +text), that is two `RealtyNotificationEvent` fires, not one event with two targets. The multi-target +list is for the case where several people get the *same* message — notably `/realty offer rejectall`. + +### 3. The two silent sweeps + +`clearExpiredBidPayments()` and `clearExpiredOfferPayments()` gain notification events. Their +records, `RealtyBackend.ExpiredBidPayment` and `ExpiredOfferPayment`, identify a region by string id +alone, so both gain a `@Nullable UUID worldId`, populated from the `RealtyRegionEntity` the methods +already select — **no SQL change and no migration**. Where the region row is missing, `worldId` is +null, the event fires with a null region, and **the refund is still processed unconditionally**. + +Adding upstream-style *domain* post-events for these two sweeps is deliberately out of scope: it is +a gap in upstream's catalogue, not in notification delivery, and belongs in its own change. + +### 4. Delivery moves into adapter modules + +`NotificationService`, `TransientNotificationService`, `EssentialsNotificationService`, +`RegionNotificationListener`, and the Essentials/transient branch in `Realty.onEnable` are deleted. +Core renders and fires; it delivers nothing. + +- **`realty-paper-adapters/chat-adapter`** — one `@EventHandler` on `RealtyNotificationEvent`, + sending `getMessage()` to each online target. Bundled in the plugin jar and extracted on enable if + absent, never overwriting, so a stock install keeps today's behaviour. +- **`realty-paper-adapters/essentials-adapter`** — one `@EventHandler` at `EventPriority.HIGH`, + mailing offline targets. Also carries `EssentialsSafeBlockPredicate`, registered through + `RealtyPaperApi.setSafeBlockPredicate(...)`. + +Because notification events are synchronous and fired through `fireSync`, both adapters already run +on the main thread — no marshalling, and none of the branch's async-dispatch machinery survives. + +`paper-plugin.yml` keeps its `Essentials` softdepend with `join-classpath: true`: module jars load +through a `URLClassLoader` parented to Realty's plugin class loader, so that entry is the only reason +EssX types resolve inside the adapter at runtime. + +### 5. Prerequisite: the module system + +`eb9667f` ("Add module support via plugin-infrastructure") is unpushed and absent from `origin/main`. +It lands first, and conflicts in exactly two hand-mergeable files: `Realty.java` and `messages.yml`. + +## What carries over, and what dies + +**Survives** +- `eb9667f` — the `plugin-infrastructure` module system. +- Both adapter subprojects, retargeted at the standalone event. +- `BundledModuleExtractor` and the chat-adapter bundling, including the never-overwrite guarantee. +- `Realty.executorState()` / `paperApi()`, the `volatile` swappable safe-block predicate, and + `RealtyPaperApi.setSafeBlockPredicate(...)`. +- The `worldId` addition to the two expiry records — now load-bearing for the sweep notifications. + +**Deleted** +- All 21 branch event classes and their base. +- `NotificationDispatcher` and its test — `RealtyEventDispatch` supersedes it. +- Every call-site migration to the branch's events. +- `NotificationKeyCoverageTest` — its key-to-class naming convention does not hold for upstream's + catalogue. +- `EventBindingTest` — it reflectively checked the branch's 21 constructors; with one notification + event there is nothing left for it to sweep. +- The async-fired-from-main-thread fix, moot against synchronous events. + +## Testing + +- Adapter listener tests carry over: online target gets chat only; offline target gets mail only; + multi-target fan-out; an unresolvable Essentials user does not cost the other targets their mail. +- `RealtyNotificationEvent` contract: immutable target list, empty list rejected, null message and + null target list rejected, a null region accepted. +- A test asserting no `queueNotification` call and no `NotificationService` reference survives. +- `runServer` smoke pass: both modules load; a buy notifies an online holder by chat and an offline + one by mail; `/realty teleport` still lands safely. + +## Risks + +- `realty-paper-api` is published. Deleting `NotificationService` is breaking, on top of a surface + upstream also just changed. +- Deleting `RegionNotificationListener` removes a class a colleague added days ago. Its nine handlers + move back to their fire sites as `RealtyNotificationEvent` fires. This should be raised with them + rather than landed silently. From 456ad9ffbaaef5e453a571c94f5ac966c1f8cc3f Mon Sep 17 00:00:00 2001 From: Andrew Wong <42793301+md5sha256@users.noreply.github.com> Date: Fri, 21 Aug 2026 13:02:45 +1000 Subject: [PATCH 02/15] Add module support via plugin-infrastructure Depend on com.minecraftcitiesnetwork:plugin-infrastructure from realty-paper and replace the overlapping paper-side utilities with the shared versions. - Realty owns a ModuleLifecycleManager over plugins/Realty/modules: modules start last in onEnable and stop first in onDisable. New /realty module list and /realty module reload , plus module refresh on /realty reload; all manager access is marshalled onto the main thread. - Dropped local DateFormatter, DurationParserUtil, ComponentSerializer and SimpleDateFormatSerializer in favour of the shared ones. - localisation.MessageContainer extends the shared one, adding only deserializeRaw for paginated commands, which substitute a command into a tag argument that no TagResolver can fill. - The library is a realty-paper dependency only. Its module system imports the Paper API, so the backend layers must not see it: realty-backend and realty-backend-api keep their own CurrencyFormatter, MigrationStep and MariaSchemaMigrator. CurrencyFormatter's DecimalFormat is now ThreadLocal, since commands format concurrently on the async executors. - plugin-infrastructure is shaded but deliberately not relocated: module jars are compiled against those types and loaded into this plugin's class loader. Bump version to 1.4.0. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014kvZD6gWHjdMkN9Ebt3UbF --- .../main/kotlin/realty-conventions.gradle.kts | 8 + .../realty/api/CurrencyFormatter.java | 7 +- realty-paper/build.gradle.kts | 4 + .../io/github/md5sha256/realty/Realty.java | 56 ++++- .../realty/command/AuctionCommandGroup.java | 8 +- .../realty/command/HistoryCommand.java | 8 +- .../md5sha256/realty/command/InfoCommand.java | 6 +- .../realty/command/ModuleCommandGroup.java | 105 +++++++++ .../realty/command/util/DurationParser.java | 1 + .../command/util/DurationParserUtil.java | 103 --------- .../realty/localisation/MessageContainer.java | 124 ++--------- .../realty/localisation/MessageKeys.java | 7 + .../realty/util/ComponentSerializer.java | 61 ------ .../md5sha256/realty/util/DateFormatter.java | 20 -- .../util/SimpleDateFormatSerializer.java | 39 ---- realty-paper/src/main/resources/messages.yml | 6 + .../src/main/resources/paper-plugin.yml | 6 + .../command/util/DurationParserUtilTest.java | 205 ------------------ 18 files changed, 228 insertions(+), 546 deletions(-) create mode 100644 realty-paper/src/main/java/io/github/md5sha256/realty/command/ModuleCommandGroup.java delete mode 100644 realty-paper/src/main/java/io/github/md5sha256/realty/command/util/DurationParserUtil.java delete mode 100644 realty-paper/src/main/java/io/github/md5sha256/realty/util/ComponentSerializer.java delete mode 100644 realty-paper/src/main/java/io/github/md5sha256/realty/util/DateFormatter.java delete mode 100644 realty-paper/src/main/java/io/github/md5sha256/realty/util/SimpleDateFormatSerializer.java delete mode 100644 realty-paper/src/test/java/io/github/md5sha256/realty/command/util/DurationParserUtilTest.java diff --git a/buildSrc/src/main/kotlin/realty-conventions.gradle.kts b/buildSrc/src/main/kotlin/realty-conventions.gradle.kts index 7308f07..2c076f4 100644 --- a/buildSrc/src/main/kotlin/realty-conventions.gradle.kts +++ b/buildSrc/src/main/kotlin/realty-conventions.gradle.kts @@ -12,6 +12,14 @@ java.toolchain.languageVersion.set(JavaLanguageVersion.of(targetJavaVersion)) repositories { mavenLocal() mavenCentral() + maven { + name = "democracycraft-snapshots" + url = uri("https://maven.democracycraft.net/snapshots") + } + maven { + name = "democracycraft-releases" + url = uri("https://maven.democracycraft.net/releases") + } maven { name = "papermc-repo" url = uri("https://repo.papermc.io/repository/maven-public/") diff --git a/realty-backend-api/src/main/java/io/github/md5sha256/realty/api/CurrencyFormatter.java b/realty-backend-api/src/main/java/io/github/md5sha256/realty/api/CurrencyFormatter.java index e544829..2c1e658 100644 --- a/realty-backend-api/src/main/java/io/github/md5sha256/realty/api/CurrencyFormatter.java +++ b/realty-backend-api/src/main/java/io/github/md5sha256/realty/api/CurrencyFormatter.java @@ -6,11 +6,14 @@ public final class CurrencyFormatter { - private static final DecimalFormat FORMAT = new DecimalFormat("#,##0.00"); + // DecimalFormat is not thread-safe; a shared instance corrupts output when commands format + // concurrently on the async executors. Give each thread its own. + private static final ThreadLocal FORMAT = + ThreadLocal.withInitial(() -> new DecimalFormat("#,##0.00")); private CurrencyFormatter() {} public static @NotNull String format(double amount) { - return FORMAT.format(amount); + return FORMAT.get().format(amount); } } diff --git a/realty-paper/build.gradle.kts b/realty-paper/build.gradle.kts index 3291ce3..e007f48 100644 --- a/realty-paper/build.gradle.kts +++ b/realty-paper/build.gradle.kts @@ -28,6 +28,10 @@ dependencies { compileOnly("org.jetbrains:annotations:26.0.2-1") implementation("org.incendo:cloud-paper:2.0.0-beta.10") implementation("org.spongepowered:configurate-yaml:4.2.0") + // Shared module system, schema migrations and formatting helpers. Deliberately NOT relocated in + // shadowJar: module jars are compiled against these types and loaded into this plugin's class + // loader, so the names must match. + implementation("com.minecraftcitiesnetwork:plugin-infrastructure:1.0.0-SNAPSHOT") testImplementation("io.papermc.paper:paper-api:1.21.8-R0.1-SNAPSHOT") testImplementation("net.democracycraft:treasury-api:2.0.0") diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/Realty.java b/realty-paper/src/main/java/io/github/md5sha256/realty/Realty.java index dded3a1..03c8ddb 100644 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/Realty.java +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/Realty.java @@ -1,5 +1,10 @@ package io.github.md5sha256.realty; +import com.minecraftcitiesnetwork.pluginInfrastructure.configurate.ComponentSerializer; +import com.minecraftcitiesnetwork.pluginInfrastructure.configurate.SimpleDateFormatSerializer; +import com.minecraftcitiesnetwork.pluginInfrastructure.modules.ModuleLifecycleManager; +import com.minecraftcitiesnetwork.pluginInfrastructure.modules.ModuleLoader; +import com.minecraftcitiesnetwork.pluginInfrastructure.util.DateFormatter; import com.sk89q.worldedit.bukkit.BukkitAdapter; import com.sk89q.worldguard.WorldGuard; import com.sk89q.worldguard.protection.managers.RegionManager; @@ -39,6 +44,7 @@ import io.github.md5sha256.realty.command.ListCommand; import io.github.md5sha256.realty.command.OfferCommandGroup; import io.github.md5sha256.realty.command.RegisterCommand; +import io.github.md5sha256.realty.command.ModuleCommandGroup; import io.github.md5sha256.realty.command.ReloadCommand; import io.github.md5sha256.realty.command.RemoveCommand; import io.github.md5sha256.realty.command.RentCommand; @@ -79,11 +85,8 @@ import io.github.md5sha256.realty.settings.RegionTagSettings; import io.github.md5sha256.realty.settings.Settings; import io.github.md5sha256.realty.settings.TaxSettings; -import io.github.md5sha256.realty.util.ComponentSerializer; -import io.github.md5sha256.realty.util.DateFormatter; import io.github.md5sha256.realty.util.EssentialsNotificationService; import io.github.md5sha256.realty.util.EssentialsSafeBlockPredicate; -import io.github.md5sha256.realty.util.SimpleDateFormatSerializer; import io.github.md5sha256.realty.util.SquirrelIdUsernameResolver; import io.github.md5sha256.realty.util.TransientNotificationService; import io.papermc.paper.util.Tick; @@ -154,6 +157,7 @@ public final class Realty extends JavaPlugin { private SignTextApplicator signTextApplicator; private RealtyPaperApi paperApi; private RealtyEventDispatch eventDispatch; + private ModuleLifecycleManager moduleManager; private boolean failedLoad = false; private static @NotNull PermissionDefault toBukkitPermission(@NotNull ConfigRegionTag tag) { @@ -260,7 +264,7 @@ public void onEnable() { } this.logic = new RealtyBackendImpl(mariaDatabase, this.nameResolver::getUsername, - dateTime -> DateFormatter.format(this.settings.get(), dateTime), + dateTime -> DateFormatter.format(this.settings.get().dateFormat(), dateTime), () -> this.settings.get().offerPaymentDurationSeconds()); EconomyProvider economyProvider = resolveEconomyProvider(); this.economyProvider = economyProvider; @@ -301,6 +305,10 @@ public void onEnable() { getServer(), this.executorState.mainThreadExec(), task -> getServer().getScheduler().runTaskAsynchronously(this, task)); + this.moduleManager = new ModuleLifecycleManager<>(this, + new ModuleLoader(getDataFolder().toPath().resolve("modules")), + Realty.class.getName(), + getLogger()); scheduleTasks(); registerCommands(this.paperApi, this.executorState, @@ -312,12 +320,19 @@ public void onEnable() { getServer().getServicesManager() .register(RealtyPaperApi.class, this.paperApi, this, ServicePriority.Normal); warnOrphanedTags(); + // Modules start last so that everything they might reach for — the API services, commands + // and listeners — is already in place. + startModules(); getLogger().info("Plugin enabled successfully"); } @Override public void onDisable() { // Plugin shutdown logic + if (this.moduleManager != null) { + // Shut modules down first: they may still be using the executors and database below. + this.moduleManager.stop(); + } if (this.profileApplicator != null) { this.profileApplicator.cancel(); } @@ -641,6 +656,38 @@ private void performReload() throws IOException { this.taxSettings.set(loadTaxSettings()); reloadMessages(); warnOrphanedTags(); + reloadModules(); + } + + private void startModules() { + Path moduleDir = getDataFolder().toPath().resolve("modules"); + try { + Files.createDirectories(moduleDir); + this.moduleManager.start(); + } catch (IOException ex) { + // A broken module directory is not worth taking the whole plugin down for. + getLogger().severe("Failed to load modules from " + moduleDir + ": " + ex.getMessage()); + } + } + + /** + * Asks every reloadable module to refresh its configuration. Called from {@code /realty reload}, + * which runs off the main thread, so the manager access is marshalled back onto it. + */ + private void reloadModules() { + if (this.moduleManager == null) { + return; + } + this.executorState.mainThreadExec().execute(() -> { + for (String moduleName : this.moduleManager.getActiveModules().keySet()) { + this.moduleManager.reloadAsync(moduleName).exceptionally(error -> { + // A module that declares itself non-reloadable fails here by design. + getLogger().warning("Failed to reload module " + moduleName + ": " + + error.getMessage()); + return null; + }); + } + }); } private void registerCommands( @@ -702,6 +749,7 @@ private void registerCommands( new TerminateCommand(paperApi, messageContainer, this.eventDispatch), new TransferCommand(paperApi, messageContainer, this.eventDispatch), new UnsetCommandGroup(paperApi, messageContainer), + new ModuleCommandGroup(this.moduleManager, executorState, messageContainer), new ReloadCommand(executorState, () -> { performReload(); return null; diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/command/AuctionCommandGroup.java b/realty-paper/src/main/java/io/github/md5sha256/realty/command/AuctionCommandGroup.java index 0c39d39..4d4df33 100644 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/command/AuctionCommandGroup.java +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/command/AuctionCommandGroup.java @@ -1,5 +1,6 @@ package io.github.md5sha256.realty.command; +import com.minecraftcitiesnetwork.pluginInfrastructure.util.DateFormatter; import io.github.md5sha256.realty.api.CurrencyFormatter; import io.github.md5sha256.realty.api.DurationFormatter; import io.github.md5sha256.realty.api.NotificationService; @@ -21,7 +22,6 @@ import io.github.md5sha256.realty.localisation.MessageContainer; import io.github.md5sha256.realty.localisation.MessageKeys; import io.github.md5sha256.realty.settings.Settings; -import io.github.md5sha256.realty.util.DateFormatter; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.TextComponent; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; @@ -132,11 +132,11 @@ private void executeInfo(@NotNull CommandContext ctx) { textBuilder.appendNewline() .append(messages.messageFor(MessageKeys.AUCTION_INFO_DETAILS, Placeholder.unparsed("auctioneer", resolveName(auction.auctioneerId())), - Placeholder.unparsed("start_date", DateFormatter.format(settings.get(), auction.startDate())), + Placeholder.unparsed("start_date", DateFormatter.format(settings.get().dateFormat(), auction.startDate())), Placeholder.unparsed("duration", DurationFormatter.format(Duration.ofSeconds(auction.biddingDurationSeconds()))), - Placeholder.unparsed("bidding_end_date", DateFormatter.format(settings.get(), biddingEndDate)), - Placeholder.unparsed("deadline", DateFormatter.format(settings.get(), auction.paymentDeadline())), + Placeholder.unparsed("bidding_end_date", DateFormatter.format(settings.get().dateFormat(), biddingEndDate)), + Placeholder.unparsed("deadline", DateFormatter.format(settings.get().dateFormat(), auction.paymentDeadline())), Placeholder.unparsed("min_bid", CurrencyFormatter.format(auction.minBid())), Placeholder.unparsed("min_step", CurrencyFormatter.format(auction.minStep())), Placeholder.unparsed("highest_bid_amount", highestBidAmount), diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/command/HistoryCommand.java b/realty-paper/src/main/java/io/github/md5sha256/realty/command/HistoryCommand.java index 1be721d..c665e87 100644 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/command/HistoryCommand.java +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/command/HistoryCommand.java @@ -1,5 +1,6 @@ package io.github.md5sha256.realty.command; +import com.minecraftcitiesnetwork.pluginInfrastructure.util.DateFormatter; import io.github.md5sha256.realty.api.CurrencyFormatter; import io.github.md5sha256.realty.api.DurationFormatter; import io.github.md5sha256.realty.api.HistoryEventType; @@ -13,7 +14,6 @@ import io.github.md5sha256.realty.localisation.MessageContainer; import io.github.md5sha256.realty.localisation.MessageKeys; import io.github.md5sha256.realty.settings.Settings; -import io.github.md5sha256.realty.util.DateFormatter; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.TextComponent; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; @@ -156,7 +156,7 @@ private void execute(@NotNull CommandContext ctx) { String messageKey = resolveEventMessageKey(freehold.eventType()); builder.append( messages.messageFor(messageKey, - Placeholder.unparsed("time", DateFormatter.format(settings.get(), freehold.eventTime())), + Placeholder.unparsed("time", DateFormatter.format(settings.get().dateFormat(), freehold.eventTime())), Placeholder.unparsed("buyer", resolveName(freehold.buyerId())), Placeholder.unparsed("authority", resolveName(freehold.authorityId())), Placeholder.unparsed("price", CurrencyFormatter.format(freehold.price())))); @@ -165,7 +165,7 @@ private void execute(@NotNull CommandContext ctx) { String messageKey = resolveEventMessageKey(agent.eventType()); builder.append( messages.messageFor(messageKey, - Placeholder.unparsed("time", DateFormatter.format(settings.get(), agent.eventTime())), + Placeholder.unparsed("time", DateFormatter.format(settings.get().dateFormat(), agent.eventTime())), Placeholder.unparsed("agent", resolveName(agent.agentId())), Placeholder.unparsed("actor", resolveName(agent.actorId())))); } @@ -173,7 +173,7 @@ private void execute(@NotNull CommandContext ctx) { String messageKey = resolveLeaseholdEventMessageKey(lease.eventType()); builder.append( messages.messageFor(messageKey, - Placeholder.unparsed("time", DateFormatter.format(settings.get(), lease.eventTime())), + Placeholder.unparsed("time", DateFormatter.format(settings.get().dateFormat(), lease.eventTime())), Placeholder.unparsed("tenant", resolveName(lease.tenantId())), Placeholder.unparsed("landlord", resolveName(lease.landlordId())), Placeholder.unparsed("price", diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/command/InfoCommand.java b/realty-paper/src/main/java/io/github/md5sha256/realty/command/InfoCommand.java index 188dc88..93b58bf 100644 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/command/InfoCommand.java +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/command/InfoCommand.java @@ -1,5 +1,6 @@ package io.github.md5sha256.realty.command; +import com.minecraftcitiesnetwork.pluginInfrastructure.util.DateFormatter; import io.github.md5sha256.realty.api.CurrencyFormatter; import io.github.md5sha256.realty.api.DurationFormatter; import io.github.md5sha256.realty.api.RealtyPaperApi; @@ -14,7 +15,6 @@ import io.github.md5sha256.realty.settings.ConfigRegionTag; import io.github.md5sha256.realty.settings.RealtyTags; import io.github.md5sha256.realty.settings.Settings; -import io.github.md5sha256.realty.util.DateFormatter; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.TextComponent; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; @@ -204,10 +204,10 @@ private void appendLeaseholdInfo(@NotNull TextComponent.Builder builder, Placeholder.unparsed("duration", DurationFormatter.format(Duration.ofSeconds(leasehold.durationSeconds()))), Placeholder.unparsed("start_date", leasehold.startDate() != null - ? DateFormatter.format(settings.get(), leasehold.startDate()) + ? DateFormatter.format(settings.get().dateFormat(), leasehold.startDate()) : "N/A"), Placeholder.unparsed("end_date", leasehold.endDate() != null - ? DateFormatter.format(settings.get(), leasehold.endDate()) + ? DateFormatter.format(settings.get().dateFormat(), leasehold.endDate()) : "N/A"), Placeholder.unparsed("time_left", DurationFormatter.formatTimeLeft(leasehold.endDate())), Placeholder.unparsed("extensions", extensions))); diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/command/ModuleCommandGroup.java b/realty-paper/src/main/java/io/github/md5sha256/realty/command/ModuleCommandGroup.java new file mode 100644 index 0000000..8643dc3 --- /dev/null +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/command/ModuleCommandGroup.java @@ -0,0 +1,105 @@ +package io.github.md5sha256.realty.command; + +import com.minecraftcitiesnetwork.pluginInfrastructure.modules.LoadedModule; +import com.minecraftcitiesnetwork.pluginInfrastructure.modules.ModuleLifecycleManager; +import io.github.md5sha256.realty.Realty; +import io.github.md5sha256.realty.api.ExecutorState; +import io.github.md5sha256.realty.localisation.MessageContainer; +import io.github.md5sha256.realty.localisation.MessageKeys; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import org.bukkit.command.CommandSender; +import org.incendo.cloud.Command; +import org.incendo.cloud.context.CommandContext; +import org.incendo.cloud.parser.standard.StringParser; +import org.incendo.cloud.paper.util.sender.Source; +import org.incendo.cloud.suggestion.Suggestion; +import org.incendo.cloud.suggestion.SuggestionProvider; +import org.jetbrains.annotations.NotNull; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +/** + * Handles {@code /realty module list} and {@code /realty module reload }. + * + *

{@link ModuleLifecycleManager} is not thread-safe, so both handlers hop onto the main thread + * before touching it.

+ * + *

Permissions: {@code realty.command.module.list}, {@code realty.command.module.reload}.

+ */ +public record ModuleCommandGroup( + @NotNull ModuleLifecycleManager moduleManager, + @NotNull ExecutorState executorState, + @NotNull MessageContainer messages +) implements CustomCommandBean { + + @Override + public @NotNull List> commands(@NotNull Command.Builder builder) { + Command list = builder + .literal("module") + .literal("list") + .permission("realty.command.module.list") + .handler(this::executeList) + .build(); + Command reload = builder + .literal("module") + .literal("reload") + .required("module", StringParser.stringParser(), moduleSuggestions()) + .permission("realty.command.module.reload") + .handler(this::executeReload) + .build(); + return List.of(list, reload); + } + + private @NotNull SuggestionProvider moduleSuggestions() { + return (ctx, input) -> CompletableFuture.completedFuture( + moduleManager.getActiveModules().keySet().stream() + .map(Suggestion::suggestion) + .toList() + ); + } + + private void executeList(@NotNull CommandContext ctx) { + CommandSender sender = ctx.sender().source(); + executorState.mainThreadExec().execute(() -> { + Map> active = moduleManager.getActiveModules(); + if (active.isEmpty()) { + sender.sendMessage(messages.messageFor(MessageKeys.MODULE_LIST_EMPTY)); + return; + } + sender.sendMessage(messages.messageFor(MessageKeys.MODULE_LIST_HEADER, + Placeholder.unparsed("count", String.valueOf(active.size())))); + for (LoadedModule module : active.values()) { + sender.sendMessage(messages.messageFor(MessageKeys.MODULE_LIST_ENTRY, + Placeholder.unparsed("module", module.manifest().moduleName()), + Placeholder.unparsed("author", module.manifest().author()), + Placeholder.unparsed("reloadable", + String.valueOf(module.manifest().reloadable())))); + } + }); + } + + private void executeReload(@NotNull CommandContext ctx) { + CommandSender sender = ctx.sender().source(); + String moduleName = ctx.get("module"); + executorState.mainThreadExec().execute(() -> + moduleManager.reloadAsync(moduleName).whenComplete((ignored, error) -> { + if (error == null) { + sender.sendMessage(messages.messageFor(MessageKeys.MODULE_RELOAD_SUCCESS, + Placeholder.unparsed("module", moduleName))); + return; + } + Throwable cause = error.getCause() != null ? error.getCause() : error; + sender.sendMessage(messages.messageFor(MessageKeys.MODULE_RELOAD_ERROR, + Placeholder.unparsed("module", moduleName), + Placeholder.unparsed("error", describe(cause)))); + })); + } + + private static @NotNull String describe(@NotNull Throwable throwable) { + String message = throwable.getMessage(); + return message != null ? message : throwable.getClass().getSimpleName(); + } + +} diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/command/util/DurationParser.java b/realty-paper/src/main/java/io/github/md5sha256/realty/command/util/DurationParser.java index 9374174..ee39bfc 100644 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/command/util/DurationParser.java +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/command/util/DurationParser.java @@ -1,5 +1,6 @@ package io.github.md5sha256.realty.command.util; +import com.minecraftcitiesnetwork.pluginInfrastructure.util.DurationParserUtil; import org.incendo.cloud.paper.util.sender.Source; import org.incendo.cloud.context.CommandContext; import org.incendo.cloud.context.CommandInput; diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/command/util/DurationParserUtil.java b/realty-paper/src/main/java/io/github/md5sha256/realty/command/util/DurationParserUtil.java deleted file mode 100644 index 54af0f6..0000000 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/command/util/DurationParserUtil.java +++ /dev/null @@ -1,103 +0,0 @@ -package io.github.md5sha256.realty.command.util; - -import org.jetbrains.annotations.NotNull; - -import java.time.Duration; -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -/** - * Static utility class for parsing human-readable duration strings into {@link Duration} objects. - * - *

Supported time units:

- *
    - *
  • {@code s} — seconds
  • - *
  • {@code m}, {@code min} — minutes
  • - *
  • {@code h}, {@code hr} — hours
  • - *
  • {@code d} — days
  • - *
  • {@code w}, {@code wk} — weeks
  • - *
- * - *

Units can be combined in a single string (e.g. {@code 1d3hr}, {@code 2w5d12h30m10s}).

- */ -public final class DurationParserUtil { - - /** - * Ordered map of unit suffix → multiplier in seconds, longest suffix first so that - * greedy matching picks multi-char suffixes (e.g. "min") before single-char ones (e.g. "m"). - */ - private static final Map UNIT_TO_SECONDS; - - static { - // Use a LinkedHashMap so iteration order is insertion order (longest suffixes first). - UNIT_TO_SECONDS = new LinkedHashMap<>(); - UNIT_TO_SECONDS.put("min", 60L); - UNIT_TO_SECONDS.put("wk", 7L * 24 * 60 * 60); - UNIT_TO_SECONDS.put("hr", 60L * 60); - UNIT_TO_SECONDS.put("w", 7L * 24 * 60 * 60); - UNIT_TO_SECONDS.put("d", 24L * 60 * 60); - UNIT_TO_SECONDS.put("h", 60L * 60); - UNIT_TO_SECONDS.put("m", 60L); - UNIT_TO_SECONDS.put("s", 1L); - } - - /** - * Pattern that matches one or more segments of {@code }. - * Used for full-string validation. - */ - private static final Pattern FULL_PATTERN; - - /** - * Pattern that captures a single {@code } segment. - */ - private static final Pattern SEGMENT_PATTERN; - - static { - // Build a group that matches any known unit suffix (longest first for correct greedy match). - String unitGroup = String.join("|", UNIT_TO_SECONDS.keySet()); // min|wk|hr|w|d|h|m|s - SEGMENT_PATTERN = Pattern.compile("(\\d+)(" + unitGroup + ")", Pattern.CASE_INSENSITIVE); - FULL_PATTERN = Pattern.compile("^(?:" + SEGMENT_PATTERN.pattern() + ")+$", Pattern.CASE_INSENSITIVE); - } - - private DurationParserUtil() { - throw new AssertionError("Utility class"); - } - - /** - * Parse a human-readable duration string into a {@link Duration}. - * - * @param input the duration string (e.g. {@code "1d3hr"}, {@code "30m"}, {@code "2w5d"}) - * @return the parsed {@link Duration} - * @throws IllegalArgumentException if the input is null, empty, or contains invalid segments - */ - public static @NotNull Duration parse(@NotNull String input) { - if (input.isEmpty()) { - throw new IllegalArgumentException("Duration string must not be empty"); - } - - String normalized = input.toLowerCase(); - - if (!FULL_PATTERN.matcher(normalized).matches()) { - throw new IllegalArgumentException("Invalid duration format: '" + input + "'"); - } - - long totalSeconds = 0; - Matcher matcher = SEGMENT_PATTERN.matcher(normalized); - - while (matcher.find()) { - long amount = Long.parseLong(matcher.group(1)); - String unit = matcher.group(2); - Long multiplier = UNIT_TO_SECONDS.get(unit); - // multiplier should never be null here since we already validated against FULL_PATTERN - totalSeconds += amount * multiplier; - } - - if (totalSeconds <= 0) { - throw new IllegalArgumentException("Duration must be positive: '" + input + "'"); - } - - return Duration.ofSeconds(totalSeconds); - } -} diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/localisation/MessageContainer.java b/realty-paper/src/main/java/io/github/md5sha256/realty/localisation/MessageContainer.java index 755fdb9..59caf3f 100644 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/localisation/MessageContainer.java +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/localisation/MessageContainer.java @@ -1,115 +1,37 @@ package io.github.md5sha256.realty.localisation; - import net.kyori.adventure.text.Component; import net.kyori.adventure.text.minimessage.MiniMessage; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver; -import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; -import org.spongepowered.configurate.ConfigurateException; -import org.spongepowered.configurate.ConfigurationNode; import javax.annotation.Nonnull; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.stream.Collectors; - -public class MessageContainer { - - private final Map rawMessages = new ConcurrentHashMap<>(); - - private @Nonnull TagResolver prefixResolver() { - String raw = this.rawMessages.get("prefix"); - if (raw == null) { - return Placeholder.component("prefix", Component.empty()); - } - Component prefix = MiniMessage.miniMessage().deserialize(raw); - return Placeholder.component("prefix", prefix); - } +/** + * Realty's message store. Loading, rendering and the {@code } placeholder all come from + * {@link com.minecraftcitiesnetwork.pluginInfrastructure.configurate.MessageContainer}; this + * subclass only adds {@link #deserializeRaw(String)}. + */ +public class MessageContainer + extends com.minecraftcitiesnetwork.pluginInfrastructure.configurate.MessageContainer { + + /** + * Renders an already-substituted MiniMessage string, resolving {@code } as usual. + * + *

The input must be plugin-authored, never player-authored. Paginated + * commands build their navigation links by substituting a {@code /realty …} command into a + * {@code } tag argument, which no {@link TagResolver} can fill; this method exists for + * that case alone. Runtime values belong in {@link #value(String, String)}.

+ */ @Nonnull public Component deserializeRaw(@Nonnull String raw) { - return MiniMessage.miniMessage().deserialize(raw, prefixResolver()); - } - - public String plaintextMessageFor(@Nonnull String key) { - return PlainTextComponentSerializer.plainText().serialize(messageFor(key)); - } - - @Nonnull - public Component messageFor(@Nonnull String key) { - String raw = this.rawMessages.get(key); - if (raw == null) { - return Component.text(key); - } - return MiniMessage.miniMessage().deserialize(raw, prefixResolver()); - } - - @Nonnull - public Component messageFor(@Nonnull String key, @Nonnull TagResolver... resolvers) { - String raw = this.rawMessages.get(key); - if (raw == null) { - return Component.text(key); - } - TagResolver[] combined = Arrays.copyOf(resolvers, resolvers.length + 1); - combined[resolvers.length] = prefixResolver(); - return MiniMessage.miniMessage().deserialize(raw, combined); - } - - @Nonnull - public String miniMessageFormattedFor(@Nonnull String key) { - return this.rawMessages.getOrDefault(key, key); - } - - public void setMessage(@Nonnull String key, @Nonnull String rawMiniMessage) { - this.rawMessages.put(key, rawMiniMessage); - } - - public void clear() { - this.rawMessages.clear(); - } - - public void load(@Nonnull ConfigurationNode root) throws ConfigurateException { - Map temp = new HashMap<>(); - loadInto("", root, temp); - this.rawMessages.putAll(temp); - } - - public void save(@Nonnull ConfigurationNode root) throws ConfigurateException { - for (Map.Entry entry : this.rawMessages.entrySet()) { - root.node((Object[]) entry.getKey().split("\\.")).set(entry.getValue()); - } - } - - private void loadInto(String path, - ConfigurationNode root, - Map temp) throws ConfigurateException { - if (!root.empty()) { - if (root.isList()) { - List strings = root.getList(String.class, Collections.emptyList()); - String joined = strings.stream() - .filter(s -> !s.isBlank()) - .collect(Collectors.joining("\n")); - if (!joined.isEmpty()) { - temp.put(path, joined); - } - } else { - String raw = root.getString(); - if (raw != null) { - temp.put(path, raw); - } - } - } - for (Map.Entry entry : root.childrenMap().entrySet()) { - String key = entry.getKey().toString(); - ConfigurationNode node = entry.getValue(); - String newPath = path.isEmpty() ? key : path + "." + key; - loadInto(newPath, node, temp); - } + // messageFor renders a missing key as the key itself, so an unset prefix would print + // "prefix". Fall back to empty, matching how the base class resolves . + String rawPrefix = miniMessageFormattedFor("prefix"); + Component prefix = rawPrefix.equals("prefix") + ? Component.empty() + : MiniMessage.miniMessage().deserialize(rawPrefix); + return MiniMessage.miniMessage().deserialize(raw, Placeholder.component("prefix", prefix)); } } diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/localisation/MessageKeys.java b/realty-paper/src/main/java/io/github/md5sha256/realty/localisation/MessageKeys.java index f325c9e..30b7411 100644 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/localisation/MessageKeys.java +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/localisation/MessageKeys.java @@ -257,6 +257,13 @@ private MessageKeys() {} public static final String RELOAD_SUCCESS = "reload.success"; public static final String RELOAD_ERROR = "reload.error"; + // module + public static final String MODULE_LIST_HEADER = "module.list-header"; + public static final String MODULE_LIST_ENTRY = "module.list-entry"; + public static final String MODULE_LIST_EMPTY = "module.list-empty"; + public static final String MODULE_RELOAD_SUCCESS = "module.reload-success"; + public static final String MODULE_RELOAD_ERROR = "module.reload-error"; + // remove public static final String REMOVE_CHECK_PERMISSIONS_ERROR = "remove.check-permissions-error"; public static final String REMOVE_NO_PERMISSION = "remove.no-permission"; diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/util/ComponentSerializer.java b/realty-paper/src/main/java/io/github/md5sha256/realty/util/ComponentSerializer.java deleted file mode 100644 index 1631e9c..0000000 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/util/ComponentSerializer.java +++ /dev/null @@ -1,61 +0,0 @@ -package io.github.md5sha256.realty.util; - -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.minimessage.MiniMessage; -import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; -import org.checkerframework.checker.nullness.qual.Nullable; -import org.spongepowered.configurate.ConfigurationNode; -import org.spongepowered.configurate.serialize.SerializationException; -import org.spongepowered.configurate.serialize.TypeSerializer; - -import javax.annotation.Nonnull; -import java.lang.reflect.Type; -import java.util.function.Function; - -public class ComponentSerializer implements TypeSerializer { - - public static final ComponentSerializer MINI_MESSAGE = new ComponentSerializer(MiniMessage.miniMessage()::deserialize, - MiniMessage.miniMessage()::serialize); - - public static final ComponentSerializer LEGACY_AMPERSAND = new ComponentSerializer(LegacyComponentSerializer.legacyAmpersand()::deserialize, - LegacyComponentSerializer.legacyAmpersand()::serialize); - - - private final Function deserializer; - private final Function serializer; - - public ComponentSerializer( - @Nonnull Function deserializer, - @Nonnull Function serializer - ) { - this.deserializer = deserializer; - this.serializer = serializer; - } - - @Override - public Component deserialize(Type type, ConfigurationNode node) throws SerializationException { - if (node.isNull()) { - return null; - } - String s = node.getString(); - if (s == null) { - return null; - } - try { - return this.deserializer.apply(s); - } catch (Exception ex) { - throw new SerializationException(ex); - } - } - - @Override - public void serialize(Type type, @Nullable Component obj, ConfigurationNode node) throws SerializationException { - if (obj == null) { - node.set(String.class, null); - return; - } - String item = this.serializer.apply(obj); - node.set(String.class, item); - } - -} diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/util/DateFormatter.java b/realty-paper/src/main/java/io/github/md5sha256/realty/util/DateFormatter.java deleted file mode 100644 index f3c637f..0000000 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/util/DateFormatter.java +++ /dev/null @@ -1,20 +0,0 @@ -package io.github.md5sha256.realty.util; - -import io.github.md5sha256.realty.settings.Settings; -import org.jetbrains.annotations.NotNull; - -import java.text.DateFormat; -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.util.Date; - -public final class DateFormatter { - - private DateFormatter() {} - - public static @NotNull String format(@NotNull Settings settings, @NotNull LocalDateTime dateTime) { - DateFormat dateFormat = settings.dateFormat(); - Date date = Date.from(dateTime.atZone(ZoneId.systemDefault()).toInstant()); - return dateFormat.format(date); - } -} diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/util/SimpleDateFormatSerializer.java b/realty-paper/src/main/java/io/github/md5sha256/realty/util/SimpleDateFormatSerializer.java deleted file mode 100644 index 6d5f16e..0000000 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/util/SimpleDateFormatSerializer.java +++ /dev/null @@ -1,39 +0,0 @@ -package io.github.md5sha256.realty.util; - -import org.checkerframework.checker.nullness.qual.Nullable; -import org.spongepowered.configurate.ConfigurationNode; -import org.spongepowered.configurate.serialize.SerializationException; -import org.spongepowered.configurate.serialize.TypeSerializer; - -import java.lang.reflect.Type; -import java.text.SimpleDateFormat; - -public final class SimpleDateFormatSerializer implements TypeSerializer { - - public static final SimpleDateFormatSerializer INSTANCE = new SimpleDateFormatSerializer(); - - private SimpleDateFormatSerializer() { - } - - @Override - public SimpleDateFormat deserialize(Type type, ConfigurationNode node) throws SerializationException { - String pattern = node.getString(); - if (pattern == null) { - throw new SerializationException("date-format pattern cannot be null"); - } - try { - return new SimpleDateFormat(pattern); - } catch (IllegalArgumentException ex) { - throw new SerializationException("Invalid date-format pattern: " + pattern + ": " + ex.getMessage()); - } - } - - @Override - public void serialize(Type type, @Nullable SimpleDateFormat obj, ConfigurationNode node) throws SerializationException { - if (obj == null) { - node.set(String.class, null); - return; - } - node.set(String.class, obj.toPattern()); - } -} diff --git a/realty-paper/src/main/resources/messages.yml b/realty-paper/src/main/resources/messages.yml index e37149d..fbd14d4 100644 --- a/realty-paper/src/main/resources/messages.yml +++ b/realty-paper/src/main/resources/messages.yml @@ -369,6 +369,12 @@ rentable: reload: success: Messages reloaded successfully. error: ' Failed to reload messages: ' +module: + list-header: ' Active modules ():' + list-entry: by (reloadable: ) + list-empty: No modules are currently loaded. + reload-success: Reloaded module . + reload-error: ' Failed to reload module : ' remove: check-permissions-error: ' Failed to check permissions: ' no-permission: You do not have permission to remove players from this diff --git a/realty-paper/src/main/resources/paper-plugin.yml b/realty-paper/src/main/resources/paper-plugin.yml index 5e4b5a6..5a19a0a 100644 --- a/realty-paper/src/main/resources/paper-plugin.yml +++ b/realty-paper/src/main/resources/paper-plugin.yml @@ -207,6 +207,12 @@ permissions: realty.command.reload: description: Allows using /realty reload default: op + realty.command.module.list: + description: Allows using /realty module list + default: op + realty.command.module.reload: + description: Allows using /realty module reload + default: op realty.command.cleanup.tags: description: Allows using /realty cleanup tags default: op diff --git a/realty-paper/src/test/java/io/github/md5sha256/realty/command/util/DurationParserUtilTest.java b/realty-paper/src/test/java/io/github/md5sha256/realty/command/util/DurationParserUtilTest.java deleted file mode 100644 index 520848b..0000000 --- a/realty-paper/src/test/java/io/github/md5sha256/realty/command/util/DurationParserUtilTest.java +++ /dev/null @@ -1,205 +0,0 @@ -package io.github.md5sha256.realty.command.util; - -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.CsvSource; -import org.junit.jupiter.params.provider.ValueSource; - -import java.time.Duration; - -class DurationParserUtilTest { - - @Nested - @DisplayName("Single unit parsing") - class SingleUnit { - - @Test - @DisplayName("parse seconds") - void parseSeconds() { - Assertions.assertEquals(Duration.ofSeconds(30), DurationParserUtil.parse("30s")); - } - - @Test - @DisplayName("parse minutes with 'm'") - void parseMinutesShort() { - Assertions.assertEquals(Duration.ofMinutes(5), DurationParserUtil.parse("5m")); - } - - @Test - @DisplayName("parse minutes with 'min'") - void parseMinutesLong() { - Assertions.assertEquals(Duration.ofMinutes(10), DurationParserUtil.parse("10min")); - } - - @Test - @DisplayName("parse hours with 'h'") - void parseHoursShort() { - Assertions.assertEquals(Duration.ofHours(2), DurationParserUtil.parse("2h")); - } - - @Test - @DisplayName("parse hours with 'hr'") - void parseHoursLong() { - Assertions.assertEquals(Duration.ofHours(3), DurationParserUtil.parse("3hr")); - } - - @Test - @DisplayName("parse days") - void parseDays() { - Assertions.assertEquals(Duration.ofDays(7), DurationParserUtil.parse("7d")); - } - - @Test - @DisplayName("parse weeks with 'w'") - void parseWeeksShort() { - Assertions.assertEquals(Duration.ofDays(14), DurationParserUtil.parse("2w")); - } - - @Test - @DisplayName("parse weeks with 'wk'") - void parseWeeksLong() { - Assertions.assertEquals(Duration.ofDays(21), DurationParserUtil.parse("3wk")); - } - } - - @Nested - @DisplayName("Combined unit parsing") - class CombinedUnits { - - @Test - @DisplayName("days and hours: 1d3hr") - void parseDaysAndHours() { - Duration expected = Duration.ofDays(1).plusHours(3); - Assertions.assertEquals(expected, DurationParserUtil.parse("1d3hr")); - } - - @Test - @DisplayName("hours and minutes: 2h30m") - void parseHoursAndMinutes() { - Duration expected = Duration.ofHours(2).plusMinutes(30); - Assertions.assertEquals(expected, DurationParserUtil.parse("2h30m")); - } - - @Test - @DisplayName("weeks and days: 1w2d") - void parseWeeksAndDays() { - Duration expected = Duration.ofDays(9); // 7 + 2 - Assertions.assertEquals(expected, DurationParserUtil.parse("1w2d")); - } - - @Test - @DisplayName("all units: 1w2d3h15min30s") - void parseAllUnits() { - Duration expected = Duration.ofDays(9) - .plusHours(3) - .plusMinutes(15) - .plusSeconds(30); - Assertions.assertEquals(expected, DurationParserUtil.parse("1w2d3h15min30s")); - } - - @Test - @DisplayName("days, hours, and minutes: 2d12h45m") - void parseDaysHoursMinutes() { - Duration expected = Duration.ofDays(2).plusHours(12).plusMinutes(45); - Assertions.assertEquals(expected, DurationParserUtil.parse("2d12h45m")); - } - } - - @Nested - @DisplayName("Case insensitivity") - class CaseInsensitivity { - - @ParameterizedTest - @CsvSource({ - "5S, 5", - "5s, 5", - "10M, 600", - "3H, 10800", - "2HR, 7200", - "1D, 86400", - "1W, 604800", - "1WK, 604800", - "5MIN, 300", - }) - @DisplayName("unit suffixes are case-insensitive") - void caseInsensitive(String input, long expectedSeconds) { - Assertions.assertEquals(Duration.ofSeconds(expectedSeconds), DurationParserUtil.parse(input)); - } - } - - @Nested - @DisplayName("Invalid input handling") - class InvalidInput { - - @Test - @DisplayName("empty string throws IllegalArgumentException") - void emptyString() { - Assertions.assertThrows(IllegalArgumentException.class, () -> DurationParserUtil.parse("")); - } - - @ParameterizedTest - @ValueSource(strings = {"abc", "hello", "xyz"}) - @DisplayName("non-numeric input throws IllegalArgumentException") - void nonNumericInput(String input) { - Assertions.assertThrows(IllegalArgumentException.class, () -> DurationParserUtil.parse(input)); - } - - @ParameterizedTest - @ValueSource(strings = {"5", "100", "0"}) - @DisplayName("number without unit throws IllegalArgumentException") - void numberWithoutUnit(String input) { - Assertions.assertThrows(IllegalArgumentException.class, () -> DurationParserUtil.parse(input)); - } - - @ParameterizedTest - @ValueSource(strings = {"5x", "10y", "3z"}) - @DisplayName("unknown unit throws IllegalArgumentException") - void unknownUnit(String input) { - Assertions.assertThrows(IllegalArgumentException.class, () -> DurationParserUtil.parse(input)); - } - - @Test - @DisplayName("zero duration throws IllegalArgumentException") - void zeroDuration() { - Assertions.assertThrows(IllegalArgumentException.class, () -> DurationParserUtil.parse("0s")); - } - - @ParameterizedTest - @ValueSource(strings = {"1d abc", "5m 3h", " 1d"}) - @DisplayName("input with spaces throws IllegalArgumentException") - void inputWithSpaces(String input) { - Assertions.assertThrows(IllegalArgumentException.class, () -> DurationParserUtil.parse(input)); - } - } - - @Nested - @DisplayName("Edge cases") - class EdgeCases { - - @Test - @DisplayName("large values are supported") - void largeValues() { - Duration expected = Duration.ofDays(365); - Assertions.assertEquals(expected, DurationParserUtil.parse("365d")); - } - - @Test - @DisplayName("single unit with value 1") - void singleUnitValue1() { - Assertions.assertEquals(Duration.ofSeconds(1), DurationParserUtil.parse("1s")); - } - - @Test - @DisplayName("mixed long and short suffixes: 1wk2d3hr30min10s") - void mixedLongAndShortSuffixes() { - Duration expected = Duration.ofDays(9) - .plusHours(3) - .plusMinutes(30) - .plusSeconds(10); - Assertions.assertEquals(expected, DurationParserUtil.parse("1wk2d3hr30min10s")); - } - } -} From abddb6a2b71fbc825afffd2b67bd6506d717cc23 Mon Sep 17 00:00:00 2001 From: Andrew Wong <42793301+md5sha256@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:29:22 +1000 Subject: [PATCH 03/15] chore: ignore .superpowers agent scratch directory Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014kvZD6gWHjdMkN9Ebt3UbF --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index eb96699..8bde3dd 100644 --- a/.gitignore +++ b/.gitignore @@ -122,4 +122,6 @@ runs/ memory-bank .clinerules .claude -CLAUDE.md \ No newline at end of file +CLAUDE.md +# Superpowers scratch (agent workspace, not project content) +.superpowers/ From 5c9927ebdbd8fc955bf50b0ae27dc4a6747ae9ee Mon Sep 17 00:00:00 2001 From: Andrew Wong <42793301+md5sha256@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:17:32 +1000 Subject: [PATCH 04/15] docs: keep RegionNotificationListener; convert it to fire notification events Inventory showed the listener already renders notifications from domain events - the model this plan completes. Only its delivery path needs changing, which is a smaller change than relocating its nine handlers. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014kvZD6gWHjdMkN9Ebt3UbF --- .../2026-08-21-reconcile-notifications.md | 27 ++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/docs/superpowers/plans/2026-08-21-reconcile-notifications.md b/docs/superpowers/plans/2026-08-21-reconcile-notifications.md index 859c078..6b682fa 100644 --- a/docs/superpowers/plans/2026-08-21-reconcile-notifications.md +++ b/docs/superpowers/plans/2026-08-21-reconcile-notifications.md @@ -4,7 +4,7 @@ **Goal:** On top of `origin/main`'s existing event system, make every Realty notification a fired `RealtyNotificationEvent` carrying pre-rendered text, delete `NotificationService` entirely, and move delivery into two adapter module jars. -**Architecture:** One new standalone `RealtyNotificationEvent extends Event` in `realty-paper-api`, with its own `HandlerList`, carrying `List targets`, a rendered `Component`, and a nullable `WorldGuardRegion`. Every notification fire site renders as it does today and fires this event **alongside** the domain post-event it already fires. Upstream's 47 event classes are not modified. `NotificationService`, both implementations, `RegionNotificationListener` and the Essentials/transient branch in `onEnable` are deleted; `realty-paper-adapters/chat-adapter` and `.../essentials-adapter` deliver. +**Architecture:** One new standalone `RealtyNotificationEvent extends Event` in `realty-paper-api`, with its own `HandlerList`, carrying `List targets`, a rendered `Component`, and a nullable `WorldGuardRegion`. Every notification fire site renders as it does today and fires this event **alongside** the domain post-event it already fires. Upstream's 47 event classes are not modified. `NotificationService`, both implementations, and the Essentials/transient branch in `onEnable` are deleted; `RegionNotificationListener` is kept and converted to fire notification events instead of delivering; `realty-paper-adapters/chat-adapter` and `.../essentials-adapter` deliver. **Tech Stack:** Java 21, Gradle Kotlin DSL, PaperMC 1.21.8, Adventure, Incendo Cloud, `com.minecraftcitiesnetwork:plugin-infrastructure`, EssentialsX 2.21.2 (adapter only), JUnit 5, Mockito. @@ -348,29 +348,38 @@ git commit -m "refactor: fire notification events from the command call sites" - Delete: `realty-paper-api/src/main/java/io/github/md5sha256/realty/api/NotificationService.java` - Delete: `realty-paper/src/main/java/io/github/md5sha256/realty/util/TransientNotificationService.java` - Delete: `realty-paper/src/main/java/io/github/md5sha256/realty/util/EssentialsNotificationService.java` -- Delete: `realty-paper/src/main/java/io/github/md5sha256/realty/listener/RegionNotificationListener.java` +- Modify: `realty-paper/src/main/java/io/github/md5sha256/realty/listener/RegionNotificationListener.java` (**keep it** — see below) **Interfaces:** - Consumes: `RealtyNotificationEvent`, the Task 3 records. - Produces: a core with no notification delivery at all. `EssentialsSafeBlockPredicate` still exists in `util/` — Task 7 moves it. -`RegionNotificationListener` renders and delivers nine events' worth of notifications. Its handlers move back to their fire sites as `RealtyNotificationEvent` fires. For each of its nine handlers, find where the corresponding domain event is fired and fire the notification there instead, carrying the same `MessageKeys` constant and the same placeholders. Its private `resolveName(UUID)` helper is needed wherever a name is interpolated — move it to the fire site's class or a shared utility rather than duplicating it. +**`RegionNotificationListener` is kept, not deleted.** It already does the right thing: it renders +notifications from domain events, which is precisely the model this plan is completing. Its only +problem is that it *delivers* through `NotificationService`. Change each of its nine handlers to fire +a `RealtyNotificationEvent` instead of calling `notificationService.queueNotification(...)`, keeping +every `MessageKeys` constant, placeholder and target exactly as they are. Its constructor loses the +`NotificationService` parameter and gains `RealtyEventDispatch`. Its `resolveName(UUID)` helper stays +where it is. + +Each handler has the event's `WorldGuardRegion` available via `event.getRegion()` — pass it as the +notification's region. Where a handler notifies two parties with *different* text (lease expiry, +lease terminated), that is two `RealtyNotificationEvent` fires, not one event with two targets. `Realty.scheduleTasks()` also calls `queueNotification` directly for auction end, expired bid payments, and expired offer payments. Migrate those too. For the two payment sweeps, build the `WorldGuardRegion` only if `payment.worldId()` is non-null and the world and WG region both resolve; otherwise pass `null` for the region. **The refund must not become conditional on any of that.** The leasehold-expiry sweep already hops to the main thread with `scheduler.runTask` because it calls `regionProfileService.applyFlags`. Keep that hop and fire from inside it. -Then delete the four files, the `notificationService` field, the Essentials/transient selection branch in `onEnable` (leaving `SafeLocationFinder safeLocationFinder = new SafeLocationFinder();` — but **keep** the `EssentialsSafeBlockPredicate` line for now; Task 7 removes it), the `registerEvents(new RegionNotificationListener(...))` registration, and every `NotificationService` import and parameter. +Then delete the four files, the `notificationService` field, the Essentials/transient selection branch in `onEnable` (leaving `SafeLocationFinder safeLocationFinder = new SafeLocationFinder();` — but **keep** the `EssentialsSafeBlockPredicate` line for now; Task 7 removes it), and every `NotificationService` import and parameter. -- [ ] **Step 1: Move the nine `RegionNotificationListener` handlers to their fire sites** +- [ ] **Step 1: Convert `RegionNotificationListener`'s nine handlers to fire notification events** - [ ] **Step 2: Migrate the three `scheduleTasks()` call sites** -- [ ] **Step 3: Delete the four files and strip the wiring** +- [ ] **Step 3: Delete the three files and strip the wiring** ```bash git rm realty-paper-api/src/main/java/io/github/md5sha256/realty/api/NotificationService.java git rm realty-paper/src/main/java/io/github/md5sha256/realty/util/TransientNotificationService.java git rm realty-paper/src/main/java/io/github/md5sha256/realty/util/EssentialsNotificationService.java -git rm realty-paper/src/main/java/io/github/md5sha256/realty/listener/RegionNotificationListener.java ``` - [ ] **Step 4: Verify** @@ -385,8 +394,8 @@ Run: `./gradlew test` — expected BUILD SUCCESSFUL. git add -A git commit -m "refactor!: delete NotificationService; notifications are events only -Removes a published realty-paper-api type and RegionNotificationListener; -its nine handlers move back to their fire sites as notification events." +Removes a published realty-paper-api type. RegionNotificationListener is +kept and now fires notification events instead of delivering directly." ``` --- From f6e76a958f11f6e8b573e4f287bf2d5bf0c99b1c Mon Sep 17 00:00:00 2001 From: Andrew Wong <42793301+md5sha256@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:17:39 +1000 Subject: [PATCH 05/15] feat(api): add RealtyNotificationEvent --- realty-paper-api/build.gradle.kts | 1 + .../api/event/RealtyNotificationEvent.java | 70 +++++++++++++++++++ .../event/RealtyNotificationEventTest.java | 69 ++++++++++++++++++ 3 files changed, 140 insertions(+) create mode 100644 realty-paper-api/src/main/java/io/github/md5sha256/realty/api/event/RealtyNotificationEvent.java create mode 100644 realty-paper-api/src/test/java/io/github/md5sha256/realty/api/event/RealtyNotificationEventTest.java diff --git a/realty-paper-api/build.gradle.kts b/realty-paper-api/build.gradle.kts index f1a2e0c..fd57562 100644 --- a/realty-paper-api/build.gradle.kts +++ b/realty-paper-api/build.gradle.kts @@ -11,6 +11,7 @@ dependencies { } compileOnlyApi("org.jetbrains:annotations:26.0.2-1") api("org.spongepowered:configurate-yaml:4.2.0") + testImplementation("io.papermc.paper:paper-api:1.21.8-R0.1-SNAPSHOT") } publishing { diff --git a/realty-paper-api/src/main/java/io/github/md5sha256/realty/api/event/RealtyNotificationEvent.java b/realty-paper-api/src/main/java/io/github/md5sha256/realty/api/event/RealtyNotificationEvent.java new file mode 100644 index 0000000..57feb31 --- /dev/null +++ b/realty-paper-api/src/main/java/io/github/md5sha256/realty/api/event/RealtyNotificationEvent.java @@ -0,0 +1,70 @@ +package io.github.md5sha256.realty.api.event; + +import io.github.md5sha256.realty.api.WorldGuardRegion; +import net.kyori.adventure.text.Component; +import org.bukkit.event.Event; +import org.bukkit.event.HandlerList; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +/** + * Fired whenever Realty has something to tell one or more players. The message is rendered by the + * fire site from {@code messages.yml}; this event only carries it. + * + *

Realty itself delivers nothing — adapter modules listen for this event and decide what reaches + * the target. It is fired alongside, not instead of, the domain event describing what happened.

+ * + *

Synchronous: fired through {@code RealtyEventDispatch.fireSync}, so handlers run on the main + * thread and may use the Bukkit API directly.

+ */ +public final class RealtyNotificationEvent extends Event { + + private static final HandlerList HANDLERS = new HandlerList(); + + private final List targets; + private final Component message; + private final WorldGuardRegion region; + + /** + * @param targets who should be told; never empty. Several targets means several people get the + * same message — different text per person is separate events. + * @param message the rendered message + * @param region the region this concerns, or null when it cannot be resolved — a refund is + * still announced when its region has already been deleted + */ + public RealtyNotificationEvent(@NotNull List targets, + @NotNull Component message, + @Nullable WorldGuardRegion region) { + this.targets = List.copyOf(Objects.requireNonNull(targets, "targets")); + if (this.targets.isEmpty()) { + throw new IllegalArgumentException("A notification needs at least one target"); + } + this.message = Objects.requireNonNull(message, "message"); + this.region = region; + } + + public @NotNull List getTargets() { + return this.targets; + } + + public @NotNull Component getMessage() { + return this.message; + } + + public @Nullable WorldGuardRegion getRegion() { + return this.region; + } + + @Override + public @NotNull HandlerList getHandlers() { + return HANDLERS; + } + + public static @NotNull HandlerList getHandlerList() { + return HANDLERS; + } +} diff --git a/realty-paper-api/src/test/java/io/github/md5sha256/realty/api/event/RealtyNotificationEventTest.java b/realty-paper-api/src/test/java/io/github/md5sha256/realty/api/event/RealtyNotificationEventTest.java new file mode 100644 index 0000000..cd88af3 --- /dev/null +++ b/realty-paper-api/src/test/java/io/github/md5sha256/realty/api/event/RealtyNotificationEventTest.java @@ -0,0 +1,69 @@ +package io.github.md5sha256.realty.api.event; + +import net.kyori.adventure.text.Component; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +class RealtyNotificationEventTest { + + private static final Component MESSAGE = Component.text("rendered"); + + @Test + void exposesTargetsAndMessage() { + UUID target = UUID.randomUUID(); + RealtyNotificationEvent event = + new RealtyNotificationEvent(List.of(target), MESSAGE, null); + + Assertions.assertEquals(List.of(target), event.getTargets()); + Assertions.assertEquals(MESSAGE, event.getMessage()); + Assertions.assertNull(event.getRegion()); + } + + @Test + void targetsAreDefensivelyCopiedAndImmutable() { + List mutable = new ArrayList<>(); + mutable.add(UUID.randomUUID()); + RealtyNotificationEvent event = + new RealtyNotificationEvent(mutable, MESSAGE, null); + + mutable.add(UUID.randomUUID()); + + Assertions.assertEquals(1, event.getTargets().size()); + Assertions.assertThrows(UnsupportedOperationException.class, + () -> event.getTargets().add(UUID.randomUUID())); + } + + @Test + void rejectsEmptyTargets() { + Assertions.assertThrows(IllegalArgumentException.class, + () -> new RealtyNotificationEvent(List.of(), MESSAGE, null)); + } + + @Test + void rejectsNulls() { + Assertions.assertThrows(NullPointerException.class, + () -> new RealtyNotificationEvent(null, MESSAGE, null)); + Assertions.assertThrows(NullPointerException.class, + () -> new RealtyNotificationEvent(List.of(UUID.randomUUID()), null, null)); + } + + @Test + void isSynchronous() { + RealtyNotificationEvent event = + new RealtyNotificationEvent(List.of(UUID.randomUUID()), MESSAGE, null); + + Assertions.assertFalse(event.isAsynchronous()); + } + + @Test + void handlerListIsShared() { + RealtyNotificationEvent event = + new RealtyNotificationEvent(List.of(UUID.randomUUID()), MESSAGE, null); + + Assertions.assertSame(RealtyNotificationEvent.getHandlerList(), event.getHandlers()); + } +} From 7394aa3c5c50dbe2ac593f6716437e920f09c7fc Mon Sep 17 00:00:00 2001 From: Andrew Wong <42793301+md5sha256@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:21:28 +1000 Subject: [PATCH 06/15] feat(backend): carry worldId on the two payment-expiry records --- .../github/md5sha256/realty/api/RealtyBackend.java | 12 ++++++++++-- .../md5sha256/realty/database/RealtyBackendImpl.java | 6 ++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/realty-backend-api/src/main/java/io/github/md5sha256/realty/api/RealtyBackend.java b/realty-backend-api/src/main/java/io/github/md5sha256/realty/api/RealtyBackend.java index 652626f..67980dd 100644 --- a/realty-backend-api/src/main/java/io/github/md5sha256/realty/api/RealtyBackend.java +++ b/realty-backend-api/src/main/java/io/github/md5sha256/realty/api/RealtyBackend.java @@ -646,13 +646,21 @@ record ExpiredBiddingAuction( // --- Expired Bid Payments --- - record ExpiredBidPayment(@NotNull UUID bidderId, double refundAmount, @NotNull String regionId) {} + /** + * @param worldId null if the region row has already been deleted + */ + record ExpiredBidPayment(@NotNull UUID bidderId, double refundAmount, @NotNull String regionId, + @Nullable UUID worldId) {} @NotNull List clearExpiredBidPayments(); // --- Expired Offer Payments --- - record ExpiredOfferPayment(@NotNull UUID offererId, double refundAmount, @NotNull String regionId) {} + /** + * @param worldId null if the region row has already been deleted + */ + record ExpiredOfferPayment(@NotNull UUID offererId, double refundAmount, @NotNull String regionId, + @Nullable UUID worldId) {} @NotNull List clearExpiredOfferPayments(); diff --git a/realty-backend/src/main/java/io/github/md5sha256/realty/database/RealtyBackendImpl.java b/realty-backend/src/main/java/io/github/md5sha256/realty/database/RealtyBackendImpl.java index 6130067..dcf1e54 100644 --- a/realty-backend/src/main/java/io/github/md5sha256/realty/database/RealtyBackendImpl.java +++ b/realty-backend/src/main/java/io/github/md5sha256/realty/database/RealtyBackendImpl.java @@ -1835,7 +1835,8 @@ public void rollbackPayBid(@NotNull String worldGuardRegionId, RealtyRegionEntity region = wrapper.realtyRegionMapper().selectById(payment.realtyRegionId()); paymentMapper.deleteByBidId(payment.bidId()); String regionName = region != null ? region.worldGuardRegionId() : "unknown"; - refunds.add(new ExpiredBidPayment(payment.bidderId(), payment.currentPayment(), regionName)); + UUID worldId = region != null ? region.worldId() : null; + refunds.add(new ExpiredBidPayment(payment.bidderId(), payment.currentPayment(), regionName, worldId)); FreeholdContractAuctionEntity auction = auctionMapper.selectById(payment.freeholdContractAuctionId()); if (auction != null) { LocalDateTime nextDeadline = LocalDateTime.now().plusSeconds(auction.paymentDurationSeconds()); @@ -1866,7 +1867,8 @@ public void rollbackPayBid(@NotNull String worldGuardRegionId, wrapper.freeholdContractOfferPaymentMapper().deleteByOfferId(payment.offerId()); wrapper.session().commit(); String regionName = region != null ? region.worldGuardRegionId() : "unknown"; - refunds.add(new ExpiredOfferPayment(payment.offererId(), payment.currentPayment(), regionName)); + UUID worldId = region != null ? region.worldId() : null; + refunds.add(new ExpiredOfferPayment(payment.offererId(), payment.currentPayment(), regionName, worldId)); } } return refunds; From f6926852027c7ee163e4188b965cddb3c542f6db Mon Sep 17 00:00:00 2001 From: Andrew Wong <42793301+md5sha256@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:28:25 +1000 Subject: [PATCH 07/15] refactor: fire notification events from the command call sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace all 14 notificationService.queueNotification(...) calls in the agent, auction and offer command classes with events.fireSync(new RealtyNotificationEvent(List.of(target), message, region)), firing the event alongside the existing domain events. Collapse the OfferCommandGroup reject-all loop into a single multi-target event, guarded against an empty offerer list. Drop the now-unused NotificationService constructor components/imports from these command classes and their construction sites in Realty.java. Also add a fireSync(RealtyNotificationEvent) overload to RealtyEventDispatch, since RealtyNotificationEvent extends Event directly rather than RealtyRegionEvent (it allows a null region) — fireOrHop/cancelled are widened from RealtyRegionEvent to Event to support both overloads without duplicating the threading logic. --- .../io/github/md5sha256/realty/Realty.java | 12 +++---- .../command/AgentInviteAcceptCommand.java | 8 ++--- .../realty/command/AgentInviteCommand.java | 8 ++--- .../command/AgentInviteRejectCommand.java | 8 ++--- .../command/AgentInviteWithdrawCommand.java | 8 ++--- .../realty/command/AgentRemoveCommand.java | 8 ++--- .../realty/command/AuctionCommandGroup.java | 15 +++++---- .../realty/command/OfferCommandGroup.java | 31 ++++++++++--------- .../realty/event/RealtyEventDispatch.java | 20 ++++++++++-- 9 files changed, 66 insertions(+), 52 deletions(-) diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/Realty.java b/realty-paper/src/main/java/io/github/md5sha256/realty/Realty.java index 03c8ddb..cf5f2fd 100644 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/Realty.java +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/Realty.java @@ -715,13 +715,12 @@ private void registerCommands( List commands = List.of( new VersionCommand(version), new AddCommand(messageContainer), - new AgentInviteCommand(paperApi, notificationService, messageContainer, this.eventDispatch), - new AgentInviteAcceptCommand(paperApi, notificationService, messageContainer, this.eventDispatch), - new AgentInviteRejectCommand(paperApi, notificationService, messageContainer, this.eventDispatch), - new AgentInviteWithdrawCommand(paperApi, notificationService, messageContainer, this.eventDispatch), - new AgentRemoveCommand(paperApi, notificationService, messageContainer, this.eventDispatch), + new AgentInviteCommand(paperApi, messageContainer, this.eventDispatch), + new AgentInviteAcceptCommand(paperApi, messageContainer, this.eventDispatch), + new AgentInviteRejectCommand(paperApi, messageContainer, this.eventDispatch), + new AgentInviteWithdrawCommand(paperApi, messageContainer, this.eventDispatch), + new AgentRemoveCommand(paperApi, messageContainer, this.eventDispatch), new AuctionCommandGroup(paperApi, - notificationService, this.settings, messageContainer, this.eventDispatch), @@ -737,7 +736,6 @@ private void registerCommands( messageContainer), new ListCommand(paperApi, messageContainer), new OfferCommandGroup(paperApi, - notificationService, messageContainer, this.eventDispatch), new ExtendCommand(paperApi, messageContainer, this.eventDispatch), diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/command/AgentInviteAcceptCommand.java b/realty-paper/src/main/java/io/github/md5sha256/realty/command/AgentInviteAcceptCommand.java index 7de6e6e..7616340 100644 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/command/AgentInviteAcceptCommand.java +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/command/AgentInviteAcceptCommand.java @@ -1,10 +1,10 @@ package io.github.md5sha256.realty.command; -import io.github.md5sha256.realty.api.NotificationService; import io.github.md5sha256.realty.api.RealtyBackend; import io.github.md5sha256.realty.api.RealtyPaperApi; import io.github.md5sha256.realty.api.WorldGuardRegion; import io.github.md5sha256.realty.api.event.AgentInviteAcceptedEvent; +import io.github.md5sha256.realty.api.event.RealtyNotificationEvent; import io.github.md5sha256.realty.command.util.WorldGuardRegionResolver; import io.github.md5sha256.realty.event.RealtyEventDispatch; import io.github.md5sha256.realty.localisation.MessageContainer; @@ -17,6 +17,7 @@ import org.incendo.cloud.context.CommandContext; import org.jetbrains.annotations.NotNull; +import java.util.List; import java.util.UUID; /** @@ -27,7 +28,6 @@ *

Permission: {@code realty.command.agent.invite.accept}.

*/ public record AgentInviteAcceptCommand(@NotNull RealtyPaperApi api, - @NotNull NotificationService notificationService, @NotNull MessageContainer messages, @NotNull RealtyEventDispatch events) implements CustomCommandBean.Single { @@ -63,10 +63,10 @@ private void execute(@NotNull CommandContext ctx) { case RealtyBackend.AcceptAgentInviteResult.Success(UUID inviterId) -> { sender.sendMessage(messages.messageFor(MessageKeys.AGENT_INVITE_ACCEPT_SUCCESS, Placeholder.unparsed("region", regionId))); - notificationService.queueNotification(inviterId, + events.fireSync(new RealtyNotificationEvent(List.of(inviterId), messages.messageFor(MessageKeys.NOTIFICATION_AGENT_INVITE_ACCEPTED, Placeholder.unparsed("player", player.getName()), - Placeholder.unparsed("region", regionId))); + Placeholder.unparsed("region", regionId)), region)); events.fireSync(new AgentInviteAcceptedEvent(region, inviteeId)); } case RealtyBackend.AcceptAgentInviteResult.NotFound() -> diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/command/AgentInviteCommand.java b/realty-paper/src/main/java/io/github/md5sha256/realty/command/AgentInviteCommand.java index 10b7b2a..e2a90c4 100644 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/command/AgentInviteCommand.java +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/command/AgentInviteCommand.java @@ -1,12 +1,12 @@ package io.github.md5sha256.realty.command; -import io.github.md5sha256.realty.api.NotificationService; import io.github.md5sha256.realty.api.RealtyBackend; import io.github.md5sha256.realty.api.RealtyPaperApi; import io.github.md5sha256.realty.command.util.AuthorityParser; import io.github.md5sha256.realty.api.WorldGuardRegion; import io.github.md5sha256.realty.api.event.AgentInviteEvent; import io.github.md5sha256.realty.api.event.AgentInvitedEvent; +import io.github.md5sha256.realty.api.event.RealtyNotificationEvent; import io.github.md5sha256.realty.command.util.WorldGuardRegionResolver; import io.github.md5sha256.realty.event.RealtyEventDispatch; import io.github.md5sha256.realty.localisation.MessageContainer; @@ -20,6 +20,7 @@ import org.incendo.cloud.context.CommandContext; import org.jetbrains.annotations.NotNull; +import java.util.List; import java.util.UUID; /** @@ -31,7 +32,6 @@ *

Permission: {@code realty.command.agent.invite}.

*/ public record AgentInviteCommand(@NotNull RealtyPaperApi api, - @NotNull NotificationService notificationService, @NotNull MessageContainer messages, @NotNull RealtyEventDispatch events) implements CustomCommandBean.Single { @@ -78,10 +78,10 @@ private void execute(@NotNull CommandContext ctx) { sender.sendMessage(messages.messageFor(MessageKeys.AGENT_INVITE_SUCCESS, Placeholder.unparsed("player", inviteeName), Placeholder.unparsed("region", regionId))); - notificationService.queueNotification(inviteeId, + events.fireSync(new RealtyNotificationEvent(List.of(inviteeId), messages.messageFor(MessageKeys.NOTIFICATION_AGENT_INVITED, Placeholder.unparsed("player", player.getName()), - Placeholder.unparsed("region", regionId))); + Placeholder.unparsed("region", regionId)), region)); events.fireSync(new AgentInvitedEvent(region, player.getUniqueId(), inviteeId)); } case RealtyBackend.InviteAgentResult.NoFreeholdContract() -> diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/command/AgentInviteRejectCommand.java b/realty-paper/src/main/java/io/github/md5sha256/realty/command/AgentInviteRejectCommand.java index 836e411..edc14ef 100644 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/command/AgentInviteRejectCommand.java +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/command/AgentInviteRejectCommand.java @@ -1,10 +1,10 @@ package io.github.md5sha256.realty.command; -import io.github.md5sha256.realty.api.NotificationService; import io.github.md5sha256.realty.api.RealtyBackend; import io.github.md5sha256.realty.api.RealtyPaperApi; import io.github.md5sha256.realty.api.WorldGuardRegion; import io.github.md5sha256.realty.api.event.AgentInviteRejectedEvent; +import io.github.md5sha256.realty.api.event.RealtyNotificationEvent; import io.github.md5sha256.realty.command.util.WorldGuardRegionResolver; import io.github.md5sha256.realty.event.RealtyEventDispatch; import io.github.md5sha256.realty.localisation.MessageContainer; @@ -17,6 +17,7 @@ import org.incendo.cloud.context.CommandContext; import org.jetbrains.annotations.NotNull; +import java.util.List; import java.util.UUID; /** @@ -28,7 +29,6 @@ *

Permission: {@code realty.command.agent.invite.reject}.

*/ public record AgentInviteRejectCommand(@NotNull RealtyPaperApi api, - @NotNull NotificationService notificationService, @NotNull MessageContainer messages, @NotNull RealtyEventDispatch events) implements CustomCommandBean.Single { @@ -64,10 +64,10 @@ private void execute(@NotNull CommandContext ctx) { case RealtyBackend.RejectAgentInviteResult.Success(UUID inviterId) -> { sender.sendMessage(messages.messageFor(MessageKeys.AGENT_INVITE_REJECT_SUCCESS, Placeholder.unparsed("region", regionId))); - notificationService.queueNotification(inviterId, + events.fireSync(new RealtyNotificationEvent(List.of(inviterId), messages.messageFor(MessageKeys.NOTIFICATION_AGENT_INVITE_REJECTED, Placeholder.unparsed("player", player.getName()), - Placeholder.unparsed("region", regionId))); + Placeholder.unparsed("region", regionId)), region)); events.fireSync(new AgentInviteRejectedEvent(region, inviteeId)); } case RealtyBackend.RejectAgentInviteResult.NotFound() -> diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/command/AgentInviteWithdrawCommand.java b/realty-paper/src/main/java/io/github/md5sha256/realty/command/AgentInviteWithdrawCommand.java index c500b5c..24216d2 100644 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/command/AgentInviteWithdrawCommand.java +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/command/AgentInviteWithdrawCommand.java @@ -1,11 +1,11 @@ package io.github.md5sha256.realty.command; -import io.github.md5sha256.realty.api.NotificationService; import io.github.md5sha256.realty.api.RealtyBackend; import io.github.md5sha256.realty.api.RealtyPaperApi; import io.github.md5sha256.realty.command.util.AuthorityParser; import io.github.md5sha256.realty.api.WorldGuardRegion; import io.github.md5sha256.realty.api.event.AgentInviteWithdrawnEvent; +import io.github.md5sha256.realty.api.event.RealtyNotificationEvent; import io.github.md5sha256.realty.command.util.WorldGuardRegionResolver; import io.github.md5sha256.realty.event.RealtyEventDispatch; import io.github.md5sha256.realty.localisation.MessageContainer; @@ -19,6 +19,7 @@ import org.incendo.cloud.context.CommandContext; import org.jetbrains.annotations.NotNull; +import java.util.List; import java.util.UUID; /** @@ -29,7 +30,6 @@ *

Permission: {@code realty.command.agent.invite.withdraw}.

*/ public record AgentInviteWithdrawCommand(@NotNull RealtyPaperApi api, - @NotNull NotificationService notificationService, @NotNull MessageContainer messages, @NotNull RealtyEventDispatch events) implements CustomCommandBean.Single { @@ -74,10 +74,10 @@ private void execute(@NotNull CommandContext ctx) { sender.sendMessage(messages.messageFor(MessageKeys.AGENT_INVITE_WITHDRAW_SUCCESS, Placeholder.unparsed("player", inviteeName), Placeholder.unparsed("region", regionId))); - notificationService.queueNotification(inviteeId, + events.fireSync(new RealtyNotificationEvent(List.of(inviteeId), messages.messageFor(MessageKeys.NOTIFICATION_AGENT_INVITE_WITHDRAWN, Placeholder.unparsed("player", resolveName(player.getUniqueId())), - Placeholder.unparsed("region", regionId))); + Placeholder.unparsed("region", regionId)), region)); events.fireSync(new AgentInviteWithdrawnEvent(region, player.getUniqueId(), inviteeId)); } case RealtyBackend.WithdrawAgentInviteResult.NotFound() -> diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/command/AgentRemoveCommand.java b/realty-paper/src/main/java/io/github/md5sha256/realty/command/AgentRemoveCommand.java index 2a7404d..6a9144a 100644 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/command/AgentRemoveCommand.java +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/command/AgentRemoveCommand.java @@ -1,10 +1,10 @@ package io.github.md5sha256.realty.command; -import io.github.md5sha256.realty.api.NotificationService; import io.github.md5sha256.realty.api.RealtyPaperApi; import io.github.md5sha256.realty.command.util.AuthorityParser; import io.github.md5sha256.realty.api.WorldGuardRegion; import io.github.md5sha256.realty.api.event.AgentRemovedEvent; +import io.github.md5sha256.realty.api.event.RealtyNotificationEvent; import io.github.md5sha256.realty.command.util.WorldGuardRegionResolver; import io.github.md5sha256.realty.event.RealtyEventDispatch; import io.github.md5sha256.realty.localisation.MessageContainer; @@ -18,6 +18,7 @@ import org.incendo.cloud.context.CommandContext; import org.jetbrains.annotations.NotNull; +import java.util.List; import java.util.UUID; /** @@ -28,7 +29,6 @@ *

Permission: {@code realty.command.agent.remove}.

*/ public record AgentRemoveCommand(@NotNull RealtyPaperApi api, - @NotNull NotificationService notificationService, @NotNull MessageContainer messages, @NotNull RealtyEventDispatch events) implements CustomCommandBean.Single { @@ -72,10 +72,10 @@ private void execute(@NotNull CommandContext ctx) { sender.sendMessage(messages.messageFor(MessageKeys.AGENT_REMOVE_SUCCESS, Placeholder.unparsed("player", targetName), Placeholder.unparsed("region", regionId))); - notificationService.queueNotification(targetId, + events.fireSync(new RealtyNotificationEvent(List.of(targetId), messages.messageFor(MessageKeys.NOTIFICATION_AGENT_REMOVED, Placeholder.unparsed("player", player.getName()), - Placeholder.unparsed("region", regionId))); + Placeholder.unparsed("region", regionId)), region)); events.fireSync(new AgentRemovedEvent(region, actorId, targetId)); } else { sender.sendMessage(messages.messageFor(MessageKeys.AGENT_REMOVE_NOT_FOUND, diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/command/AuctionCommandGroup.java b/realty-paper/src/main/java/io/github/md5sha256/realty/command/AuctionCommandGroup.java index 4d4df33..4dc096b 100644 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/command/AuctionCommandGroup.java +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/command/AuctionCommandGroup.java @@ -3,7 +3,6 @@ import com.minecraftcitiesnetwork.pluginInfrastructure.util.DateFormatter; import io.github.md5sha256.realty.api.CurrencyFormatter; import io.github.md5sha256.realty.api.DurationFormatter; -import io.github.md5sha256.realty.api.NotificationService; import io.github.md5sha256.realty.api.RealtyBackend; import io.github.md5sha256.realty.api.RealtyPaperApi; import io.github.md5sha256.realty.command.util.DurationParser; @@ -15,6 +14,7 @@ import io.github.md5sha256.realty.api.event.AuctionCreateEvent; import io.github.md5sha256.realty.api.event.AuctionCreatedEvent; import io.github.md5sha256.realty.api.event.AuctionWonPurchaseEvent; +import io.github.md5sha256.realty.api.event.RealtyNotificationEvent; import io.github.md5sha256.realty.command.util.WorldGuardRegionResolver; import io.github.md5sha256.realty.event.RealtyEventDispatch; import io.github.md5sha256.realty.database.entity.FreeholdContractAuctionEntity; @@ -52,7 +52,6 @@ */ public record AuctionCommandGroup( @NotNull RealtyPaperApi api, - @NotNull NotificationService notificationService, @NotNull AtomicReference settings, @NotNull MessageContainer messages, @NotNull RealtyEventDispatch events @@ -228,9 +227,9 @@ private void executeCancel(@NotNull CommandContext ctx) { sender.sendMessage(messages.messageFor(MessageKeys.CANCEL_AUCTION_SUCCESS, Placeholder.unparsed("region", regionId))); for (UUID bidderId : result.bidderIds()) { - notificationService.queueNotification(bidderId, + events.fireSync(new RealtyNotificationEvent(List.of(bidderId), messages.messageFor(MessageKeys.NOTIFICATION_AUCTION_CANCELLED, - Placeholder.unparsed("region", regionId))); + Placeholder.unparsed("region", regionId)), region)); } if (sender instanceof Player canceller) { events.fireSync(new AuctionCancelledEvent(region, canceller.getUniqueId())); @@ -269,10 +268,10 @@ private void executeBid(@NotNull CommandContext ctx) { Placeholder.unparsed("amount", CurrencyFormatter.format(bidAmount)), Placeholder.unparsed("region", regionId))); if (success.previousBidderId() != null) { - notificationService.queueNotification(success.previousBidderId(), + events.fireSync(new RealtyNotificationEvent(List.of(success.previousBidderId()), messages.messageFor(MessageKeys.NOTIFICATION_OUTBID, Placeholder.unparsed("region", regionId), - Placeholder.unparsed("amount", CurrencyFormatter.format(bidAmount)))); + Placeholder.unparsed("amount", CurrencyFormatter.format(bidAmount))), region)); } events.fireSync(new AuctionBidPlacedEvent(region, sender.getUniqueId(), bidAmount)); } @@ -323,10 +322,10 @@ private void executePayBid(@NotNull CommandContext ctx) { sender.sendMessage(messages.messageFor(MessageKeys.PAY_BID_TRANSFER_SUCCESS, Placeholder.unparsed("region", fullyPaid.regionId()))); if (fullyPaid.previousTitleHolderId() != null) { - notificationService.queueNotification(fullyPaid.previousTitleHolderId(), + events.fireSync(new RealtyNotificationEvent(List.of(fullyPaid.previousTitleHolderId()), messages.messageFor(MessageKeys.NOTIFICATION_OWNERSHIP_TRANSFERRED, Placeholder.unparsed("player", sender.getName()), - Placeholder.unparsed("region", fullyPaid.regionId()))); + Placeholder.unparsed("region", fullyPaid.regionId())), region)); } events.fireSync(new AuctionWonPurchaseEvent(region, sender.getUniqueId(), fullyPaid.previousTitleHolderId(), fullyPaid.amount())); diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/command/OfferCommandGroup.java b/realty-paper/src/main/java/io/github/md5sha256/realty/command/OfferCommandGroup.java index 373b688..f2d7b2a 100644 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/command/OfferCommandGroup.java +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/command/OfferCommandGroup.java @@ -2,7 +2,6 @@ import io.github.md5sha256.realty.api.CurrencyFormatter; import io.github.md5sha256.realty.api.DateTimeFormatters; -import io.github.md5sha256.realty.api.NotificationService; import io.github.md5sha256.realty.api.RealtyBackend; import io.github.md5sha256.realty.api.RealtyPaperApi; import io.github.md5sha256.realty.command.util.ParseBounds; @@ -14,6 +13,7 @@ import io.github.md5sha256.realty.api.event.OfferPurchaseCompletedEvent; import io.github.md5sha256.realty.api.event.OfferRejectedEvent; import io.github.md5sha256.realty.api.event.OfferWithdrawnEvent; +import io.github.md5sha256.realty.api.event.RealtyNotificationEvent; import io.github.md5sha256.realty.command.util.WorldGuardRegionResolver; import io.github.md5sha256.realty.event.RealtyEventDispatch; import io.github.md5sha256.realty.database.entity.InboundOfferView; @@ -53,7 +53,6 @@ */ public record OfferCommandGroup( @NotNull RealtyPaperApi api, - @NotNull NotificationService notificationService, @NotNull MessageContainer messages, @NotNull RealtyEventDispatch events ) implements CustomCommandBean { @@ -150,11 +149,11 @@ private void executeSend(@NotNull CommandContext ctx) { Placeholder.unparsed("price", CurrencyFormatter.format(price)), Placeholder.unparsed("region", regionId))); if (success.titleHolderId() != null) { - notificationService.queueNotification(success.titleHolderId(), + events.fireSync(new RealtyNotificationEvent(List.of(success.titleHolderId()), messages.messageFor(MessageKeys.NOTIFICATION_OFFER_PLACED, Placeholder.unparsed("player", sender.getName()), Placeholder.unparsed("price", CurrencyFormatter.format(price)), - Placeholder.unparsed("region", regionId))); + Placeholder.unparsed("region", regionId)), region)); } events.fireSync(new OfferPlacedEvent(region, sender.getUniqueId(), success.titleHolderId(), price)); @@ -304,9 +303,9 @@ private void executeAccept(@NotNull CommandContext ctx) { sender.sendMessage(messages.messageFor(MessageKeys.ACCEPT_OFFER_SUCCESS, Placeholder.unparsed("player", playerName), Placeholder.unparsed("region", regionId))); - notificationService.queueNotification(target.getUniqueId(), + events.fireSync(new RealtyNotificationEvent(List.of(target.getUniqueId()), messages.messageFor(MessageKeys.NOTIFICATION_OFFER_ACCEPTED, - Placeholder.unparsed("region", regionId))); + Placeholder.unparsed("region", regionId)), region)); events.fireSync(new OfferAcceptedEvent(region, sender.getUniqueId(), target.getUniqueId())); } @@ -361,10 +360,10 @@ private void executePay(@NotNull CommandContext ctx) { sender.sendMessage(messages.messageFor(MessageKeys.PAY_OFFER_TRANSFER_SUCCESS, Placeholder.unparsed("region", fullyPaid.regionId()))); if (fullyPaid.previousTitleHolderId() != null) { - notificationService.queueNotification(fullyPaid.previousTitleHolderId(), + events.fireSync(new RealtyNotificationEvent(List.of(fullyPaid.previousTitleHolderId()), messages.messageFor(MessageKeys.NOTIFICATION_OWNERSHIP_TRANSFERRED, Placeholder.unparsed("player", sender.getName()), - Placeholder.unparsed("region", fullyPaid.regionId()))); + Placeholder.unparsed("region", fullyPaid.regionId())), region)); } events.fireSync(new OfferPurchaseCompletedEvent(region, sender.getUniqueId(), fullyPaid.previousTitleHolderId(), fullyPaid.amount())); @@ -413,10 +412,10 @@ private void executeWithdraw(@NotNull CommandContext ctx) { sender.sendMessage(messages.messageFor(MessageKeys.WITHDRAW_OFFER_SUCCESS, Placeholder.unparsed("region", regionId))); if (titleHolderId != null) { - notificationService.queueNotification(titleHolderId, + events.fireSync(new RealtyNotificationEvent(List.of(titleHolderId), messages.messageFor(MessageKeys.NOTIFICATION_OFFER_WITHDRAWN, Placeholder.unparsed("player", sender.getName()), - Placeholder.unparsed("region", regionId))); + Placeholder.unparsed("region", regionId)), region)); } events.fireSync(new OfferWithdrawnEvent(region, sender.getUniqueId(), titleHolderId)); } @@ -463,9 +462,9 @@ private void executeReject(@NotNull CommandContext ctx) { sender.sendMessage(messages.messageFor(MessageKeys.REJECT_OFFER_SUCCESS, Placeholder.unparsed("player", playerName), Placeholder.unparsed("region", regionId))); - notificationService.queueNotification(target.getUniqueId(), + events.fireSync(new RealtyNotificationEvent(List.of(target.getUniqueId()), messages.messageFor(MessageKeys.NOTIFICATION_OFFER_REJECTED, - Placeholder.unparsed("region", regionId))); + Placeholder.unparsed("region", regionId)), region)); events.fireSync(new OfferRejectedEvent(region, sender.getUniqueId(), target.getUniqueId())); } @@ -508,10 +507,12 @@ private void executeRejectAll(@NotNull CommandContext ctx) { sender.sendMessage(messages.messageFor(MessageKeys.REJECT_OFFER_ALL_SUCCESS, Placeholder.unparsed("count", String.valueOf(success.offererIds().size())), Placeholder.unparsed("region", regionId))); - Component notification = messages.messageFor(MessageKeys.NOTIFICATION_OFFER_REJECTED, - Placeholder.unparsed("region", regionId)); + if (!success.offererIds().isEmpty()) { + events.fireSync(new RealtyNotificationEvent(List.copyOf(success.offererIds()), + messages.messageFor(MessageKeys.NOTIFICATION_OFFER_REJECTED, + Placeholder.unparsed("region", regionId)), region)); + } for (UUID offererId : success.offererIds()) { - notificationService.queueNotification(offererId, notification); events.fireSync(new OfferRejectedEvent(region, sender.getUniqueId(), offererId)); } } diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/event/RealtyEventDispatch.java b/realty-paper/src/main/java/io/github/md5sha256/realty/event/RealtyEventDispatch.java index ccedc85..78feea1 100644 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/event/RealtyEventDispatch.java +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/event/RealtyEventDispatch.java @@ -1,8 +1,10 @@ package io.github.md5sha256.realty.event; +import io.github.md5sha256.realty.api.event.RealtyNotificationEvent; import io.github.md5sha256.realty.api.event.RealtyRegionEvent; import org.bukkit.Server; import org.bukkit.event.Cancellable; +import org.bukkit.event.Event; import org.bukkit.plugin.PluginManager; import org.jetbrains.annotations.NotNull; @@ -56,6 +58,20 @@ public boolean fireSync(@NotNull RealtyRegionEvent event) { "Cancellable synchronous events must be fired from the main thread: "); } + /** + * Fires a synchronous {@link RealtyNotificationEvent}, hopping to the main + * thread if the caller is not already on it. Notification events are never + * cancellable, so this always reports {@code true}. + */ + public boolean fireSync(@NotNull RealtyNotificationEvent event) { + if (event.isAsynchronous()) { + throw new IllegalArgumentException( + "fireSync requires a synchronous event; use fireAsync for " + event.getEventName()); + } + return fireOrHop(event, this.server.isPrimaryThread(), this.mainThreadExecutor, + "Cancellable synchronous events must be fired from the main thread: "); + } + /** * Fires an asynchronous Realty event, hopping off the main thread if the * caller is on it. @@ -84,7 +100,7 @@ public boolean fireAsync(@NotNull RealtyRegionEvent event) { * cancellable event that would require such a hop throws, since its * cancellation verdict could not be reported on return. */ - private boolean fireOrHop(@NotNull RealtyRegionEvent event, + private boolean fireOrHop(@NotNull Event event, boolean onRequiredThread, @NotNull Executor targetExecutor, @NotNull String cancellableHopError) { @@ -99,7 +115,7 @@ private boolean fireOrHop(@NotNull RealtyRegionEvent event, return true; } - private static boolean cancelled(@NotNull RealtyRegionEvent event) { + private static boolean cancelled(@NotNull Event event) { return event instanceof Cancellable c && c.isCancelled(); } } From d69143e61ca38229e36c645e71ff61401497609d Mon Sep 17 00:00:00 2001 From: Andrew Wong <42793301+md5sha256@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:36:11 +1000 Subject: [PATCH 08/15] refactor!: delete NotificationService; notifications are events only Removes a published realty-paper-api type. RegionNotificationListener is kept and now fires notification events instead of delivering directly. --- .../realty/api/NotificationService.java | 18 --- .../io/github/md5sha256/realty/Realty.java | 110 ++++++++++++------ .../listener/RegionNotificationListener.java | 85 ++++++++------ .../util/EssentialsNotificationService.java | 83 ------------- .../util/TransientNotificationService.java | 63 ---------- 5 files changed, 124 insertions(+), 235 deletions(-) delete mode 100644 realty-paper-api/src/main/java/io/github/md5sha256/realty/api/NotificationService.java delete mode 100644 realty-paper/src/main/java/io/github/md5sha256/realty/util/EssentialsNotificationService.java delete mode 100644 realty-paper/src/main/java/io/github/md5sha256/realty/util/TransientNotificationService.java diff --git a/realty-paper-api/src/main/java/io/github/md5sha256/realty/api/NotificationService.java b/realty-paper-api/src/main/java/io/github/md5sha256/realty/api/NotificationService.java deleted file mode 100644 index 1d2318f..0000000 --- a/realty-paper-api/src/main/java/io/github/md5sha256/realty/api/NotificationService.java +++ /dev/null @@ -1,18 +0,0 @@ -package io.github.md5sha256.realty.api; - -import net.kyori.adventure.text.Component; -import org.jetbrains.annotations.NotNull; - -import java.util.UUID; - -public interface NotificationService { - - void queueNotification(@NotNull UUID authorityId, @NotNull Component text); - - void queueNotification(@NotNull UUID authorityId, @NotNull Component text, long expiryEpochSecond); - - void queueNotification(@NotNull UUID authorityId, @NotNull String plainText); - - void queueNotification(@NotNull UUID authorityId, @NotNull String plaintext, long expiryEpochSecond); - -} diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/Realty.java b/realty-paper/src/main/java/io/github/md5sha256/realty/Realty.java index cf5f2fd..01cc600 100644 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/Realty.java +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/Realty.java @@ -11,7 +11,6 @@ import com.sk89q.worldguard.protection.regions.ProtectedRegion; import io.github.md5sha256.realty.api.CurrencyFormatter; import io.github.md5sha256.realty.api.ExecutorState; -import io.github.md5sha256.realty.api.NotificationService; import io.github.md5sha256.realty.api.ProfileApplicator; import io.github.md5sha256.realty.api.RealtyBackend; import io.github.md5sha256.realty.api.RealtyPaperApi; @@ -25,6 +24,7 @@ import io.github.md5sha256.realty.api.event.AuctionEndedEvent; import io.github.md5sha256.realty.api.event.LeaseExpiredEvent; import io.github.md5sha256.realty.api.event.LeaseTerminatedEvent; +import io.github.md5sha256.realty.api.event.RealtyNotificationEvent; import io.github.md5sha256.realty.command.AddCommand; import io.github.md5sha256.realty.command.AgentInviteAcceptCommand; import io.github.md5sha256.realty.command.AgentInviteCommand; @@ -85,10 +85,8 @@ import io.github.md5sha256.realty.settings.RegionTagSettings; import io.github.md5sha256.realty.settings.Settings; import io.github.md5sha256.realty.settings.TaxSettings; -import io.github.md5sha256.realty.util.EssentialsNotificationService; import io.github.md5sha256.realty.util.EssentialsSafeBlockPredicate; import io.github.md5sha256.realty.util.SquirrelIdUsernameResolver; -import io.github.md5sha256.realty.util.TransientNotificationService; import io.papermc.paper.util.Tick; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; @@ -131,6 +129,7 @@ import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.UUID; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ThreadFactory; @@ -152,7 +151,6 @@ public final class Realty extends JavaPlugin { private RealtyBackend logic; private ProfileApplicator profileApplicator; private DatabaseSettings databaseSettings; - private NotificationService notificationService; private Database database; private SignTextApplicator signTextApplicator; private RealtyPaperApi paperApi; @@ -275,13 +273,9 @@ public void onEnable() { } SafeLocationFinder safeLocationFinder; if (getServer().getPluginManager().isPluginEnabled("Essentials")) { - getLogger().info("Detected Essentials, using essentials as the mail service"); - this.notificationService = new EssentialsNotificationService(this.executorState.mainThreadExec()); getLogger().info("Using EssentialsX safe-block predicate for teleportation"); safeLocationFinder = new SafeLocationFinder(new EssentialsSafeBlockPredicate()); } else { - getLogger().info("Using the transient notification service"); - this.notificationService = new TransientNotificationService(this.executorState.mainThreadExec()); safeLocationFinder = new SafeLocationFinder(); } this.signTextApplicator = new SignTextApplicator( @@ -313,7 +307,6 @@ public void onEnable() { registerCommands(this.paperApi, this.executorState, this.messageContainer, - this.notificationService, safeLocationFinder); getServer().getServicesManager() .register(RealtyBackend.class, this.logic, this, ServicePriority.Normal); @@ -399,19 +392,8 @@ private void scheduleTasks() { return; } List endedAuctions = this.logic.clearExpiredBiddingAuctions(); - for (RealtyBackend.ExpiredBiddingAuction auction : endedAuctions) { - if (auction.winnerId() != null) { - this.notificationService.queueNotification(auction.winnerId(), - this.messageContainer.messageFor(MessageKeys.NOTIFICATION_AUCTION_WON, - Placeholder.unparsed("region", auction.worldGuardRegionId()))); - } else { - this.notificationService.queueNotification(auction.auctioneerId(), - this.messageContainer.messageFor(MessageKeys.NOTIFICATION_AUCTION_ENDED_NO_BIDS, - Placeholder.unparsed("region", auction.worldGuardRegionId()))); - } - } if (!endedAuctions.isEmpty()) { - // Resolve WorldGuard regions and fire post-events on the main thread. + // Resolve WorldGuard regions and fire notifications/post-events on the main thread. scheduler.runTask(this, () -> { for (RealtyBackend.ExpiredBiddingAuction auction : endedAuctions) { World world = getServer().getWorld(auction.worldId()); @@ -425,26 +407,55 @@ private void scheduleTasks() { } ProtectedRegion protectedRegion = regionManager.getRegion(auction.worldGuardRegionId()); if (protectedRegion != null) { + WorldGuardRegion wgRegion = new WorldGuardRegion(protectedRegion, world); + if (auction.winnerId() != null) { + this.eventDispatch.fireSync(new RealtyNotificationEvent( + List.of(auction.winnerId()), + this.messageContainer.messageFor(MessageKeys.NOTIFICATION_AUCTION_WON, + Placeholder.unparsed("region", auction.worldGuardRegionId())), + wgRegion)); + } else { + this.eventDispatch.fireSync(new RealtyNotificationEvent( + List.of(auction.auctioneerId()), + this.messageContainer.messageFor(MessageKeys.NOTIFICATION_AUCTION_ENDED_NO_BIDS, + Placeholder.unparsed("region", auction.worldGuardRegionId())), + wgRegion)); + } this.eventDispatch.fireSync(new AuctionEndedEvent( - new WorldGuardRegion(protectedRegion, world), - auction.winnerId(), auction.auctioneerId())); + wgRegion, auction.winnerId(), auction.auctioneerId())); } } }); } - for (RealtyBackend.ExpiredBidPayment payment : this.logic.clearExpiredBidPayments()) { - this.notificationService.queueNotification(payment.bidderId(), - this.messageContainer.messageFor(MessageKeys.NOTIFICATION_BID_PAYMENT_EXPIRED, - Placeholder.unparsed("region", payment.regionId()), - Placeholder.unparsed("amount", - CurrencyFormatter.format(payment.refundAmount())))); + List expiredBidPayments = this.logic.clearExpiredBidPayments(); + if (!expiredBidPayments.isEmpty()) { + scheduler.runTask(this, () -> { + for (RealtyBackend.ExpiredBidPayment payment : expiredBidPayments) { + WorldGuardRegion wgRegion = resolveRegion(payment.worldId(), payment.regionId()); + this.eventDispatch.fireSync(new RealtyNotificationEvent( + List.of(payment.bidderId()), + this.messageContainer.messageFor(MessageKeys.NOTIFICATION_BID_PAYMENT_EXPIRED, + Placeholder.unparsed("region", payment.regionId()), + Placeholder.unparsed("amount", + CurrencyFormatter.format(payment.refundAmount()))), + wgRegion)); + } + }); } - for (RealtyBackend.ExpiredOfferPayment payment : this.logic.clearExpiredOfferPayments()) { - this.notificationService.queueNotification(payment.offererId(), - this.messageContainer.messageFor(MessageKeys.NOTIFICATION_OFFER_PAYMENT_EXPIRED, - Placeholder.unparsed("region", payment.regionId()), - Placeholder.unparsed("amount", - CurrencyFormatter.format(payment.refundAmount())))); + List expiredOfferPayments = this.logic.clearExpiredOfferPayments(); + if (!expiredOfferPayments.isEmpty()) { + scheduler.runTask(this, () -> { + for (RealtyBackend.ExpiredOfferPayment payment : expiredOfferPayments) { + WorldGuardRegion wgRegion = resolveRegion(payment.worldId(), payment.regionId()); + this.eventDispatch.fireSync(new RealtyNotificationEvent( + List.of(payment.offererId()), + this.messageContainer.messageFor(MessageKeys.NOTIFICATION_OFFER_PAYMENT_EXPIRED, + Placeholder.unparsed("region", payment.regionId()), + Placeholder.unparsed("amount", + CurrencyFormatter.format(payment.refundAmount()))), + wgRegion)); + } + }); } List expiredLeaseholds = this.logic.clearExpiredLeaseholds(); if (!expiredLeaseholds.isEmpty()) { @@ -523,6 +534,32 @@ private void scheduleTasks() { }, intervalTicks, intervalTicks); } + /** + * Resolves a {@link WorldGuardRegion} for a sweep-produced payment record, returning + * {@code null} when the world id is unknown or either the world or the WorldGuard region + * itself cannot be resolved (e.g. the region row has already been deleted). Must be called + * on the main thread. + */ + private @Nullable WorldGuardRegion resolveRegion(@Nullable UUID worldId, @NotNull String worldGuardRegionId) { + if (worldId == null) { + return null; + } + World world = getServer().getWorld(worldId); + if (world == null) { + return null; + } + RegionManager regionManager = WorldGuard.getInstance().getPlatform() + .getRegionContainer().get(BukkitAdapter.adapt(world)); + if (regionManager == null) { + return null; + } + ProtectedRegion protectedRegion = regionManager.getRegion(worldGuardRegionId); + if (protectedRegion == null) { + return null; + } + return new WorldGuardRegion(protectedRegion, world); + } + private void initDataFolder() throws IOException { File dataFolder = getDataFolder(); if (!dataFolder.isDirectory()) { @@ -694,7 +731,6 @@ private void registerCommands( @NotNull RealtyPaperApi paperApi, @NotNull ExecutorState executorState, @NotNull MessageContainer messageContainer, - @NotNull NotificationService notificationService, @NotNull SafeLocationFinder safeLocationFinder ) { String version = getPluginMeta().getVersion(); @@ -710,7 +746,7 @@ private void registerCommands( new SubregionWandListener(this, subregionWand, subregionWandManager, messageContainer), this); pluginManager.registerEvents( - new RegionNotificationListener(notificationService, messageContainer), this); + new RegionNotificationListener(this.eventDispatch, messageContainer), this); List commands = List.of( new VersionCommand(version), diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/listener/RegionNotificationListener.java b/realty-paper/src/main/java/io/github/md5sha256/realty/listener/RegionNotificationListener.java index a16422b..c660387 100644 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/listener/RegionNotificationListener.java +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/listener/RegionNotificationListener.java @@ -3,16 +3,17 @@ import io.github.md5sha256.realty.api.CurrencyFormatter; import io.github.md5sha256.realty.api.DateTimeFormatters; import io.github.md5sha256.realty.api.LeaseholdRoles; -import io.github.md5sha256.realty.api.NotificationService; import io.github.md5sha256.realty.api.event.LeaseExpiredEvent; import io.github.md5sha256.realty.api.event.LeaseModificationProposedEvent; import io.github.md5sha256.realty.api.event.LeaseModificationResolvedEvent; import io.github.md5sha256.realty.api.event.LeaseTerminatedEvent; import io.github.md5sha256.realty.api.event.LeaseTerminationCancelledEvent; import io.github.md5sha256.realty.api.event.LeaseTerminationScheduledEvent; +import io.github.md5sha256.realty.api.event.RealtyNotificationEvent; import io.github.md5sha256.realty.api.event.RegionBoughtEvent; import io.github.md5sha256.realty.api.event.RegionRentedEvent; import io.github.md5sha256.realty.api.event.RegionUnrentedEvent; +import io.github.md5sha256.realty.event.RealtyEventDispatch; import io.github.md5sha256.realty.localisation.MessageContainer; import io.github.md5sha256.realty.localisation.MessageKeys; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; @@ -23,6 +24,7 @@ import org.bukkit.event.Listener; import org.jetbrains.annotations.NotNull; +import java.util.List; import java.util.UUID; /** @@ -35,12 +37,12 @@ */ public final class RegionNotificationListener implements Listener { - private final NotificationService notificationService; + private final RealtyEventDispatch events; private final MessageContainer messages; - public RegionNotificationListener(@NotNull NotificationService notificationService, + public RegionNotificationListener(@NotNull RealtyEventDispatch events, @NotNull MessageContainer messages) { - this.notificationService = notificationService; + this.events = events; this.messages = messages; } @@ -50,73 +52,83 @@ public void onRegionBought(@NotNull RegionBoughtEvent event) { if (seller == null) { return; } - this.notificationService.queueNotification(seller, + this.events.fireSync(new RealtyNotificationEvent(List.of(seller), this.messages.messageFor(MessageKeys.NOTIFICATION_REGION_BOUGHT, Placeholder.unparsed("player", resolveName(event.getBuyerId())), Placeholder.unparsed("price", CurrencyFormatter.format(event.getPrice())), - Placeholder.unparsed("region", event.getRegionId()))); + Placeholder.unparsed("region", event.getRegionId())), + event.getRegion())); } @EventHandler public void onRegionRented(@NotNull RegionRentedEvent event) { - this.notificationService.queueNotification(event.getLandlordId(), + this.events.fireSync(new RealtyNotificationEvent(List.of(event.getLandlordId()), this.messages.messageFor(MessageKeys.NOTIFICATION_REGION_RENTED, Placeholder.unparsed("player", resolveName(event.getTenantId())), Placeholder.unparsed("price", CurrencyFormatter.format(event.getPrice())), - Placeholder.unparsed("region", event.getRegionId()))); + Placeholder.unparsed("region", event.getRegionId())), + event.getRegion())); } @EventHandler public void onRegionUnrented(@NotNull RegionUnrentedEvent event) { - this.notificationService.queueNotification(event.getLandlordId(), + this.events.fireSync(new RealtyNotificationEvent(List.of(event.getLandlordId()), this.messages.messageFor(MessageKeys.NOTIFICATION_REGION_UNRENTED, Placeholder.unparsed("player", resolveName(event.getTenantId())), Placeholder.unparsed("region", event.getRegionId()), - Placeholder.unparsed("refund", CurrencyFormatter.format(event.getRefund())))); + Placeholder.unparsed("refund", CurrencyFormatter.format(event.getRefund()))), + event.getRegion())); } @EventHandler public void onLeaseExpired(@NotNull LeaseExpiredEvent event) { - this.notificationService.queueNotification(event.getTenantId(), + this.events.fireSync(new RealtyNotificationEvent(List.of(event.getTenantId()), this.messages.messageFor(MessageKeys.NOTIFICATION_LEASEHOLD_EXPIRED, - Placeholder.unparsed("region", event.getRegionId()))); - this.notificationService.queueNotification(event.getLandlordId(), + Placeholder.unparsed("region", event.getRegionId())), + event.getRegion())); + this.events.fireSync(new RealtyNotificationEvent(List.of(event.getLandlordId()), this.messages.messageFor(MessageKeys.NOTIFICATION_LEASEHOLD_EXPIRED_LANDLORD, - Placeholder.unparsed("region", event.getRegionId()))); + Placeholder.unparsed("region", event.getRegionId())), + event.getRegion())); } @EventHandler public void onModificationProposed(@NotNull LeaseModificationProposedEvent event) { if (LeaseholdRoles.LANDLORD.equals(event.getProposerRole())) { // Landlord proposed: notify the tenant, who decides by renewing or not. - this.notificationService.queueNotification(event.getTenantId(), + this.events.fireSync(new RealtyNotificationEvent(List.of(event.getTenantId()), this.messages.messageFor(MessageKeys.NOTIFICATION_MODIFY_PROPOSED_LANDLORD, - Placeholder.unparsed("region", event.getRegionId()))); + Placeholder.unparsed("region", event.getRegionId())), + event.getRegion())); } else { // Tenant proposed: notify the landlord, who must accept or reject. - this.notificationService.queueNotification(event.getLandlordId(), + this.events.fireSync(new RealtyNotificationEvent(List.of(event.getLandlordId()), this.messages.messageFor(MessageKeys.NOTIFICATION_MODIFY_PROPOSED_TENANT, Placeholder.unparsed("player", resolveName(event.getProposerId())), - Placeholder.unparsed("region", event.getRegionId()))); + Placeholder.unparsed("region", event.getRegionId())), + event.getRegion())); } } @EventHandler public void onModificationResolved(@NotNull LeaseModificationResolvedEvent event) { switch (event.getResolution()) { - case "ACCEPTED" -> this.notificationService.queueNotification(event.getTenantId(), + case "ACCEPTED" -> this.events.fireSync(new RealtyNotificationEvent(List.of(event.getTenantId()), this.messages.messageFor(MessageKeys.NOTIFICATION_MODIFY_ACCEPTED, - Placeholder.unparsed("region", event.getRegionId()))); - case "REJECTED" -> this.notificationService.queueNotification(event.getTenantId(), + Placeholder.unparsed("region", event.getRegionId())), + event.getRegion())); + case "REJECTED" -> this.events.fireSync(new RealtyNotificationEvent(List.of(event.getTenantId()), this.messages.messageFor(MessageKeys.NOTIFICATION_MODIFY_REJECTED, - Placeholder.unparsed("region", event.getRegionId()))); + Placeholder.unparsed("region", event.getRegionId())), + event.getRegion())); case "WITHDRAWN" -> { // Notify the party that did not withdraw. UUID target = LeaseholdRoles.LANDLORD.equals(event.getProposerRole()) ? event.getTenantId() : event.getLandlordId(); - this.notificationService.queueNotification(target, + this.events.fireSync(new RealtyNotificationEvent(List.of(target), this.messages.messageFor(MessageKeys.NOTIFICATION_MODIFY_WITHDRAWN, - Placeholder.unparsed("region", event.getRegionId()))); + Placeholder.unparsed("region", event.getRegionId())), + event.getRegion())); } default -> { } } @@ -126,15 +138,17 @@ public void onModificationResolved(@NotNull LeaseModificationResolvedEvent event public void onTerminationScheduled(@NotNull LeaseTerminationScheduledEvent event) { String date = event.getEffectiveDate().format(DateTimeFormatters.DATE_TIME); if (LeaseholdRoles.LANDLORD.equals(event.getTerminatedByRole())) { - this.notificationService.queueNotification(event.getTenantId(), + this.events.fireSync(new RealtyNotificationEvent(List.of(event.getTenantId()), this.messages.messageFor(MessageKeys.NOTIFICATION_TERMINATION_SCHEDULED_TENANT, Placeholder.unparsed("region", event.getRegionId()), - Placeholder.unparsed("date", date))); + Placeholder.unparsed("date", date)), + event.getRegion())); } else { - this.notificationService.queueNotification(event.getLandlordId(), + this.events.fireSync(new RealtyNotificationEvent(List.of(event.getLandlordId()), this.messages.messageFor(MessageKeys.NOTIFICATION_TERMINATION_SCHEDULED_LANDLORD, Placeholder.unparsed("region", event.getRegionId()), - Placeholder.unparsed("date", date))); + Placeholder.unparsed("date", date)), + event.getRegion())); } } @@ -143,20 +157,23 @@ public void onTerminationCancelled(@NotNull LeaseTerminationCancelledEvent event // Notify the party that did not initiate the (now-cancelled) termination. UUID target = LeaseholdRoles.LANDLORD.equals(event.getTerminatedByRole()) ? event.getTenantId() : event.getLandlordId(); - this.notificationService.queueNotification(target, + this.events.fireSync(new RealtyNotificationEvent(List.of(target), this.messages.messageFor(MessageKeys.NOTIFICATION_TERMINATION_CANCELLED, - Placeholder.unparsed("region", event.getRegionId()))); + Placeholder.unparsed("region", event.getRegionId())), + event.getRegion())); } @EventHandler public void onLeaseTerminated(@NotNull LeaseTerminatedEvent event) { - this.notificationService.queueNotification(event.getTenantId(), + this.events.fireSync(new RealtyNotificationEvent(List.of(event.getTenantId()), this.messages.messageFor(MessageKeys.NOTIFICATION_LEASEHOLD_TERMINATED_TENANT, Placeholder.unparsed("region", event.getRegionId()), - Placeholder.unparsed("refund", CurrencyFormatter.format(event.getRefund())))); - this.notificationService.queueNotification(event.getLandlordId(), + Placeholder.unparsed("refund", CurrencyFormatter.format(event.getRefund()))), + event.getRegion())); + this.events.fireSync(new RealtyNotificationEvent(List.of(event.getLandlordId()), this.messages.messageFor(MessageKeys.NOTIFICATION_LEASEHOLD_TERMINATED_LANDLORD, - Placeholder.unparsed("region", event.getRegionId()))); + Placeholder.unparsed("region", event.getRegionId())), + event.getRegion())); } /** diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/util/EssentialsNotificationService.java b/realty-paper/src/main/java/io/github/md5sha256/realty/util/EssentialsNotificationService.java deleted file mode 100644 index e364aca..0000000 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/util/EssentialsNotificationService.java +++ /dev/null @@ -1,83 +0,0 @@ -package io.github.md5sha256.realty.util; - -import com.earth2me.essentials.Console; -import com.earth2me.essentials.Essentials; -import com.earth2me.essentials.IEssentials; -import io.github.md5sha256.realty.api.NotificationService; -import net.ess3.api.IUser; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; -import org.bukkit.Bukkit; -import org.jetbrains.annotations.NotNull; - -import java.util.UUID; -import java.util.concurrent.Executor; -import java.util.concurrent.TimeUnit; -import java.util.logging.Logger; - -public class EssentialsNotificationService implements NotificationService { - - private final IEssentials essentials; - private final Executor mainThreadExec; - private final Logger logger; - - public EssentialsNotificationService(@NotNull Executor executor) { - essentials = (Essentials) Bukkit.getPluginManager().getPlugin("Essentials"); - this.mainThreadExec = executor; - this.logger = Bukkit.getPluginManager().getPlugin("Realty").getLogger(); - } - - @Override - public void queueNotification(@NotNull UUID authorityId, - @NotNull Component text, - long expiryEpochSecond) { - queueNotification(authorityId, - LegacyComponentSerializer.legacySection().serialize(text), - expiryEpochSecond); - } - - @Override - public void queueNotification(@NotNull UUID authorityId, - @NotNull String plaintext, - long expiryEpochSecond) { - Runnable runnable = () -> { - IUser user = essentials.getUser(authorityId); - if (user == null) { - logger.warning("Failed to resolve Essentials user for UUID " + authorityId); - return; - } - essentials.getMail() - .sendMail(user, - Console.getInstance(), - plaintext, - TimeUnit.SECONDS.toMillis(expiryEpochSecond)); - }; - if (Bukkit.isPrimaryThread()) { - runnable.run(); - } else { - this.mainThreadExec.execute(runnable); - } - } - - @Override - public void queueNotification(@NotNull UUID authorityId, @NotNull Component text) { - queueNotification(authorityId, LegacyComponentSerializer.legacySection().serialize(text)); - } - - @Override - public void queueNotification(@NotNull UUID authorityId, @NotNull String plaintext) { - Runnable runnable = () -> { - IUser user = essentials.getUser(authorityId); - if (user == null) { - logger.warning("Failed to resolve Essentials user for UUID " + authorityId); - return; - } - essentials.getMail().sendMail(user, Console.getInstance(), plaintext); - }; - if (Bukkit.isPrimaryThread()) { - runnable.run(); - } else { - this.mainThreadExec.execute(runnable); - } - } -} diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/util/TransientNotificationService.java b/realty-paper/src/main/java/io/github/md5sha256/realty/util/TransientNotificationService.java deleted file mode 100644 index 91e1ed4..0000000 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/util/TransientNotificationService.java +++ /dev/null @@ -1,63 +0,0 @@ -package io.github.md5sha256.realty.util; - -import io.github.md5sha256.realty.api.NotificationService; -import net.kyori.adventure.text.Component; -import org.bukkit.Bukkit; -import org.bukkit.entity.Player; -import org.jetbrains.annotations.NotNull; - -import java.util.UUID; -import java.util.concurrent.Executor; - -public class TransientNotificationService implements NotificationService { - - private final Executor mainThreadExec; - - public TransientNotificationService(@NotNull Executor mainThreadExec) { - this.mainThreadExec = mainThreadExec; - } - - @Override - public void queueNotification(@NotNull UUID authorityId, @NotNull Component text) { - Runnable runnable = () -> { - Player player = Bukkit.getPlayer(authorityId); - if (player != null) { - player.sendMessage(text); - } - }; - if (Bukkit.isPrimaryThread()) { - runnable.run(); - } else { - mainThreadExec.execute(runnable); - } - } - - @Override - public void queueNotification(@NotNull UUID authorityId, - @NotNull Component text, - long expiryEpochSecond) { - queueNotification(authorityId, text); - } - - @Override - public void queueNotification(@NotNull UUID authorityId, @NotNull String plaintext) { - Runnable runnable = () -> { - Player player = Bukkit.getPlayer(authorityId); - if (player != null) { - player.sendPlainMessage(plaintext); - } - }; - if (Bukkit.isPrimaryThread()) { - runnable.run(); - } else { - mainThreadExec.execute(runnable); - } - } - - @Override - public void queueNotification(@NotNull UUID authorityId, - @NotNull String plaintext, - long expiryEpochSecond) { - queueNotification(authorityId, plaintext); - } -} From 0dc59c4bdf3c0e591b24ff436aecd5e4641e79a4 Mon Sep 17 00:00:00 2001 From: Andrew Wong <42793301+md5sha256@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:43:01 +1000 Subject: [PATCH 09/15] fix: auction sweep notifications must not depend on WG resolution Reuse resolveRegion for the auction-won / auction-ended-no-bids notifications so they fire unconditionally with a possibly-null region, matching the payment sweeps. Only the AuctionEndedEvent fire (whose region is @NotNull) stays gated on a resolved region. --- .../io/github/md5sha256/realty/Realty.java | 38 +++++++------------ 1 file changed, 14 insertions(+), 24 deletions(-) diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/Realty.java b/realty-paper/src/main/java/io/github/md5sha256/realty/Realty.java index 01cc600..d4746cf 100644 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/Realty.java +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/Realty.java @@ -396,31 +396,21 @@ private void scheduleTasks() { // Resolve WorldGuard regions and fire notifications/post-events on the main thread. scheduler.runTask(this, () -> { for (RealtyBackend.ExpiredBiddingAuction auction : endedAuctions) { - World world = getServer().getWorld(auction.worldId()); - if (world == null) { - continue; + WorldGuardRegion wgRegion = resolveRegion(auction.worldId(), auction.worldGuardRegionId()); + if (auction.winnerId() != null) { + this.eventDispatch.fireSync(new RealtyNotificationEvent( + List.of(auction.winnerId()), + this.messageContainer.messageFor(MessageKeys.NOTIFICATION_AUCTION_WON, + Placeholder.unparsed("region", auction.worldGuardRegionId())), + wgRegion)); + } else { + this.eventDispatch.fireSync(new RealtyNotificationEvent( + List.of(auction.auctioneerId()), + this.messageContainer.messageFor(MessageKeys.NOTIFICATION_AUCTION_ENDED_NO_BIDS, + Placeholder.unparsed("region", auction.worldGuardRegionId())), + wgRegion)); } - RegionManager regionManager = WorldGuard.getInstance().getPlatform() - .getRegionContainer().get(BukkitAdapter.adapt(world)); - if (regionManager == null) { - continue; - } - ProtectedRegion protectedRegion = regionManager.getRegion(auction.worldGuardRegionId()); - if (protectedRegion != null) { - WorldGuardRegion wgRegion = new WorldGuardRegion(protectedRegion, world); - if (auction.winnerId() != null) { - this.eventDispatch.fireSync(new RealtyNotificationEvent( - List.of(auction.winnerId()), - this.messageContainer.messageFor(MessageKeys.NOTIFICATION_AUCTION_WON, - Placeholder.unparsed("region", auction.worldGuardRegionId())), - wgRegion)); - } else { - this.eventDispatch.fireSync(new RealtyNotificationEvent( - List.of(auction.auctioneerId()), - this.messageContainer.messageFor(MessageKeys.NOTIFICATION_AUCTION_ENDED_NO_BIDS, - Placeholder.unparsed("region", auction.worldGuardRegionId())), - wgRegion)); - } + if (wgRegion != null) { this.eventDispatch.fireSync(new AuctionEndedEvent( wgRegion, auction.winnerId(), auction.auctioneerId())); } From 2205cb1b4390398f4839a0f081b66c8dbdaf6722 Mon Sep 17 00:00:00 2001 From: Andrew Wong <42793301+md5sha256@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:48:09 +1000 Subject: [PATCH 10/15] Expose module seams: executorState/paperApi accessors, swappable SafeLocationFinder predicate Adds Realty#executorState() and Realty#paperApi() accessors, makes SafeLocationFinder's safety predicate volatile and swappable via setSafetyPredicate()/safetyPredicate(), and adds RealtyPaperApi#setSafeBlockPredicate(Predicate) delegating to the single SafeLocationFinder instance constructed in onEnable before RealtyPaperApiImpl and passed to registerCommands. This gives the upcoming chat and Essentials adapter modules the seams they need without delivering any behavior itself. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014kvZD6gWHjdMkN9Ebt3UbF --- .../md5sha256/realty/api/RealtyPaperApi.java | 12 ++++++++ .../io/github/md5sha256/realty/Realty.java | 10 ++++++- .../realty/api/RealtyPaperApiImpl.java | 13 +++++++- .../command/util/SafeLocationFinder.java | 15 +++++++++- .../realty/api/RealtyPaperApiImplTest.java | 4 ++- .../command/util/SafeLocationFinderTest.java | 30 +++++++++++++++++++ 6 files changed, 80 insertions(+), 4 deletions(-) create mode 100644 realty-paper/src/test/java/io/github/md5sha256/realty/command/util/SafeLocationFinderTest.java diff --git a/realty-paper-api/src/main/java/io/github/md5sha256/realty/api/RealtyPaperApi.java b/realty-paper-api/src/main/java/io/github/md5sha256/realty/api/RealtyPaperApi.java index b504151..612e71f 100644 --- a/realty-paper-api/src/main/java/io/github/md5sha256/realty/api/RealtyPaperApi.java +++ b/realty-paper-api/src/main/java/io/github/md5sha256/realty/api/RealtyPaperApi.java @@ -7,6 +7,7 @@ import io.github.md5sha256.realty.database.entity.LeaseholdModificationView; import io.github.md5sha256.realty.database.entity.OutboundOfferView; import io.github.md5sha256.realty.database.entity.RealtySignEntity; +import org.bukkit.block.Block; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -15,9 +16,20 @@ import java.util.Map; import java.util.UUID; import java.util.concurrent.CompletableFuture; +import java.util.function.Predicate; public interface RealtyPaperApi { + /** + * Replaces the safe-teleport-location predicate used when finding a safe + * block to teleport a player to. Adapter modules (e.g. an EssentialsX + * integration) call this once during their own startup. + * + * @param predicate predicate that tests the feet-level block; returns + * {@code true} if safe to teleport to + */ + void setSafeBlockPredicate(@NotNull Predicate predicate); + // ═══════════════════════════════════════════════════ // COMPLEX OPERATIONS (economy + WG + signs/flags) // ═══════════════════════════════════════════════════ diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/Realty.java b/realty-paper/src/main/java/io/github/md5sha256/realty/Realty.java index d4746cf..c5272a4 100644 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/Realty.java +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/Realty.java @@ -182,6 +182,14 @@ public Settings settings() { return this.settings.get(); } + public ExecutorState executorState() { + return this.executorState; + } + + public RealtyPaperApi paperApi() { + return this.paperApi; + } + public RegionProfileSettings regionFlagSettings() { return this.regionFlagSettings.get(); } @@ -294,7 +302,7 @@ public void onEnable() { this.paperApi = new RealtyPaperApiImpl( this.logic, economyProvider, this.executorState, this.database, this.regionProfileService, this.signTextApplicator, this.signCache, - () -> this.settings.get().terminationNoticeSeconds()); + () -> this.settings.get().terminationNoticeSeconds(), safeLocationFinder); this.eventDispatch = new RealtyEventDispatch( getServer(), this.executorState.mainThreadExec(), diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/api/RealtyPaperApiImpl.java b/realty-paper/src/main/java/io/github/md5sha256/realty/api/RealtyPaperApiImpl.java index 54baa07..7217376 100644 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/api/RealtyPaperApiImpl.java +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/api/RealtyPaperApiImpl.java @@ -21,10 +21,12 @@ import io.github.md5sha256.realty.database.entity.RealtyRegionEntity; import io.github.md5sha256.realty.database.entity.RealtySignEntity; import io.github.md5sha256.realty.api.ExecutorState; +import io.github.md5sha256.realty.command.util.SafeLocationFinder; import io.github.md5sha256.realty.economy.EconomyProvider; import io.github.md5sha256.realty.economy.PaymentResult; import org.bukkit.Bukkit; import org.bukkit.World; +import org.bukkit.block.Block; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -37,6 +39,7 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; +import java.util.function.Predicate; import java.util.function.Supplier; public class RealtyPaperApiImpl implements RealtyPaperApi { @@ -49,6 +52,7 @@ public class RealtyPaperApiImpl implements RealtyPaperApi { private final SignTextApplicator signTextApplicator; private final SignCache signCache; private final java.util.function.LongSupplier terminationNoticeSeconds; + private final SafeLocationFinder safeLocationFinder; /** * Per-region serialisation chains. Each entry is the tail of a queue of @@ -65,7 +69,8 @@ public RealtyPaperApiImpl(@NotNull RealtyBackend realtyApi, @NotNull RegionProfileService regionProfileService, @NotNull SignTextApplicator signTextApplicator, @NotNull SignCache signCache, - @NotNull java.util.function.LongSupplier terminationNoticeSeconds) { + @NotNull java.util.function.LongSupplier terminationNoticeSeconds, + @NotNull SafeLocationFinder safeLocationFinder) { this.realtyApi = realtyApi; this.economyProvider = economyProvider; this.executorState = executorState; @@ -74,6 +79,12 @@ public RealtyPaperApiImpl(@NotNull RealtyBackend realtyApi, this.signTextApplicator = signTextApplicator; this.signCache = signCache; this.terminationNoticeSeconds = terminationNoticeSeconds; + this.safeLocationFinder = safeLocationFinder; + } + + @Override + public void setSafeBlockPredicate(@NotNull Predicate predicate) { + this.safeLocationFinder.setSafetyPredicate(predicate); } /** diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/command/util/SafeLocationFinder.java b/realty-paper/src/main/java/io/github/md5sha256/realty/command/util/SafeLocationFinder.java index 1e6969c..bc16e1f 100644 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/command/util/SafeLocationFinder.java +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/command/util/SafeLocationFinder.java @@ -51,7 +51,7 @@ public final class SafeLocationFinder { Material.MAGMA_BLOCK ); - private final Predicate safetyPredicate; + private volatile Predicate safetyPredicate; /** * Creates a finder with a custom safety predicate. @@ -80,6 +80,19 @@ public SafeLocationFinder() { return SafeLocationFinder::defaultIsSafe; } + /** + * Replaces the safety predicate on this live instance. Adapter modules call this through + * {@link io.github.md5sha256.realty.api.RealtyPaperApi#setSafeBlockPredicate(Predicate)} — + * they start after commands are registered, so the finder is already in use by then. + */ + public void setSafetyPredicate(@NotNull Predicate safetyPredicate) { + this.safetyPredicate = safetyPredicate; + } + + public @NotNull Predicate safetyPredicate() { + return this.safetyPredicate; + } + /** * Default safety check: solid non-hazardous ground, passable feet/head space, * no liquids or dangerous surrounding blocks. diff --git a/realty-paper/src/test/java/io/github/md5sha256/realty/api/RealtyPaperApiImplTest.java b/realty-paper/src/test/java/io/github/md5sha256/realty/api/RealtyPaperApiImplTest.java index 199b624..abab5d7 100644 --- a/realty-paper/src/test/java/io/github/md5sha256/realty/api/RealtyPaperApiImplTest.java +++ b/realty-paper/src/test/java/io/github/md5sha256/realty/api/RealtyPaperApiImplTest.java @@ -1,5 +1,6 @@ package io.github.md5sha256.realty.api; +import io.github.md5sha256.realty.command.util.SafeLocationFinder; import com.sk89q.worldedit.bukkit.BukkitAdapter; import com.sk89q.worldedit.math.BlockVector3; import com.sk89q.worldguard.WorldGuard; @@ -79,7 +80,8 @@ void setUp() { signCache = new SignCache(); ExecutorState executorState = new ExecutorState(Runnable::run, sameThreadExecutorService(), sameThreadExecutorService()); api = new RealtyPaperApiImpl(realtyApi, economyProvider, executorState, database, - regionProfileService, signTextApplicator, signCache, () -> 604800); + regionProfileService, signTextApplicator, signCache, () -> 604800, + new SafeLocationFinder()); lenient().when(world.getUID()).thenReturn(WORLD_ID); diff --git a/realty-paper/src/test/java/io/github/md5sha256/realty/command/util/SafeLocationFinderTest.java b/realty-paper/src/test/java/io/github/md5sha256/realty/command/util/SafeLocationFinderTest.java new file mode 100644 index 0000000..9ef1bf9 --- /dev/null +++ b/realty-paper/src/test/java/io/github/md5sha256/realty/command/util/SafeLocationFinderTest.java @@ -0,0 +1,30 @@ +package io.github.md5sha256.realty.command.util; + +import org.bukkit.block.Block; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.function.Predicate; + +class SafeLocationFinderTest { + + @Test + void predicateCanBeReplacedAfterConstruction() { + Predicate alwaysUnsafe = block -> false; + SafeLocationFinder finder = new SafeLocationFinder(alwaysUnsafe); + + Assertions.assertSame(alwaysUnsafe, finder.safetyPredicate()); + + Predicate alwaysSafe = block -> true; + finder.setSafetyPredicate(alwaysSafe); + + Assertions.assertSame(alwaysSafe, finder.safetyPredicate()); + } + + @Test + void defaultPredicateIsUsedWhenNoneSupplied() { + SafeLocationFinder finder = new SafeLocationFinder(); + + Assertions.assertNotNull(finder.safetyPredicate()); + } +} From 61bf158d355e8990d2e7ca1bd826113292810d4a Mon Sep 17 00:00:00 2001 From: Andrew Wong <42793301+md5sha256@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:54:21 +1000 Subject: [PATCH 11/15] feat(chat-adapter): deliver Realty notifications to online players --- .../chat-adapter/build.gradle.kts | 16 ++++ .../adapter/chat/ChatAdapterModule.java | 30 ++++++++ .../chat/ChatNotificationListener.java | 51 +++++++++++++ .../src/main/resources/module-manifest.yml | 5 ++ .../chat/ChatNotificationListenerTest.java | 74 +++++++++++++++++++ settings.gradle.kts | 1 + 6 files changed, 177 insertions(+) create mode 100644 realty-paper-adapters/chat-adapter/build.gradle.kts create mode 100644 realty-paper-adapters/chat-adapter/src/main/java/io/github/md5sha256/realty/adapter/chat/ChatAdapterModule.java create mode 100644 realty-paper-adapters/chat-adapter/src/main/java/io/github/md5sha256/realty/adapter/chat/ChatNotificationListener.java create mode 100644 realty-paper-adapters/chat-adapter/src/main/resources/module-manifest.yml create mode 100644 realty-paper-adapters/chat-adapter/src/test/java/io/github/md5sha256/realty/adapter/chat/ChatNotificationListenerTest.java diff --git a/realty-paper-adapters/chat-adapter/build.gradle.kts b/realty-paper-adapters/chat-adapter/build.gradle.kts new file mode 100644 index 0000000..85d7059 --- /dev/null +++ b/realty-paper-adapters/chat-adapter/build.gradle.kts @@ -0,0 +1,16 @@ +plugins { + `java-library` + `realty-conventions` + id("com.gradleup.shadow") version "9.3.1" +} + +dependencies { + compileOnly(project(":realty-paper")) + compileOnly(project(":realty-paper-api")) + compileOnly("io.papermc.paper:paper-api:1.21.8-R0.1-SNAPSHOT") + compileOnly("org.jetbrains:annotations:26.0.2-1") + compileOnly("com.minecraftcitiesnetwork:plugin-infrastructure:1.0.0-SNAPSHOT") + + testImplementation(project(":realty-paper-api")) + testImplementation("io.papermc.paper:paper-api:1.21.8-R0.1-SNAPSHOT") +} diff --git a/realty-paper-adapters/chat-adapter/src/main/java/io/github/md5sha256/realty/adapter/chat/ChatAdapterModule.java b/realty-paper-adapters/chat-adapter/src/main/java/io/github/md5sha256/realty/adapter/chat/ChatAdapterModule.java new file mode 100644 index 0000000..a5133a0 --- /dev/null +++ b/realty-paper-adapters/chat-adapter/src/main/java/io/github/md5sha256/realty/adapter/chat/ChatAdapterModule.java @@ -0,0 +1,30 @@ +package io.github.md5sha256.realty.adapter.chat; + +import com.minecraftcitiesnetwork.pluginInfrastructure.modules.SimplePluginModule; +import io.github.md5sha256.realty.Realty; +import net.kyori.adventure.audience.Audience; +import org.bukkit.Bukkit; +import org.jetbrains.annotations.NotNull; + +import java.nio.file.Path; +import java.util.UUID; +import java.util.function.Function; + +/** + * Sends every Realty notification to its target's chat, when that target is online. + */ +public final class ChatAdapterModule extends SimplePluginModule { + + @Override + public void initialize(@NotNull Realty plugin, @NotNull Path dataFolder) { + super.initialize(plugin, dataFolder); + Function lookup = Bukkit::getPlayer; + registerListener(new ChatNotificationListener(lookup)); + } + + @Override + public void shutdown(@NotNull Realty plugin) { + unregisterListeners(); + super.shutdown(plugin); + } +} diff --git a/realty-paper-adapters/chat-adapter/src/main/java/io/github/md5sha256/realty/adapter/chat/ChatNotificationListener.java b/realty-paper-adapters/chat-adapter/src/main/java/io/github/md5sha256/realty/adapter/chat/ChatNotificationListener.java new file mode 100644 index 0000000..e505d87 --- /dev/null +++ b/realty-paper-adapters/chat-adapter/src/main/java/io/github/md5sha256/realty/adapter/chat/ChatNotificationListener.java @@ -0,0 +1,51 @@ +package io.github.md5sha256.realty.adapter.chat; + +import io.github.md5sha256.realty.api.event.RealtyNotificationEvent; +import net.kyori.adventure.audience.Audience; +import net.kyori.adventure.text.Component; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.List; +import java.util.UUID; +import java.util.function.Function; + +/** + * Delivers Realty notifications to targets who are online, and drops them otherwise. + * + *

This is the baseline every server gets. Adapters that can reach offline players — the + * Essentials mail adapter, for one — listen at a higher priority and handle that case.

+ * + *

Known race, accepted. This listener and the Essentials mail listener each resolve a + * target's online-ness independently. A player who logs in or out between those two checks can + * therefore receive both a chat message and a mail, or neither. Splitting the notification is a + * deliberate cost of keeping the adapters independent of one another; the window is a tick or two + * and the failure mode is a duplicate or a missed courtesy message, never a lost transaction.

+ * + *

{@link RealtyNotificationEvent} is fired synchronously through {@code + * RealtyEventDispatch.fireSync}, so this handler already runs on the main thread and does not need + * to marshal there itself.

+ */ +public final class ChatNotificationListener implements Listener { + + private final Function playerLookup; + + public ChatNotificationListener(@NotNull Function playerLookup) { + this.playerLookup = playerLookup; + } + + @EventHandler(priority = EventPriority.NORMAL) + public void onNotification(@NotNull RealtyNotificationEvent event) { + Component message = event.getMessage(); + List targets = event.getTargets(); + for (UUID target : targets) { + @Nullable Audience audience = this.playerLookup.apply(target); + if (audience != null) { + audience.sendMessage(message); + } + } + } +} diff --git a/realty-paper-adapters/chat-adapter/src/main/resources/module-manifest.yml b/realty-paper-adapters/chat-adapter/src/main/resources/module-manifest.yml new file mode 100644 index 0000000..fdfabb3 --- /dev/null +++ b/realty-paper-adapters/chat-adapter/src/main/resources/module-manifest.yml @@ -0,0 +1,5 @@ +moduleName: chat-adapter +entryClass: io.github.md5sha256.realty.adapter.chat.ChatAdapterModule +author: md5sha256 +expectedPluginClass: io.github.md5sha256.realty.Realty +reloadable: true diff --git a/realty-paper-adapters/chat-adapter/src/test/java/io/github/md5sha256/realty/adapter/chat/ChatNotificationListenerTest.java b/realty-paper-adapters/chat-adapter/src/test/java/io/github/md5sha256/realty/adapter/chat/ChatNotificationListenerTest.java new file mode 100644 index 0000000..01e7da4 --- /dev/null +++ b/realty-paper-adapters/chat-adapter/src/test/java/io/github/md5sha256/realty/adapter/chat/ChatNotificationListenerTest.java @@ -0,0 +1,74 @@ +package io.github.md5sha256.realty.adapter.chat; + +import io.github.md5sha256.realty.api.event.RealtyNotificationEvent; +import net.kyori.adventure.audience.Audience; +import net.kyori.adventure.text.Component; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +class ChatNotificationListenerTest { + + @Test + void sendsToOnlineTargets() { + UUID online = UUID.randomUUID(); + List received = new ArrayList<>(); + Map players = new HashMap<>(); + players.put(online, new RecordingAudience(received)); + + ChatNotificationListener listener = new ChatNotificationListener(players::get); + RealtyNotificationEvent event = + new RealtyNotificationEvent(List.of(online), Component.text("rejected"), null); + + listener.onNotification(event); + + Assertions.assertEquals(List.of(Component.text("rejected")), received); + } + + @Test + void offlineTargetIsSkippedWithoutThrowing() { + ChatNotificationListener listener = new ChatNotificationListener(uuid -> null); + RealtyNotificationEvent event = new RealtyNotificationEvent( + List.of(UUID.randomUUID()), Component.text("rejected"), null); + + Assertions.assertDoesNotThrow(() -> listener.onNotification(event)); + } + + @Test + void multiTargetEventFansOutOncePerOnlineTarget() { + UUID first = UUID.randomUUID(); + UUID second = UUID.randomUUID(); + UUID offline = UUID.randomUUID(); + List received = new ArrayList<>(); + Map players = new HashMap<>(); + players.put(first, new RecordingAudience(received)); + players.put(second, new RecordingAudience(received)); + + ChatNotificationListener listener = new ChatNotificationListener(players::get); + RealtyNotificationEvent event = new RealtyNotificationEvent( + List.of(first, second, offline), Component.text("rejected"), null); + + listener.onNotification(event); + + Assertions.assertEquals(2, received.size()); + } + + private static final class RecordingAudience implements Audience { + + private final List received; + + RecordingAudience(List received) { + this.received = received; + } + + @Override + public void sendMessage(Component message) { + this.received.add(message); + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index c45c3e1..9d8c520 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -6,3 +6,4 @@ include("realty-paper-api") include("realty-paper") include("realty-areashop-importer") include("realty-paper-plan-extension") +include("realty-paper-adapters:chat-adapter") From 9c3b35943669ff559378446ba441e527ca84011f Mon Sep 17 00:00:00 2001 From: Andrew Wong <42793301+md5sha256@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:58:55 +1000 Subject: [PATCH 12/15] feat(essentials-adapter): move Essentials mail and safe-block predicate out of core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port essentials-adapter from pre-reconcile-event-driven-notifications, adapted for the synchronous RealtyNotificationEvent (getTargets()/getMessage(), no Executor marshalling). Mail goes only to offline targets so the chat adapter and this listener don't double up; a per-target send failure is logged and does not stop the remaining targets. Core no longer depends on EssentialsX: EssentialsSafeBlockPredicate moved into the module, the compileOnly dependency is dropped from realty-paper, and Realty.onEnable now constructs SafeLocationFinder unconditionally, letting the module supply the predicate when it loads. paper-plugin.yml's Essentials softdepend is untouched — join-classpath is still what lets the module resolve EssentialsX types at runtime. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014kvZD6gWHjdMkN9Ebt3UbF --- .../essentials-adapter/build.gradle.kts | 20 +++++ .../essentials/EssentialsAdapterModule.java | 66 ++++++++++++++ .../essentials/EssentialsMailListener.java | 70 +++++++++++++++ .../EssentialsSafeBlockPredicate.java | 16 ++-- .../src/main/resources/module-manifest.yml | 5 ++ .../EssentialsMailListenerTest.java | 87 +++++++++++++++++++ realty-paper/build.gradle.kts | 4 - .../io/github/md5sha256/realty/Realty.java | 9 +- settings.gradle.kts | 1 + 9 files changed, 259 insertions(+), 19 deletions(-) create mode 100644 realty-paper-adapters/essentials-adapter/build.gradle.kts create mode 100644 realty-paper-adapters/essentials-adapter/src/main/java/io/github/md5sha256/realty/adapter/essentials/EssentialsAdapterModule.java create mode 100644 realty-paper-adapters/essentials-adapter/src/main/java/io/github/md5sha256/realty/adapter/essentials/EssentialsMailListener.java rename {realty-paper/src/main/java/io/github/md5sha256/realty/util => realty-paper-adapters/essentials-adapter/src/main/java/io/github/md5sha256/realty/adapter/essentials}/EssentialsSafeBlockPredicate.java (64%) create mode 100644 realty-paper-adapters/essentials-adapter/src/main/resources/module-manifest.yml create mode 100644 realty-paper-adapters/essentials-adapter/src/test/java/io/github/md5sha256/realty/adapter/essentials/EssentialsMailListenerTest.java diff --git a/realty-paper-adapters/essentials-adapter/build.gradle.kts b/realty-paper-adapters/essentials-adapter/build.gradle.kts new file mode 100644 index 0000000..625f3b6 --- /dev/null +++ b/realty-paper-adapters/essentials-adapter/build.gradle.kts @@ -0,0 +1,20 @@ +plugins { + `java-library` + `realty-conventions` + id("com.gradleup.shadow") version "9.3.1" +} + +dependencies { + compileOnly(project(":realty-paper")) + compileOnly(project(":realty-paper-api")) + compileOnly("io.papermc.paper:paper-api:1.21.8-R0.1-SNAPSHOT") + compileOnly("org.jetbrains:annotations:26.0.2-1") + compileOnly("com.minecraftcitiesnetwork:plugin-infrastructure:1.0.0-SNAPSHOT") + compileOnly("net.essentialsx:EssentialsX:2.21.2") { + exclude(group = "org.bukkit", module = "bukkit") + exclude(group = "org.spigotmc", module = "spigot-api") + } + + testImplementation(project(":realty-paper-api")) + testImplementation("io.papermc.paper:paper-api:1.21.8-R0.1-SNAPSHOT") +} diff --git a/realty-paper-adapters/essentials-adapter/src/main/java/io/github/md5sha256/realty/adapter/essentials/EssentialsAdapterModule.java b/realty-paper-adapters/essentials-adapter/src/main/java/io/github/md5sha256/realty/adapter/essentials/EssentialsAdapterModule.java new file mode 100644 index 0000000..344505c --- /dev/null +++ b/realty-paper-adapters/essentials-adapter/src/main/java/io/github/md5sha256/realty/adapter/essentials/EssentialsAdapterModule.java @@ -0,0 +1,66 @@ +package io.github.md5sha256.realty.adapter.essentials; + +import com.earth2me.essentials.Console; +import com.earth2me.essentials.IEssentials; +import com.minecraftcitiesnetwork.pluginInfrastructure.modules.SimplePluginModule; +import io.github.md5sha256.realty.Realty; +import io.github.md5sha256.realty.command.util.SafeLocationFinder; +import net.ess3.api.IUser; +import org.bukkit.Bukkit; +import org.bukkit.plugin.Plugin; +import org.jetbrains.annotations.NotNull; + +import java.nio.file.Path; +import java.util.UUID; + +/** + * Adds EssentialsX support to Realty: notifications for offline players become Essentials mail, + * and teleport safety uses EssentialsX's own block checks. + */ +public final class EssentialsAdapterModule extends SimplePluginModule { + + /** + * {@inheritDoc} + * + *

{@code SimplePluginModule.initialize} does not declare a checked exception, so this + * override cannot widen it back to {@code ModuleInitializationException} — an override's + * throws clause may only narrow, never re-widen, what its superclass declares. The module + * lifecycle manager that invokes modules through the {@code PluginModule} interface catches + * {@code ModuleInitializationException | RuntimeException} identically (logs severe, unloads + * the module), so an unchecked failure here is handled exactly the same way a checked one + * would be.

+ * + * @throws IllegalStateException if Essentials is absent or disabled + */ + @Override + public void initialize(@NotNull Realty plugin, @NotNull Path dataFolder) { + super.initialize(plugin, dataFolder); + Plugin essentialsPlugin = Bukkit.getPluginManager().getPlugin("Essentials"); + if (!(essentialsPlugin instanceof IEssentials essentials) || !essentialsPlugin.isEnabled()) { + throw new IllegalStateException( + "EssentialsX is not installed or not enabled — essentials-adapter cannot start"); + } + registerListener(new EssentialsMailListener( + (uuid, text) -> sendMail(essentials, uuid, text), + uuid -> Bukkit.getPlayer(uuid) != null, + plugin.getLogger())); + plugin.paperApi().setSafeBlockPredicate(new EssentialsSafeBlockPredicate(essentials)); + } + + @Override + public void shutdown(@NotNull Realty plugin) { + unregisterListeners(); + plugin.paperApi().setSafeBlockPredicate(SafeLocationFinder.defaultPredicate()); + super.shutdown(plugin); + } + + private static void sendMail(@NotNull IEssentials essentials, + @NotNull UUID target, + @NotNull String text) { + IUser user = essentials.getUser(target); + if (user == null) { + throw new IllegalStateException("no Essentials user for " + target); + } + essentials.getMail().sendMail(user, Console.getInstance(), text); + } +} diff --git a/realty-paper-adapters/essentials-adapter/src/main/java/io/github/md5sha256/realty/adapter/essentials/EssentialsMailListener.java b/realty-paper-adapters/essentials-adapter/src/main/java/io/github/md5sha256/realty/adapter/essentials/EssentialsMailListener.java new file mode 100644 index 0000000..415de6f --- /dev/null +++ b/realty-paper-adapters/essentials-adapter/src/main/java/io/github/md5sha256/realty/adapter/essentials/EssentialsMailListener.java @@ -0,0 +1,70 @@ +package io.github.md5sha256.realty.adapter.essentials; + +import io.github.md5sha256.realty.api.event.RealtyNotificationEvent; +import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.jetbrains.annotations.NotNull; + +import java.util.List; +import java.util.UUID; +import java.util.function.BiConsumer; +import java.util.function.Predicate; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Sends Realty notifications to offline targets as Essentials mail. Online targets are left to + * the chat adapter, so on a best-effort basis nobody gets the same notification twice. + * + *

Known race, accepted. This listener and the chat adapter's listener each resolve a + * target's online-ness independently. A player who logs in or out between those two checks can + * therefore receive both a chat message and a mail, or neither. The de-duplication below is + * best-effort, not a guarantee.

+ * + *

Mail is a legacy-section format, so the Component is flattened on the way out via + * {@link LegacyComponentSerializer#legacySection()} — RGB and hover/click data do not survive.

+ * + *

{@link RealtyNotificationEvent} is fired synchronously through {@code + * RealtyEventDispatch.fireSync}, so this handler already runs on the main thread and does not need + * to marshal there itself.

+ */ +public final class EssentialsMailListener implements Listener { + + private final BiConsumer mailSender; + private final Predicate isOnline; + private final Logger logger; + + /** Package-private: exists only so tests can omit the logger. */ + EssentialsMailListener(@NotNull BiConsumer mailSender, + @NotNull Predicate isOnline) { + this(mailSender, isOnline, Logger.getLogger(EssentialsMailListener.class.getName())); + } + + public EssentialsMailListener(@NotNull BiConsumer mailSender, + @NotNull Predicate isOnline, + @NotNull Logger logger) { + this.mailSender = mailSender; + this.isOnline = isOnline; + this.logger = logger; + } + + @EventHandler(priority = EventPriority.HIGH) + public void onNotification(@NotNull RealtyNotificationEvent event) { + String legacy = LegacyComponentSerializer.legacySection().serialize(event.getMessage()); + List targets = event.getTargets(); + for (UUID target : targets) { + if (this.isOnline.test(target)) { + continue; + } + try { + this.mailSender.accept(target, legacy); + } catch (RuntimeException ex) { + // One unresolvable user must not cost the other targets their mail. + this.logger.log(Level.WARNING, + "Realty: failed to mail notification to " + target, ex); + } + } + } +} diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/util/EssentialsSafeBlockPredicate.java b/realty-paper-adapters/essentials-adapter/src/main/java/io/github/md5sha256/realty/adapter/essentials/EssentialsSafeBlockPredicate.java similarity index 64% rename from realty-paper/src/main/java/io/github/md5sha256/realty/util/EssentialsSafeBlockPredicate.java rename to realty-paper-adapters/essentials-adapter/src/main/java/io/github/md5sha256/realty/adapter/essentials/EssentialsSafeBlockPredicate.java index 22eb1e6..6a6352c 100644 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/util/EssentialsSafeBlockPredicate.java +++ b/realty-paper-adapters/essentials-adapter/src/main/java/io/github/md5sha256/realty/adapter/essentials/EssentialsSafeBlockPredicate.java @@ -1,26 +1,28 @@ -package io.github.md5sha256.realty.util; +package io.github.md5sha256.realty.adapter.essentials; import com.earth2me.essentials.IEssentials; import com.earth2me.essentials.utils.LocationUtil; -import org.bukkit.Bukkit; import org.bukkit.block.Block; import org.jetbrains.annotations.NotNull; import java.util.function.Predicate; /** - * Safety predicate that delegates to EssentialsX's {@link LocationUtil#isBlockUnsafe} - * for determining whether a player can safely stand at a given feet-level block. + * Safety predicate that delegates to EssentialsX's {@link LocationUtil#isBlockUnsafe} for + * deciding whether a player can safely stand at a given feet-level block. */ public final class EssentialsSafeBlockPredicate implements Predicate { - private final IEssentials essentials = (IEssentials) Bukkit.getPluginManager() - .getPlugin("Essentials"); + private final IEssentials essentials; + + public EssentialsSafeBlockPredicate(@NotNull IEssentials essentials) { + this.essentials = essentials; + } @Override public boolean test(@NotNull Block feetBlock) { return !LocationUtil.isBlockUnsafe( - essentials, + this.essentials, feetBlock.getWorld(), feetBlock.getX(), feetBlock.getY(), diff --git a/realty-paper-adapters/essentials-adapter/src/main/resources/module-manifest.yml b/realty-paper-adapters/essentials-adapter/src/main/resources/module-manifest.yml new file mode 100644 index 0000000..a443545 --- /dev/null +++ b/realty-paper-adapters/essentials-adapter/src/main/resources/module-manifest.yml @@ -0,0 +1,5 @@ +moduleName: essentials-adapter +entryClass: io.github.md5sha256.realty.adapter.essentials.EssentialsAdapterModule +author: md5sha256 +expectedPluginClass: io.github.md5sha256.realty.Realty +reloadable: false diff --git a/realty-paper-adapters/essentials-adapter/src/test/java/io/github/md5sha256/realty/adapter/essentials/EssentialsMailListenerTest.java b/realty-paper-adapters/essentials-adapter/src/test/java/io/github/md5sha256/realty/adapter/essentials/EssentialsMailListenerTest.java new file mode 100644 index 0000000..1ecf368 --- /dev/null +++ b/realty-paper-adapters/essentials-adapter/src/test/java/io/github/md5sha256/realty/adapter/essentials/EssentialsMailListenerTest.java @@ -0,0 +1,87 @@ +package io.github.md5sha256.realty.adapter.essentials; + +import io.github.md5sha256.realty.api.event.RealtyNotificationEvent; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +class EssentialsMailListenerTest { + + @Test + void mailsOfflineTargets() { + UUID offline = UUID.randomUUID(); + List> sent = new ArrayList<>(); + + EssentialsMailListener listener = new EssentialsMailListener( + (uuid, text) -> sent.add(Map.entry(uuid, text)), + uuid -> false); + RealtyNotificationEvent event = new RealtyNotificationEvent( + List.of(offline), Component.text("rejected"), null); + + listener.onNotification(event); + + Assertions.assertEquals(1, sent.size()); + Assertions.assertEquals(offline, sent.get(0).getKey()); + Assertions.assertEquals("rejected", sent.get(0).getValue()); + } + + @Test + void onlineTargetIsNotMailed() { + List> sent = new ArrayList<>(); + + EssentialsMailListener listener = new EssentialsMailListener( + (uuid, text) -> sent.add(Map.entry(uuid, text)), + uuid -> true); + RealtyNotificationEvent event = new RealtyNotificationEvent( + List.of(UUID.randomUUID()), Component.text("rejected"), null); + + listener.onNotification(event); + + Assertions.assertEquals(List.of(), sent); + } + + @Test + void messageIsSerializedToLegacySection() { + List> sent = new ArrayList<>(); + + EssentialsMailListener listener = new EssentialsMailListener( + (uuid, text) -> sent.add(Map.entry(uuid, text)), + uuid -> false); + RealtyNotificationEvent event = new RealtyNotificationEvent( + List.of(UUID.randomUUID()), Component.text("sold", NamedTextColor.RED), null); + + listener.onNotification(event); + + Assertions.assertEquals("§csold", sent.get(0).getValue()); + } + + @Test + void aFailingSendDoesNotStopRemainingTargets() { + UUID first = UUID.randomUUID(); + UUID second = UUID.randomUUID(); + Set delivered = new HashSet<>(); + + EssentialsMailListener listener = new EssentialsMailListener( + (uuid, text) -> { + if (uuid.equals(first)) { + throw new IllegalStateException("no such user"); + } + delivered.add(uuid); + }, + uuid -> false); + RealtyNotificationEvent event = new RealtyNotificationEvent( + List.of(first, second), Component.text("rejected"), null); + + listener.onNotification(event); + + Assertions.assertEquals(Set.of(second), delivered); + } +} diff --git a/realty-paper/build.gradle.kts b/realty-paper/build.gradle.kts index e007f48..7e75a33 100644 --- a/realty-paper/build.gradle.kts +++ b/realty-paper/build.gradle.kts @@ -20,10 +20,6 @@ dependencies { implementation("org.xerial:sqlite-jdbc:3.46.1.0") { isTransitive = false } - compileOnly("net.essentialsx:EssentialsX:2.21.2") { - exclude(group = "org.bukkit", module = "bukkit") - exclude(group = "org.spigotmc", module = "spigot-api") - } compileOnly("net.democracycraft:treasury-api:2.0.0") compileOnly("org.jetbrains:annotations:26.0.2-1") implementation("org.incendo:cloud-paper:2.0.0-beta.10") diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/Realty.java b/realty-paper/src/main/java/io/github/md5sha256/realty/Realty.java index c5272a4..b071ded 100644 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/Realty.java +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/Realty.java @@ -85,7 +85,6 @@ import io.github.md5sha256.realty.settings.RegionTagSettings; import io.github.md5sha256.realty.settings.Settings; import io.github.md5sha256.realty.settings.TaxSettings; -import io.github.md5sha256.realty.util.EssentialsSafeBlockPredicate; import io.github.md5sha256.realty.util.SquirrelIdUsernameResolver; import io.papermc.paper.util.Tick; import net.kyori.adventure.text.Component; @@ -279,13 +278,7 @@ public void onEnable() { getServer().getPluginManager().disablePlugin(this); return; } - SafeLocationFinder safeLocationFinder; - if (getServer().getPluginManager().isPluginEnabled("Essentials")) { - getLogger().info("Using EssentialsX safe-block predicate for teleportation"); - safeLocationFinder = new SafeLocationFinder(new EssentialsSafeBlockPredicate()); - } else { - safeLocationFinder = new SafeLocationFinder(); - } + SafeLocationFinder safeLocationFinder = new SafeLocationFinder(); this.signTextApplicator = new SignTextApplicator( this.regionProfileService, this.logic, this.database, this.signCache, getLogger()); this.profileApplicator = new ProfileApplicator( diff --git a/settings.gradle.kts b/settings.gradle.kts index 9d8c520..9267cdd 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -7,3 +7,4 @@ include("realty-paper") include("realty-areashop-importer") include("realty-paper-plan-extension") include("realty-paper-adapters:chat-adapter") +include("realty-paper-adapters:essentials-adapter") From 17643996df0bd5a6e39302d262ee257ae195bdc3 Mon Sep 17 00:00:00 2001 From: Andrew Wong <42793301+md5sha256@users.noreply.github.com> Date: Fri, 21 Aug 2026 18:02:45 +1000 Subject: [PATCH 13/15] feat: bundle chat-adapter in the plugin jar and extract it on first enable --- realty-paper/build.gradle.kts | 21 +++++++++ .../realty/BundledModuleExtractor.java | 36 ++++++++++++++++ .../io/github/md5sha256/realty/Realty.java | 8 ++++ .../realty/BundledModuleExtractionTest.java | 43 +++++++++++++++++++ 4 files changed, 108 insertions(+) create mode 100644 realty-paper/src/main/java/io/github/md5sha256/realty/BundledModuleExtractor.java create mode 100644 realty-paper/src/test/java/io/github/md5sha256/realty/BundledModuleExtractionTest.java diff --git a/realty-paper/build.gradle.kts b/realty-paper/build.gradle.kts index 7e75a33..0340296 100644 --- a/realty-paper/build.gradle.kts +++ b/realty-paper/build.gradle.kts @@ -65,6 +65,13 @@ tasks { relocate("org.enginehub.squirrelid", "${base}.org.enginehub.squirrelid") relocate("org.sqlite", "${base}.org.sqlite") mergeServiceFiles() + + dependsOn(":realty-paper-adapters:chat-adapter:shadowJar") + from(project(":realty-paper-adapters:chat-adapter") + .tasks.named("shadowJar").map { it.outputs.files.singleFile }) { + into("modules") + rename { "chat-adapter.jar" } + } } processResources { @@ -75,6 +82,20 @@ tasks { } runServer { + dependsOn(":realty-paper-adapters:chat-adapter:shadowJar") + // EssentialsX is downloaded below, so stage the adapter that pairs with it too — otherwise + // the spec's Essentials smoke test cannot be run as written. + dependsOn(":realty-paper-adapters:essentials-adapter:shadowJar") + doFirst { + val moduleDir = project.layout.projectDirectory.dir("run/plugins/Realty/modules").asFile + moduleDir.mkdirs() + val chatAdapterJar = project(":realty-paper-adapters:chat-adapter") + .tasks.named("shadowJar").get().outputs.files.singleFile + chatAdapterJar.copyTo(moduleDir.resolve("chat-adapter.jar"), overwrite = true) + val essentialsAdapterJar = project(":realty-paper-adapters:essentials-adapter") + .tasks.named("shadowJar").get().outputs.files.singleFile + essentialsAdapterJar.copyTo(moduleDir.resolve("essentials-adapter.jar"), overwrite = true) + } minecraftVersion("1.21.8") downloadPlugins { // WorldEdit 7.4.0 diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/BundledModuleExtractor.java b/realty-paper/src/main/java/io/github/md5sha256/realty/BundledModuleExtractor.java new file mode 100644 index 0000000..b2002ab --- /dev/null +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/BundledModuleExtractor.java @@ -0,0 +1,36 @@ +package io.github.md5sha256.realty; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.function.Supplier; + +/** + * Writes a module jar shipped inside the plugin jar out to the modules directory, once. + * + *

An existing file is never replaced: an operator who removed or swapped a bundled module + * keeps that choice across restarts.

+ */ +public final class BundledModuleExtractor { + + private BundledModuleExtractor() { + } + + public static void extract(@NotNull Path target, + @NotNull Supplier<@Nullable InputStream> resource) throws IOException { + if (Files.exists(target)) { + return; + } + try (InputStream stream = resource.get()) { + if (stream == null) { + return; + } + Files.createDirectories(target.getParent()); + Files.copy(stream, target); + } + } +} diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/Realty.java b/realty-paper/src/main/java/io/github/md5sha256/realty/Realty.java index b071ded..207bff8 100644 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/Realty.java +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/Realty.java @@ -691,6 +691,14 @@ private void startModules() { Path moduleDir = getDataFolder().toPath().resolve("modules"); try { Files.createDirectories(moduleDir); + try { + BundledModuleExtractor.extract(moduleDir.resolve("chat-adapter.jar"), + () -> getClass().getClassLoader().getResourceAsStream("modules/chat-adapter.jar")); + } catch (IOException ex) { + // No chat adapter means no chat notifications, a degradation, not a fault worth + // taking the plugin down for. + getLogger().warning("Failed to extract bundled chat-adapter module: " + ex.getMessage()); + } this.moduleManager.start(); } catch (IOException ex) { // A broken module directory is not worth taking the whole plugin down for. diff --git a/realty-paper/src/test/java/io/github/md5sha256/realty/BundledModuleExtractionTest.java b/realty-paper/src/test/java/io/github/md5sha256/realty/BundledModuleExtractionTest.java new file mode 100644 index 0000000..8a7d2ef --- /dev/null +++ b/realty-paper/src/test/java/io/github/md5sha256/realty/BundledModuleExtractionTest.java @@ -0,0 +1,43 @@ +package io.github.md5sha256.realty; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +class BundledModuleExtractionTest { + + @Test + void extractsWhenAbsent(@TempDir Path moduleDir) throws IOException { + Path target = moduleDir.resolve("chat-adapter.jar"); + + BundledModuleExtractor.extract(target, + () -> new ByteArrayInputStream("jar-bytes".getBytes(StandardCharsets.UTF_8))); + + Assertions.assertEquals("jar-bytes", Files.readString(target)); + } + + @Test + void neverOverwritesAnExistingFile(@TempDir Path moduleDir) throws IOException { + Path target = moduleDir.resolve("chat-adapter.jar"); + Files.writeString(target, "operator-replaced-this"); + + BundledModuleExtractor.extract(target, + () -> new ByteArrayInputStream("jar-bytes".getBytes(StandardCharsets.UTF_8))); + + Assertions.assertEquals("operator-replaced-this", Files.readString(target)); + } + + @Test + void missingResourceIsNotFatal(@TempDir Path moduleDir) { + Path target = moduleDir.resolve("chat-adapter.jar"); + + Assertions.assertDoesNotThrow(() -> BundledModuleExtractor.extract(target, () -> null)); + Assertions.assertFalse(Files.exists(target)); + } +} From 308a4a96ba75169b4a66bd744ae0f85525243272 Mon Sep 17 00:00:00 2001 From: Andrew Wong <42793301+md5sha256@users.noreply.github.com> Date: Fri, 21 Aug 2026 18:07:32 +1000 Subject: [PATCH 14/15] feat: warn when EssentialsX is installed without the essentials-adapter module --- .../src/main/java/io/github/md5sha256/realty/Realty.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/Realty.java b/realty-paper/src/main/java/io/github/md5sha256/realty/Realty.java index 207bff8..9a71cd3 100644 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/Realty.java +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/Realty.java @@ -700,6 +700,12 @@ private void startModules() { getLogger().warning("Failed to extract bundled chat-adapter module: " + ex.getMessage()); } this.moduleManager.start(); + if (getServer().getPluginManager().isPluginEnabled("Essentials") + && !this.moduleManager.getActiveModules().containsKey("essentials-adapter")) { + getLogger().warning("Essentials is enabled, but the essentials-adapter module is not loaded. " + + "Offline players will not receive mail notifications and EssentialsX-based teleport " + + "safety will not be applied. Place essentials-adapter.jar in " + moduleDir + " to enable it."); + } } catch (IOException ex) { // A broken module directory is not worth taking the whole plugin down for. getLogger().severe("Failed to load modules from " + moduleDir + ": " + ex.getMessage()); From 20dfd9250da768da664abfb8479ed6fe925097b2 Mon Sep 17 00:00:00 2001 From: Andrew Wong <42793301+md5sha256@users.noreply.github.com> Date: Fri, 21 Aug 2026 18:22:53 +1000 Subject: [PATCH 15/15] Fix code review findings for notification reconciliation - Warn when no delivery module is loaded at all, and separately when chat-adapter specifically is missing, so silent total notification outage is no longer unlogged (Realty.java). - Correct the chat/essentials adapter Javadocs: the previously documented online-ness race does not exist under a synchronous RealtyNotificationEvent dispatch; state the real invariant (exactly-once delivery per target) and its dependency on the event staying synchronous. - Marshal ModuleCommandGroup's reload-argument suggestion provider onto the main thread, matching the other handlers, since Cloud resolves suggestions asynchronously and ModuleLifecycleManager is not thread-safe. - Skip non-reloadable modules in reloadModules() so /realty reload stops logging an expected-by-design failure for essentials-adapter every time. - Reorder EssentialsAdapterModule.initialize so all fallible work happens before the listener is registered. - Document that a null RealtyNotificationEvent.getRegion() is routine (payment-expiry sweeps after region deletion), not pathological. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014kvZD6gWHjdMkN9Ebt3UbF --- .../chat/ChatNotificationListener.java | 15 ++++++++++----- .../essentials/EssentialsAdapterModule.java | 6 +++++- .../essentials/EssentialsMailListener.java | 15 ++++++++++----- .../api/event/RealtyNotificationEvent.java | 7 +++++++ .../io/github/md5sha256/realty/Realty.java | 19 ++++++++++++++++--- .../realty/command/ModuleCommandGroup.java | 18 ++++++++++++------ 6 files changed, 60 insertions(+), 20 deletions(-) diff --git a/realty-paper-adapters/chat-adapter/src/main/java/io/github/md5sha256/realty/adapter/chat/ChatNotificationListener.java b/realty-paper-adapters/chat-adapter/src/main/java/io/github/md5sha256/realty/adapter/chat/ChatNotificationListener.java index e505d87..cb5a6cf 100644 --- a/realty-paper-adapters/chat-adapter/src/main/java/io/github/md5sha256/realty/adapter/chat/ChatNotificationListener.java +++ b/realty-paper-adapters/chat-adapter/src/main/java/io/github/md5sha256/realty/adapter/chat/ChatNotificationListener.java @@ -19,11 +19,16 @@ *

This is the baseline every server gets. Adapters that can reach offline players — the * Essentials mail adapter, for one — listen at a higher priority and handle that case.

* - *

Known race, accepted. This listener and the Essentials mail listener each resolve a - * target's online-ness independently. A player who logs in or out between those two checks can - * therefore receive both a chat message and a mail, or neither. Splitting the notification is a - * deliberate cost of keeping the adapters independent of one another; the window is a tick or two - * and the failure mode is a duplicate or a missed courtesy message, never a lost transaction.

+ *

Exactly-once delivery per target. This listener and the Essentials mail listener each + * resolve a target's online-ness independently, but every target still receives exactly one of a + * chat message or a mail, never both and never neither. That holds because {@link + * RealtyNotificationEvent} is dispatched synchronously: both handlers run inside a single {@code + * PluginManager.callEvent} on the main thread, this one at {@link EventPriority#NORMAL} and the + * mail listener at {@link EventPriority#HIGH}, with no yield between them; online-ness only ever + * changes on that same main thread (player join/quit), so it cannot change mid-dispatch. This + * guarantee depends on the event staying synchronous — if it were ever made asynchronous, the two + * online-ness checks would run independently and could race, reintroducing duplicate or missed + * delivery.

* *

{@link RealtyNotificationEvent} is fired synchronously through {@code * RealtyEventDispatch.fireSync}, so this handler already runs on the main thread and does not need diff --git a/realty-paper-adapters/essentials-adapter/src/main/java/io/github/md5sha256/realty/adapter/essentials/EssentialsAdapterModule.java b/realty-paper-adapters/essentials-adapter/src/main/java/io/github/md5sha256/realty/adapter/essentials/EssentialsAdapterModule.java index 344505c..2a7f2d3 100644 --- a/realty-paper-adapters/essentials-adapter/src/main/java/io/github/md5sha256/realty/adapter/essentials/EssentialsAdapterModule.java +++ b/realty-paper-adapters/essentials-adapter/src/main/java/io/github/md5sha256/realty/adapter/essentials/EssentialsAdapterModule.java @@ -40,11 +40,15 @@ public void initialize(@NotNull Realty plugin, @NotNull Path dataFolder) { throw new IllegalStateException( "EssentialsX is not installed or not enabled — essentials-adapter cannot start"); } + // All fallible work must happen before the listener is registered: if anything after + // registerListener throws, ModuleLifecycleManager closes the class loader without calling + // shutdown(), so the listener would never be unregistered and would remain live on a dead + // class loader. + plugin.paperApi().setSafeBlockPredicate(new EssentialsSafeBlockPredicate(essentials)); registerListener(new EssentialsMailListener( (uuid, text) -> sendMail(essentials, uuid, text), uuid -> Bukkit.getPlayer(uuid) != null, plugin.getLogger())); - plugin.paperApi().setSafeBlockPredicate(new EssentialsSafeBlockPredicate(essentials)); } @Override diff --git a/realty-paper-adapters/essentials-adapter/src/main/java/io/github/md5sha256/realty/adapter/essentials/EssentialsMailListener.java b/realty-paper-adapters/essentials-adapter/src/main/java/io/github/md5sha256/realty/adapter/essentials/EssentialsMailListener.java index 415de6f..dc6cbcb 100644 --- a/realty-paper-adapters/essentials-adapter/src/main/java/io/github/md5sha256/realty/adapter/essentials/EssentialsMailListener.java +++ b/realty-paper-adapters/essentials-adapter/src/main/java/io/github/md5sha256/realty/adapter/essentials/EssentialsMailListener.java @@ -16,12 +16,17 @@ /** * Sends Realty notifications to offline targets as Essentials mail. Online targets are left to - * the chat adapter, so on a best-effort basis nobody gets the same notification twice. + * the chat adapter, so every target receives exactly one of a chat message or a mail. * - *

Known race, accepted. This listener and the chat adapter's listener each resolve a - * target's online-ness independently. A player who logs in or out between those two checks can - * therefore receive both a chat message and a mail, or neither. The de-duplication below is - * best-effort, not a guarantee.

+ *

Exactly-once delivery per target. This listener and the chat adapter's listener each + * resolve a target's online-ness independently, but never disagree in a way that causes a + * duplicate or a miss. That holds because {@link RealtyNotificationEvent} is dispatched + * synchronously: both handlers run inside a single {@code PluginManager.callEvent} on the main + * thread, the chat listener at {@link EventPriority#NORMAL} and this one at {@link + * EventPriority#HIGH}, with no yield between them; online-ness only ever changes on that same + * main thread (player join/quit), so it cannot change mid-dispatch. This guarantee depends on the + * event staying synchronous — if it were ever made asynchronous, the two online-ness checks would + * run independently and could race, reintroducing duplicate or missed delivery.

* *

Mail is a legacy-section format, so the Component is flattened on the way out via * {@link LegacyComponentSerializer#legacySection()} — RGB and hover/click data do not survive.

diff --git a/realty-paper-api/src/main/java/io/github/md5sha256/realty/api/event/RealtyNotificationEvent.java b/realty-paper-api/src/main/java/io/github/md5sha256/realty/api/event/RealtyNotificationEvent.java index 57feb31..c029739 100644 --- a/realty-paper-api/src/main/java/io/github/md5sha256/realty/api/event/RealtyNotificationEvent.java +++ b/realty-paper-api/src/main/java/io/github/md5sha256/realty/api/event/RealtyNotificationEvent.java @@ -55,6 +55,13 @@ public RealtyNotificationEvent(@NotNull List targets, return this.message; } + /** + * The region this notification concerns, or {@code null} when it cannot be resolved. + * + *

A null region is routine, not pathological: payment-expiry sweeps announce a refund + * after the region itself has already been deleted, so no {@link WorldGuardRegion} exists to + * report at that point.

+ */ public @Nullable WorldGuardRegion getRegion() { return this.region; } diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/Realty.java b/realty-paper/src/main/java/io/github/md5sha256/realty/Realty.java index 9a71cd3..e6db6dd 100644 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/Realty.java +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/Realty.java @@ -700,6 +700,15 @@ private void startModules() { getLogger().warning("Failed to extract bundled chat-adapter module: " + ex.getMessage()); } this.moduleManager.start(); + if (this.moduleManager.getActiveModules().isEmpty()) { + getLogger().warning("No notification delivery module is loaded. Realty fires notification " + + "events but delivers nothing on its own; every notification (sale, lease, offer, " + + "auction, etc.) will reach nobody. Place chat-adapter.jar in " + moduleDir + + " to enable it."); + } else if (!this.moduleManager.getActiveModules().containsKey("chat-adapter")) { + getLogger().warning("The chat-adapter module is not loaded. Online players will not receive " + + "chat notifications. Place chat-adapter.jar in " + moduleDir + " to enable it."); + } if (getServer().getPluginManager().isPluginEnabled("Essentials") && !this.moduleManager.getActiveModules().containsKey("essentials-adapter")) { getLogger().warning("Essentials is enabled, but the essentials-adapter module is not loaded. " @@ -721,14 +730,18 @@ private void reloadModules() { return; } this.executorState.mainThreadExec().execute(() -> { - for (String moduleName : this.moduleManager.getActiveModules().keySet()) { + this.moduleManager.getActiveModules().forEach((moduleName, loadedModule) -> { + if (!loadedModule.manifest().reloadable()) { + // Skip modules that declare themselves non-reloadable; reloading them would + // fail by design and only produce a warning operators are trained to ignore. + return; + } this.moduleManager.reloadAsync(moduleName).exceptionally(error -> { - // A module that declares itself non-reloadable fails here by design. getLogger().warning("Failed to reload module " + moduleName + ": " + error.getMessage()); return null; }); - } + }); }); } diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/command/ModuleCommandGroup.java b/realty-paper/src/main/java/io/github/md5sha256/realty/command/ModuleCommandGroup.java index 8643dc3..c0e5d3d 100644 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/command/ModuleCommandGroup.java +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/command/ModuleCommandGroup.java @@ -23,8 +23,9 @@ /** * Handles {@code /realty module list} and {@code /realty module reload }. * - *

{@link ModuleLifecycleManager} is not thread-safe, so both handlers hop onto the main thread - * before touching it.

+ *

{@link ModuleLifecycleManager} is not thread-safe, so every handler that touches it — + * including the {@code reload} argument's suggestion provider, which Cloud resolves off the main + * thread — hops onto the main thread first.

* *

Permissions: {@code realty.command.module.list}, {@code realty.command.module.reload}.

*/ @@ -53,11 +54,16 @@ public record ModuleCommandGroup( } private @NotNull SuggestionProvider moduleSuggestions() { - return (ctx, input) -> CompletableFuture.completedFuture( - moduleManager.getActiveModules().keySet().stream() + return (ctx, input) -> { + CompletableFuture> future = new CompletableFuture<>(); + executorState.mainThreadExec().execute(() -> { + List suggestions = moduleManager.getActiveModules().keySet().stream() .map(Suggestion::suggestion) - .toList() - ); + .toList(); + future.complete(suggestions); + }); + return future; + }; } private void executeList(@NotNull CommandContext ctx) {