Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -122,4 +122,6 @@ runs/
memory-bank
.clinerules
.claude
CLAUDE.md
CLAUDE.md
# Superpowers scratch (agent workspace, not project content)
.superpowers/
8 changes: 8 additions & 0 deletions buildSrc/src/main/kotlin/realty-conventions.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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/")
Expand Down
593 changes: 593 additions & 0 deletions docs/superpowers/plans/2026-08-21-reconcile-notifications.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -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<UUID> targets;
private final Component message;
private final WorldGuardRegion region; // nullable

public RealtyNotificationEvent(@NotNull List<UUID> targets,
@NotNull Component message,
@Nullable WorldGuardRegion region) { /* ... */ }

public @NotNull List<UUID> 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.
Original file line number Diff line number Diff line change
Expand Up @@ -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<DecimalFormat> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<ExpiredBidPayment> 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<ExpiredOfferPayment> clearExpiredOfferPayments();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down Expand Up @@ -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;
Expand Down
16 changes: 16 additions & 0 deletions realty-paper-adapters/chat-adapter/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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")
}
Original file line number Diff line number Diff line change
@@ -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<Realty> {

@Override
public void initialize(@NotNull Realty plugin, @NotNull Path dataFolder) {
super.initialize(plugin, dataFolder);
Function<UUID, Audience> lookup = Bukkit::getPlayer;
registerListener(new ChatNotificationListener(lookup));
}

@Override
public void shutdown(@NotNull Realty plugin) {
unregisterListeners();
super.shutdown(plugin);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
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.
*
* <p>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.</p>
*
* <p><b>Exactly-once delivery per target.</b> 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.</p>
*
* <p>{@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.</p>
*/
public final class ChatNotificationListener implements Listener {

private final Function<UUID, Audience> playerLookup;

public ChatNotificationListener(@NotNull Function<UUID, Audience> playerLookup) {
this.playerLookup = playerLookup;
}

@EventHandler(priority = EventPriority.NORMAL)
public void onNotification(@NotNull RealtyNotificationEvent event) {
Component message = event.getMessage();
List<UUID> targets = event.getTargets();
for (UUID target : targets) {
@Nullable Audience audience = this.playerLookup.apply(target);
if (audience != null) {
audience.sendMessage(message);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading